From ba8170b8868a52d3a595be9a79177634cbc82f36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20L=C3=B3pez?= Date: Tue, 14 Jul 2026 21:01:28 +0200 Subject: [PATCH 01/15] tui-first: add pack CRUD and tmux.AttachCmd primitives Backend helpers the persistent TUI needs before the UI itself: - pack.Create/Duplicate/Delete operate on the existing on-disk format. - tmux.AttachCmd returns the attach command (unrun) for tea.ExecProcess, so Bubble Tea can release and restore the screen around a Claude session. AttachChild now delegates to it. --- internal/pack/manage.go | 155 +++++++++++++++++++++++++++++++++++ internal/pack/manage_test.go | 99 ++++++++++++++++++++++ internal/tmux/tmux.go | 18 ++-- 3 files changed, 267 insertions(+), 5 deletions(-) create mode 100644 internal/pack/manage.go create mode 100644 internal/pack/manage_test.go diff --git a/internal/pack/manage.go b/internal/pack/manage.go new file mode 100644 index 0000000..f7add6d --- /dev/null +++ b/internal/pack/manage.go @@ -0,0 +1,155 @@ +package pack + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// starterCLAUDE is the CLAUDE.md a freshly created pack ships until the user +// edits it. It mirrors the built-in default so a new pack is useful at once. +const starterCLAUDE = `# Project + +Describe the project. + +# Architecture + +Describe the architecture. + +# Coding conventions + +Describe conventions. + +# Important notes + +Write important information here. +` + +// validateName keeps a pack name safe to use as a directory and readable in the +// selector — the same rule Crabby applies to task names. +func validateName(name string) error { + if name == "" { + return fmt.Errorf("a pack 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("pack names may only contain letters, digits, '-' and '_' (got %q)", name) + } + } + return nil +} + +// dirFor returns the on-disk directory a pack of the given name lives in. +func dirFor(name string) (string, error) { + root, err := Dir() + if err != nil { + return "", err + } + return filepath.Join(root, name), nil +} + +// manifestBody renders a pack.yaml from its fields, writing only the keys that +// have a value so the file stays as small as the pack needs it to be. +func manifestBody(p Pack) string { + var b strings.Builder + fmt.Fprintf(&b, "name: %s\n", p.Name) + if p.Description != "" { + fmt.Fprintf(&b, "description: %s\n", p.Description) + } + if p.Author != "" { + fmt.Fprintf(&b, "author: %s\n", p.Author) + } + if p.Version != "" { + fmt.Fprintf(&b, "version: %s\n", p.Version) + } + return b.String() +} + +// Create makes a new pack directory with a manifest and a starter CLAUDE.md, +// then returns the loaded pack. It fails if a pack with that name already +// exists, so an accidental second create never clobbers work. +func Create(name, description string) (Pack, error) { + if err := validateName(name); err != nil { + return Pack{}, err + } + dir, err := dirFor(name) + if err != nil { + return Pack{}, err + } + if _, err := os.Stat(dir); err == nil { + return Pack{}, fmt.Errorf("a pack named %q already exists", name) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return Pack{}, err + } + p := Pack{Name: name, Description: description, Version: "0.1.0"} + if err := os.WriteFile(filepath.Join(dir, manifest), []byte(manifestBody(p)), 0o644); err != nil { + return Pack{}, err + } + if err := os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte(starterCLAUDE), 0o644); err != nil { + return Pack{}, err + } + return Load(dir) +} + +// Duplicate copies an existing pack to a new name and returns the copy. Every +// payload file is carried over; only the manifest's name is changed so the copy +// is a true starting point rather than a second pack claiming the same name. +func Duplicate(src Pack, newName string) (Pack, error) { + if err := validateName(newName); err != nil { + return Pack{}, err + } + dest, err := dirFor(newName) + if err != nil { + return Pack{}, err + } + if _, err := os.Stat(dest); err == nil { + return Pack{}, fmt.Errorf("a pack named %q already exists", newName) + } + if err := copyTree(src.Dir, dest); err != nil { + return Pack{}, err + } + // Rewrite the manifest so the copy owns its new name while keeping the rest. + clone := src + clone.Name = newName + if err := os.WriteFile(filepath.Join(dest, manifest), []byte(manifestBody(clone)), 0o644); err != nil { + return Pack{}, err + } + return Load(dest) +} + +// Delete removes a pack's directory. As a safety measure it refuses to touch +// anything that is not inside the packs directory. +func Delete(p Pack) error { + root, err := Dir() + if err != nil { + return err + } + rel, err := filepath.Rel(root, p.Dir) + if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { + return fmt.Errorf("refusing to delete %q: not a pack directory", p.Dir) + } + return os.RemoveAll(p.Dir) +} + +// copyTree recursively copies the directory at src to dest, preserving the +// layout. It reuses copyFile for the leaves. +func copyTree(src, dest string) error { + return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dest, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + return copyFile(path, target) + }) +} diff --git a/internal/pack/manage_test.go b/internal/pack/manage_test.go new file mode 100644 index 0000000..5f74227 --- /dev/null +++ b/internal/pack/manage_test.go @@ -0,0 +1,99 @@ +package pack + +import ( + "os" + "path/filepath" + "testing" +) + +// withPacksDir points the packs directory at a temp dir for the duration of a +// test, so Create/Duplicate/Delete never touch the real ~/.config. +func withPacksDir(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", "") + root, err := Dir() + if err != nil { + t.Fatal(err) + } + return root +} + +func TestCreateWritesManifestAndStarter(t *testing.T) { + withPacksDir(t) + + p, err := Create("go-api", "A Go API pack") + if err != nil { + t.Fatalf("Create: %v", err) + } + if p.Name != "go-api" || p.Description != "A Go API pack" { + t.Fatalf("created pack = %+v", p) + } + if _, err := os.Stat(filepath.Join(p.Dir, "CLAUDE.md")); err != nil { + t.Fatalf("starter CLAUDE.md missing: %v", err) + } + + // A second create with the same name must fail rather than clobber. + if _, err := Create("go-api", "again"); err == nil { + t.Fatal("expected error creating a duplicate name") + } + + // Invalid names are rejected. + if _, err := Create("bad name", ""); err == nil { + t.Fatal("expected error for an invalid name") + } +} + +func TestDuplicateCopiesFilesAndRenames(t *testing.T) { + withPacksDir(t) + + src, err := Create("base", "Base pack") + if err != nil { + t.Fatal(err) + } + // Add an extra payload file to prove the whole tree is copied. + if err := os.MkdirAll(filepath.Join(src.Dir, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src.Dir, ".claude", "settings.md"), []byte("x\n"), 0o644); err != nil { + t.Fatal(err) + } + + dup, err := Duplicate(src, "copy") + if err != nil { + t.Fatalf("Duplicate: %v", err) + } + if dup.Name != "copy" { + t.Fatalf("duplicate name = %q, want copy", dup.Name) + } + if _, err := os.Stat(filepath.Join(dup.Dir, ".claude", "settings.md")); err != nil { + t.Fatalf("nested payload not duplicated: %v", err) + } + // The manifest of the copy must carry the new name. + reloaded, err := Load(dup.Dir) + if err != nil || reloaded.Name != "copy" { + t.Fatalf("reloaded copy = %+v (err %v)", reloaded, err) + } +} + +func TestDeleteRemovesOnlyPackDirs(t *testing.T) { + withPacksDir(t) + + p, err := Create("gone", "") + if err != nil { + t.Fatal(err) + } + if err := Delete(p); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := os.Stat(p.Dir); !os.IsNotExist(err) { + t.Fatal("pack directory still present after Delete") + } + + // Delete must refuse a directory outside the packs root. + outside := Pack{Name: "evil", Dir: t.TempDir()} + if err := Delete(outside); err == nil { + t.Fatal("expected Delete to refuse a non-pack directory") + } +} diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 143bd14..00694b9 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -148,15 +148,23 @@ func (c Client) RenameWindow(session, name string) error { return c.run("rename-window", "-t", exact(session), name) } -// AttachChild attaches to the session as a child process, inheriting the -// terminal. It blocks until the user leaves the session (detaches or Claude -// exits) and then returns — which is what lets Crabby take back control. -func (c Client) AttachChild(name string) error { +// AttachCmd builds the command that attaches to a session, wired to the real +// terminal. It does not run: the TUI hands it to tea.ExecProcess so Bubble Tea +// can release the screen, run the attach, and restore itself afterwards — +// giving a seamless step in and out of a Claude session without leaving Crabby. +func (c Client) AttachCmd(name string) *exec.Cmd { cmd := exec.Command(c.Binary, c.args("attach-session", "-t", exact(name))...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - return cmd.Run() + return cmd +} + +// AttachChild attaches to the session as a child process, inheriting the +// terminal. It blocks until the user leaves the session (detaches or Claude +// exits) and then returns — which is what lets Crabby take back control. +func (c Client) AttachChild(name string) error { + return c.AttachCmd(name).Run() } // Configure makes the session feel like part of Crabby rather than tmux: From 96f14fce92230f9e8056e5d6f2cc0d604f75de35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20L=C3=B3pez?= Date: Tue, 14 Jul 2026 21:13:41 +0200 Subject: [PATCH 02/15] =?UTF-8?q?v0.8.0:=20TUI-first=20=E2=80=94=20persist?= =?UTF-8?q?ent=20multi-panel=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crabby becomes a terminal application. `crabby` opens a persistent Bubble Tea app and every day-to-day action lives inside it; the shell is no longer part of the workflow. - Dashboard is a three-column layout: Workspaces | Tasks | Details, with keyboard focus moving between panes (tab / arrows) and Enter attaching the selected task to Claude. - Attaching runs through tea.ExecProcess, so the screen is released for Claude and the dashboard restored on return — no leaving Crabby. The prefix-free return key (F12) is shown on screen and in Help. - In-app flows: new workspace (directory -> pack -> create), new task, pack management (new / duplicate / edit via $EDITOR / delete), a Settings section (Packs, Diagnostics, About), and a Help screen listing every shortcut. - CLI slims to the two commands that belong outside the interface: `version` and `doctor`. init/import/task/attach/start/ps/rm move into the TUI. - Data model, session storage and pack format are unchanged; existing workspaces, tasks and packs keep working. The old flat-list dashboard and shell-loop home() are removed. --- internal/cli/cli.go | 587 +------------- internal/tui/app.go | 342 ++++++++ internal/tui/dashboard.go | 537 +++++++++++++ internal/tui/forms.go | 239 ++++++ internal/tui/help.go | 73 ++ internal/tui/input.go | 93 +++ internal/tui/insights_render_test.go | 141 ++-- internal/tui/packs.go | 207 +++++ internal/tui/settings.go | 126 +++ internal/tui/styles.go | 162 ++++ internal/tui/tui.go | 1076 -------------------------- 11 files changed, 1878 insertions(+), 1705 deletions(-) create mode 100644 internal/tui/app.go create mode 100644 internal/tui/dashboard.go create mode 100644 internal/tui/forms.go create mode 100644 internal/tui/help.go create mode 100644 internal/tui/input.go create mode 100644 internal/tui/packs.go create mode 100644 internal/tui/settings.go create mode 100644 internal/tui/styles.go delete mode 100644 internal/tui/tui.go diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 4f27b55..610d283 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -1,24 +1,19 @@ // Package cli wires Crabby's commands together with Cobra. +// +// From v0.8 Crabby is TUI-first: `crabby` opens the application and every +// day-to-day action lives inside it. The CLI keeps only the two commands that +// make sense outside the interface — `version` and `doctor`. package cli import ( - "bufio" "fmt" "os" - "os/exec" - "strings" - "time" "github.com/spf13/cobra" "github.com/marioolf/crabby/internal/config" "github.com/marioolf/crabby/internal/doctor" - "github.com/marioolf/crabby/internal/importcmd" - "github.com/marioolf/crabby/internal/initcmd" "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" @@ -39,13 +34,14 @@ func newRootCmd() *cobra.Command { Short: "A workspace manager for Claude Code", Long: tui.Banner() + "\n\n" + "A workspace manager for Claude Code.\n" + - "Run it with no arguments to open the home screen, pick a project, and jump straight into Claude.\n\n" + + "Run it with no arguments to open the app: create workspaces and tasks,\n" + + "jump into Claude, and manage packs — all without leaving the terminal.\n\n" + version.Repo, SilenceUsage: true, SilenceErrors: true, Version: version.String(), Args: cobra.NoArgs, - // `crabby` with no subcommand opens the home screen. + // `crabby` with no subcommand opens the application. RunE: func(cmd *cobra.Command, args []string) error { return home() }, @@ -55,22 +51,14 @@ func newRootCmd() *cobra.Command { root.CompletionOptions.DisableDefaultCmd = true root.AddCommand( - newInitCmd(), - newImportCmd(), - newTaskCmd(), - newPsCmd(), - newAttachCmd(), - newStartCmd(), - newRmCmd(), - newDoctorCmd(), newVersionCmd(), + newDoctorCmd(), ) return root } -// home is Crabby's main loop: show the project list, open the chosen session, -// and — when the user leaves it — return to the list. The user stays inside -// Crabby the whole time. +// home launches the persistent TUI. The user stays inside it for the whole +// session; it returns only when the user quits. func home() error { cfg, err := config.Load() if err != nil { @@ -80,205 +68,9 @@ func home() error { if !t.Available() { return errTmuxMissing(cfg.TmuxBinary) } - - // One collector for the whole home-screen loop, so its incremental - // transcript cache survives returning from a session. - coll := insights.New() - for { - res, err := tui.Run(t, cfg.DetachKey, coll) - if err != nil { - return err - } - switch res.Action { - case tui.ActionQuit: - return nil - case tui.ActionOpen: - 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) { - fmt.Fprintln(os.Stderr, "\ncrabby:", err) - fmt.Fprint(os.Stderr, "\nPress Enter to return to Crabby...") - bufio.NewReader(os.Stdin).ReadString('\n') -} - -// newProject initializes the current working directory as a project, choosing -// a pack when more than one is available. -func newProject() error { - cwd, err := os.Getwd() - if err != nil { - return err - } - p, cancelled, err := choosePack() - if err != nil { - return err - } - if cancelled { - return nil - } - res, err := initcmd.Init(cwd, p) - if err != nil { - return err - } - printInitSummary(res) - fmt.Print("\nPress Enter to return to Crabby...") - bufio.NewReader(os.Stdin).ReadString('\n') - return nil -} - -// choosePack decides which pack `init` should use: none (built-in default), -// the only one available, or one picked from a selector. The bool reports -// whether the user cancelled the selection. -func choosePack() (p *pack.Pack, cancelled bool, err error) { - packs, err := pack.List() - if err != nil { - return nil, false, err - } - switch len(packs) { - case 0: - return nil, false, nil - case 1: - return &packs[0], false, nil - default: - chosen, err := tui.SelectPack(packs) - if err != nil { - return nil, false, err - } - return chosen, chosen == nil, nil - } -} - -func printInitSummary(res initcmd.Result) { - if res.PackID != "" { - fmt.Printf("Initialized %q using pack %q\n", res.Project.Name, res.PackID) - } else { - fmt.Printf("Initialized %q\n", res.Project.Name) - } - for _, f := range res.Created { - fmt.Printf(" + %s\n", f) - } - for _, f := range res.Skipped { - fmt.Printf(" ! %s (already exists — kept)\n", f) - } - if res.PackID == "" { - fmt.Printf("\nTip: drop reusable project packs in %s to customize new projects.\n", pack.DisplayDir()) - fmt.Println(" Copy an example: cp -r examples/ " + pack.DisplayDir() + "/") - } -} - -// 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(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 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(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 + // One collector for the whole session so its incremental transcript cache + // survives returning from a Claude session. + return tui.Run(cfg, t, insights.New()) } func newVersionCmd() *cobra.Command { @@ -294,321 +86,6 @@ func newVersionCmd() *cobra.Command { } } -func newInitCmd() *cobra.Command { - return &cobra.Command{ - Use: "init [path]", - Short: "Initialize a project for Claude Code (current directory by default)", - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - dir, err := os.Getwd() - if err != nil { - return err - } - if len(args) == 1 { - dir = args[0] - } - p, cancelled, err := choosePack() - if err != nil { - return err - } - if cancelled { - return nil - } - res, err := initcmd.Init(dir, p) - if err != nil { - return err - } - printInitSummary(res) - fmt.Println("\nNext: run `crabby` and press Enter on this project to open it.") - return nil - }, - } -} - -// newImportCmd adopts existing Claude Code repositories in bulk: it scans a -// directory tree for git repos that already have a CLAUDE.md and registers the -// ones the user picks. No files are changed beyond Crabby's own metadata. -func newImportCmd() *cobra.Command { - return &cobra.Command{ - Use: "import [path]", - Short: "Import existing Claude Code repositories into Crabby", - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - root := "." - if len(args) == 1 { - root = args[0] - } - fmt.Printf("Scanning %s ...\n", root) - found, err := importcmd.Discover(root) - if err != nil { - return err - } - if len(found) == 0 { - fmt.Printf("No Claude workspaces found under %s.\n", root) - fmt.Println("A workspace is any folder containing a CLAUDE.md.") - return nil - } - - chosen, cancelled, err := tui.SelectImports(found) - if err != nil { - return err - } - if cancelled || len(chosen) == 0 { - fmt.Println("Nothing imported.") - return nil - } - - for _, w := range chosen { - // Init with no pack registers the project and writes Crabby's - // metadata without touching the repo's existing CLAUDE.md. - res, err := initcmd.Init(w.Path, nil) - if err != nil { - fmt.Printf(" ✗ %s (%v)\n", w.Name, err) - continue - } - fmt.Printf(" + %s\n", res.Project.Name) - } - fmt.Printf("\nImported %s. Run `crabby` to see them.\n", pluralWord(len(chosen), "project")) - return nil - }, - } -} - -// pluralWord formats a count with its noun for import summaries. -func pluralWord(n int, noun string) string { - if n == 1 { - return fmt.Sprintf("1 %s", noun) - } - return fmt.Sprintf("%d %ss", n, noun) -} - -// 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 cmd.Help() - }, - } - cmd.AddCommand( - newTaskCreateCmd(), - newTaskListCmd(), - newTaskDeleteCmd(), - newTaskRestartCmd(), - ) - return cmd -} - -func newTaskCreateCmd() *cobra.Command { - return &cobra.Command{ - 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 { - return err - } - t := tmux.New(cfg.TmuxBinary) - if !t.Available() { - return errTmuxMissing(cfg.TmuxBinary) - } - p, err := resolveProject("") - if err != nil { - return err - } - tk, err := createTask(p, args[0]) - if err != nil { - return err - } - return openTask(cfg, t, p, tk) - }, - } -} - -func newTaskListCmd() *cobra.Command { - return &cobra.Command{ - 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 { - return err - } - t := tmux.New(cfg.TmuxBinary) - if !t.Available() { - return errTmuxMissing(cfg.TmuxBinary) - } - 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) - } - 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 [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(arg(args, 0)) - if err != nil { - return err - } - - if !yes { - fmt.Printf("Remove %q from Crabby? Your files are kept. (y/N): ", p.Name) - line, _ := bufio.NewReader(os.Stdin).ReadString('\n') - if a := strings.TrimSpace(strings.ToLower(line)); a != "y" && a != "yes" { - fmt.Println("Cancelled.") - return nil - } - } - - // Stop every task's session first, then forget the workspace. - t := tmux.New(cfg.TmuxBinary) - 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 - } - fmt.Printf("Removed %q. Files left untouched — run `crabby init` there to re-add it.\n", p.Name) - return nil - }, - } - cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip the confirmation prompt") - return cmd -} - func newDoctorCmd() *cobra.Command { return &cobra.Command{ Use: "doctor", @@ -644,41 +121,3 @@ func newDoctorCmd() *cobra.Command { func errTmuxMissing(binary string) error { return fmt.Errorf("Crabby needs tmux, which was not found (looked for %q).\n\nInstall it with: sudo apt install tmux", binary) } - -func lookClaude(cfg config.Config) (string, error) { - path, err := exec.LookPath(cfg.ClaudeCommand) - if err != nil { - return "", fmt.Errorf("Claude Code not found (looked for %q).\n\nInstall it from https://claude.com/claude-code, or set claude_command in ~/.config/crabby/config.yaml", cfg.ClaudeCommand) - } - return path, nil -} - -// 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("workspace %q is not registered.\n\nRun `crabby init` inside it first.", name) - } - return p, err - } - - cwd, err := os.Getwd() - if err != nil { - return project.Project{}, err - } - p, err := project.FindByPath(cwd) - if err == project.ErrNotFound { - 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/tui/app.go b/internal/tui/app.go new file mode 100644 index 0000000..6172fbc --- /dev/null +++ b/internal/tui/app.go @@ -0,0 +1,342 @@ +// Package tui implements Crabby's terminal application. +// +// From v0.8 Crabby is TUI-first: `crabby` opens a persistent, multi-panel home +// screen and everything — creating workspaces and tasks, attaching to Claude, +// managing packs, diagnostics — happens inside it. The shell is no longer part +// of the normal workflow. A single Bubble Tea program owns the whole session; +// attaching to a Claude session is done through tea.ExecProcess so the screen is +// released and restored around it, without ever leaving Crabby. +package tui + +import ( + "fmt" + "os" + "os/exec" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/marioolf/crabby/internal/config" + "github.com/marioolf/crabby/internal/doctor" + "github.com/marioolf/crabby/internal/insights" + "github.com/marioolf/crabby/internal/pack" + "github.com/marioolf/crabby/internal/project" + "github.com/marioolf/crabby/internal/tmux" +) + +const refreshEach = time.Second + +// screen is which view the app is currently showing. +type screen int + +const ( + scrDashboard screen = iota + scrNewWorkspace + scrNewTask + scrPacks + scrSettings + scrHelp +) + +// pane identifies the focused column of the dashboard. +type pane int + +const ( + paneWorkspaces pane = iota + paneTasks +) + +// model is the whole application state. A single struct keeps the shared +// services (tmux, insights, config) and the transitions between screens in one +// place, which suits an app where every screen acts on the same workspaces. +type model struct { + // Services, shared by every screen. + cfg config.Config + tmux tmux.Client + insights *insights.Collector + + width, height int + screen screen + + // --- Dashboard --------------------------------------------------------- + rows []wsRow + wsCursor int + taskCursor int + focus pane + confirm confirmKind + // Live activity tracking, persisted across refreshes so a session that + // starts working rings the bell exactly once. + lastActivity map[string]time.Time + working map[string]bool + firstLoad bool + ringBell bool + + // --- New-workspace flow ------------------------------------------------ + wsStep wsStep + wsDir string + packList []pack.Pack + packCursor int + + // --- New-task flow ----------------------------------------------------- + formProj project.Project + + // --- Packs screen ------------------------------------------------------ + packs []pack.Pack + packsCursor int + packAction packAction + + // input is the shared single-line editor, reused by the flows above. It is + // reset whenever a flow begins. + input textInput + + // --- Settings screen --------------------------------------------------- + settingsCursor int + settingsPane settingsPane + checks []doctor.Check + + // notice is a transient message shown in the status bar (errors, results). + notice string + noticeErr bool +} + +// Run starts the persistent application and blocks until the user quits. The +// collector is reused across the whole session so its incremental transcript +// cache survives returning from a Claude session. +func Run(cfg config.Config, t tmux.Client, coll *insights.Collector) error { + if coll == nil { + coll = insights.New() + } + m := model{ + cfg: cfg, + tmux: t, + insights: coll, + lastActivity: map[string]time.Time{}, + working: map[string]bool{}, + firstLoad: true, + } + m.refresh() + _, err := tea.NewProgram(m, tea.WithAltScreen()).Run() + return err +} + +// --- Messages -------------------------------------------------------------- + +type tickMsg time.Time + +// openTaskMsg asks the app to attach to a task, creating its session first if +// needed. Every "open a Claude session" path funnels through here. +type openTaskMsg struct { + proj project.Project + task project.Task +} + +// execDoneMsg is delivered after an external program (a Claude attach, or an +// editor) finishes and the screen has been restored. +type execDoneMsg struct{ err error } + +// noticeMsg sets the status-bar message. +type noticeMsg struct { + text string + isErr bool +} + +func tick() tea.Cmd { + return tea.Tick(refreshEach, func(t time.Time) tea.Msg { return tickMsg(t) }) +} + +// bellCmd rings the terminal bell out-of-band (BEL does not disturb the screen). +func bellCmd() tea.Msg { + fmt.Fprint(os.Stderr, "\a") + return nil +} + +func (m model) Init() tea.Cmd { return tick() } + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + return m, nil + + case tickMsg: + m.refresh() + if m.screen == scrDashboard && m.ringBell { + return m, tea.Batch(tick(), bellCmd) + } + return m, tick() + + case noticeMsg: + m.notice, m.noticeErr = msg.text, msg.isErr + return m, nil + + case openTaskMsg: + return m.attach(msg.proj, msg.task) + + case editMsg: + return m.launchEditor(msg.dir) + + case execDoneMsg: + m.screen = scrDashboard + m.refresh() + if msg.err != nil { + m.notice, m.noticeErr = "session ended with an error: "+msg.err.Error(), true + } + return m, nil + + case editDoneMsg: + // Editing a pack returns to the packs list, not the dashboard. + m.screen = scrPacks + m.packs, _ = pack.List() + m.clampPacksCursor() + if msg.err != nil { + m.notice, m.noticeErr = "editor exited with an error: "+msg.err.Error(), true + } + return m, nil + + case tea.KeyMsg: + if msg.Type == tea.KeyCtrlC { + return m, tea.Quit + } + return m.handleKey(msg) + } + return m, nil +} + +// handleKey routes a keypress to the active screen. +func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch m.screen { + case scrNewWorkspace: + return m.updateNewWorkspace(msg) + case scrNewTask: + return m.updateNewTask(msg) + case scrPacks: + return m.updatePacks(msg) + case scrSettings: + return m.updateSettings(msg) + case scrHelp: + return m.updateHelp(msg) + default: + return m.updateDashboard(msg) + } +} + +func (m model) View() string { + switch m.screen { + case scrNewWorkspace: + return m.viewNewWorkspace() + case scrNewTask: + return m.viewNewTask() + case scrPacks: + return m.viewPacks() + case scrSettings: + return m.viewSettings() + case scrHelp: + return m.viewHelp() + default: + return m.viewDashboard() + } +} + +// attach opens a task's Claude session. Quick tmux calls (create, configure, +// rename) run inline; the blocking attach is handed to tea.ExecProcess so Bubble +// Tea releases the screen for Claude and restores the dashboard afterwards. +func (m model) attach(p project.Project, task project.Task) (tea.Model, tea.Cmd) { + if !m.tmux.HasSession(task.Session) { + if _, err := exec.LookPath(m.cfg.ClaudeCommand); err != nil { + m.notice = fmt.Sprintf("Claude Code not found on PATH (looked for %q)", m.cfg.ClaudeCommand) + m.noticeErr = true + return m, nil + } + if err := m.tmux.NewSession(task.Session, p.Path, m.cfg.ClaudeCommand); err != nil { + m.notice = fmt.Sprintf("could not start task %q: %v", task.Name, err) + m.noticeErr = true + return m, nil + } + _ = m.tmux.RenameWindow(task.Session, windowLabel(p, task)) + } + // Best-effort branding + the prefix-free return key; a failure here only + // means a plain status bar, so never block the attach on it. + _ = m.tmux.Configure(m.cfg.DetachKey) + m.notice, m.noticeErr = "", false + cmd := m.tmux.AttachCmd(task.Session) + return m, tea.ExecProcess(cmd, func(err error) tea.Msg { return execDoneMsg{err} }) +} + +// 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 +} + +// noticeLine renders the status-bar message, coloured by whether it is an error. +func (m model) noticeLine() string { + if m.notice == "" { + return "" + } + if m.noticeErr { + return errorStyle.Render(m.notice) + } + return okStyle.Render(m.notice) +} + +// goDashboard returns to the home screen, clearing any transient state. +func (m *model) goDashboard() { + m.screen = scrDashboard + m.confirm = confirmNone +} + +// frame is the shared chrome for every non-dashboard screen: the identity +// banner, a title, the screen's body, and a footer of keys — so every screen +// reads as the same application. The status notice, if any, sits at the bottom. +func (m model) frame(title, body, footer string) string { + var b strings.Builder + b.WriteString(center(m.width, Banner())) + b.WriteString("\n\n") + b.WriteString(center(m.width, divider())) + b.WriteString("\n\n") + if title != "" { + b.WriteString(center(m.width, titleStyle.Render(title))) + b.WriteString("\n\n") + } + b.WriteString(blockCenter(m.width, body)) + b.WriteString("\n\n") + b.WriteString(center(m.width, divider())) + b.WriteString("\n") + b.WriteString(center(m.width, footer)) + if n := m.noticeLine(); n != "" { + b.WriteString("\n\n" + center(m.width, n)) + } + return b.String() +} + +// blockCenter centres a multi-line block as a unit: every line gets the same +// left margin, so the block sits in the middle while its internal left-aligned +// columns stay aligned (unlike per-line centring, which lets each line drift). +func blockCenter(width int, s string) string { + if width <= 0 { + return s + } + lines := strings.Split(s, "\n") + blockWidth := 0 + for _, l := range lines { + if w := lipgloss.Width(l); w > blockWidth { + blockWidth = w + } + } + margin := (width - blockWidth) / 2 + if margin <= 0 { + return s + } + pad := strings.Repeat(" ", margin) + for i, l := range lines { + if l != "" { + lines[i] = pad + l + } + } + return strings.Join(lines, "\n") +} diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go new file mode 100644 index 0000000..3033cc6 --- /dev/null +++ b/internal/tui/dashboard.go @@ -0,0 +1,537 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/marioolf/crabby/internal/insights" + "github.com/marioolf/crabby/internal/project" + "github.com/marioolf/crabby/internal/session" +) + +// taskRow is one task's live state within a workspace. +type taskRow struct { + task project.Task + state session.State + working bool + uptime time.Duration +} + +// wsRow is a workspace and its tasks, with the workspace-level insight (model, +// tokens, activity) that is shared across tasks in the same directory. +type wsRow struct { + proj project.Project + branch string + insight insights.Insight + tasks []taskRow + best int // best (lowest) task rank, for ordering workspaces +} + +// repr picks a representative state and working flag for the workspace, used for +// its dot in the list: the best task's state, working if any task is working. +func (r wsRow) repr() (session.State, bool) { + if len(r.tasks) == 0 { + return session.Stopped, false + } + working := false + for _, t := range r.tasks { + if t.working { + working = true + } + } + return r.tasks[0].state, working +} + +type confirmKind int + +const ( + confirmNone confirmKind = iota + confirmStop + confirmRemoveTask + confirmRemoveWorkspace +) + +// refresh reloads workspaces and their live tmux state in a single call, sorts +// them by importance, and flags a bell when a background task starts working. +func (m *model) refresh() { + projects, err := project.Load() + if err != nil { + projects = nil + } + sessions := m.tmux.ListSessions() + + rising := false + rows := make([]wsRow, 0, len(projects)) + for _, p := range projects { + r := wsRow{proj: p, branch: p.Branch(), insight: m.insights.For(p.Path), best: 99} + for _, tk := range p.Tasks { + info, present := sessions[tk.Session] + tr := taskRow{task: tk, state: session.Classify(present, info.Attached)} + if present { + prev, seen := m.lastActivity[tk.Session] + tr.working = seen && info.Activity.After(prev) + if tr.working && !m.working[tk.Session] && !m.firstLoad { + rising = true + } + if !info.Created.IsZero() { + tr.uptime = time.Since(info.Created) + } + m.lastActivity[tk.Session] = info.Activity + m.working[tk.Session] = tr.working + } else { + delete(m.lastActivity, tk.Session) + delete(m.working, tk.Session) + } + if rk := session.Rank(tr.state); rk < r.best { + r.best = rk + } + r.tasks = append(r.tasks, tr) + } + sort.SliceStable(r.tasks, func(i, j int) bool { + ri, rj := session.Rank(r.tasks[i].state), session.Rank(r.tasks[j].state) + if ri != rj { + return ri < rj + } + return r.tasks[i].task.Name < r.tasks[j].task.Name + }) + rows = append(rows, r) + } + sort.SliceStable(rows, func(i, j int) bool { + if rows[i].best != rows[j].best { + return rows[i].best < rows[j].best + } + return rows[i].proj.Name < rows[j].proj.Name + }) + + m.rows = rows + m.ringBell = rising + m.firstLoad = false + m.clampCursors() +} + +// clampCursors keeps both cursors within range after a refresh. +func (m *model) clampCursors() { + if m.wsCursor >= len(m.rows) { + m.wsCursor = len(m.rows) - 1 + } + if m.wsCursor < 0 { + m.wsCursor = 0 + } + if ws, ok := m.selectedWs(); ok { + if m.taskCursor >= len(ws.tasks) { + m.taskCursor = len(ws.tasks) - 1 + } + if m.taskCursor < 0 { + m.taskCursor = 0 + } + } else { + m.taskCursor = 0 + } +} + +func (m model) selectedWs() (wsRow, bool) { + if m.wsCursor < 0 || m.wsCursor >= len(m.rows) { + return wsRow{}, false + } + return m.rows[m.wsCursor], true +} + +func (m model) selectedTask() (wsRow, taskRow, bool) { + ws, ok := m.selectedWs() + if !ok || m.taskCursor < 0 || m.taskCursor >= len(ws.tasks) { + return wsRow{}, taskRow{}, false + } + return ws, ws.tasks[m.taskCursor], true +} + +// --- Update ---------------------------------------------------------------- + +func (m model) updateDashboard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.confirm != confirmNone { + return m.handleConfirm(msg) + } + + switch msg.String() { + case "q": + return m, tea.Quit + case "esc": + if m.notice != "" { + m.notice = "" + } else if m.focus == paneTasks { + m.focus = paneWorkspaces + } + case "up", "k": + m.moveUp() + case "down", "j": + m.moveDown() + case "tab", "right", "l": + m.focus = paneTasks + case "shift+tab", "left", "h": + m.focus = paneWorkspaces + case "enter": + if ws, tr, ok := m.selectedTask(); ok { + proj, task := ws.proj, tr.task + return m, func() tea.Msg { return openTaskMsg{proj, task} } + } + case "n": + return m.startNewWorkspace() + case "t": + if ws, ok := m.selectedWs(); ok { + return m.startNewTask(ws.proj) + } + case "x": + if _, tr, ok := m.selectedTask(); ok && tr.state != session.Stopped { + m.confirm = confirmStop + } + case "d": + if ws, _, ok := m.selectedTask(); ok { + if m.focus == paneTasks && len(ws.tasks) > 1 { + m.confirm = confirmRemoveTask + } else { + m.confirm = confirmRemoveWorkspace + } + } + case "r": + m.refresh() + case "p": + return m.startPacks() + case "s": + return m.startSettings() + case "?": + m.screen = scrHelp + } + return m, nil +} + +func (m *model) moveUp() { + if m.focus == paneWorkspaces { + if m.wsCursor > 0 { + m.wsCursor-- + m.taskCursor = 0 + } + return + } + if m.taskCursor > 0 { + m.taskCursor-- + } +} + +func (m *model) moveDown() { + if m.focus == paneWorkspaces { + if m.wsCursor < len(m.rows)-1 { + m.wsCursor++ + m.taskCursor = 0 + } + return + } + if ws, ok := m.selectedWs(); ok && m.taskCursor < len(ws.tasks)-1 { + m.taskCursor++ + } +} + +func (m model) handleConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if s := msg.String(); s == "y" || s == "Y" { + if ws, tr, ok := m.selectedTask(); ok { + switch m.confirm { + case confirmStop: + _ = m.tmux.KillSession(tr.task.Session) + case confirmRemoveTask: + _ = m.tmux.KillSession(tr.task.Session) + _ = project.RemoveTask(ws.proj.Name, tr.task.Name) + case confirmRemoveWorkspace: + for _, t := range ws.proj.Tasks { + _ = m.tmux.KillSession(t.Session) + } + _ = project.Remove(ws.proj.Name) + } + m.refresh() + } + } + m.confirm = confirmNone + return m, nil +} + +// --- View ------------------------------------------------------------------ + +func (m model) viewDashboard() string { + var b strings.Builder + b.WriteString(center(m.width, Banner())) + b.WriteString("\n\n") + + if len(m.rows) == 0 { + b.WriteString(center(m.width, divider()) + "\n\n") + b.WriteString(center(m.width, "No workspaces yet.") + "\n\n") + b.WriteString(center(m.width, metaStyle.Render("Press n to create one — point it at any project folder.")) + "\n\n") + b.WriteString(center(m.width, divider()) + "\n") + b.WriteString(center(m.width, actionKey("n", "new workspace")+" "+actionKey("p", "packs")+" "+actionKey("?", "help")+" "+actionKey("q", "quit"))) + if n := m.noticeLine(); n != "" { + b.WriteString("\n\n" + center(m.width, n)) + } + return b.String() + } + + leftW, midW, rightW := m.paneWidths() + h := m.paneHeight() + cols := lipgloss.JoinHorizontal(lipgloss.Top, + m.workspaceColumn(leftW, h), " ", + m.taskColumn(midW, h), " ", + m.detailColumn(rightW, h), + ) + b.WriteString(center(m.width, cols)) + b.WriteString("\n") + if s := m.summaryLine(); s != "" { + b.WriteString(center(m.width, s) + "\n") + } + + switch m.confirm { + case confirmStop: + _, tr, _ := m.selectedTask() + b.WriteString(center(m.width, confirmStyle.Render(fmt.Sprintf("Stop %q? (y/n)", tr.task.Name)))) + return b.String() + case confirmRemoveTask: + ws, tr, _ := m.selectedTask() + b.WriteString(center(m.width, confirmStyle.Render(fmt.Sprintf("Remove task %q from %q? (y/n)", tr.task.Name, ws.proj.Name)))) + return b.String() + case confirmRemoveWorkspace: + ws, _ := m.selectedWs() + b.WriteString(center(m.width, confirmStyle.Render(fmt.Sprintf("Remove workspace %q from Crabby? Files are kept. (y/n)", ws.proj.Name)))) + return b.String() + } + + b.WriteString(center(m.width, m.dashboardFooter())) + if n := m.noticeLine(); n != "" { + b.WriteString("\n" + center(m.width, n)) + } + return b.String() +} + +// paneWidths splits the terminal width into the three content columns. +func (m model) paneWidths() (int, int, int) { + w := m.width + if w <= 0 { + w = 80 + } + budget := w - 2 - 12 - 2 // outer margin, 3×(border+padding), 2 gaps + if budget < 36 { + budget = 36 + } + left := budget * 26 / 100 + mid := budget * 32 / 100 + right := budget - left - mid + if left < 12 { + left = 12 + } + if mid < 14 { + mid = 14 + } + if right < 18 { + right = 18 + } + return left, mid, right +} + +// paneHeight is how tall the three columns may be, given the banner and footer. +func (m model) paneHeight() int { + h := m.height + if h <= 0 { + h = 24 + } + avail := h - lipgloss.Height(Banner()) - 8 + if avail < 6 { + avail = 6 + } + return avail +} + +// box wraps inner content in a rounded border of the given content size, the +// border accented when the pane holds focus. +func box(inner string, w, h int, focused bool) string { + s := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Padding(0, 1). + Width(w).Height(h) + if focused { + return s.BorderForeground(accent).Render(inner) + } + return s.BorderForeground(lipgloss.Color("240")).Render(inner) +} + +// paneTitle renders a column's heading, accented when the column is focused. +func paneTitle(label string, focused bool) string { + if focused { + return paneFocusedTitle.Render(label) + } + return paneTitleStyle.Render(label) +} + +// window returns the [start,end) slice of a list of n items that keeps cursor +// visible within a window of the given size. +func window(n, cursor, size int) (int, int) { + if size < 1 { + size = 1 + } + if n <= size { + return 0, n + } + start := cursor - size/2 + if start < 0 { + start = 0 + } + if start+size > n { + start = n - size + } + return start, start + size +} + +func (m model) workspaceColumn(w, h int) string { + focused := m.focus == paneWorkspaces + var lines []string + lines = append(lines, paneTitle("Workspaces", focused), "") + budget := h - 2 + start, end := window(len(m.rows), m.wsCursor, budget) + for i := start; i < end; i++ { + r := m.rows[i] + selected := i == m.wsCursor + st, working := r.repr() + dot, dotStyle := glyph(st, working) + name := r.proj.Name + nameR := nameStyle.Render(name) + if selected { + nameR = selNameStyle.Render(name) + } + count := "" + if len(r.tasks) > 1 { + count = metaStyle.Render(fmt.Sprintf(" (%d)", len(r.tasks))) + } + gutter := " " + if selected && focused { + gutter = barStyle.Render("▌ ") + } + lines = append(lines, gutter+dotStyle.Render(dot)+" "+nameR+count) + } + return box(strings.Join(lines, "\n"), w, h, focused) +} + +func (m model) taskColumn(w, h int) string { + focused := m.focus == paneTasks + ws, ok := m.selectedWs() + var lines []string + lines = append(lines, paneTitle("Tasks", focused), "") + if !ok { + lines = append(lines, metaStyle.Render("—")) + return box(strings.Join(lines, "\n"), w, h, focused) + } + budget := h - 2 + start, end := window(len(ws.tasks), m.taskCursor, budget) + for i := start; i < end; i++ { + tr := ws.tasks[i] + selected := i == m.taskCursor + dot, dotStyle := glyph(tr.state, tr.working) + nameR := nameStyle.Render(tr.task.Name) + if selected { + nameR = selNameStyle.Render(tr.task.Name) + } + gutter := " " + if selected && focused { + gutter = barStyle.Render("▌ ") + } + lines = append(lines, gutter+dotStyle.Render(dot)+" "+nameR) + } + return box(strings.Join(lines, "\n"), w, h, focused) +} + +func (m model) detailColumn(w, h int) string { + var lines []string + lines = append(lines, paneTitle("Details", false), "") + ws, tr, ok := m.selectedTask() + if !ok { + lines = append(lines, metaStyle.Render("—")) + return box(strings.Join(lines, "\n"), w, h, false) + } + ins := ws.insight + add := func(label, value string) { + if value == "" { + return + } + lines = append(lines, metaStyle.Render(fmt.Sprintf("%-9s", label))+value) + } + add("status", stateLabel(tr.state, tr.working)) + add("task", nameStyle.Render(tr.task.Name)) + add("branch", ws.branch) + if ins.Model != "" { + add("model", ins.Model) + } + if ins.SessionTokens > 0 { + add("tokens", formatTokens(ins.SessionTokens)+" session") + } + if ins.TodayTokens > 0 { + add("today", formatTokens(ins.TodayTokens)+" tokens") + } + if tr.uptime > 0 { + add("uptime", formatDuration(tr.uptime)) + } + if tr.working { + add("activity", workingActivity(ins)) + } + if !ins.LastActivity.IsZero() { + add("last", formatAgo(time.Since(ins.LastActivity))) + } + add("path", metaStyle.Render(ws.proj.Path)) + return box(strings.Join(lines, "\n"), w, h, false) +} + +// summaryLine is the global usage bar: workspace and task counts plus tokens. +func (m model) summaryLine() string { + if len(m.rows) == 0 { + return "" + } + var active, stopped, working, tokens int + for _, r := range m.rows { + tokens += r.insight.TodayTokens + for _, t := range r.tasks { + if t.state == session.Stopped { + stopped++ + } else { + active++ + } + if t.working { + working++ + } + } + } + parts := []string{plural(len(m.rows), "workspace")} + if active > 0 { + parts = append(parts, fmt.Sprintf("%d active", active)) + } + if stopped > 0 { + parts = append(parts, fmt.Sprintf("%d stopped", stopped)) + } + if working > 0 { + parts = append(parts, fmt.Sprintf("%d working", working)) + } + if tokens > 0 { + parts = append(parts, formatTokens(tokens)+" tokens today") + } + return summaryStyle.Render(strings.Join(parts, " · ")) +} + +// dashboardFooter shows the actions and the on-screen hint for returning from a +// Claude session — the single, discoverable answer to "how do I get back?". +func (m model) dashboardFooter() string { + line1 := strings.Join([]string{ + actionKey("↑↓", "move"), actionKey("tab", "pane"), + actionKey("enter", "open"), actionKey("n", "new"), + actionKey("t", "task"), actionKey("x", "stop"), actionKey("d", "remove"), + }, " ") + line2 := strings.Join([]string{ + actionKey("p", "packs"), actionKey("s", "settings"), + actionKey("?", "help"), actionKey("q", "quit"), + helpStyle.Render("· " + m.cfg.DetachKey + " returns from a session"), + }, " ") + return line1 + "\n" + line2 +} diff --git a/internal/tui/forms.go b/internal/tui/forms.go new file mode 100644 index 0000000..db97335 --- /dev/null +++ b/internal/tui/forms.go @@ -0,0 +1,239 @@ +package tui + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/marioolf/crabby/internal/initcmd" + "github.com/marioolf/crabby/internal/pack" + "github.com/marioolf/crabby/internal/project" + "github.com/marioolf/crabby/internal/session" +) + +// wsStep is which step of the new-workspace flow is on screen. +type wsStep int + +const ( + wsStepDir wsStep = iota + wsStepPack +) + +// --- New workspace --------------------------------------------------------- + +func (m model) startNewWorkspace() (tea.Model, tea.Cmd) { + cwd, _ := os.Getwd() + m.screen = scrNewWorkspace + m.wsStep = wsStepDir + m.input = newTextInput(cwd) + m.notice = "" + return m, nil +} + +func (m model) updateNewWorkspace(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch m.wsStep { + case wsStepDir: + switch msg.String() { + case "esc": + m.goDashboard() + case "enter": + dir := strings.TrimSpace(m.input.Value()) + if dir == "" { + return m, nil + } + m.wsDir = expandHome(dir) + packs, _ := pack.List() + switch len(packs) { + case 0: + return m.createWorkspace(nil) + case 1: + p := packs[0] + return m.createWorkspace(&p) + default: + m.packList = packs + m.packCursor = 0 + m.wsStep = wsStepPack + } + default: + m.input = m.input.update(msg) + } + case wsStepPack: + switch msg.String() { + case "esc": + m.wsStep = wsStepDir + case "up", "k": + if m.packCursor > 0 { + m.packCursor-- + } + case "down", "j": + if m.packCursor < len(m.packList) { // last index is the "No pack" choice + m.packCursor++ + } + case "enter": + if m.packCursor >= len(m.packList) { + return m.createWorkspace(nil) + } + p := m.packList[m.packCursor] + return m.createWorkspace(&p) + } + } + return m, nil +} + +// createWorkspace runs init for the captured directory and pack, returning to +// the dashboard with the new workspace selected. On failure it keeps the flow +// open so the path can be corrected. +func (m model) createWorkspace(p *pack.Pack) (tea.Model, tea.Cmd) { + res, err := initcmd.Init(m.wsDir, p) + if err != nil { + m.notice, m.noticeErr = err.Error(), true + m.wsStep = wsStepDir + return m, nil + } + m.goDashboard() + m.refresh() + m.selectByName(res.Project.Name) + label := res.Project.Name + if res.PackID != "" { + label += fmt.Sprintf(" (pack %q)", res.PackID) + } + m.notice, m.noticeErr = "Created workspace "+label, false + return m, nil +} + +func (m model) viewNewWorkspace() string { + switch m.wsStep { + case wsStepPack: + var b strings.Builder + b.WriteString(metaStyle.Render(m.wsDir) + "\n\n") + b.WriteString("Choose a pack:\n\n") + for i, p := range m.packList { + b.WriteString(m.selectLine(i == m.packCursor, p.Name)) + if p.Description != "" { + b.WriteString(" " + metaStyle.Render(p.Description)) + } + b.WriteString("\n") + } + b.WriteString(m.selectLine(m.packCursor >= len(m.packList), "No pack — default CLAUDE.md")) + footer := actionKey("↑↓", "move") + " " + actionKey("enter", "create") + " " + actionKey("esc", "back") + return m.frame("New workspace", b.String(), footer) + default: + body := "Directory to initialize:\n\n " + m.input.view("path to a project folder") + footer := actionKey("enter", "continue") + " " + actionKey("esc", "cancel") + return m.frame("New workspace", body, footer) + } +} + +// selectLine renders one selectable row with the accent bar when chosen. +func (m model) selectLine(selected bool, label string) string { + if selected { + return barStyle.Render("▌ ") + selNameStyle.Render(label) + } + return " " + nameStyle.Render(label) +} + +// --- New task -------------------------------------------------------------- + +func (m model) startNewTask(p project.Project) (tea.Model, tea.Cmd) { + m.screen = scrNewTask + m.formProj = p + m.input = newTextInput("") + m.notice = "" + return m, nil +} + +func (m model) updateNewTask(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.goDashboard() + case "enter": + name := strings.TrimSpace(m.input.Value()) + if name == "" { + return m, nil + } + tk, err := createTask(m.formProj, name) + if err != nil { + m.notice, m.noticeErr = err.Error(), true + return m, nil + } + proj := m.formProj + m.goDashboard() + m.refresh() + m.selectByName(proj.Name) + // Open the new task straight away — a parallel session without leaving Crabby. + return m, func() tea.Msg { return openTaskMsg{proj, tk} } + default: + m.input = m.input.update(msg) + } + return m, nil +} + +func (m model) viewNewTask() string { + body := fmt.Sprintf("New task in %q.\n\nTask name:\n\n ", m.formProj.Name) + m.input.view("e.g. tests, docs, refactor") + footer := actionKey("enter", "create & open") + " " + actionKey("esc", "cancel") + return m.frame("New task", body, footer) +} + +// --- Shared helpers -------------------------------------------------------- + +// createTask validates a name, builds 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 +} + +// selectByName moves the workspace cursor onto the named workspace, if present. +func (m *model) selectByName(name string) { + for i, r := range m.rows { + if r.proj.Name == name { + m.wsCursor = i + m.taskCursor = 0 + return + } + } +} + +// expandHome turns a leading ~ into the user's home directory and makes the path +// absolute, so a relative path typed on the dashboard resolves as expected. +func expandHome(path string) string { + if path == "~" || strings.HasPrefix(path, "~/") { + if home, err := os.UserHomeDir(); err == nil { + path = filepath.Join(home, strings.TrimPrefix(path, "~")) + } + } + if abs, err := filepath.Abs(path); err == nil { + return abs + } + return path +} diff --git a/internal/tui/help.go b/internal/tui/help.go new file mode 100644 index 0000000..27b4136 --- /dev/null +++ b/internal/tui/help.go @@ -0,0 +1,73 @@ +package tui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +func (m model) updateHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc", "q", "?", "enter": + m.goDashboard() + } + return m, nil +} + +// helpEntry is one documented key and what it does. +type helpEntry struct{ key, desc string } + +// helpSection groups related keys under a heading. +type helpSection struct { + title string + entries []helpEntry +} + +var helpSections = []helpSection{ + {"Navigation", []helpEntry{ + {"↑ ↓ / j k", "move within the focused column"}, + {"tab / →", "focus the Tasks column"}, + {"shift+tab / ←", "focus the Workspaces column"}, + {"esc", "step back / clear a message"}, + }}, + {"Workspaces & tasks", []helpEntry{ + {"enter", "open the selected task in Claude"}, + {"n", "new workspace (point it at a folder)"}, + {"t", "new task in the selected workspace"}, + {"x", "stop the selected task's session"}, + {"d", "remove the selected task or workspace"}, + {"r", "refresh now"}, + }}, + {"Elsewhere", []helpEntry{ + {"p", "manage packs (new / duplicate / edit / delete)"}, + {"s", "settings — packs, diagnostics, about"}, + {"?", "this help"}, + {"q", "quit Crabby"}, + }}, +} + +func (m model) viewHelp() string { + var b strings.Builder + // Lay keys out in an aligned two-column block per section. + width := 0 + for _, s := range helpSections { + for _, e := range s.entries { + if len(e.key) > width { + width = len(e.key) + } + } + } + for i, s := range helpSections { + if i > 0 { + b.WriteString("\n") + } + b.WriteString(headerStyle.Render(s.title) + "\n") + for _, e := range s.entries { + pad := strings.Repeat(" ", width-len(e.key)+2) + b.WriteString(" " + keyStyle.Render(e.key) + pad + helpStyle.Render(e.desc) + "\n") + } + } + body := strings.TrimRight(b.String(), "\n") + "\n\n" + + metaStyle.Render("Inside a Claude session, press "+m.cfg.DetachKey+" to return here.") + return m.frame("Help", body, actionKey("esc", "back")) +} diff --git a/internal/tui/input.go b/internal/tui/input.go new file mode 100644 index 0000000..076d87c --- /dev/null +++ b/internal/tui/input.go @@ -0,0 +1,93 @@ +package tui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// textInput is a minimal single-line text editor. Crabby only ever asks for a +// short string (a task name, a path, a pack name), so a full input widget would +// be more dependency than the job needs — this covers typing, deleting, and +// moving the caret, and nothing more. +type textInput struct { + value string + cursor int // caret position, in runes, within value +} + +// newTextInput returns an input primed with an optional starting value, with +// the caret at the end so the user can keep typing straight away. +func newTextInput(value string) textInput { + return textInput{value: value, cursor: len([]rune(value))} +} + +// Value returns the current text. +func (t textInput) Value() string { return t.value } + +// update applies a key to the input and returns the new state. It only handles +// editing keys; the caller deals with Enter/Esc. +func (t textInput) update(msg tea.KeyMsg) textInput { + runes := []rune(t.value) + switch msg.Type { + case tea.KeyRunes, tea.KeySpace: + in := msg.Runes + if msg.Type == tea.KeySpace { + in = []rune{' '} + } + runes = append(runes[:t.cursor], append(append([]rune{}, in...), runes[t.cursor:]...)...) + t.cursor += len(in) + case tea.KeyBackspace: + if t.cursor > 0 { + runes = append(runes[:t.cursor-1], runes[t.cursor:]...) + t.cursor-- + } + case tea.KeyDelete: + if t.cursor < len(runes) { + runes = append(runes[:t.cursor], runes[t.cursor+1:]...) + } + case tea.KeyLeft: + if t.cursor > 0 { + t.cursor-- + } + case tea.KeyRight: + if t.cursor < len(runes) { + t.cursor++ + } + case tea.KeyHome, tea.KeyCtrlA: + t.cursor = 0 + case tea.KeyEnd, tea.KeyCtrlE: + t.cursor = len(runes) + case tea.KeyCtrlU: + runes = runes[:0] + t.cursor = 0 + } + t.value = string(runes) + return t +} + +var caretStyle = lipgloss.NewStyle().Reverse(true) + +// view renders the field with a block caret, so the user can see where they are +// typing. An empty field shows the placeholder faintly. +func (t textInput) view(placeholder string) string { + runes := []rune(t.value) + if len(runes) == 0 && placeholder != "" { + return caretStyle.Render(" ") + metaStyle.Render(placeholder) + } + var b strings.Builder + for i := 0; i <= len(runes); i++ { + if i == t.cursor { + if i < len(runes) { + b.WriteString(caretStyle.Render(string(runes[i]))) + } else { + b.WriteString(caretStyle.Render(" ")) + } + continue + } + if i < len(runes) { + b.WriteString(string(runes[i])) + } + } + return b.String() +} diff --git a/internal/tui/insights_render_test.go b/internal/tui/insights_render_test.go index 304ee23..fe68606 100644 --- a/internal/tui/insights_render_test.go +++ b/internal/tui/insights_render_test.go @@ -5,93 +5,107 @@ import ( "testing" "time" + "github.com/marioolf/crabby/internal/config" "github.com/marioolf/crabby/internal/insights" "github.com/marioolf/crabby/internal/project" "github.com/marioolf/crabby/internal/session" + "github.com/marioolf/crabby/internal/tmux" ) -func TestViewRendersInsightsAndSummary(t *testing.T) { - m := model{ - insights: insights.New(), - items: []item{ - { - 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", - SessionTokens: 132000, - TodayTokens: 132000, - LastActivity: time.Now().Add(-20 * time.Second), - Activity: insights.Editing, - Detail: "Edit", - }, +// dashModel builds a dashboard model wide enough that the detail column is not +// truncated, so substring assertions on rendered values are reliable. +func dashModel(rows []wsRow) model { + return model{ + cfg: config.Default(), + tmux: tmux.New("tmux"), + insights: insights.New(), + width: 200, + height: 50, + rows: rows, + lastActivity: map[string]time.Time{}, + working: map[string]bool{}, + } +} + +func TestDashboardRendersInsightsAndSummary(t *testing.T) { + rows := []wsRow{ + { + proj: project.Project{Name: "payments"}, + branch: "main", + insight: insights.Insight{ + Found: true, Model: "Opus 4.8", + SessionTokens: 132000, TodayTokens: 132000, + LastActivity: time.Now().Add(-20 * time.Second), + Activity: insights.Editing, Detail: "Edit", }, - {workspace: project.Project{Name: "docs"}, task: project.Task{Name: "main"}, kind: rowSingle, groupStart: true, state: session.Stopped}, + tasks: []taskRow{{ + task: project.Task{Name: "main"}, + state: session.Waiting, + working: true, + uptime: 2*time.Hour + 14*time.Minute, + }}, + }, + { + proj: project.Project{Name: "docs"}, + tasks: []taskRow{{task: project.Task{Name: "main"}, state: session.Stopped}}, }, } - - out := m.View() + out := dashModel(rows).View() for _, want := range []string{ "payments", + "docs", + "Opus 4.8", // model, in the detail column + "132k", // token figure "Editing files", // activity from the transcript tail "20s ago", // last activity - "main", // branch - "up 2h14m", // uptime - "Opus 4.8", // model - "132k tok", // session tokens - "Stopped", // the stopped project + "2h14m", // uptime "2 workspaces", // global summary "1 working", "132k tokens today", } { if !strings.Contains(out, want) { - t.Errorf("View() is missing %q", want) + t.Errorf("dashboard is missing %q", want) } } } -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}, +func TestDashboardTaskNavigation(t *testing.T) { + rows := []wsRow{{ + proj: project.Project{Name: "payments"}, + tasks: []taskRow{ + {task: project.Task{Name: "refactor"}, state: session.Waiting, working: true}, + {task: project.Task{Name: "tests"}, state: session.Stopped}, }, - } + }} + m := dashModel(rows) + m.focus = paneTasks - // The header is not selectable; navigation lands on task rows only. - if m.selectable(0) { - t.Error("header row should not be selectable") + if _, tr, ok := m.selectedTask(); !ok || tr.task.Name != "refactor" { + t.Fatalf("initial task = %+v (ok %v), want refactor", tr, ok) + } + m.moveDown() + if _, tr, _ := m.selectedTask(); tr.task.Name != "tests" { + t.Fatalf("after moveDown task = %q, want tests", tr.task.Name) } - if n := m.nextSelectable(0, 1); n != 1 { - t.Errorf("nextSelectable from header = %d, want 1", n) + m.moveDown() // clamp at the last task + if m.taskCursor != 1 { + t.Fatalf("taskCursor = %d, want it clamped at 1", m.taskCursor) } out := m.View() - for _, want := range []string{"payments", "refactor", "tests", "Working", "Stopped"} { + for _, want := range []string{"refactor", "tests", "Stopped"} { if !strings.Contains(out, want) { - t.Errorf("View() missing %q", want) + t.Errorf("dashboard is missing %q", want) } } } -func TestViewWithoutTranscriptFallsBack(t *testing.T) { - m := model{ - insights: insights.New(), - items: []item{ - {workspace: project.Project{Name: "plain"}, task: project.Task{Name: "main"}, kind: rowSingle, groupStart: true, state: session.Waiting}, - }, - } - out := m.View() +func TestDashboardWithoutTranscriptFallsBack(t *testing.T) { + rows := []wsRow{{ + proj: project.Project{Name: "plain"}, + tasks: []taskRow{{task: project.Task{Name: "main"}, state: session.Waiting}}, + }} + out := dashModel(rows).View() if strings.Contains(out, "tok") { t.Error("no token figure should appear without a transcript") } @@ -99,3 +113,20 @@ func TestViewWithoutTranscriptFallsBack(t *testing.T) { t.Error("an alive session with no transcript should read as Idle") } } + +func TestFormatHelpers(t *testing.T) { + cases := []struct{ got, want string }{ + {formatTokens(950), "950"}, + {formatTokens(12000), "12k"}, + {formatDuration(5 * time.Minute), "5m"}, + {formatDuration(2*time.Hour + 14*time.Minute), "2h14m"}, + {formatAgo(20 * time.Second), "20s ago"}, + {plural(1, "workspace"), "1 workspace"}, + {plural(3, "workspace"), "3 workspaces"}, + } + for _, c := range cases { + if c.got != c.want { + t.Errorf("got %q, want %q", c.got, c.want) + } + } +} diff --git a/internal/tui/packs.go b/internal/tui/packs.go new file mode 100644 index 0000000..db49c82 --- /dev/null +++ b/internal/tui/packs.go @@ -0,0 +1,207 @@ +package tui + +import ( + "fmt" + "os" + "os/exec" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/marioolf/crabby/internal/pack" +) + +// packAction is the sub-mode of the packs screen: browsing, naming a new or +// duplicated pack, or confirming a deletion. +type packAction int + +const ( + packActionNone packAction = iota + packActionCreate + packActionDuplicate + packActionConfirmDelete +) + +// editMsg asks the app to open a pack directory in the user's editor. +type editMsg struct{ dir string } + +// editDoneMsg is delivered after the editor exits, returning to the packs list. +type editDoneMsg struct{ err error } + +func (m model) startPacks() (tea.Model, tea.Cmd) { + m.screen = scrPacks + m.packs, _ = pack.List() + m.packsCursor = 0 + m.packAction = packActionNone + m.notice = "" + return m, nil +} + +func (m *model) clampPacksCursor() { + if m.packsCursor >= len(m.packs) { + m.packsCursor = len(m.packs) - 1 + } + if m.packsCursor < 0 { + m.packsCursor = 0 + } +} + +func (m model) updatePacks(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch m.packAction { + case packActionCreate, packActionDuplicate: + switch msg.String() { + case "esc": + m.packAction = packActionNone + case "enter": + return m.performPackName() + default: + m.input = m.input.update(msg) + } + return m, nil + case packActionConfirmDelete: + if s := msg.String(); s == "y" || s == "Y" { + return m.performPackDelete() + } + m.packAction = packActionNone + return m, nil + } + + // Browsing. + switch msg.String() { + case "esc", "q": + m.goDashboard() + case "up", "k": + if m.packsCursor > 0 { + m.packsCursor-- + } + case "down", "j": + if m.packsCursor < len(m.packs)-1 { + m.packsCursor++ + } + case "n": + m.packAction = packActionCreate + m.input = newTextInput("") + case "c": + if len(m.packs) > 0 { + m.packAction = packActionDuplicate + m.input = newTextInput(m.packs[m.packsCursor].Name + "-copy") + } + case "e": + if len(m.packs) > 0 { + dir := m.packs[m.packsCursor].Dir + return m, func() tea.Msg { return editMsg{dir} } + } + case "d": + if len(m.packs) > 0 { + m.packAction = packActionConfirmDelete + } + case "r": + m.packs, _ = pack.List() + m.clampPacksCursor() + } + return m, nil +} + +// performPackName creates or duplicates a pack from the typed name. +func (m model) performPackName() (tea.Model, tea.Cmd) { + name := strings.TrimSpace(m.input.Value()) + if name == "" { + return m, nil + } + var ( + p pack.Pack + err error + ) + switch m.packAction { + case packActionDuplicate: + p, err = pack.Duplicate(m.packs[m.packsCursor], name) + default: + p, err = pack.Create(name, "") + } + if err != nil { + m.notice, m.noticeErr = err.Error(), true + return m, nil + } + m.packAction = packActionNone + m.packs, _ = pack.List() + m.selectPackByName(p.Name) + m.notice, m.noticeErr = "Created pack "+p.Name+" — press e to edit it", false + return m, nil +} + +func (m model) performPackDelete() (tea.Model, tea.Cmd) { + p := m.packs[m.packsCursor] + if err := pack.Delete(p); err != nil { + m.notice, m.noticeErr = err.Error(), true + } else { + m.notice, m.noticeErr = "Deleted pack "+p.Name, false + } + m.packAction = packActionNone + m.packs, _ = pack.List() + m.clampPacksCursor() + return m, nil +} + +// launchEditor opens a directory in $EDITOR (falling back to vi) through +// tea.ExecProcess, so the screen is released for the editor and the packs list +// restored when it exits. +func (m model) launchEditor(dir string) (tea.Model, tea.Cmd) { + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vi" + } + // Honour editors configured with flags, e.g. EDITOR="code -w". + fields := strings.Fields(editor) + args := append(fields[1:], dir) + cmd := exec.Command(fields[0], args...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + return m, tea.ExecProcess(cmd, func(err error) tea.Msg { return editDoneMsg{err} }) +} + +func (m *model) selectPackByName(name string) { + for i, p := range m.packs { + if p.Name == name { + m.packsCursor = i + return + } + } +} + +func (m model) viewPacks() string { + var b strings.Builder + if len(m.packs) == 0 { + b.WriteString(metaStyle.Render("No packs yet.") + "\n\n") + } + for i, p := range m.packs { + b.WriteString(m.selectLine(i == m.packsCursor && m.packAction == packActionNone, p.Name)) + if p.Version != "" { + b.WriteString(metaStyle.Render(" v" + p.Version)) + } + b.WriteString("\n") + if p.Description != "" { + b.WriteString(" " + metaStyle.Render(p.Description) + "\n") + } + } + b.WriteString("\n" + metaStyle.Render("packs live in "+pack.DisplayDir())) + + var footer string + switch m.packAction { + case packActionCreate: + b.WriteString("\n\n" + "New pack name: " + m.input.view("letters, digits, - and _")) + footer = actionKey("enter", "create") + " " + actionKey("esc", "cancel") + case packActionDuplicate: + src := m.packs[m.packsCursor].Name + b.WriteString("\n\n" + fmt.Sprintf("Copy %q to: ", src) + m.input.view("new pack name")) + footer = actionKey("enter", "duplicate") + " " + actionKey("esc", "cancel") + case packActionConfirmDelete: + p := m.packs[m.packsCursor] + b.WriteString("\n\n" + confirmStyle.Render(fmt.Sprintf("Delete pack %q from disk? (y/n)", p.Name))) + footer = actionKey("y", "delete") + " " + actionKey("n", "cancel") + default: + footer = strings.Join([]string{ + actionKey("↑↓", "move"), actionKey("n", "new"), actionKey("c", "duplicate"), + actionKey("e", "edit"), actionKey("d", "delete"), actionKey("esc", "back"), + }, " ") + } + return m.frame("Packs", b.String(), footer) +} diff --git a/internal/tui/settings.go b/internal/tui/settings.go new file mode 100644 index 0000000..f65d661 --- /dev/null +++ b/internal/tui/settings.go @@ -0,0 +1,126 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/marioolf/crabby/internal/config" + "github.com/marioolf/crabby/internal/doctor" + "github.com/marioolf/crabby/internal/pack" + "github.com/marioolf/crabby/internal/version" +) + +// settingsPane is which part of Settings is on screen: the menu, or one of the +// leaf views it opens. +type settingsPane int + +const ( + settingsMenu settingsPane = iota + settingsDiagnostics + settingsAbout +) + +// settingsItems is the menu, intentionally small for now. +var settingsItems = []string{"Packs", "Diagnostics", "About"} + +func (m model) startSettings() (tea.Model, tea.Cmd) { + m.screen = scrSettings + m.settingsPane = settingsMenu + m.settingsCursor = 0 + m.notice = "" + return m, nil +} + +func (m model) updateSettings(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.settingsPane != settingsMenu { + // The leaf views only need a way back. + if s := msg.String(); s == "esc" || s == "q" || s == "enter" { + m.settingsPane = settingsMenu + } + return m, nil + } + + switch msg.String() { + case "esc", "q": + m.goDashboard() + case "up", "k": + if m.settingsCursor > 0 { + m.settingsCursor-- + } + case "down", "j": + if m.settingsCursor < len(settingsItems)-1 { + m.settingsCursor++ + } + case "enter": + switch settingsItems[m.settingsCursor] { + case "Packs": + return m.startPacks() + case "Diagnostics": + m.checks = doctor.Run(m.cfg) + m.settingsPane = settingsDiagnostics + case "About": + m.settingsPane = settingsAbout + } + } + return m, nil +} + +func (m model) viewSettings() string { + switch m.settingsPane { + case settingsDiagnostics: + return m.frame("Diagnostics", m.diagnosticsBody(), actionKey("esc", "back")) + case settingsAbout: + return m.frame("About", m.aboutBody(), actionKey("esc", "back")) + default: + var b strings.Builder + for i, it := range settingsItems { + b.WriteString(m.selectLine(i == m.settingsCursor, it) + "\n") + } + footer := actionKey("↑↓", "move") + " " + actionKey("enter", "open") + " " + actionKey("esc", "back") + return m.frame("Settings", b.String(), footer) + } +} + +func (m model) diagnosticsBody() string { + var b strings.Builder + for _, c := range m.checks { + mark := okStyle.Render("✓") + if !c.OK { + mark = errorStyle.Render("✗") + } + b.WriteString(mark + " " + c.Name) + if c.Note != "" { + b.WriteString(" " + metaStyle.Render("("+c.Note+")")) + } + b.WriteString("\n") + } + return strings.TrimRight(b.String(), "\n") +} + +func (m model) aboutBody() string { + cfg := m.cfg + def := config.Default() + lines := []string{ + fmt.Sprintf("%s v%s", version.App, version.String()), + metaStyle.Render(version.Tagline), + "", + metaStyle.Render("by " + version.Author + " · " + version.Repo), + "", + metaStyle.Render("claude command ") + valueOrDefault(cfg.ClaudeCommand, def.ClaudeCommand), + metaStyle.Render("tmux binary ") + valueOrDefault(cfg.TmuxBinary, def.TmuxBinary), + metaStyle.Render("return key ") + cfg.DetachKey, + metaStyle.Render("packs directory ") + pack.DisplayDir(), + } + return strings.Join(lines, "\n") +} + +// valueOrDefault labels a config value, marking the built-in default so the +// About view shows what is configured versus what is implied. +func valueOrDefault(v, def string) string { + if v == def { + return v + metaStyle.Render(" (default)") + } + return v +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..5fead42 --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,162 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + + "github.com/marioolf/crabby/internal/insights" + "github.com/marioolf/crabby/internal/session" +) + +// accent is Crabby's primary highlight colour (the crab's orange). +const accent = lipgloss.Color("#FF6B4A") + +// Shared styles for every screen, so the whole app reads as one surface. +var ( + dividerStyle = lipgloss.NewStyle().Faint(true) + metaStyle = lipgloss.NewStyle().Faint(true) + helpStyle = lipgloss.NewStyle().Faint(true) + nameStyle = lipgloss.NewStyle() + selNameStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("231")) + barStyle = lipgloss.NewStyle().Foreground(accent) + confirmStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")) + workingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) + okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) + + keyStyle = lipgloss.NewStyle().Foreground(accent).Bold(true) + summaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + headerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")).Bold(true) + titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")).Bold(true) + + // Panel chrome for the dashboard's three columns. + paneTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Bold(true) + paneBorderStyle = lipgloss.NewStyle().Faint(true) + paneFocusedTitle = lipgloss.NewStyle().Foreground(accent).Bold(true) + + stateStyles = map[session.State]lipgloss.Style{ + session.Running: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("42")), // green + session.Waiting: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")), // yellow + session.Stopped: lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("244")), + } +) + +// center places content horizontally in the given terminal width (0 = as-is). +func center(width int, content string) string { + if width <= 0 { + return content + } + return lipgloss.PlaceHorizontal(width, lipgloss.Center, content) +} + +// divider spans the banner's width so headers line up with the identity block. +func divider() string { return dividerStyle.Render(strings.Repeat("─", BannerWidth())) } + +// actionKey styles a keybinding: the key in the accent colour, its description +// faint. It is the single vocabulary for every footer and help line. +func actionKey(k, desc string) string { + return keyStyle.Render(k) + helpStyle.Render(" "+desc) +} + +// glyph picks the status symbol and colour. A working session pulses green. +func glyph(s session.State, working bool) (string, lipgloss.Style) { + if s == session.Stopped { + return "○", stateStyles[s] + } + if working { + return "●", workingStyle + } + return "●", stateStyles[s] +} + +// stateLabel is the short, reliable status word for a task, derived only from +// tmux (never from a transcript, which cannot be attributed to one task). +func stateLabel(s session.State, working bool) string { + switch { + case s == session.Stopped: + return stateStyles[session.Stopped].Render("Stopped") + case working: + return workingStyle.Render("Working") + case s == session.Running: + return stateStyles[session.Running].Render("Attached") + default: + return metaStyle.Render("Idle") + } +} + +// workingActivity turns a transcript activity into a label, always returning +// something — it falls back to a plain "Working" when the tail gives no hint. +func workingActivity(ins insights.Insight) string { + switch ins.Activity { + case insights.Thinking: + return "Thinking…" + case insights.Editing: + return "Editing files" + case insights.Reading: + return "Reading files" + case insights.Running: + if ins.Detail == "" || ins.Detail == "Bash" { + return "Running command" + } + return "Running " + ins.Detail + case insights.Responding: + return "Responding" + default: + return "Working" + } +} + +// formatTokens renders a token count compactly: 950, 12k, 428k. +func formatTokens(n int) string { + if n < 1000 { + return fmt.Sprintf("%d", n) + } + return fmt.Sprintf("%dk", (n+500)/1000) +} + +// formatDuration renders an uptime as 5m, 2h14m, or 3d4h. +func formatDuration(d time.Duration) string { + switch { + case d < time.Minute: + return "<1m" + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 24*time.Hour: + 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: + 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) + } +} + +// formatAgo renders how long ago something happened: 20s ago, 5m ago, 2h ago. +func formatAgo(d time.Duration) string { + switch { + case d < 0: + return "just now" + case d < time.Minute: + return fmt.Sprintf("%ds ago", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + default: + return fmt.Sprintf("%dd ago", int(d.Hours())/24) + } +} + +// plural formats a count with its noun, adding an "s" for anything but one. +func plural(n int, noun string) string { + if n == 1 { + return fmt.Sprintf("1 %s", noun) + } + return fmt.Sprintf("%d %ss", n, noun) +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go deleted file mode 100644 index a69cf8a..0000000 --- a/internal/tui/tui.go +++ /dev/null @@ -1,1076 +0,0 @@ -// Package tui implements Crabby's dashboard and pack selector. -// -// The dashboard is the application: a live, auto-refreshing view of every -// project and its Claude session, ordered by importance. Selecting a project -// opens it in Claude; leaving the session returns straight here. -package tui - -import ( - "fmt" - "os" - "sort" - "strings" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/marioolf/crabby/internal/importcmd" - "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" -) - -const refreshEach = time.Second - -// accent is Crabby's primary highlight colour (the crab's orange). -const accent = lipgloss.Color("#FF6B4A") - -var ( - dividerStyle = lipgloss.NewStyle().Faint(true) - metaStyle = lipgloss.NewStyle().Faint(true) - helpStyle = lipgloss.NewStyle().Faint(true) - nameStyle = lipgloss.NewStyle() - selNameStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("231")) - barStyle = lipgloss.NewStyle().Foreground(accent) - confirmStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")) - workingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) - - stateStyles = map[session.State]lipgloss.Style{ - session.Running: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("42")), // green - session.Waiting: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")), // yellow - session.Stopped: lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("244")), - } -) - -// divider spans the banner's width so the whole card lines up. -func divider() string { return dividerStyle.Render(strings.Repeat("─", BannerWidth())) } - -// center places content horizontally in the given terminal width (0 = as-is). -func center(width int, content string) string { - if width <= 0 { - return content - } - return lipgloss.PlaceHorizontal(width, lipgloss.Center, content) -} - -// Action is what the user chose on the dashboard. -type Action int - -const ( - ActionQuit Action = iota - ActionOpen - ActionNewProject - ActionNewTask -) - -// 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 - 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 { - 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 - -type model struct { - tmux tmux.Client - detachKey string - insights *insights.Collector - width int - items []item - cursor int - confirm confirmKind - result Result - - // activity/working tracking, persisted across refreshes - lastActivity map[string]time.Time - working map[string]bool - ringBell bool - firstLoad bool -} - -// Run displays the dashboard and returns the chosen action. The collector -// supplies transcript-derived insights and is reused across refreshes so its -// incremental cache survives; pass nil to run without them. -func Run(t tmux.Client, detachKey string, coll *insights.Collector) (Result, error) { - if coll == nil { - coll = insights.New() - } - m := model{ - tmux: t, - detachKey: detachKey, - insights: coll, - result: Result{Action: ActionQuit}, - lastActivity: map[string]time.Time{}, - working: map[string]bool{}, - firstLoad: true, - } - m.refresh() - - final, err := tea.NewProgram(m, tea.WithAltScreen()).Run() - if err != nil { - return Result{Action: ActionQuit}, err - } - return final.(model).result, nil -} - -// 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 { - projects = nil - } - sessions := m.tmux.ListSessions() - - 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 { - 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 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) - } - if r := session.Rank(it.state); r < g.best { - g.best = r - } - g.rows = append(g.rows, 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(groups, func(i, j int) bool { - if groups[i].best != groups[j].best { - return groups[i].best < groups[j].best - } - 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 { - return tea.Tick(refreshEach, func(t time.Time) tea.Msg { return tickMsg(t) }) -} - -// bellCmd rings the terminal bell out-of-band (BEL does not disturb the screen). -func bellCmd() tea.Msg { - fmt.Fprint(os.Stderr, "\a") - return nil -} - -func (m model) Init() tea.Cmd { return tick() } - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - return m, nil - case tickMsg: - m.refresh() - if m.ringBell { - return m, tea.Batch(tick(), bellCmd) - } - return m, tick() - case tea.KeyMsg: - return m.handleKey(msg) - } - return m, nil -} - -func (m model) handleKey(key tea.KeyMsg) (tea.Model, tea.Cmd) { - if m.confirm != confirmNone { - if s := key.String(); s == "y" || s == "Y" { - it := m.items[m.cursor] - switch m.confirm { - case confirmStop: - _ = 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() - } - m.confirm = confirmNone - return m, nil - } - - switch key.String() { - case "q", "ctrl+c", "esc": - m.result = Result{Action: ActionQuit} - return m, tea.Quit - case "up", "k": - if n := m.nextSelectable(m.cursor, -1); n >= 0 { - m.cursor = n - } - case "down", "j": - 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 m.selectable(m.cursor) && m.items[m.cursor].state != session.Stopped { - m.confirm = confirmStop - } - case "d": - 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 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 -} - -func (m model) View() string { - // Everything is centred on the terminal. Standalone lines (banner, dividers, - // summary, footer) centre individually; the workspace list centres as one - // block so its columns stay left-aligned. - var b strings.Builder - b.WriteString(center(m.width, Banner())) - b.WriteString("\n\n") - b.WriteString(center(m.width, divider())) - b.WriteString("\n\n") - - if len(m.items) == 0 { - b.WriteString(center(m.width, "No workspaces yet.") + "\n\n") - b.WriteString(center(m.width, metaStyle.Render("Run crabby init inside a project,")) + "\n") - b.WriteString(center(m.width, metaStyle.Render("or press n to add the current directory.")) + "\n\n") - b.WriteString(center(m.width, divider()) + "\n") - b.WriteString(center(m.width, actionKey("n", "new")+" "+actionKey("q", "quit"))) - return b.String() - } - - b.WriteString(blockCenter(m.width, m.renderRows())) - b.WriteString("\n") - b.WriteString(center(m.width, divider())) - b.WriteString("\n") - if summary := m.summaryLine(); summary != "" { - b.WriteString(center(m.width, summary) + "\n\n") - } - switch m.confirm { - case confirmStop: - b.WriteString(center(m.width, confirmStyle.Render(fmt.Sprintf("Stop \"%s\"? (y/n)", m.items[m.cursor].displayName())))) - return b.String() - case confirmRemoveTask: - it := m.items[m.cursor] - b.WriteString(center(m.width, confirmStyle.Render(fmt.Sprintf("Remove task \"%s\" from %q? (y/n)", it.task.Name, it.workspace.Name)))) - return b.String() - case confirmRemoveWorkspace: - b.WriteString(center(m.width, confirmStyle.Render(fmt.Sprintf("Remove workspace \"%s\" from Crabby? Files are kept. (y/n)", m.items[m.cursor].workspace.Name)))) - return b.String() - } - b.WriteString(center(m.width, m.helpLines())) - return 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 { - plain[i] = it.plainLabel() - if w := lipgloss.Width(plain[i]); w > labelWidth { - labelWidth = w - } - } - - 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") - } - } - return b.String() -} - -// blockCenter centres a multi-line block as a unit: every line gets the same -// left margin, so the block sits in the middle while its internal left-aligned -// columns stay aligned (unlike per-line centring, which lets each line drift). -func blockCenter(width int, s string) string { - if width <= 0 { - return s - } - lines := strings.Split(s, "\n") - blockWidth := 0 - for _, l := range lines { - if w := lipgloss.Width(l); w > blockWidth { - blockWidth = w - } - } - margin := (width - blockWidth) / 2 - if margin <= 0 { - return s - } - pad := strings.Repeat(" ", margin) - for i, l := range lines { - if l != "" { - lines[i] = pad + l - } - } - return strings.Join(lines, "\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)) - } - return data - default: - data := m.activityLabel(it) - if ago := lastActivityAgo(it.insight); ago != "" { - data += metaStyle.Render(" · " + ago) - } - return data - } -} - -// 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 "" - } - if meta := metaLine(it); meta != "" { - return metaStyle.Render(meta) - } - return "" -} - -// glyph picks the status symbol and colour. A working session pulses green. -func glyph(s session.State, working bool) (string, lipgloss.Style) { - if s == session.Stopped { - return "○", stateStyles[s] - } - if working { - return "●", workingStyle - } - return "●", stateStyles[s] -} - -// 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") - } - if it.working { - return workingStyle.Render(workingActivity(it.insight)) - } - if it.insight.Activity == insights.Responding { - return stateStyles[session.Waiting].Render("Waiting for input") - } - return metaStyle.Render("Idle") -} - -// workingActivity turns a transcript activity into a label, always returning -// something — it falls back to a plain "Working" when the tail gives no hint. -func workingActivity(ins insights.Insight) string { - switch ins.Activity { - case insights.Thinking: - return "Thinking…" - case insights.Editing: - return "Editing files" - case insights.Reading: - return "Reading files" - case insights.Running: - if ins.Detail == "" || ins.Detail == "Bash" { - return "Running command" - } - return "Running " + ins.Detail - case insights.Responding: - return "Responding" - default: - return "Working" - } -} - -// metaLine joins the factual bits we have for a project into one faint line, -// omitting anything unavailable. -func metaLine(it item) string { - var parts []string - if it.branch != "" { - parts = append(parts, it.branch) - } - if it.uptime > 0 { - parts = append(parts, "up "+formatDuration(it.uptime)) - } - 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, " · ") -} - -func lastActivityAgo(ins insights.Insight) string { - if !ins.Found || ins.LastActivity.IsZero() { - return "" - } - return formatAgo(time.Since(ins.LastActivity)) -} - -// 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 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.Stopped: - stopped++ - default: // Running or Waiting are both "alive" - waiting++ - } - if it.working { - working++ - } - } - parts := []string{plural(workspaces, "workspace")} - if waiting > 0 { - parts = append(parts, fmt.Sprintf("%d active", waiting)) - } - if stopped > 0 { - parts = append(parts, fmt.Sprintf("%d stopped", stopped)) - } - if working > 0 { - parts = append(parts, fmt.Sprintf("%d working", working)) - } - if tokens > 0 { - parts = append(parts, formatTokens(tokens)+" tokens today") - } - return summaryStyle.Render(strings.Join(parts, " · ")) -} - -// formatTokens renders a token count compactly: 950, 12k, 428k. -func formatTokens(n int) string { - if n < 1000 { - return fmt.Sprintf("%d", n) - } - return fmt.Sprintf("%dk", (n+500)/1000) -} - -// formatDuration renders an uptime as 5m, 2h14m, or 3d4h. -func formatDuration(d time.Duration) string { - switch { - case d < time.Minute: - return "<1m" - case d < time.Hour: - return fmt.Sprintf("%dm", int(d.Minutes())) - case d < 24*time.Hour: - 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: - 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) - } -} - -// formatAgo renders how long ago something happened: 20s ago, 5m ago, 2h ago. -func formatAgo(d time.Duration) string { - switch { - case d < 0: - return "just now" - case d < time.Minute: - return fmt.Sprintf("%ds ago", int(d.Seconds())) - case d < time.Hour: - return fmt.Sprintf("%dm ago", int(d.Minutes())) - case d < 24*time.Hour: - return fmt.Sprintf("%dh ago", int(d.Hours())) - default: - return fmt.Sprintf("%dd ago", int(d.Hours())/24) - } -} - -// --- 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 { - packs []pack.Pack - cursor int - width int - chosen *pack.Pack -} - -// SelectPack shows the available packs and returns the chosen one, or nil if -// the user cancels. -func SelectPack(packs []pack.Pack) (*pack.Pack, error) { - final, err := tea.NewProgram(packModel{packs: packs}, tea.WithAltScreen()).Run() - if err != nil { - return nil, err - } - return final.(packModel).chosen, nil -} - -func (m packModel) Init() tea.Cmd { return nil } - -func (m packModel) 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.packs)-1 { - m.cursor++ - } - case "enter": - p := m.packs[m.cursor] - m.chosen = &p - return m, tea.Quit - } - return m, nil -} - -func (m packModel) View() string { - var b strings.Builder - b.WriteString(Banner()) - b.WriteString("\n\n") - b.WriteString(divider()) - b.WriteString("\n\n") - b.WriteString(wordmarkStyle.Render("Select a pack")) - b.WriteString("\n\n") - for i, p := range m.packs { - bar := " " - name := nameStyle.Render(p.Name) - if i == m.cursor { - bar = barStyle.Render("▌ ") - name = selNameStyle.Render(p.Name) - } - b.WriteString(bar + name + "\n") - if p.Description != "" { - b.WriteString(" " + metaStyle.Render(p.Description) + "\n") - } - } - b.WriteString("\n") - b.WriteString(metaStyle.Render("packs live in " + pack.DisplayDir())) - b.WriteString("\n") - b.WriteString(helpStyle.Render("↑/↓ move enter select q cancel")) - return center(m.width, b.String()) -} - -// --- Import selector ------------------------------------------------------- - -type importModel struct { - workspaces []importcmd.Workspace - selected []bool - cursor int - width int - confirmed bool // Enter pressed — import the selection -} - -// SelectImports shows the discovered workspaces and lets the user choose which -// to import. It returns the chosen workspaces and whether the user cancelled. -// Already-imported workspaces are shown for context but can never be selected. -func SelectImports(workspaces []importcmd.Workspace) (chosen []importcmd.Workspace, cancelled bool, err error) { - selected := make([]bool, len(workspaces)) - for i, w := range workspaces { - // Default everything importable to checked — adopting a whole folder of - // repos should take one keypress, not one per project. - selected[i] = !w.Imported - } - - final, runErr := tea.NewProgram( - importModel{workspaces: workspaces, selected: selected}, - tea.WithAltScreen(), - ).Run() - if runErr != nil { - return nil, false, runErr - } - m := final.(importModel) - if !m.confirmed { - return nil, true, nil - } - for i, w := range m.workspaces { - if m.selected[i] && !w.Imported { - chosen = append(chosen, w) - } - } - return chosen, false, nil -} - -func (m importModel) Init() tea.Cmd { return nil } - -func (m importModel) 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.workspaces)-1 { - m.cursor++ - } - case " ": - // Already-imported workspaces are locked — there is nothing to toggle. - if !m.workspaces[m.cursor].Imported { - m.selected[m.cursor] = !m.selected[m.cursor] - } - case "a": - m.toggleAll() - case "enter": - m.confirmed = true - return m, tea.Quit - } - return m, nil -} - -// toggleAll selects every importable workspace, or clears them if all are -// already selected. -func (m *importModel) toggleAll() { - allOn := true - for i, w := range m.workspaces { - if !w.Imported && !m.selected[i] { - allOn = false - break - } - } - for i, w := range m.workspaces { - if !w.Imported { - m.selected[i] = !allOn - } - } -} - -func (m importModel) View() string { - var b strings.Builder - b.WriteString(Banner()) - b.WriteString("\n\n") - b.WriteString(divider()) - b.WriteString("\n\n") - b.WriteString(wordmarkStyle.Render("Import Claude workspaces")) - b.WriteString("\n") - b.WriteString(metaStyle.Render(fmt.Sprintf("Found %s.", plural(len(m.workspaces), "workspace")))) - b.WriteString("\n\n") - - for i, w := range m.workspaces { - bar := " " - name := nameStyle.Render(w.Name) - if i == m.cursor { - bar = barStyle.Render("▌ ") - name = selNameStyle.Render(w.Name) - } - b.WriteString(fmt.Sprintf("%s%s %s", bar, checkbox(w.Imported, m.selected[i]), name)) - if w.Imported { - b.WriteString(" " + metaStyle.Render("already imported")) - } else if w.Branch != "" { - b.WriteString(" " + metaStyle.Render(w.Branch)) - } - b.WriteString("\n") - } - - b.WriteString("\n") - b.WriteString(divider()) - b.WriteString("\n") - b.WriteString(helpStyle.Render("space select a all enter import q cancel")) - return center(m.width, b.String()) -} - -// checkbox renders an item's selection state: a locked tick for workspaces that -// are already imported, otherwise a normal on/off box. -func checkbox(imported, selected bool) string { - switch { - case imported: - return metaStyle.Render("[✓]") - case selected: - return barStyle.Render("[x]") - default: - return "[ ]" - } -} - -// plural formats a count with its noun, adding an "s" for anything but one. -func plural(n int, noun string) string { - if n == 1 { - return fmt.Sprintf("1 %s", noun) - } - return fmt.Sprintf("%d %ss", n, noun) -} From 77c34bc437a9d1d1082c966fc48b1f6aca4aeaf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20L=C3=B3pez?= Date: Tue, 14 Jul 2026 21:21:20 +0200 Subject: [PATCH 03/15] tui-first: in-app import, help alignment, screen smoke tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Import moves into the TUI (i): scan a folder for workspaces with a CLAUDE.md and pick which to adopt. The scan runs off the UI thread via a tea.Cmd with a Scanning… state, so a deep tree never freezes Crabby. This keeps the importcmd package in use rather than orphaned. - Help columns align on display width, not byte length (arrow glyphs). - Add smoke tests rendering every screen and asserting dashboard key transitions, plus unit tests for the format helpers. - Bump VERSION to 0.8.0. --- VERSION | 2 +- internal/tui/app.go | 15 +++ internal/tui/dashboard.go | 4 +- internal/tui/help.go | 8 +- internal/tui/imports.go | 211 +++++++++++++++++++++++++++++++++++ internal/tui/screens_test.go | 126 +++++++++++++++++++++ 6 files changed, 361 insertions(+), 5 deletions(-) create mode 100644 internal/tui/imports.go create mode 100644 internal/tui/screens_test.go diff --git a/VERSION b/VERSION index f38fc53..a3df0a6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.3 +0.8.0 diff --git a/internal/tui/app.go b/internal/tui/app.go index 6172fbc..399b323 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -20,6 +20,7 @@ import ( "github.com/marioolf/crabby/internal/config" "github.com/marioolf/crabby/internal/doctor" + "github.com/marioolf/crabby/internal/importcmd" "github.com/marioolf/crabby/internal/insights" "github.com/marioolf/crabby/internal/pack" "github.com/marioolf/crabby/internal/project" @@ -34,6 +35,7 @@ type screen int const ( scrDashboard screen = iota scrNewWorkspace + scrImport scrNewTask scrPacks scrSettings @@ -79,6 +81,12 @@ type model struct { packList []pack.Pack packCursor int + // --- Import flow ------------------------------------------------------- + importStep importStep + importList []importcmd.Workspace + importSel []bool + importCursor int + // --- New-task flow ----------------------------------------------------- formProj project.Project @@ -174,6 +182,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case openTaskMsg: return m.attach(msg.proj, msg.task) + case discoverDoneMsg: + return m.applyDiscover(msg) + case editMsg: return m.launchEditor(msg.dir) @@ -209,6 +220,8 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch m.screen { case scrNewWorkspace: return m.updateNewWorkspace(msg) + case scrImport: + return m.updateImport(msg) case scrNewTask: return m.updateNewTask(msg) case scrPacks: @@ -226,6 +239,8 @@ func (m model) View() string { switch m.screen { case scrNewWorkspace: return m.viewNewWorkspace() + case scrImport: + return m.viewImport() case scrNewTask: return m.viewNewTask() case scrPacks: diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index 3033cc6..9328399 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -180,6 +180,8 @@ func (m model) updateDashboard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "n": return m.startNewWorkspace() + case "i": + return m.startImport() case "t": if ws, ok := m.selectedWs(); ok { return m.startNewTask(ws.proj) @@ -529,7 +531,7 @@ func (m model) dashboardFooter() string { actionKey("t", "task"), actionKey("x", "stop"), actionKey("d", "remove"), }, " ") line2 := strings.Join([]string{ - actionKey("p", "packs"), actionKey("s", "settings"), + actionKey("i", "import"), actionKey("p", "packs"), actionKey("s", "settings"), actionKey("?", "help"), actionKey("q", "quit"), helpStyle.Render("· " + m.cfg.DetachKey + " returns from a session"), }, " ") diff --git a/internal/tui/help.go b/internal/tui/help.go index 27b4136..ef880fe 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -4,6 +4,7 @@ import ( "strings" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) func (m model) updateHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) { @@ -33,6 +34,7 @@ var helpSections = []helpSection{ {"Workspaces & tasks", []helpEntry{ {"enter", "open the selected task in Claude"}, {"n", "new workspace (point it at a folder)"}, + {"i", "import workspaces found under a folder"}, {"t", "new task in the selected workspace"}, {"x", "stop the selected task's session"}, {"d", "remove the selected task or workspace"}, @@ -52,8 +54,8 @@ func (m model) viewHelp() string { width := 0 for _, s := range helpSections { for _, e := range s.entries { - if len(e.key) > width { - width = len(e.key) + if w := lipgloss.Width(e.key); w > width { + width = w } } } @@ -63,7 +65,7 @@ func (m model) viewHelp() string { } b.WriteString(headerStyle.Render(s.title) + "\n") for _, e := range s.entries { - pad := strings.Repeat(" ", width-len(e.key)+2) + pad := strings.Repeat(" ", width-lipgloss.Width(e.key)+2) b.WriteString(" " + keyStyle.Render(e.key) + pad + helpStyle.Render(e.desc) + "\n") } } diff --git a/internal/tui/imports.go b/internal/tui/imports.go new file mode 100644 index 0000000..7eeb47d --- /dev/null +++ b/internal/tui/imports.go @@ -0,0 +1,211 @@ +package tui + +import ( + "fmt" + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/marioolf/crabby/internal/importcmd" + "github.com/marioolf/crabby/internal/initcmd" +) + +// importStep is which step of the import flow is on screen: choosing where to +// scan, or picking which discovered workspaces to adopt. +type importStep int + +const ( + importStepPath importStep = iota + importStepScanning + importStepPick +) + +// discoverDoneMsg carries the result of a background workspace scan. Scanning a +// large tree can take a moment, so it runs off the UI thread and reports back. +type discoverDoneMsg struct { + found []importcmd.Workspace + err error +} + +func (m model) startImport() (tea.Model, tea.Cmd) { + cwd, _ := os.Getwd() + m.screen = scrImport + m.importStep = importStepPath + m.input = newTextInput(cwd) + m.importList = nil + m.importSel = nil + m.importCursor = 0 + m.notice = "" + return m, nil +} + +func (m model) updateImport(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch m.importStep { + case importStepPath: + switch msg.String() { + case "esc": + m.goDashboard() + case "enter": + root := expandHome(strings.TrimSpace(m.input.Value())) + if root == "" { + return m, nil + } + // Scan off the UI thread — a deep tree must not freeze Crabby. + m.importStep = importStepScanning + m.notice = "" + return m, func() tea.Msg { + found, err := importcmd.Discover(root) + return discoverDoneMsg{found, err} + } + default: + m.input = m.input.update(msg) + } + case importStepScanning: + // Only Esc does anything while scanning; a stale result is discarded in + // the message handler if the user has already left. + if msg.String() == "esc" { + m.importStep = importStepPath + } + case importStepPick: + switch msg.String() { + case "esc": + m.importStep = importStepPath + case "up", "k": + if m.importCursor > 0 { + m.importCursor-- + } + case "down", "j": + if m.importCursor < len(m.importList)-1 { + m.importCursor++ + } + case " ": + if !m.importList[m.importCursor].Imported { + m.importSel[m.importCursor] = !m.importSel[m.importCursor] + } + case "a": + m.toggleAllImports() + case "enter": + return m.performImport() + } + } + return m, nil +} + +// applyDiscover consumes a background scan result: it moves to the pick list, or +// reports an empty/failed scan. A result that arrives after the user has left +// the scan is ignored. +func (m model) applyDiscover(msg discoverDoneMsg) (tea.Model, tea.Cmd) { + if m.screen != scrImport || m.importStep != importStepScanning { + return m, nil + } + if msg.err != nil { + m.importStep = importStepPath + m.notice, m.noticeErr = msg.err.Error(), true + return m, nil + } + if len(msg.found) == 0 { + m.importStep = importStepPath + m.notice, m.noticeErr = "No workspaces (folders with a CLAUDE.md) found there.", true + return m, nil + } + m.importList = msg.found + m.importSel = make([]bool, len(msg.found)) + for i, w := range msg.found { + // Everything importable starts checked — adopting a folder should take + // one keypress, not one per project. + m.importSel[i] = !w.Imported + } + m.importCursor = 0 + m.importStep = importStepPick + return m, nil +} + +// toggleAllImports selects every importable workspace, or clears them all if +// they are already selected. +func (m *model) toggleAllImports() { + allOn := true + for i, w := range m.importList { + if !w.Imported && !m.importSel[i] { + allOn = false + break + } + } + for i, w := range m.importList { + if !w.Imported { + m.importSel[i] = !allOn + } + } +} + +// performImport registers each chosen workspace with init (no pack, so existing +// CLAUDE.md files are left untouched) and returns to the dashboard. +func (m model) performImport() (tea.Model, tea.Cmd) { + imported := 0 + for i, w := range m.importList { + if !m.importSel[i] || w.Imported { + continue + } + if _, err := initcmd.Init(w.Path, nil); err == nil { + imported++ + } + } + m.goDashboard() + m.refresh() + if imported == 0 { + m.notice, m.noticeErr = "Nothing imported.", false + } else { + m.notice, m.noticeErr = fmt.Sprintf("Imported %s.", plural(imported, "workspace")), false + } + return m, nil +} + +func (m model) viewImport() string { + switch m.importStep { + case importStepPath: + body := "Scan a folder for workspaces (any folder with a CLAUDE.md):\n\n " + + m.input.view("path to scan") + footer := actionKey("enter", "scan") + " " + actionKey("esc", "cancel") + return m.frame("Import workspaces", body, footer) + case importStepScanning: + body := metaStyle.Render("Scanning for workspaces… this can take a moment on a large folder.") + return m.frame("Import workspaces", body, actionKey("esc", "cancel")) + } + + var b strings.Builder + b.WriteString(metaStyle.Render(fmt.Sprintf("Found %s.", plural(len(m.importList), "workspace"))) + "\n\n") + for i, w := range m.importList { + bar := " " + name := nameStyle.Render(w.Name) + if i == m.importCursor { + bar = barStyle.Render("▌ ") + name = selNameStyle.Render(w.Name) + } + b.WriteString(bar + importCheckbox(w.Imported, m.importSel[i]) + " " + name) + switch { + case w.Imported: + b.WriteString(" " + metaStyle.Render("already imported")) + case w.Branch != "": + b.WriteString(" " + metaStyle.Render(w.Branch)) + } + b.WriteString("\n") + } + footer := strings.Join([]string{ + actionKey("space", "select"), actionKey("a", "all"), + actionKey("enter", "import"), actionKey("esc", "back"), + }, " ") + return m.frame("Import workspaces", b.String(), footer) +} + +// importCheckbox renders a row's selection state: a locked tick for workspaces +// already imported, otherwise a normal on/off box. +func importCheckbox(imported, selected bool) string { + switch { + case imported: + return metaStyle.Render("[✓]") + case selected: + return barStyle.Render("[x]") + default: + return "[ ]" + } +} diff --git a/internal/tui/screens_test.go b/internal/tui/screens_test.go new file mode 100644 index 0000000..45fca4b --- /dev/null +++ b/internal/tui/screens_test.go @@ -0,0 +1,126 @@ +package tui + +import ( + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/marioolf/crabby/internal/config" + "github.com/marioolf/crabby/internal/importcmd" + "github.com/marioolf/crabby/internal/insights" + "github.com/marioolf/crabby/internal/pack" + "github.com/marioolf/crabby/internal/tmux" +) + +// baseModel is a model with the services a View needs and a sane terminal size. +func baseModel() model { + return model{ + cfg: config.Default(), + tmux: tmux.New("tmux"), + insights: insights.New(), + width: 120, + height: 40, + lastActivity: map[string]time.Time{}, + working: map[string]bool{}, + } +} + +// TestEveryScreenRenders makes sure no screen's View panics, whatever sub-state +// it is in — the failure mode most likely to slip past the compiler. +func TestEveryScreenRenders(t *testing.T) { + variants := []struct { + name string + setup func(m *model) + }{ + {"dashboard-empty", func(m *model) { m.screen = scrDashboard }}, + {"new-workspace-dir", func(m *model) { + m.screen = scrNewWorkspace + m.wsStep = wsStepDir + m.input = newTextInput("/tmp/x") + }}, + {"new-workspace-pack", func(m *model) { + m.screen = scrNewWorkspace + m.wsStep = wsStepPack + m.wsDir = "/tmp/x" + m.packList = []pack.Pack{{Name: "go", Description: "Go"}} + }}, + {"import-path", func(m *model) { + m.screen = scrImport + m.importStep = importStepPath + m.input = newTextInput("/tmp") + }}, + {"import-pick", func(m *model) { + m.screen = scrImport + m.importStep = importStepPick + m.importList = []importcmd.Workspace{{Name: "a", Branch: "main"}, {Name: "b", Imported: true}} + m.importSel = []bool{true, false} + }}, + {"new-task", func(m *model) { + m.screen = scrNewTask + m.input = newTextInput("") + }}, + {"packs-empty", func(m *model) { m.screen = scrPacks }}, + {"packs-create", func(m *model) { + m.screen = scrPacks + m.packAction = packActionCreate + m.input = newTextInput("new") + }}, + {"packs-duplicate", func(m *model) { + m.screen = scrPacks + m.packs = []pack.Pack{{Name: "base"}} + m.packAction = packActionDuplicate + m.input = newTextInput("base-copy") + }}, + {"packs-delete", func(m *model) { + m.screen = scrPacks + m.packs = []pack.Pack{{Name: "base"}} + m.packAction = packActionConfirmDelete + }}, + {"settings-menu", func(m *model) { m.screen = scrSettings }}, + {"settings-diagnostics", func(m *model) { + m.screen = scrSettings + m.settingsPane = settingsDiagnostics + }}, + {"settings-about", func(m *model) { + m.screen = scrSettings + m.settingsPane = settingsAbout + }}, + {"help", func(m *model) { m.screen = scrHelp }}, + } + + for _, v := range variants { + t.Run(v.name, func(t *testing.T) { + m := baseModel() + v.setup(&m) + out := m.View() + if strings.TrimSpace(out) == "" { + t.Fatalf("%s rendered empty", v.name) + } + }) + } +} + +// TestDashboardTransitions checks the keys that open each screen land on it. +func TestDashboardTransitions(t *testing.T) { + cases := []struct { + key string + want screen + }{ + {"n", scrNewWorkspace}, + {"i", scrImport}, + {"p", scrPacks}, + {"s", scrSettings}, + {"?", scrHelp}, + } + for _, c := range cases { + m := baseModel() + m.screen = scrDashboard + next, _ := m.updateDashboard(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(c.key)}) + got := next.(model).screen + if got != c.want { + t.Errorf("key %q -> screen %d, want %d", c.key, got, c.want) + } + } +} From 3bbd05a0a5aa501549a097992ca56dc9416b70db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20L=C3=B3pez?= Date: Tue, 14 Jul 2026 21:23:53 +0200 Subject: [PATCH 04/15] docs: rewrite README + CHANGELOG for TUI-first (0.8.0) - README: new three-pane dashboard mockup and key list; workspaces/tasks, adopting projects, and packs sections rewritten around the in-app flows; Commands table trimmed to crabby / version / doctor. - CHANGELOG: 0.8.0 (TUI-first) and the 0.7.3 mouse-wheel fix. --- CHANGELOG.md | 45 ++++++++++ README.md | 240 ++++++++++++++++++++++----------------------------- 2 files changed, 150 insertions(+), 135 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b418bcc..79186e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.0] - 2026-07-14 + +### Changed + +- **Crabby is now a TUI-first terminal application.** Running `crabby` opens a + persistent, multi-panel home screen and every day-to-day action happens inside + it — the shell is no longer part of the workflow. The dashboard is three + columns (**Workspaces | Tasks | Details**) with keyboard focus moving between + panes (Tab / arrows); Enter opens the selected task in Claude. +- Attaching to a Claude session now runs through the app (via `tea.ExecProcess`), + so the screen is released for Claude and the dashboard restored on return — + without ever leaving Crabby. The prefix-free return key (F12) is shown on + screen and in Help. + +### Added + +- In-app **workspace creation** (n): pick a directory and a pack, no shell. +- In-app **import** (i): scan a folder for workspaces with a `CLAUDE.md` and pick + which to adopt; the scan runs off the UI thread so a large tree never freezes. +- In-app **task management**: create (t), stop (x), remove (d), and open (Enter). +- In-app **pack management** (p): create, duplicate, edit (via `$EDITOR`), and + delete packs — no manual filesystem editing required. +- A **Settings** section (Packs, Diagnostics, About) and a **Help** screen (?) + listing every navigation key, shortcut, and action. + +### Removed + +- The `init`, `import`, `task`, `attach`, `start`, `ps`, and `rm` CLI + subcommands. Everything they did now lives in the interface. The command line + keeps only `crabby`, `crabby version`, and `crabby doctor`. + +Existing workspaces, tasks, and packs keep working — the data model, session +storage, and pack format are unchanged. + +## [0.7.3] - 2026-07-14 + +### Fixed + +- **Mouse wheel scrolled Claude's prompt history instead of the pane.** With + tmux's mouse mode off (the default), the wheel was forwarded to Claude Code as + arrow keys, cycling through old prompts rather than scrolling up through the + response. Crabby now enables mouse mode on its tmux socket so the wheel scrolls + the pane's scrollback. (Hold Shift while dragging for the terminal's native + selection.) + ## [0.7.2] - 2026-07-13 ### Fixed diff --git a/README.md b/README.md index feb5a94..284664c 100644 --- a/README.md +++ b/README.md @@ -76,101 +76,89 @@ crabby doctor ## Everyday use -Add a project once: - -```bash -cd my-project -crabby init -``` - -Already have repositories with a `CLAUDE.md`? Adopt them without changing -anything about how they work — see [Adopting existing projects](#adopting-existing-projects). - -Then just run Crabby: +Crabby is a terminal application. Open it once and stay inside it — creating +workspaces and tasks, jumping into Claude, and managing packs all happen in the +interface. There are no commands to remember. ```bash crabby ``` ``` - (\/) (\/) - \(o..o)/ - /`----'\ - - C R A B B Y - Many Claudes. One shell. - prepare projects · orchestrate claude · one terminal - -──────────────────────────────────────────────────── - - ● test Waiting for input · 1m ago - main · up 8m · Sonnet 5 · 64k tok - - payments feature/refunds · Opus 4.8 · 132k tok -▌ ● refactor Working · up 2h - ● tests Idle · up 34m - ○ docs Stopped - - ○ 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 + (\/) (\/) + \(o..o)/ + /`----'\ + + C R A B B Y + Many Claudes. One shell. + prepare projects · orchestrate claude · one terminal + +╭──────────────────╮ ╭──────────────────╮ ╭──────────────────────────╮ +│ Workspaces │ │ Tasks │ │ Details │ +│ │ │ │ │ │ +│ ▌ ● payments (3) │ │ ▌ ● refactor │ │ status Working │ +│ ● test │ │ ● tests │ │ task refactor │ +│ ○ SSH-AI │ │ ○ docs │ │ branch feature/refunds │ +│ │ │ │ │ model Opus 4.8 │ +│ │ │ │ │ tokens 132k session │ +│ │ │ │ │ uptime 2h14m │ +│ │ │ │ │ activity Editing files │ +╰──────────────────╯ ╰──────────────────╯ ╰──────────────────────────╯ + 5 workspaces · 2 active · 4 stopped · 196k tokens today + ↑↓ move tab pane enter open n new t task x stop d remove + i import p packs s settings ? help q quit · F12 returns from a session ``` -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. +The home screen is three columns — **Workspaces**, **Tasks**, **Details** — with +the identity banner above and the actions and return-key hint below. Move the +cursor with the arrows, switch columns with **Tab**, and press **Enter** to open +the selected task. The dashboard **refreshes automatically** (about once a +second) and sorts workspaces 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 Details come from. +- **↑ ↓** move within the focused column; **Tab / Shift+Tab** (or **← →**) switch + between Workspaces and Tasks. - **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. -- **n** adds the current directory as a new workspace (see [Packs](#packs)). +- **F12** (inside a session) brings you straight back here — no tmux shortcuts to + learn. The session keeps running in the background. +- **n** creates a new workspace from any folder (see [Packs](#packs)); **i** + imports every workspace found under a folder. - **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. +- **d** removes the highlighted task, or the whole workspace (asks first). Your + files are kept; only Crabby forgets it. +- **p** manages packs, **s** opens Settings, **?** shows the full key list, and + **q** quits. -The return key is shown on a small Crabby status bar while you work, so you -never have to remember it. +Every shortcut is on screen, and the return key is shown on a small Crabby status +bar while you work — so you never have to remember anything. ## 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. + truth; it's what **n** (new) and **i** (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: +repository without cloning it — all from the home screen: -```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 -``` +- Select the workspace and press **t** to start a new task (say `refactor`); it + opens straight away in its own Claude. +- Press **t** again for a second, independent task (say `tests`) in the same repo. +- Both appear in the **Tasks** column; **Tab** into it, pick one, **Enter** to + open, **d** to remove 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. +Every workspace starts with one default task (`main`), so a workspace with a +single task behaves exactly like a single session. Tasks are independent — Crabby +does not coordinate them, share memory between them, or manage git branches; each +is just its own Claude. ## Workspace insights @@ -217,81 +205,66 @@ shows its tmux state, branch, and uptime. ## Adopting existing projects If you already use Claude Code, you probably have repositories with a -`CLAUDE.md` in them. You don't need to recreate them — bring them in as they -are. - -**One project** — point `init` at it (no need to `cd` first): +`CLAUDE.md` in them. You don't need to recreate them — bring them in as they are, +from inside Crabby. -```bash -crabby init ~/repos/payments -``` - -**A whole folder of them** — let Crabby find them: - -```bash -crabby import ~/repos -``` +**One project** — press **n**, type (or paste) its path, and create it. A folder +that already has a `CLAUDE.md` keeps it untouched. -`import` scans the tree for folders that contain a `CLAUDE.md` and shows what it -found — a git repository isn't required. Pick the ones you want and press Enter: +**A whole folder of them** — press **i**, point it at a parent folder, and let +Crabby find them. It scans the tree for folders that contain a `CLAUDE.md` and +shows what it found — a git repository isn't required. Pick the ones you want and +press **Enter**: ``` - (\/) (\/) - \(o..o)/ - /`----'\ +──────────────────────────────────────────────────── - C R A B B Y -Many Claudes. One shell. + Import workspaces -Import Claude workspaces -Found 4 workspaces. + Found 4 workspaces. -▌ [x] ai-security main - [x] frontend main - [✓] payments already imported - [ ] old-test wip/spike + ▌ [x] ai-security main + [x] frontend main + [✓] payments already imported + [ ] old-test wip/spike -──────────────────────────────────── -space select a all enter import q cancel +──────────────────────────────────────────────────── + space select a all enter import esc back ``` - **Space** toggles a workspace; **a** toggles all. - Projects you've already imported show `[✓]` and can't be added twice. -- Everything importable starts checked, so adopting a folder is usually just - `crabby import ~/repos` then **Enter**. +- Everything importable starts checked, so adopting a folder is usually just **i**, + the path, and **Enter**. The scan runs in the background, so a large folder + never freezes the interface. Import never edits your repositories — it registers them and writes Crabby's own `.claude/crabby.yaml`, leaving your existing `CLAUDE.md` and workflow untouched. -Imported projects behave exactly like ones created with `crabby init`. -`import` skips the obvious noise while scanning — `.git`, `node_modules`, -`.venv`, `vendor`, and similar — and stops at the first `CLAUDE.md` on each -branch of the tree, so a project's own subdirectory context files aren't -imported as separate workspaces. When a folder is a git repository its current -branch is shown for context; when it isn't, it's still importable. +Scanning skips the obvious noise — `.git`, `node_modules`, `.venv`, `vendor`, and +similar — and stops at the first `CLAUDE.md` on each branch of the tree, so a +project's own subdirectory context files aren't imported as separate workspaces. +When a folder is a git repository its current branch is shown for context; when +it isn't, it's still importable. ## Commands +Crabby is TUI-first: everything you do day to day lives inside the app, so the +command line is intentionally tiny. + | Command | What it does | | --- | --- | -| `crabby` | Open the home screen (this is all you normally need). | -| `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 [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` | Open the application — the home screen and everything from there. | | `crabby doctor` | Check that WSL, Ubuntu, tmux, Claude, and crabby are present. | | `crabby version` | Print the installed version. | +Creating and importing workspaces, managing tasks, and managing packs all moved +into the interface — press **?** inside Crabby for the full key list. + ## Packs A **pack** is a reusable project setup — nothing more than a directory of files -that Crabby copies into a project when you run `crabby init`. No plugins, no +that Crabby copies into a project when you create a workspace. No plugins, no templates, no variables: just folders. Packs live in: @@ -329,32 +302,29 @@ version: 1.0.0 ### Using a pack -Run `crabby init` in a project (or press **n** on the home screen): +Press **n** to create a workspace and point it at a folder: - **No packs installed** → Crabby writes a minimal default `CLAUDE.md`. - **One pack** → it's used automatically. -- **Several packs** → Crabby shows a selector. +- **Several packs** → Crabby shows a pack picker in the flow. Files that already exist in the project are **never overwritten** — they're reported and kept. Crabby always writes its own `.claude/crabby.yaml` (the registry metadata) regardless of the pack. -### Creating your own pack +### Managing packs -Copy one of the bundled examples and edit it — that's the whole workflow. The -repo ships a few in [`examples/`](examples/): `simple-pack`, `go-pack`, -`docs-pack`. +Press **p** (or **s** → Packs) to manage packs without leaving Crabby: -```bash -cp -r examples/go-pack ~/.config/crabby/packs/go -# edit ~/.config/crabby/packs/go/pack.yaml (set the name) -# edit ~/.config/crabby/packs/go/CLAUDE.md, add any other files -``` +- **n** creates a new pack (a starter `pack.yaml` and `CLAUDE.md`). +- **c** duplicates the selected pack under a new name. +- **e** opens the pack directory in your `$EDITOR` (falling back to `vi`) so you + can edit its `CLAUDE.md` and add any files you want copied in. +- **d** deletes a pack from disk (asks first). -Next time you run `crabby init`, your pack is available (and with two or more, -you get the selector). Packs are just directories, so managing them is `cp`, -`rm`, and your editor. Crabby prints the packs directory after a plain `init` -and shows it in the selector, so you always know where they go. +Packs are still just directories under `~/.config/crabby/packs/`, so you can also +copy one of the bundled examples in [`examples/`](examples/) (`simple-pack`, +`go-pack`, `docs-pack`) and edit it by hand — but you never have to. ## Session states @@ -407,15 +377,15 @@ cmd/ crabby/ # Linux (WSL) entrypoint crabby-windows/ # Windows wrapper -> crabby.exe internal/ - cli/ # cobra commands - initcmd/ # crabby init (+ pack application) - importcmd/ # crabby import (workspace discovery) + cli/ # cobra: `crabby`, `version`, `doctor` + initcmd/ # workspace initialization (+ pack application) + importcmd/ # workspace discovery for import insights/ # reads Claude transcripts for dashboard metrics - pack/ # local project packs + pack/ # local project packs (list + create/duplicate/delete) project/ # projects.json registry session/ # project <-> session mapping + state tmux/ # tmux driver (dedicated socket, F12 detach, status bar) - tui/ # bubble tea home screen + pack/import selectors + tui/ # bubble tea application (dashboard, forms, packs, settings) doctor/ # environment checks config/ # global config version/ # version (single source of truth: VERSION file) From 9b50925c944a0e9615263cf024cff7e53c7aa0dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20L=C3=B3pez?= Date: Tue, 14 Jul 2026 21:44:53 +0200 Subject: [PATCH 05/15] tui-first: clean transitions in and out of a session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attaching drops out of Crabby's alternate screen into the terminal's normal buffer, and on detach tmux prints '[detached (from session …)]' there. Left alone that noise piled up in the shell and the buffer switch briefly showed the previous session or shell. Run the attach through a tea.Exec command that clears the normal buffer either side of it, so both transitions are clean and the shell stays empty after quitting. The tmux session name is still passed as a single argv element (no shell), so unusual workspace names can't be word-split. Remove the now-unused tmux.AttachChild. --- internal/tmux/tmux.go | 15 +++++---------- internal/tui/app.go | 44 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 00694b9..6e57779 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -149,9 +149,11 @@ func (c Client) RenameWindow(session, name string) error { } // AttachCmd builds the command that attaches to a session, wired to the real -// terminal. It does not run: the TUI hands it to tea.ExecProcess so Bubble Tea -// can release the screen, run the attach, and restore itself afterwards — -// giving a seamless step in and out of a Claude session without leaving Crabby. +// terminal. It does not run: the TUI wraps it for tea.Exec so Bubble Tea can +// release the screen, run the attach, and restore itself afterwards — a seamless +// step in and out of a Claude session without ever leaving Crabby. The session +// name is passed as a single argv element, so names with unusual characters are +// never word-split or interpreted by a shell. func (c Client) AttachCmd(name string) *exec.Cmd { cmd := exec.Command(c.Binary, c.args("attach-session", "-t", exact(name))...) cmd.Stdin = os.Stdin @@ -160,13 +162,6 @@ func (c Client) AttachCmd(name string) *exec.Cmd { return cmd } -// AttachChild attaches to the session as a child process, inheriting the -// terminal. It blocks until the user leaves the session (detaches or Claude -// exits) and then returns — which is what lets Crabby take back control. -func (c Client) AttachChild(name string) error { - return c.AttachCmd(name).Run() -} - // Configure makes the session feel like part of Crabby rather than tmux: // - a single, prefix-free key (detachKey) returns to Crabby; // - a small, Crabby-branded status bar shows how to get back. diff --git a/internal/tui/app.go b/internal/tui/app.go index 399b323..79c4ee3 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -10,6 +10,7 @@ package tui import ( "fmt" + "io" "os" "os/exec" "strings" @@ -275,8 +276,47 @@ func (m model) attach(p project.Project, task project.Task) (tea.Model, tea.Cmd) // means a plain status bar, so never block the attach on it. _ = m.tmux.Configure(m.cfg.DetachKey) m.notice, m.noticeErr = "", false - cmd := m.tmux.AttachCmd(task.Session) - return m, tea.ExecProcess(cmd, func(err error) tea.Msg { return execDoneMsg{err} }) + ex := &attachExec{cmd: m.tmux.AttachCmd(task.Session)} + return m, tea.Exec(ex, func(err error) tea.Msg { return execDoneMsg{err} }) +} + +// clearNormalBuffer is the escape sequence that clears the terminal's normal +// (non-alternate) screen and homes the cursor. +const clearNormalBuffer = "\033[2J\033[H" + +// attachExec runs the tmux attach as a tea.Exec command, clearing the terminal's +// normal buffer either side of it. Attaching drops out of Crabby's alternate +// screen, and on detach tmux prints "[detached (from session …)]" to the normal +// buffer; without the clears that message accumulates in the shell and the +// buffer-switch shows the leftover session for a moment. Clearing turns both into +// a clean, blank transition. +type attachExec struct{ cmd *exec.Cmd } + +func (a *attachExec) Run() error { + fmt.Fprint(os.Stdout, clearNormalBuffer) + err := a.cmd.Run() + fmt.Fprint(os.Stdout, clearNormalBuffer) + return err +} + +// Bubble Tea wires its own I/O into a command only when unset; the attach +// command is already bound to the real terminal, so these keep that binding. +func (a *attachExec) SetStdin(r io.Reader) { + if a.cmd.Stdin == nil { + a.cmd.Stdin = r + } +} + +func (a *attachExec) SetStdout(w io.Writer) { + if a.cmd.Stdout == nil { + a.cmd.Stdout = w + } +} + +func (a *attachExec) SetStderr(w io.Writer) { + if a.cmd.Stderr == nil { + a.cmd.Stderr = w + } } // windowLabel is what shows in the tmux status bar: just the workspace for the From 73be9adb69a97d97d672481de538a6149c51ed12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20L=C3=B3pez?= Date: Tue, 14 Jul 2026 21:59:23 +0200 Subject: [PATCH 06/15] tui-first: path completion + native folder picker for new workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new-workspace directory field now helps you find the folder: - Tab completes the typed path to the matching sub-directory (shell-style), and matching sub-directories are listed live as you type — both directory-only, using stdlib, no new dependencies. - Ctrl+O opens the native Windows folder dialog (WSL) via PowerShell and converts the result with wslpath, as a convenience for Windows-side folders. It runs off the UI thread so the interface stays responsive. --- CHANGELOG.md | 4 +- README.md | 6 +- internal/tui/app.go | 18 ++++++ internal/tui/forms.go | 124 ++++++++++++++++++++++++++++++++++++- internal/tui/forms_test.go | 42 +++++++++++++ internal/tui/winpicker.go | 44 +++++++++++++ 6 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 internal/tui/forms_test.go create mode 100644 internal/tui/winpicker.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 79186e6..bbc0855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- In-app **workspace creation** (n): pick a directory and a pack, no shell. +- In-app **workspace creation** (n): pick a directory and a pack, no shell. The + path field completes with **Tab** and lists matching sub-directories as you + type; **Ctrl+O** opens the native Windows folder picker (WSL) as a convenience. - In-app **import** (i): scan a folder for workspaces with a `CLAUDE.md` and pick which to adopt; the scan runs off the UI thread so a large tree never freezes. - In-app **task management**: create (t), stop (x), remove (d), and open (Enter). diff --git a/README.md b/README.md index 284664c..458d2b5 100644 --- a/README.md +++ b/README.md @@ -123,8 +123,10 @@ and [Workspace insights](#workspace-insights) for where the Details come from. starts it for you. - **F12** (inside a session) brings you straight back here — no tmux shortcuts to learn. The session keeps running in the background. -- **n** creates a new workspace from any folder (see [Packs](#packs)); **i** - imports every workspace found under a folder. +- **n** creates a new workspace from any folder (see [Packs](#packs)) — the path + field completes with **Tab** and lists matching sub-directories as you type, and + **Ctrl+O** opens the native Windows folder picker; **i** imports every workspace + found under a folder. - **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 (asks first). Your diff --git a/internal/tui/app.go b/internal/tui/app.go index 79c4ee3..f9cba42 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -79,6 +79,7 @@ type model struct { // --- New-workspace flow ------------------------------------------------ wsStep wsStep wsDir string + wsMatches []string // sub-directories matching the typed path, for completion packList []pack.Pack packCursor int @@ -186,6 +187,23 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case discoverDoneMsg: return m.applyDiscover(msg) + case folderPickedMsg: + if m.screen != scrNewWorkspace { + return m, nil + } + switch { + case msg.err != nil: + m.notice, m.noticeErr = msg.err.Error(), true + case msg.path == "": + m.notice, m.noticeErr = "Folder picker cancelled.", false + default: + m.wsStep = wsStepDir + m.input = newTextInput(msg.path) + _, m.wsMatches = dirCandidates(msg.path) + m.notice = "" + } + return m, nil + case editMsg: return m.launchEditor(msg.dir) diff --git a/internal/tui/forms.go b/internal/tui/forms.go index db97335..0d98551 100644 --- a/internal/tui/forms.go +++ b/internal/tui/forms.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "time" @@ -30,6 +31,7 @@ func (m model) startNewWorkspace() (tea.Model, tea.Cmd) { m.screen = scrNewWorkspace m.wsStep = wsStepDir m.input = newTextInput(cwd) + _, m.wsMatches = dirCandidates(cwd) m.notice = "" return m, nil } @@ -40,6 +42,14 @@ func (m model) updateNewWorkspace(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc": m.goDashboard() + case "tab": + // Complete the typed path to the matching sub-directory, shell-style. + m.input = newTextInput(completePath(m.input.Value())) + _, m.wsMatches = dirCandidates(m.input.Value()) + case "ctrl+o": + // Optional native folder picker (WSL → Windows dialog). + m.notice, m.noticeErr = "Opening the Windows folder picker…", false + return m, pickWindowsFolder case "enter": dir := strings.TrimSpace(m.input.Value()) if dir == "" { @@ -60,6 +70,7 @@ func (m model) updateNewWorkspace(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } default: m.input = m.input.update(msg) + _, m.wsMatches = dirCandidates(m.input.Value()) } case wsStepPack: switch msg.String() { @@ -123,11 +134,122 @@ func (m model) viewNewWorkspace() string { return m.frame("New workspace", b.String(), footer) default: body := "Directory to initialize:\n\n " + m.input.view("path to a project folder") - footer := actionKey("enter", "continue") + " " + actionKey("esc", "cancel") + if len(m.wsMatches) > 0 { + body += "\n\n " + metaStyle.Render("subdirectories:") + "\n " + + metaStyle.Render(wrapMatches(m.wsMatches, 60)) + } + footer := strings.Join([]string{ + actionKey("tab", "complete"), actionKey("ctrl+o", "windows folder"), + actionKey("enter", "continue"), actionKey("esc", "cancel"), + }, " ") return m.frame("New workspace", body, footer) } } +// dirCandidates returns the directory currently being completed and the names of +// its sub-directories that match what has been typed — directory-only, so it +// only ever offers valid workspace locations. It powers both the live suggestion +// list and Tab completion. +func dirCandidates(input string) (dir string, matches []string) { + raw := strings.TrimSpace(input) + // Detect the trailing separator on the raw text: expandHome cleans the path + // (via filepath.Abs), which drops it — but it is what distinguishes "list this + // directory's children" from "complete this last component". + trailing := strings.HasSuffix(raw, string(os.PathSeparator)) + p := expandHome(raw) + var prefix string + switch { + case raw == "": + dir = "." + case trailing: + dir = p + default: + dir, prefix = filepath.Dir(p), filepath.Base(p) + } + entries, err := os.ReadDir(dir) + if err != nil { + return dir, nil + } + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + // Hide dot-directories unless the user has started typing one. + if strings.HasPrefix(name, ".") && !strings.HasPrefix(prefix, ".") { + continue + } + if strings.HasPrefix(name, prefix) { + matches = append(matches, name) + } + } + sort.Strings(matches) + return dir, matches +} + +// completePath extends a typed path toward its matching sub-directory: a single +// match is filled in with a trailing separator so drilling can continue; several +// matches extend to their longest common prefix. +func completePath(input string) string { + dir, matches := dirCandidates(input) + if len(matches) == 0 { + return input + } + if len(matches) == 1 { + return filepath.Join(dir, matches[0]) + string(os.PathSeparator) + } + lcp := matches[0] + for _, m := range matches[1:] { + lcp = commonPrefix(lcp, m) + } + return filepath.Join(dir, lcp) +} + +func commonPrefix(a, b string) string { + n := len(a) + if len(b) < n { + n = len(b) + } + i := 0 + for i < n && a[i] == b[i] { + i++ + } + return a[:i] +} + +// wrapMatches lays sub-directory names out across lines within width, each shown +// with a trailing separator, capping the list so it never floods the screen. +func wrapMatches(matches []string, width int) string { + const cap = 24 + extra := 0 + if len(matches) > cap { + extra = len(matches) - cap + matches = matches[:cap] + } + var lines []string + cur := "" + for _, name := range matches { + item := name + "/" + switch { + case cur == "": + cur = item + case len(cur)+2+len(item) > width: + lines = append(lines, cur) + cur = item + default: + cur += " " + item + } + } + if cur != "" { + lines = append(lines, cur) + } + out := strings.Join(lines, "\n ") + if extra > 0 { + out += fmt.Sprintf("\n … (+%d more)", extra) + } + return out +} + // selectLine renders one selectable row with the accent bar when chosen. func (m model) selectLine(selected bool, label string) string { if selected { diff --git a/internal/tui/forms_test.go b/internal/tui/forms_test.go new file mode 100644 index 0000000..add509c --- /dev/null +++ b/internal/tui/forms_test.go @@ -0,0 +1,42 @@ +package tui + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestDirCandidatesAndCompletion(t *testing.T) { + base := t.TempDir() + for _, d := range []string{"alpha", "alps", ".hidden"} { + if err := os.MkdirAll(filepath.Join(base, d), 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(base, "afile"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + // Listing a directory: only sub-directories, hidden ones excluded. + if _, got := dirCandidates(base + string(os.PathSeparator)); !reflect.DeepEqual(got, []string{"alpha", "alps"}) { + t.Fatalf("children = %v, want [alpha alps]", got) + } + + // A prefix filters, and a dot prefix reveals hidden directories. + if _, got := dirCandidates(filepath.Join(base, "alph")); !reflect.DeepEqual(got, []string{"alpha"}) { + t.Fatalf("prefix alph = %v, want [alpha]", got) + } + if _, got := dirCandidates(filepath.Join(base, ".hid")); !reflect.DeepEqual(got, []string{".hidden"}) { + t.Fatalf("prefix .hid = %v, want [.hidden]", got) + } + + // Completion: a single match gains a trailing separator to drill further. + if got, want := completePath(filepath.Join(base, "alph")), filepath.Join(base, "alpha")+string(os.PathSeparator); got != want { + t.Fatalf("complete alph = %q, want %q", got, want) + } + // Several matches extend to the longest common prefix only. + if got, want := completePath(base+string(os.PathSeparator)), filepath.Join(base, "alp"); got != want { + t.Fatalf("complete (all) = %q, want %q", got, want) + } +} diff --git a/internal/tui/winpicker.go b/internal/tui/winpicker.go new file mode 100644 index 0000000..83c716b --- /dev/null +++ b/internal/tui/winpicker.go @@ -0,0 +1,44 @@ +package tui + +import ( + "fmt" + "os/exec" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// folderPickedMsg carries the result of the native Windows folder picker: a +// chosen path (already converted to a WSL path), empty on cancel, or an error +// when the picker could not run. +type folderPickedMsg struct { + path string + err error +} + +// pickWindowsFolder opens Windows' folder dialog through PowerShell and converts +// the chosen path to a WSL path with wslpath. It runs as a plain command — the +// dialog is a GUI, so the terminal is never taken over — and reports back as a +// message, keeping the TUI responsive while the dialog is open. +// +// This is a convenience for folders on the Windows side; day-to-day paths under +// the Linux home are quicker to reach with Tab completion. +func pickWindowsFolder() tea.Msg { + const ps = `Add-Type -AssemblyName System.Windows.Forms | Out-Null; ` + + `$d = New-Object System.Windows.Forms.FolderBrowserDialog; ` + + `if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.SelectedPath) }` + + out, err := exec.Command("powershell.exe", "-NoProfile", "-Sta", "-Command", ps).Output() + if err != nil { + return folderPickedMsg{err: fmt.Errorf("the Windows folder picker isn't available here")} + } + win := strings.TrimSpace(string(out)) + if win == "" { + return folderPickedMsg{} // cancelled + } + conv, err := exec.Command("wslpath", "-u", win).Output() + if err != nil { + return folderPickedMsg{err: fmt.Errorf("couldn't convert the Windows path %q", win)} + } + return folderPickedMsg{path: strings.TrimSpace(string(conv))} +} From fe5a1f1dfcf566d608ad09eb23b6b2714c62649d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20L=C3=B3pez?= Date: Tue, 14 Jul 2026 22:09:43 +0200 Subject: [PATCH 07/15] tui-first: animate the crab, join the CRABBY wordmark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The banner crab now sways within a fixed-width lane and cycles between resting, claw-wave and blink poses, driven by a separate 500ms redraw tick that only rebuilds the view (no disk/tmux reads) — negligible cost. The lane is fixed width, so only the crab moves; wordmark and support line stay put. - Wordmark is now 'CRABBY' (letters together) instead of 'C R A B B Y'. Banner() stays still for CLI help; BannerFrame(t) is the animated variant. --- CHANGELOG.md | 3 ++ internal/tui/app.go | 23 +++++++++- internal/tui/dashboard.go | 2 +- internal/tui/header.go | 85 +++++++++++++++++++++++++++---------- internal/tui/header_test.go | 2 +- 5 files changed, 88 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbc0855..13c8df2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 delete packs — no manual filesystem editing required. - A **Settings** section (Packs, Diagnostics, About) and a **Help** screen (?) listing every navigation key, shortcut, and action. +- The banner's crab is **gently animated** — it sways side to side, waves a claw + and blinks — driven by its own light redraw tick (no filesystem or tmux reads), + so it costs nothing. The wordmark is now **CRABBY** (letters together). ### Removed diff --git a/internal/tui/app.go b/internal/tui/app.go index f9cba42..ceb9468 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -30,6 +30,10 @@ import ( const refreshEach = time.Second +// animEach is how often the banner redraws. It only rebuilds the view (no disk +// or tmux reads), so it can be brisk without being costly. +const animEach = 500 * time.Millisecond + // screen is which view the app is currently showing. type screen int @@ -106,6 +110,9 @@ type model struct { settingsPane settingsPane checks []doctor.Check + // anim is the banner animation counter, advanced by its own light tick. + anim int + // notice is a transient message shown in the status bar (errors, results). notice string noticeErr bool @@ -135,6 +142,10 @@ func Run(cfg config.Config, t tmux.Client, coll *insights.Collector) error { type tickMsg time.Time +// animMsg drives the banner animation, separately from the data refresh so the +// crab can move without re-reading the filesystem or tmux. +type animMsg time.Time + // openTaskMsg asks the app to attach to a task, creating its session first if // needed. Every "open a Claude session" path funnels through here. type openTaskMsg struct { @@ -156,13 +167,17 @@ func tick() tea.Cmd { return tea.Tick(refreshEach, func(t time.Time) tea.Msg { return tickMsg(t) }) } +func animTick() tea.Cmd { + return tea.Tick(animEach, func(t time.Time) tea.Msg { return animMsg(t) }) +} + // bellCmd rings the terminal bell out-of-band (BEL does not disturb the screen). func bellCmd() tea.Msg { fmt.Fprint(os.Stderr, "\a") return nil } -func (m model) Init() tea.Cmd { return tick() } +func (m model) Init() tea.Cmd { return tea.Batch(tick(), animTick()) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { @@ -177,6 +192,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tick() + case animMsg: + m.anim++ + return m, animTick() + case noticeMsg: m.notice, m.noticeErr = msg.text, msg.isErr return m, nil @@ -368,7 +387,7 @@ func (m *model) goDashboard() { // reads as the same application. The status notice, if any, sits at the bottom. func (m model) frame(title, body, footer string) string { var b strings.Builder - b.WriteString(center(m.width, Banner())) + b.WriteString(center(m.width, BannerFrame(m.anim))) b.WriteString("\n\n") b.WriteString(center(m.width, divider())) b.WriteString("\n\n") diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index 9328399..b1fd71e 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -262,7 +262,7 @@ func (m model) handleConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m model) viewDashboard() string { var b strings.Builder - b.WriteString(center(m.width, Banner())) + b.WriteString(center(m.width, BannerFrame(m.anim))) b.WriteString("\n\n") if len(m.rows) == 0 { diff --git a/internal/tui/header.go b/internal/tui/header.go index d2bb340..5a9b0cd 100644 --- a/internal/tui/header.go +++ b/internal/tui/header.go @@ -17,24 +17,58 @@ var ( // - the support line's separator is "·" (U+00B7) with surrounding spaces; // - "claude" is intentionally lowercase in the support line. const ( - Wordmark = "C R A B B Y" + Wordmark = "CRABBY" Tagline = "Many Claudes. One shell." Subtitle = "prepare projects · orchestrate claude · one terminal" ) -// crabArt is the crab. Its 3rd line contains a backtick, and Go raw strings -// cannot contain one, so the literal is split and the backtick concatenated as -// a normal string. Do NOT reformat or re-indent — the spacing is the art. -const crabArt = ` (\/) (\/) - \(o..o)/ - /` + "`" + `----'\` +// crabArt is the crab in its resting pose. Its 3rd line contains a backtick and +// its slashes are literal, so the string is written with escapes rather than a +// raw literal. Do NOT reformat — the spacing is the art. This is the canonical +// pose; the animation frames below are small variations of it. +const crabArt = " (\\/) (\\/)\n" + + " \\(o..o)/\n" + + " /`----'\\" -// Banner renders Crabby's identity block: crab art, wordmark, tagline, and the -// support line, stacked and centred on the widest line (the support line). -// This is the single source of the ASCII art — do not duplicate it elsewhere. -func Banner() string { +// crabWave flips the claws, and crabBlink closes the eyes — the two frames that, +// alternated with the resting pose, make the crab look alive. +const ( + crabWave = " (/\\) (/\\)\n" + + " \\(o..o)/\n" + + " /`----'\\" + crabBlink = " (\\/) (\\/)\n" + + " \\(-..-)/\n" + + " /`----'\\" +) + +var crabFrames = []string{crabArt, crabWave, crabBlink} + +// crabLane is the fixed width the crab is laid out in, so it can sway left and +// right inside it without moving the rest of the banner. It is the widest crab +// line (13) plus the sway range (4). +const crabLane = 17 + +// swayHome centres the crab in its lane for the still banner. +const swayHome = 2 + +// crabCycle is one loop of the animation: for each step, which frame to show and +// how far to sway it. A gentle triangle sway with an occasional claw wave and a +// blink — calm, not busy. +var crabCycle = []struct{ frame, sway int }{ + {0, 0}, {0, 1}, {1, 2}, {0, 3}, {0, 4}, {2, 3}, {1, 2}, {0, 1}, +} + +// layoutCrab places a crab pose at a horizontal offset within the fixed lane, so +// every frame is exactly crabLane wide and only the crab moves. +func layoutCrab(art string, sway int) string { + return crabStyle.Width(crabLane).PaddingLeft(sway).Render(art) +} + +// joinBanner stacks the crab, wordmark, tagline and support line, centred on the +// widest line (the support line). +func joinBanner(crab string) string { return lipgloss.JoinVertical(lipgloss.Center, - crabStyle.Render(crabArt), + crab, "", wordmarkStyle.Render(Wordmark), taglineStyle.Render(Tagline), @@ -42,16 +76,21 @@ func Banner() string { ) } -// BannerWidth is the display width of the banner (its widest line). -func BannerWidth() int { return lipgloss.Width(Banner()) } +// Banner renders Crabby's identity block in its resting pose. This is the still +// banner used outside the TUI (e.g. CLI help). Inside the app, BannerFrame gives +// the animated version. +func Banner() string { + return joinBanner(layoutCrab(crabArt, swayHome)) +} -// Splash centres the banner horizontally in the given terminal width (from -// tea.WindowSizeMsg) with a little vertical breathing room. -func Splash(width int) string { - block := Banner() - if width <= 0 { - return block - } - return lipgloss.Place(width, lipgloss.Height(block)+2, - lipgloss.Center, lipgloss.Center, block) +// BannerFrame renders the banner at animation step t (any non-negative counter); +// successive values sway the crab and cycle its pose. The lane is fixed width, so +// the wordmark and support line never move. +func BannerFrame(t int) string { + step := crabCycle[t%len(crabCycle)] + return joinBanner(layoutCrab(crabFrames[step.frame], step.sway)) } + +// BannerWidth is the display width of the banner (its widest line), stable across +// animation frames. +func BannerWidth() int { return lipgloss.Width(Banner()) } diff --git a/internal/tui/header_test.go b/internal/tui/header_test.go index 0c35f5d..97f00ab 100644 --- a/internal/tui/header_test.go +++ b/internal/tui/header_test.go @@ -14,7 +14,7 @@ func TestCrabArtIsExact(t *testing.T) { } func TestIdentityText(t *testing.T) { - if Wordmark != "C R A B B Y" { + if Wordmark != "CRABBY" { t.Errorf("Wordmark = %q", Wordmark) } if Tagline != "Many Claudes. One shell." { From 854f6105dff9aa247089c266871f765e9f64329c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20L=C3=B3pez?= Date: Tue, 14 Jul 2026 22:18:45 +0200 Subject: [PATCH 08/15] tui-first: centre the crab and animate its claws and legs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The sway is now symmetric around zero (−2…+2), so the crab rests centred over the CRABBY wordmark instead of biased to one side. Verified: at sway 0 the claws' centre matches the wordmark's exactly. - Redraw the banner by centring every line manually rather than via JoinVertical, so the crab's horizontal position is exact and independent of lipgloss padding semantics. - Give the crab legs and animate them: the poses now move claws (wave), legs (kick) and eyes (blink) together. --- internal/tui/header.go | 109 ++++++++++++++++++++++-------------- internal/tui/header_test.go | 3 +- 2 files changed, 69 insertions(+), 43 deletions(-) diff --git a/internal/tui/header.go b/internal/tui/header.go index 5a9b0cd..982e0b7 100644 --- a/internal/tui/header.go +++ b/internal/tui/header.go @@ -1,6 +1,8 @@ package tui import ( + "strings" + "github.com/charmbracelet/lipgloss" ) @@ -22,75 +24,98 @@ const ( Subtitle = "prepare projects · orchestrate claude · one terminal" ) -// crabArt is the crab in its resting pose. Its 3rd line contains a backtick and -// its slashes are literal, so the string is written with escapes rather than a -// raw literal. Do NOT reformat — the spacing is the art. This is the canonical -// pose; the animation frames below are small variations of it. +// crabArt is the crab in its resting pose: claws on top, then face, body, and +// legs. Slashes are literal and the body line holds a backtick, so the strings +// are written with escapes rather than raw literals. Do NOT reformat — the +// spacing is the art. The animation poses below are variations of it. const crabArt = " (\\/) (\\/)\n" + " \\(o..o)/\n" + - " /`----'\\" + " /`----'\\\n" + + " // \\\\" -// crabWave flips the claws, and crabBlink closes the eyes — the two frames that, -// alternated with the resting pose, make the crab look alive. +// crabWave flips the claws and kicks the legs the other way; crabBlink closes +// the eyes and tucks the legs. Cycled with the resting pose, the claws, legs and +// eyes all move, so the crab reads as alive rather than just sliding about. const ( crabWave = " (/\\) (/\\)\n" + " \\(o..o)/\n" + - " /`----'\\" + " /`----'\\\n" + + " \\\\ //" crabBlink = " (\\/) (\\/)\n" + " \\(-..-)/\n" + - " /`----'\\" + " /`----'\\\n" + + " /| |\\" ) var crabFrames = []string{crabArt, crabWave, crabBlink} -// crabLane is the fixed width the crab is laid out in, so it can sway left and -// right inside it without moving the rest of the banner. It is the widest crab -// line (13) plus the sway range (4). -const crabLane = 17 +// crabCycle is one loop of the animation: for each step, which pose to show and +// how far to sway it. The sway is symmetric around zero so the crab's resting +// position is centred over the wordmark, not off to one side. +var crabCycle = []struct{ frame, sway int }{ + {0, -2}, {0, -1}, {1, 0}, {0, 1}, {0, 2}, {2, 1}, {1, 0}, {0, -1}, +} -// swayHome centres the crab in its lane for the still banner. -const swayHome = 2 +// bannerWidth is the width of the widest line (the support line); every line is +// centred within it, and the crab sways around its centre. +func bannerWidth() int { return lipgloss.Width(Subtitle) } -// crabCycle is one loop of the animation: for each step, which frame to show and -// how far to sway it. A gentle triangle sway with an occasional claw wave and a -// blink — calm, not busy. -var crabCycle = []struct{ frame, sway int }{ - {0, 0}, {0, 1}, {1, 2}, {0, 3}, {0, 4}, {2, 3}, {1, 2}, {0, 1}, +// blockWidth is the width of the widest line in a multi-line block. +func blockWidth(s string) int { + w := 0 + for _, l := range strings.Split(s, "\n") { + if x := lipgloss.Width(l); x > w { + w = x + } + } + return w } -// layoutCrab places a crab pose at a horizontal offset within the fixed lane, so -// every frame is exactly crabLane wide and only the crab moves. -func layoutCrab(art string, sway int) string { - return crabStyle.Width(crabLane).PaddingLeft(sway).Render(art) +// centerIn left-pads a (possibly styled) line so it sits centred in width. +func centerIn(width int, s string) string { + if pad := (width - lipgloss.Width(s)) / 2; pad > 0 { + return strings.Repeat(" ", pad) + s + } + return s } -// joinBanner stacks the crab, wordmark, tagline and support line, centred on the -// widest line (the support line). -func joinBanner(crab string) string { - return lipgloss.JoinVertical(lipgloss.Center, - crab, +// renderBanner assembles the identity block with the crab at a given pose and +// horizontal sway. The crab is centred over the wordmark at sway 0 and shifts by +// sway columns either side; the wordmark, tagline and support line never move. +func renderBanner(frame, sway int) string { + bw := bannerWidth() + crab := crabFrames[frame] + pad := (bw-blockWidth(crab))/2 + sway + if pad < 0 { + pad = 0 + } + lead := strings.Repeat(" ", pad) + + var lines []string + for _, l := range strings.Split(crab, "\n") { + lines = append(lines, lead+crabStyle.Render(l)) + } + lines = append(lines, "", - wordmarkStyle.Render(Wordmark), - taglineStyle.Render(Tagline), - subStyle.Render(Subtitle), + centerIn(bw, wordmarkStyle.Render(Wordmark)), + centerIn(bw, taglineStyle.Render(Tagline)), + centerIn(bw, subStyle.Render(Subtitle)), ) + return strings.Join(lines, "\n") } -// Banner renders Crabby's identity block in its resting pose. This is the still -// banner used outside the TUI (e.g. CLI help). Inside the app, BannerFrame gives -// the animated version. -func Banner() string { - return joinBanner(layoutCrab(crabArt, swayHome)) -} +// Banner renders Crabby's identity block in its resting, centred pose. This is +// the still banner used outside the TUI (e.g. CLI help); inside the app, +// BannerFrame gives the animated version. +func Banner() string { return renderBanner(0, 0) } // BannerFrame renders the banner at animation step t (any non-negative counter); -// successive values sway the crab and cycle its pose. The lane is fixed width, so -// the wordmark and support line never move. +// successive values sway the crab and cycle its pose. func BannerFrame(t int) string { step := crabCycle[t%len(crabCycle)] - return joinBanner(layoutCrab(crabFrames[step.frame], step.sway)) + return renderBanner(step.frame, step.sway) } // BannerWidth is the display width of the banner (its widest line), stable across // animation frames. -func BannerWidth() int { return lipgloss.Width(Banner()) } +func BannerWidth() int { return bannerWidth() } diff --git a/internal/tui/header_test.go b/internal/tui/header_test.go index 97f00ab..8c93f9a 100644 --- a/internal/tui/header_test.go +++ b/internal/tui/header_test.go @@ -7,7 +7,8 @@ import "testing" func TestCrabArtIsExact(t *testing.T) { want := " (\\/) (\\/)\n" + " \\(o..o)/\n" + - " /`----'\\" + " /`----'\\\n" + + " // \\\\" if crabArt != want { t.Fatalf("crab art changed.\n got: %q\nwant: %q", crabArt, want) } From 8766c20f12f06aec9842f19e5855465a2636f4bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20L=C3=B3pez?= Date: Tue, 14 Jul 2026 22:24:00 +0200 Subject: [PATCH 09/15] tui-first: keep the crab's legs still and start it centred MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Legs no longer animate: every pose uses the same legs, so only the claws (wave) and eyes (blink) move. - The animation cycle now starts at sway 0 and swings symmetrically, so the crab both opens and rests centred over the wordmark instead of starting at the left extreme. Verified: t=0 offset is 0, sway is symmetric ±2. --- internal/tui/header.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/tui/header.go b/internal/tui/header.go index 982e0b7..373b165 100644 --- a/internal/tui/header.go +++ b/internal/tui/header.go @@ -33,27 +33,27 @@ const crabArt = " (\\/) (\\/)\n" + " /`----'\\\n" + " // \\\\" -// crabWave flips the claws and kicks the legs the other way; crabBlink closes -// the eyes and tucks the legs. Cycled with the resting pose, the claws, legs and -// eyes all move, so the crab reads as alive rather than just sliding about. +// crabWave flips the claws; crabBlink closes the eyes. The legs stay put in +// every pose — only the claws and eyes move, so the crab reads as alive without +// the busier leg motion. const ( crabWave = " (/\\) (/\\)\n" + " \\(o..o)/\n" + " /`----'\\\n" + - " \\\\ //" + " // \\\\" crabBlink = " (\\/) (\\/)\n" + " \\(-..-)/\n" + " /`----'\\\n" + - " /| |\\" + " // \\\\" ) var crabFrames = []string{crabArt, crabWave, crabBlink} // crabCycle is one loop of the animation: for each step, which pose to show and -// how far to sway it. The sway is symmetric around zero so the crab's resting -// position is centred over the wordmark, not off to one side. +// how far to sway it. It starts centred (sway 0) and swings symmetrically either +// side, so the crab both begins and rests centred over the wordmark. var crabCycle = []struct{ frame, sway int }{ - {0, -2}, {0, -1}, {1, 0}, {0, 1}, {0, 2}, {2, 1}, {1, 0}, {0, -1}, + {0, 0}, {0, 1}, {1, 2}, {0, 1}, {0, 0}, {2, -1}, {1, -2}, {0, -1}, } // bannerWidth is the width of the widest line (the support line); every line is From f3e74dd0a98c9844e95c9169ad64121066937ed8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20L=C3=B3pez?= Date: Tue, 14 Jul 2026 22:34:48 +0200 Subject: [PATCH 10/15] tui-first: fix banner centering (wordmark/tagline drifted right) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit center() uses lipgloss.PlaceHorizontal, which centres each line by its own width. The manual banner layout left-padded lines only, so lines of differing widths were re-centred independently and the wordmark and tagline drifted right of the crab and support line. Make every banner line exactly bannerWidth wide (pad both sides), so the block centres as a unit — the same shape JoinVertical produced. Verified: crab, CRABBY, tagline and support line now share one centre. --- internal/tui/header.go | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/internal/tui/header.go b/internal/tui/header.go index 373b165..59226e7 100644 --- a/internal/tui/header.go +++ b/internal/tui/header.go @@ -71,32 +71,42 @@ func blockWidth(s string) int { return w } -// centerIn left-pads a (possibly styled) line so it sits centred in width. -func centerIn(width int, s string) string { - if pad := (width - lipgloss.Width(s)) / 2; pad > 0 { - return strings.Repeat(" ", pad) + s +// placeIn positions a (possibly styled) line at the given left offset within a +// line that is exactly width wide, right-padding to fill it. Every banner line +// is made the same width this way: lipgloss.PlaceHorizontal centres each line by +// its own width, so lines of differing widths would be re-centred independently +// and drift apart — a uniform width keeps them aligned as one block. +func placeIn(width, left int, s string) string { + right := width - left - lipgloss.Width(s) + if right < 0 { + right = 0 + } + if left < 0 { + left = 0 } - return s + return strings.Repeat(" ", left) + s + strings.Repeat(" ", right) +} + +// centerIn places a line centred within width (both sides padded). +func centerIn(width int, s string) string { + return placeIn(width, (width-lipgloss.Width(s))/2, s) } // renderBanner assembles the identity block with the crab at a given pose and -// horizontal sway. The crab is centred over the wordmark at sway 0 and shifts by -// sway columns either side; the wordmark, tagline and support line never move. +// horizontal sway. Every line is exactly bannerWidth wide, so the block stays +// coherent when centred; the crab is centred over the wordmark at sway 0 and +// shifts by sway columns either side while the text lines never move. func renderBanner(frame, sway int) string { bw := bannerWidth() crab := crabFrames[frame] - pad := (bw-blockWidth(crab))/2 + sway - if pad < 0 { - pad = 0 - } - lead := strings.Repeat(" ", pad) + left := (bw-blockWidth(crab))/2 + sway var lines []string for _, l := range strings.Split(crab, "\n") { - lines = append(lines, lead+crabStyle.Render(l)) + lines = append(lines, placeIn(bw, left, crabStyle.Render(l))) } lines = append(lines, - "", + strings.Repeat(" ", bw), centerIn(bw, wordmarkStyle.Render(Wordmark)), centerIn(bw, taglineStyle.Render(Tagline)), centerIn(bw, subStyle.Render(Subtitle)), From f7d8258111e7de88733be72dda5795933bb9d40b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20L=C3=B3pez?= Date: Wed, 15 Jul 2026 19:35:59 +0200 Subject: [PATCH 11/15] v0.9: workspace import auto-scans the machine Pressing i now scans straight away instead of asking for a path: - importcmd.DiscoverDefault walks the WSL home and the common project folders under each Windows user on the mounted drives (/mnt// Users/*), in parallel, depth-capped so a deep tree can't stall it. - Matches CLAUDE.md and claude.md; prunes deps (now incl. build/dist) and hidden directories, so ~/.claude, ~/.config packs and caches stay out. - The pick list shows 'Found N' and a live 'Selected: N', with space to toggle, a/n to select all/none, i (or enter) to import, r to rescan. Already-imported workspaces are flagged and locked. --- internal/importcmd/discover_extra_test.go | 54 ++++++ internal/importcmd/importcmd.go | 194 +++++++++++++++++++--- internal/tui/imports.go | 176 +++++++++++--------- internal/tui/screens_test.go | 5 +- 4 files changed, 327 insertions(+), 102 deletions(-) create mode 100644 internal/importcmd/discover_extra_test.go diff --git a/internal/importcmd/discover_extra_test.go b/internal/importcmd/discover_extra_test.go new file mode 100644 index 0000000..b833e5b --- /dev/null +++ b/internal/importcmd/discover_extra_test.go @@ -0,0 +1,54 @@ +package importcmd + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDiscoverMatchesLowercaseClaude(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + root := t.TempDir() + + mk := func(name, file string) { + dir := filepath.Join(root, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, file), []byte("# notes\n"), 0o644); err != nil { + t.Fatal(err) + } + } + mk("upper", "CLAUDE.md") + mk("lower", "claude.md") + mk("title", "Claude.md") + + found, err := Discover(root) + if err != nil { + t.Fatalf("Discover: %v", err) + } + if len(found) != 3 { + t.Fatalf("got %d workspaces, want 3 (CLAUDE.md/claude.md/Claude.md)", len(found)) + } +} + +func TestWalkRootDepthCap(t *testing.T) { + root := t.TempDir() + + // A project three levels below the root. + deep := filepath.Join(root, "a", "b", "c", "proj") + if err := os.MkdirAll(deep, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(deep, "CLAUDE.md"), []byte("x\n"), 0o644); err != nil { + t.Fatal(err) + } + + // A shallow cap must not reach it; a generous cap must. + if got := walkRoot(root, 2, map[string]bool{}); len(got) != 0 { + t.Fatalf("depth 2 found %d, want 0 (project is 4 levels deep)", len(got)) + } + if got := walkRoot(root, 6, map[string]bool{}); len(got) != 1 { + t.Fatalf("depth 6 found %d, want 1", len(got)) + } +} diff --git a/internal/importcmd/importcmd.go b/internal/importcmd/importcmd.go index 99e3e89..066b680 100644 --- a/internal/importcmd/importcmd.go +++ b/internal/importcmd/importcmd.go @@ -1,11 +1,15 @@ // Package importcmd discovers existing Claude Code projects so they can be // adopted into Crabby without re-initializing them. // -// A folder qualifies simply by containing a CLAUDE.md — a git repository is not -// required (git only ever supplied the branch shown for context). Discovery -// walks a directory tree once, pruning dependency folders and stopping at the -// first CLAUDE.md on any branch, so a project's own subdirectory context files -// are never imported as separate workspaces. +// A folder qualifies simply by containing a CLAUDE.md (or claude.md) — a git +// repository is not required (git only ever supplied the branch shown for +// context). Discovery walks a directory tree once, pruning dependency folders +// and stopping at the first context file on any branch, so a project's own +// subdirectory context files are never imported as separate workspaces. +// +// Discover scans a single root the caller names; DiscoverDefault scans the +// machine's usual development locations across WSL and the mounted Windows +// drives, so the user can adopt everything without typing a path. package importcmd import ( @@ -13,6 +17,8 @@ import ( "os" "path/filepath" "sort" + "strings" + "sync" "github.com/marioolf/crabby/internal/project" ) @@ -35,12 +41,21 @@ var ignoredDirs = map[string]bool{ "venv": true, "vendor": true, "target": true, + "build": true, + "dist": true, "__pycache__": true, ".tox": true, ".mypy_cache": true, + ".next": true, + ".cache": true, + ".gradle": true, } -// Discover walks root and returns every folder containing a CLAUDE.md found +// contextFiles are the names that mark a Claude workspace, matched +// case-sensitively against a small set rather than statting the whole directory. +var contextFiles = []string{"CLAUDE.md", "claude.md", "Claude.md"} + +// Discover walks root and returns every folder containing a context file found // beneath it, sorted by name. Already-registered projects are flagged rather // than omitted, so the selector can show them as imported. Unreadable // directories are skipped silently rather than aborting the scan. @@ -49,31 +64,128 @@ func Discover(root string) ([]Workspace, error) { if err != nil { return nil, err } + found := walkRoot(abs, -1, registeredPaths()) + sort.Slice(found, func(i, j int) bool { return found[i].Name < found[j].Name }) + return found, nil +} - // Registered paths, so we can mark what is already imported. - registered := map[string]bool{} - if projects, err := project.Load(); err == nil { - for _, p := range projects { - registered[p.Path] = true +// DiscoverDefault scans the machine's usual development locations — the WSL home +// and the common project folders under each mounted Windows user — and returns +// every workspace found, de-duplicated and sorted. Roots are scanned in parallel +// because the Windows mounts are comparatively slow, and each is depth-capped so +// a stray deep tree can't stall the scan. +func DiscoverDefault() ([]Workspace, error) { + registered := registeredPaths() + var ( + mu sync.Mutex + wg sync.WaitGroup + out []Workspace + seen = map[string]bool{} + roots = DefaultRoots() + ) + for _, root := range roots { + root := root + depth := 8 + if strings.HasPrefix(root, "/mnt/") { + depth = 6 // the Windows mounts are slow; keep those walks shallow + } + wg.Add(1) + go func() { + defer wg.Done() + ws := walkRoot(root, depth, registered) + mu.Lock() + for _, w := range ws { + if !seen[w.Path] { + seen[w.Path] = true + out = append(out, w) + } + } + mu.Unlock() + }() + } + wg.Wait() + sort.Slice(out, func(i, j int) bool { + if out[i].Name != out[j].Name { + return out[i].Name < out[j].Name + } + return out[i].Path < out[j].Path + }) + return out, nil +} + +// DefaultRoots is the list of directories DiscoverDefault scans: the WSL home, +// plus the common development folders under each Windows user on every mounted +// drive. Only directories that actually exist are returned. +func DefaultRoots() []string { + var roots []string + if home, err := os.UserHomeDir(); err == nil { + roots = append(roots, home) + } + for _, drive := range []string{"c", "d", "e"} { + users := filepath.Join("/mnt", drive, "Users") + entries, err := os.ReadDir(users) + if err != nil { + continue + } + for _, e := range entries { + if !e.IsDir() || skipWinUser(e.Name()) { + continue + } + userHome := filepath.Join(users, e.Name()) + for _, sub := range winDevSubdirs { + p := filepath.Join(userHome, sub) + if isDir(p) { + roots = append(roots, p) + } + } } } + return dedupe(roots) +} + +// winDevSubdirs are the folders under a Windows user home where projects usually +// live. Scanning these (rather than the whole home) keeps the slow /mnt walk +// targeted and quick. +var winDevSubdirs = []string{ + "Desktop", "Documents", "source", "source/repos", "repos", + "projects", "Projects", "dev", "code", "git", + "OneDrive/Desktop", "OneDrive/Documents", +} + +func skipWinUser(name string) bool { + switch name { + case "Public", "Default", "Default User", "All Users": + return true + } + return false +} +// walkRoot walks one root, returning the workspaces beneath it. maxDepth < 0 +// means no limit; otherwise directories deeper than maxDepth below root are not +// descended into. +func walkRoot(root string, maxDepth int, registered map[string]bool) []Workspace { var found []Workspace - walkErr := filepath.WalkDir(abs, func(path string, d fs.DirEntry, err error) error { + _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { return nil // unreadable entry — keep scanning the rest } if !d.IsDir() { return nil } - // Prune dependency folders, but never the root the user asked for. - if path != abs && ignoredDirs[d.Name()] { + // Prune dependency folders and hidden directories (never the root the + // caller named). Skipping dot-directories keeps config and cache trees — + // ~/.claude, ~/.config, ~/.cache and the like — out of the results, which + // otherwise surface Crabby's own packs and the global Claude config. + if path != root && (ignoredDirs[d.Name()] || strings.HasPrefix(d.Name(), ".")) { return fs.SkipDir } - if !fileExists(filepath.Join(path, "CLAUDE.md")) { + if maxDepth >= 0 && depthUnder(root, path) > maxDepth { + return fs.SkipDir + } + if !hasContextFile(path) { return nil } - // A CLAUDE.md marks a project. Record it (with its git branch if it + // A context file marks a project. Record it (with its git branch if it // happens to be a repo) and stop descending, so nested context files // deeper in the project aren't imported as separate workspaces. found = append(found, Workspace{ @@ -84,15 +196,57 @@ func Discover(root string) ([]Workspace, error) { }) return fs.SkipDir }) - if walkErr != nil { - return nil, walkErr + return found +} + +// depthUnder reports how many directory levels path sits below root. +func depthUnder(root, path string) int { + rel, err := filepath.Rel(root, path) + if err != nil || rel == "." { + return 0 } + return strings.Count(rel, string(os.PathSeparator)) + 1 +} - sort.Slice(found, func(i, j int) bool { return found[i].Name < found[j].Name }) - return found, nil +func hasContextFile(dir string) bool { + for _, name := range contextFiles { + if fileExists(filepath.Join(dir, name)) { + return true + } + } + return false +} + +// registeredPaths returns the set of paths already registered in Crabby, so +// discovery can flag what is already imported. +func registeredPaths() map[string]bool { + registered := map[string]bool{} + if projects, err := project.Load(); err == nil { + for _, p := range projects { + registered[p.Path] = true + } + } + return registered +} + +func dedupe(in []string) []string { + seen := map[string]bool{} + var out []string + for _, s := range in { + if !seen[s] { + seen[s] = true + out = append(out, s) + } + } + return out } func fileExists(path string) bool { info, err := os.Stat(path) return err == nil && !info.IsDir() } + +func isDir(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} diff --git a/internal/tui/imports.go b/internal/tui/imports.go index 7eeb47d..886e6dc 100644 --- a/internal/tui/imports.go +++ b/internal/tui/imports.go @@ -2,7 +2,6 @@ package tui import ( "fmt" - "os" "strings" tea "github.com/charmbracelet/bubbletea" @@ -11,66 +10,51 @@ import ( "github.com/marioolf/crabby/internal/initcmd" ) -// importStep is which step of the import flow is on screen: choosing where to -// scan, or picking which discovered workspaces to adopt. +// importStep is which step of the import flow is on screen: the machine scan, or +// the pick list of what it found. type importStep int const ( - importStepPath importStep = iota - importStepScanning + importStepScanning importStep = iota importStepPick ) -// discoverDoneMsg carries the result of a background workspace scan. Scanning a -// large tree can take a moment, so it runs off the UI thread and reports back. +// discoverDoneMsg carries the result of a background workspace scan. Scanning +// can take a moment, so it runs off the UI thread and reports back. type discoverDoneMsg struct { found []importcmd.Workspace err error } +// startImport opens the import view and immediately begins scanning the machine +// — no path to type, no shell. The scan runs off the UI thread. func (m model) startImport() (tea.Model, tea.Cmd) { - cwd, _ := os.Getwd() m.screen = scrImport - m.importStep = importStepPath - m.input = newTextInput(cwd) + m.importStep = importStepScanning m.importList = nil m.importSel = nil m.importCursor = 0 m.notice = "" - return m, nil + return m, scanMachine +} + +// scanMachine runs the default multi-location scan and reports the result. +func scanMachine() tea.Msg { + found, err := importcmd.DiscoverDefault() + return discoverDoneMsg{found, err} } func (m model) updateImport(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch m.importStep { - case importStepPath: - switch msg.String() { - case "esc": - m.goDashboard() - case "enter": - root := expandHome(strings.TrimSpace(m.input.Value())) - if root == "" { - return m, nil - } - // Scan off the UI thread — a deep tree must not freeze Crabby. - m.importStep = importStepScanning - m.notice = "" - return m, func() tea.Msg { - found, err := importcmd.Discover(root) - return discoverDoneMsg{found, err} - } - default: - m.input = m.input.update(msg) - } case importStepScanning: - // Only Esc does anything while scanning; a stale result is discarded in - // the message handler if the user has already left. if msg.String() == "esc" { - m.importStep = importStepPath + m.goDashboard() } + return m, nil case importStepPick: switch msg.String() { - case "esc": - m.importStep = importStepPath + case "esc", "q": + m.goDashboard() case "up", "k": if m.importCursor > 0 { m.importCursor-- @@ -80,40 +64,42 @@ func (m model) updateImport(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.importCursor++ } case " ": - if !m.importList[m.importCursor].Imported { + if len(m.importList) > 0 && !m.importList[m.importCursor].Imported { m.importSel[m.importCursor] = !m.importSel[m.importCursor] } case "a": - m.toggleAllImports() - case "enter": + m.setAllImports(true) + case "n": + m.setAllImports(false) + case "r": + m.importStep = importStepScanning + m.importList = nil + m.importSel = nil + m.importCursor = 0 + return m, scanMachine + case "i", "I", "enter": return m.performImport() } } return m, nil } -// applyDiscover consumes a background scan result: it moves to the pick list, or -// reports an empty/failed scan. A result that arrives after the user has left -// the scan is ignored. +// applyDiscover consumes a background scan result, moving to the pick list. A +// result that arrives after the user has left the scan is ignored. func (m model) applyDiscover(msg discoverDoneMsg) (tea.Model, tea.Cmd) { if m.screen != scrImport || m.importStep != importStepScanning { return m, nil } if msg.err != nil { - m.importStep = importStepPath + m.goDashboard() m.notice, m.noticeErr = msg.err.Error(), true return m, nil } - if len(msg.found) == 0 { - m.importStep = importStepPath - m.notice, m.noticeErr = "No workspaces (folders with a CLAUDE.md) found there.", true - return m, nil - } m.importList = msg.found m.importSel = make([]bool, len(msg.found)) for i, w := range msg.found { - // Everything importable starts checked — adopting a folder should take - // one keypress, not one per project. + // Everything not-yet-imported starts checked, so adopting a machine full + // of projects is one keypress. m.importSel[i] = !w.Imported } m.importCursor = 0 @@ -121,25 +107,28 @@ func (m model) applyDiscover(msg discoverDoneMsg) (tea.Model, tea.Cmd) { return m, nil } -// toggleAllImports selects every importable workspace, or clears them all if -// they are already selected. -func (m *model) toggleAllImports() { - allOn := true +// setAllImports selects or clears every importable workspace at once. +func (m *model) setAllImports(on bool) { for i, w := range m.importList { - if !w.Imported && !m.importSel[i] { - allOn = false - break + if !w.Imported { + m.importSel[i] = on } } +} + +// importSelectedCount is how many not-yet-imported workspaces are checked. +func (m model) importSelectedCount() int { + n := 0 for i, w := range m.importList { - if !w.Imported { - m.importSel[i] = !allOn + if m.importSel[i] && !w.Imported { + n++ } } + return n } // performImport registers each chosen workspace with init (no pack, so existing -// CLAUDE.md files are left untouched) and returns to the dashboard. +// context files are left untouched) and returns to the dashboard. func (m model) performImport() (tea.Model, tea.Cmd) { imported := 0 for i, w := range m.importList { @@ -161,48 +150,77 @@ func (m model) performImport() (tea.Model, tea.Cmd) { } func (m model) viewImport() string { - switch m.importStep { - case importStepPath: - body := "Scan a folder for workspaces (any folder with a CLAUDE.md):\n\n " + - m.input.view("path to scan") - footer := actionKey("enter", "scan") + " " + actionKey("esc", "cancel") - return m.frame("Import workspaces", body, footer) - case importStepScanning: - body := metaStyle.Render("Scanning for workspaces… this can take a moment on a large folder.") + if m.importStep == importStepScanning { + body := metaStyle.Render("Scanning your machine for Claude workspaces…") + "\n" + + metaStyle.Render("(your WSL home and the common project folders on your Windows drives)") return m.frame("Import workspaces", body, actionKey("esc", "cancel")) } var b strings.Builder - b.WriteString(metaStyle.Render(fmt.Sprintf("Found %s.", plural(len(m.importList), "workspace"))) + "\n\n") - for i, w := range m.importList { + if len(m.importList) == 0 { + b.WriteString(metaStyle.Render("No Claude workspaces found.") + "\n\n") + b.WriteString(metaStyle.Render("A workspace is any folder with a CLAUDE.md.") + "\n") + footer := actionKey("r", "rescan") + " " + actionKey("esc", "back") + return m.frame("Import workspaces", b.String(), footer) + } + + b.WriteString(headerStyle.Render(fmt.Sprintf("Found %s", plural(len(m.importList), "Claude workspace")))) + b.WriteString(metaStyle.Render(fmt.Sprintf(" · Selected: %d", m.importSelectedCount()))) + b.WriteString("\n\n") + + start, end := window(len(m.importList), m.importCursor, m.importListHeight()) + if start > 0 { + b.WriteString(metaStyle.Render(" ↑ more") + "\n") + } + for i := start; i < end; i++ { + w := m.importList[i] bar := " " name := nameStyle.Render(w.Name) if i == m.importCursor { bar = barStyle.Render("▌ ") name = selNameStyle.Render(w.Name) } - b.WriteString(bar + importCheckbox(w.Imported, m.importSel[i]) + " " + name) - switch { - case w.Imported: - b.WriteString(" " + metaStyle.Render("already imported")) - case w.Branch != "": - b.WriteString(" " + metaStyle.Render(w.Branch)) + b.WriteString(bar + importCheckbox(w.Imported, m.importSel[i]) + " " + name + "\n") + detail := w.Path + if w.Imported { + detail = "✔ already imported · " + w.Path + } else if w.Branch != "" { + detail = w.Branch + " · " + w.Path } - b.WriteString("\n") + b.WriteString(" " + metaStyle.Render(detail) + "\n") } + if end < len(m.importList) { + b.WriteString(metaStyle.Render(" ↓ more") + "\n") + } + footer := strings.Join([]string{ - actionKey("space", "select"), actionKey("a", "all"), - actionKey("enter", "import"), actionKey("esc", "back"), + actionKey("↑↓", "move"), actionKey("space", "toggle"), actionKey("a", "all"), + actionKey("n", "none"), actionKey("i", "import"), actionKey("esc", "back"), }, " ") return m.frame("Import workspaces", b.String(), footer) } +// importListHeight is how many workspaces to show at once, leaving room for the +// banner, header and footer. +func (m model) importListHeight() int { + h := m.height + if h <= 0 { + h = 24 + } + // Each workspace uses two lines (name + detail); reserve the chrome. + rows := (h - 16) / 2 + if rows < 3 { + rows = 3 + } + return rows +} + // importCheckbox renders a row's selection state: a locked tick for workspaces // already imported, otherwise a normal on/off box. func importCheckbox(imported, selected bool) string { switch { case imported: - return metaStyle.Render("[✓]") + return okStyle.Render("[✔]") case selected: return barStyle.Render("[x]") default: diff --git a/internal/tui/screens_test.go b/internal/tui/screens_test.go index 45fca4b..5b15501 100644 --- a/internal/tui/screens_test.go +++ b/internal/tui/screens_test.go @@ -46,10 +46,9 @@ func TestEveryScreenRenders(t *testing.T) { m.wsDir = "/tmp/x" m.packList = []pack.Pack{{Name: "go", Description: "Go"}} }}, - {"import-path", func(m *model) { + {"import-scanning", func(m *model) { m.screen = scrImport - m.importStep = importStepPath - m.input = newTextInput("/tmp") + m.importStep = importStepScanning }}, {"import-pick", func(m *model) { m.screen = scrImport From ad856234c0d494ef669023f7f4d683cdc71a0052 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20L=C3=B3pez?= Date: Wed, 15 Jul 2026 19:46:14 +0200 Subject: [PATCH 12/15] =?UTF-8?q?v0.9:=20Mission=20Control=20=E2=80=94=20l?= =?UTF-8?q?ive=20overview=20of=20every=20Claude=20task?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F12 toggles the dashboard and a new Mission Control view (mission.go), a grid of cards, one per task. Each card shows its status (working activity / Waiting / Attached / Idle / Stopped), branch, uptime, model and tokens, and a short live preview of the session's pane. - Previews come from tmux capture-pane, reduced to the last few lines that actually carry words — box art, blank lines and Claude's persistent status chrome are stripped, so the card shows recent output and the current prompt, not the whole terminal. - Captures are cached per session and only refreshed when the session's window-activity advances, so idle tasks cost nothing and the view scales to dozens. - Arrow/Tab keys move between cards (focused card highlighted); Enter attaches to that session and leaving it returns to Mission Control. - Kept entirely separate from the dashboard's rendering. --- internal/tmux/tmux.go | 14 ++ internal/tui/app.go | 40 +++- internal/tui/dashboard.go | 5 +- internal/tui/help.go | 1 + internal/tui/mission.go | 444 +++++++++++++++++++++++++++++++++++ internal/tui/screens_test.go | 26 ++ 6 files changed, 526 insertions(+), 4 deletions(-) create mode 100644 internal/tui/mission.go diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 6e57779..98ba204 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -162,6 +162,20 @@ func (c Client) AttachCmd(name string) *exec.Cmd { return cmd } +// CapturePane returns the current visible content of a session's active pane, +// used by Mission Control for a live preview. The session name is passed plain +// (capture-pane's target-pane does not accept the "=" exact form); callers only +// capture sessions that exist, and tmux resolves an existing exact name before +// any prefix, so this cannot land on the wrong session. A missing session or any +// error yields "". +func (c Client) CapturePane(name string) string { + out, err := c.output("capture-pane", "-t", name, "-p") + if err != nil { + return "" + } + return out +} + // Configure makes the session feel like part of Crabby rather than tmux: // - a single, prefix-free key (detachKey) returns to Crabby; // - a small, Crabby-branded status bar shows how to get back. diff --git a/internal/tui/app.go b/internal/tui/app.go index ceb9468..1107e6c 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -39,6 +39,7 @@ type screen int const ( scrDashboard screen = iota + scrMission scrNewWorkspace scrImport scrNewTask @@ -66,6 +67,10 @@ type model struct { width, height int screen screen + // afterAttach is the screen to restore when a Claude session ends — so + // leaving a session opened from Mission Control returns there, not to the + // dashboard. + afterAttach screen // --- Dashboard --------------------------------------------------------- rows []wsRow @@ -79,6 +84,12 @@ type model struct { working map[string]bool firstLoad bool ringBell bool + // sessions is the tmux snapshot from the last refresh, reused within the + // same tick (e.g. by Mission Control) to avoid a second tmux call. + sessions map[string]tmux.Session + + // mc is Mission Control's state, kept separate from the dashboard's. + mc missionState // --- New-workspace flow ------------------------------------------------ wsStep wsStep @@ -187,6 +198,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tickMsg: m.refresh() + if m.screen == scrMission { + m.refreshMission() + } if m.screen == scrDashboard && m.ringBell { return m, tea.Batch(tick(), bellCmd) } @@ -227,8 +241,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.launchEditor(msg.dir) case execDoneMsg: - m.screen = scrDashboard + m.screen = m.afterAttach m.refresh() + if m.screen == scrMission { + m.refreshMission() + } if msg.err != nil { m.notice, m.noticeErr = "session ended with an error: "+msg.err.Error(), true } @@ -253,9 +270,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -// handleKey routes a keypress to the active screen. +// handleKey routes a keypress to the active screen. F12 is handled first, from +// anywhere, so Mission Control is always one key away (and one key back). func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if msg.String() == "f12" { + if m.screen == scrMission { + m.goDashboard() + return m, nil + } + return m.startMission() + } switch m.screen { + case scrMission: + return m.updateMission(msg) case scrNewWorkspace: return m.updateNewWorkspace(msg) case scrImport: @@ -275,6 +302,8 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m model) View() string { switch m.screen { + case scrMission: + return m.viewMission() case scrNewWorkspace: return m.viewNewWorkspace() case scrImport: @@ -296,6 +325,13 @@ func (m model) View() string { // rename) run inline; the blocking attach is handed to tea.ExecProcess so Bubble // Tea releases the screen for Claude and restores the dashboard afterwards. func (m model) attach(p project.Project, task project.Task) (tea.Model, tea.Cmd) { + // Remember where to land when the session ends: back in Mission Control if + // that's where we came from, otherwise the dashboard. + if m.screen == scrMission { + m.afterAttach = scrMission + } else { + m.afterAttach = scrDashboard + } if !m.tmux.HasSession(task.Session) { if _, err := exec.LookPath(m.cfg.ClaudeCommand); err != nil { m.notice = fmt.Sprintf("Claude Code not found on PATH (looked for %q)", m.cfg.ClaudeCommand) diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index b1fd71e..e56d879 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -64,6 +64,7 @@ func (m *model) refresh() { projects = nil } sessions := m.tmux.ListSessions() + m.sessions = sessions rising := false rows := make([]wsRow, 0, len(projects)) @@ -531,9 +532,9 @@ func (m model) dashboardFooter() string { actionKey("t", "task"), actionKey("x", "stop"), actionKey("d", "remove"), }, " ") line2 := strings.Join([]string{ - actionKey("i", "import"), actionKey("p", "packs"), actionKey("s", "settings"), + actionKey("F12", "mission control"), actionKey("i", "import"), + actionKey("p", "packs"), actionKey("s", "settings"), actionKey("?", "help"), actionKey("q", "quit"), - helpStyle.Render("· " + m.cfg.DetachKey + " returns from a session"), }, " ") return line1 + "\n" + line2 } diff --git a/internal/tui/help.go b/internal/tui/help.go index ef880fe..d77f036 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -41,6 +41,7 @@ var helpSections = []helpSection{ {"r", "refresh now"}, }}, {"Elsewhere", []helpEntry{ + {"F12", "Mission Control — live view of every task"}, {"p", "manage packs (new / duplicate / edit / delete)"}, {"s", "settings — packs, diagnostics, about"}, {"?", "this help"}, diff --git a/internal/tui/mission.go b/internal/tui/mission.go new file mode 100644 index 0000000..8a1c00a --- /dev/null +++ b/internal/tui/mission.go @@ -0,0 +1,444 @@ +package tui + +import ( + "sort" + "strings" + "time" + "unicode" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/marioolf/crabby/internal/insights" + "github.com/marioolf/crabby/internal/project" + "github.com/marioolf/crabby/internal/session" +) + +// Mission Control is a live overview of every Claude task at once: one card per +// task, each showing its status and a short preview of what its tmux session is +// currently showing. It is an observation surface — never a terminal emulator — +// and is kept entirely separate from the dashboard's rendering. + +// missionState is Mission Control's own state, independent of the dashboard. +type missionState struct { + cards []missionCard + cursor int + captures map[string]capture // session name -> cached pane preview +} + +// capture is a cached pane preview, keyed on the session's activity time so a +// session is only re-captured when it has actually produced new output. +type capture struct { + activity time.Time + lines []string +} + +// missionCard is one task's live snapshot. +type missionCard struct { + proj project.Project + task project.Task + label string + state session.State + working bool + branch string + insight insights.Insight + uptime time.Duration + preview []string +} + +const ( + missionPreviewLines = 3 + missionCardBody = missionPreviewLines + 4 // title, status, meta, divider +) + +func (m model) startMission() (tea.Model, tea.Cmd) { + m.screen = scrMission + m.mc.cursor = 0 + m.notice = "" + m.refresh() // make sure the session snapshot is current + m.refreshMission() + return m, nil +} + +// refreshMission rebuilds the cards from the current projects and the session +// snapshot taken by refresh(), capturing a fresh pane preview only for sessions +// whose activity has advanced since last time — so idle tasks cost nothing and +// the view scales to dozens of them. +func (m *model) refreshMission() { + if m.mc.captures == nil { + m.mc.captures = map[string]capture{} + } + projects, err := project.Load() + if err != nil { + projects = nil + } + sessions := m.sessions + if sessions == nil { + sessions = m.tmux.ListSessions() + } + + var cards []missionCard + live := map[string]bool{} + for _, p := range projects { + ins := m.insights.For(p.Path) + branch := p.Branch() + for _, tk := range p.Tasks { + info, present := sessions[tk.Session] + c := missionCard{ + proj: p, + task: tk, + label: windowLabel(p, tk), + state: session.Classify(present, info.Attached), + branch: branch, + insight: ins, + } + if present { + live[tk.Session] = true + // refresh() has already computed the working flag for this tick. + c.working = m.working[tk.Session] + if !info.Created.IsZero() { + c.uptime = time.Since(info.Created) + } + c.preview = m.previewFor(tk.Session, info.Activity) + } + cards = append(cards, c) + } + } + // Forget previews for sessions that have gone away. + for s := range m.mc.captures { + if !live[s] { + delete(m.mc.captures, s) + } + } + + sort.SliceStable(cards, func(i, j int) bool { + if ri, rj := session.Rank(cards[i].state), session.Rank(cards[j].state); ri != rj { + return ri < rj + } + return cards[i].label < cards[j].label + }) + m.mc.cards = cards + if m.mc.cursor >= len(cards) { + m.mc.cursor = len(cards) - 1 + } + if m.mc.cursor < 0 { + m.mc.cursor = 0 + } +} + +// previewFor returns the cached preview for a session, re-capturing only when +// its activity time has advanced. +func (m *model) previewFor(name string, activity time.Time) []string { + if c, ok := m.mc.captures[name]; ok && c.activity.Equal(activity) { + return c.lines + } + lines := previewLines(m.tmux.CapturePane(name)) + m.mc.captures[name] = capture{activity: activity, lines: lines} + return lines +} + +// previewLines reduces a captured pane to its last few useful lines. Claude's +// UI fills the bottom of the pane with its input box and a persistent status +// line, so a naive "last N lines" shows only chrome; this strips box-drawing, +// blank and status lines and keeps the most recent lines that actually carry +// words — the closest reliable signal to what Claude last said or is being asked. +func previewLines(raw string) []string { + if raw == "" { + return nil + } + var useful []string + for _, l := range strings.Split(raw, "\n") { + if t := trimBox(l); usefulLine(t) { + useful = append(useful, t) + } + } + if len(useful) > missionPreviewLines { + useful = useful[len(useful)-missionPreviewLines:] + } + return useful +} + +// boxRunes are the frame characters Claude (and other TUIs) draw around content. +const boxRunes = "─│╭╮╰╯┌┐└┘├┤┬┴┼═║╔╗╚╝▌▐▕▏" + +// trimBox strips surrounding box-drawing characters and spaces, leaving the +// content a boxed line actually holds. +func trimBox(s string) string { + return strings.TrimFunc(s, func(r rune) bool { + return r == ' ' || r == '\t' || strings.ContainsRune(boxRunes, r) + }) +} + +// usefulLine reports whether a line carries real content rather than decoration +// or Claude's persistent status chrome. +func usefulLine(t string) bool { + if t == "" { + return false + } + hasWord := false + for _, r := range t { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + hasWord = true + break + } + } + if !hasWord { + return false + } + low := strings.ToLower(t) + for _, chrome := range []string{"mode on", "for shortcuts", "auto-accept", "esc to interrupt"} { + if strings.Contains(low, chrome) { + return false + } + } + return true +} + +func (m model) selectedCard() (missionCard, bool) { + if m.mc.cursor < 0 || m.mc.cursor >= len(m.mc.cards) { + return missionCard{}, false + } + return m.mc.cards[m.mc.cursor], true +} + +func (m model) updateMission(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + cols := m.missionCols() + switch msg.String() { + case "esc": + m.goDashboard() + case "q": + return m, tea.Quit + case "left", "h", "shift+tab": + if m.mc.cursor > 0 { + m.mc.cursor-- + } + case "right", "l", "tab": + if m.mc.cursor < len(m.mc.cards)-1 { + m.mc.cursor++ + } + case "up", "k": + if m.mc.cursor-cols >= 0 { + m.mc.cursor -= cols + } + case "down", "j": + if m.mc.cursor+cols < len(m.mc.cards) { + m.mc.cursor += cols + } + case "r": + m.refresh() + m.refreshMission() + case "enter": + if c, ok := m.selectedCard(); ok { + proj, task := c.proj, c.task + return m, func() tea.Msg { return openTaskMsg{proj, task} } + } + } + return m, nil +} + +// --- View ------------------------------------------------------------------ + +func (m model) viewMission() string { + if len(m.mc.cards) == 0 { + body := metaStyle.Render("No Claude tasks yet.\nCreate one with t on the dashboard, then F12 to watch them here.") + return m.frame("Mission Control", body, m.missionFooter()) + } + + cols := m.missionCols() + cw := m.missionCardWidth(cols) + + boxes := make([]string, len(m.mc.cards)) + for i, c := range m.mc.cards { + boxes[i] = m.renderCard(c, i == m.mc.cursor, cw) + } + + // Group into rows, then show only the rows that fit around the cursor. + var rows []string + for i := 0; i < len(boxes); i += cols { + end := i + cols + if end > len(boxes) { + end = len(boxes) + } + rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, withGaps(boxes[i:end])...)) + } + rowH := missionCardBody + 2 // + border + maxRows := m.missionRowBudget() / rowH + if maxRows < 1 { + maxRows = 1 + } + cursorRow := m.mc.cursor / cols + start, end := window(len(rows), cursorRow, maxRows) + + var b strings.Builder + if start > 0 { + b.WriteString(metaStyle.Render("↑ more") + "\n") + } + b.WriteString(strings.Join(rows[start:end], "\n")) + if end < len(rows) { + b.WriteString("\n" + metaStyle.Render("↓ more")) + } + return m.frame("Mission Control", b.String(), m.missionFooter()) +} + +// missionCols is how many cards sit side by side, from the terminal width. +func (m model) missionCols() int { + w := m.width + if w <= 0 { + w = 80 + } + cols := w / 46 + if cols < 1 { + cols = 1 + } + if cols > 4 { + cols = 4 + } + if n := len(m.mc.cards); n > 0 && cols > n { + cols = n + } + return cols +} + +// missionCardWidth is the inner content width of each card for the column count. +func (m model) missionCardWidth(cols int) int { + w := m.width + if w <= 0 { + w = 80 + } + inner := (w - 4 - (cols - 1) - cols*4) / cols // outer margin, gaps, per-card chrome + if inner < 22 { + inner = 22 + } + if inner > 56 { + inner = 56 + } + return inner +} + +// missionRowBudget is the vertical space available for the card grid. +func (m model) missionRowBudget() int { + h := m.height + if h <= 0 { + h = 24 + } + avail := h - lipgloss.Height(Banner()) - 8 + if avail < missionCardBody+2 { + avail = missionCardBody + 2 + } + return avail +} + +// renderCard draws one task card: name and status, a line of metadata, then a +// short live preview of its pane. +func (m model) renderCard(c missionCard, focused bool, cw int) string { + // lipgloss counts the horizontal padding within Width, so the usable text + // area is cw minus the 1-column padding on each side. Everything is drawn to + // that width so nothing wraps inside the card. + inner := cw - 2 + dot, dotStyle := glyph(c.state, c.working) + label := truncate(c.label, inner-2) + name := nameStyle.Render(label) + if focused { + name = selNameStyle.Render(label) + } + statusText, statusStyle := cardStatus(c) + var lines []string + lines = append(lines, dotStyle.Render(dot)+" "+name) + lines = append(lines, statusStyle.Render(truncate(statusText, inner))) + lines = append(lines, metaStyle.Render(truncate(cardMeta(c), inner))) + lines = append(lines, dividerStyle.Render(strings.Repeat("─", inner))) + for i := 0; i < missionPreviewLines; i++ { + if i < len(c.preview) { + lines = append(lines, metaStyle.Render(truncate(c.preview[i], inner))) + } else if i == 0 && len(c.preview) == 0 { + lines = append(lines, metaStyle.Render("—")) + } else { + lines = append(lines, "") + } + } + + box := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(0, 1). + Width(cw).Height(missionCardBody) + if focused { + box = box.BorderForeground(accent) + } else { + box = box.BorderForeground(lipgloss.Color("240")) + } + return box.Render(strings.Join(lines, "\n")) +} + +// cardStatus is the one-line status as plain text plus the style to render it in +// — the live activity when working, otherwise the reliable tmux-derived state. +// Returning the two separately lets the caller truncate the text without +// corrupting the styling. +func cardStatus(c missionCard) (string, lipgloss.Style) { + switch { + case c.state == session.Stopped: + return "Stopped", stateStyles[session.Stopped] + case c.working: + return workingActivity(c.insight), workingStyle + case c.state == session.Running: + return "Attached", stateStyles[session.Running] + case c.insight.Activity == insights.Responding: + return "Waiting for input", stateStyles[session.Waiting] + default: + return "Idle", metaStyle + } +} + +// cardMeta is the branch/uptime/model/tokens line, omitting anything unknown. +func cardMeta(c missionCard) string { + var parts []string + if c.branch != "" { + parts = append(parts, c.branch) + } + if c.uptime > 0 { + parts = append(parts, "up "+formatDuration(c.uptime)) + } + if c.insight.Model != "" { + parts = append(parts, c.insight.Model) + } + if c.insight.SessionTokens > 0 { + parts = append(parts, formatTokens(c.insight.SessionTokens)+" tok") + } + return strings.Join(parts, " · ") +} + +func (m model) missionFooter() string { + return strings.Join([]string{ + actionKey("↑↓←→", "move"), actionKey("enter", "open"), + actionKey("r", "refresh"), actionKey("F12", "dashboard"), actionKey("q", "quit"), + }, " ") +} + +// withGaps inserts a single-space spacer between boxes for JoinHorizontal. +func withGaps(boxes []string) []string { + if len(boxes) <= 1 { + return boxes + } + out := make([]string, 0, len(boxes)*2-1) + for i, b := range boxes { + if i > 0 { + out = append(out, " ") + } + out = append(out, b) + } + return out +} + +// truncate shortens plain text to at most w display columns, marking the cut +// with an ellipsis. It measures display width (not rune count), so wide glyphs +// that Claude prints — ✻, ※, box art — don't overflow the card and wrap. +func truncate(s string, w int) string { + if w <= 0 { + return "" + } + if lipgloss.Width(s) <= w { + return s + } + r := []rune(s) + for len(r) > 0 && lipgloss.Width(string(r))+1 > w { + r = r[:len(r)-1] + } + return string(r) + "…" +} diff --git a/internal/tui/screens_test.go b/internal/tui/screens_test.go index 5b15501..8884d1f 100644 --- a/internal/tui/screens_test.go +++ b/internal/tui/screens_test.go @@ -11,6 +11,7 @@ import ( "github.com/marioolf/crabby/internal/importcmd" "github.com/marioolf/crabby/internal/insights" "github.com/marioolf/crabby/internal/pack" + "github.com/marioolf/crabby/internal/session" "github.com/marioolf/crabby/internal/tmux" ) @@ -35,6 +36,16 @@ func TestEveryScreenRenders(t *testing.T) { setup func(m *model) }{ {"dashboard-empty", func(m *model) { m.screen = scrDashboard }}, + {"mission-empty", func(m *model) { m.screen = scrMission }}, + {"mission-cards", func(m *model) { + m.screen = scrMission + m.mc.cards = []missionCard{ + {label: "payments", state: session.Waiting, working: true, branch: "main", + insight: insights.Insight{Model: "Opus 4.8", SessionTokens: 132000, Activity: insights.Editing}, + preview: []string{"Updated auth.go", "Running tests..."}}, + {label: "docs", state: session.Stopped}, + } + }}, {"new-workspace-dir", func(m *model) { m.screen = scrNewWorkspace m.wsStep = wsStepDir @@ -123,3 +134,18 @@ func TestDashboardTransitions(t *testing.T) { } } } + +func TestF12TogglesMissionControl(t *testing.T) { + m := baseModel() + m.screen = scrDashboard + f12 := tea.KeyMsg{Type: tea.KeyF12} + + next, _ := m.handleKey(f12) + if s := next.(model).screen; s != scrMission { + t.Fatalf("F12 from dashboard -> screen %d, want mission", s) + } + back, _ := next.(model).handleKey(f12) + if s := back.(model).screen; s != scrDashboard { + t.Fatalf("F12 from mission -> screen %d, want dashboard", s) + } +} From d1ffe80f3024c3aa6990da41acd92a3023edeadb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20L=C3=B3pez?= Date: Wed, 15 Jul 2026 19:48:48 +0200 Subject: [PATCH 13/15] v0.9.0: docs + version bump - README: rewrite the import section for the automatic machine scan, add a Mission Control section with a captured mockup, and surface F12 / mission control in the everyday-use keys. - CHANGELOG: 0.9.0 (Mission Control + auto-scan import). - VERSION -> 0.9.0. --- CHANGELOG.md | 23 ++++++++++ README.md | 119 +++++++++++++++++++++++++++++++++++++-------------- VERSION | 2 +- 3 files changed, 111 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13c8df2..05bebdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.9.0] - 2026-07-15 + +### Added + +- **Mission Control** (F12): a live overview of every Claude task at once — a + grid of cards, one per task, each showing its status, branch, uptime, model and + tokens, and a short live preview of the session's screen. Previews come from + `tmux capture-pane`, stripped of box art and Claude's status chrome so the card + shows recent output and the current prompt. Captures are cached and only + refreshed when a session produces new output, so it scales to dozens of tasks. + Arrow/Tab keys move between cards; Enter attaches and leaving returns to Mission + Control. It is an observation surface, kept separate from the dashboard. +- **Import now scans the machine automatically.** Pressing `i` no longer asks for + a path: it scans the WSL home and the common project folders under each Windows + user on the mounted drives, matching CLAUDE.md and claude.md. The pick list + shows `Found N` / `Selected N` with space to toggle, `a`/`n` to select all/none, + `i`/enter to import, and `r` to rescan; the scan runs off the UI thread. + +### Changed + +- Import pruning now also skips `build`, `dist` and hidden config folders (so + `~/.claude` and Crabby's own packs no longer appear as importable workspaces). + ## [0.8.0] - 2026-07-14 ### Changed diff --git a/README.md b/README.md index 458d2b5..7a076a8 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ crabby ╰──────────────────╯ ╰──────────────────╯ ╰──────────────────────────╯ 5 workspaces · 2 active · 4 stopped · 196k tokens today ↑↓ move tab pane enter open n new t task x stop d remove - i import p packs s settings ? help q quit · F12 returns from a session + F12 mission control i import p packs s settings ? help q quit ``` The home screen is three columns — **Workspaces**, **Tasks**, **Details** — with @@ -121,12 +121,14 @@ and [Workspace insights](#workspace-insights) for where the Details come from. between Workspaces and Tasks. - **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 here — no tmux shortcuts to - learn. The session keeps running in the background. +- **F12** toggles [Mission Control](#mission-control), the live overview of every + Claude task. Inside a Claude session, **F12** brings you straight back here — + no tmux shortcuts to learn; the session keeps running in the background. - **n** creates a new workspace from any folder (see [Packs](#packs)) — the path field completes with **Tab** and lists matching sub-directories as you type, and - **Ctrl+O** opens the native Windows folder picker; **i** imports every workspace - found under a folder. + **Ctrl+O** opens the native Windows folder picker. +- **i** [imports existing Claude workspaces](#adopting-existing-projects) — it + scans your machine automatically. - **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 (asks first). Your @@ -206,48 +208,101 @@ shows its tmux state, branch, and uptime. ## Adopting existing projects -If you already use Claude Code, you probably have repositories with a -`CLAUDE.md` in them. You don't need to recreate them — bring them in as they are, -from inside Crabby. +If you already use Claude Code, you probably have repositories with a `CLAUDE.md` +in them. You don't need to recreate them — bring them in as they are, from inside +Crabby. -**One project** — press **n**, type (or paste) its path, and create it. A folder -that already has a `CLAUDE.md` keeps it untouched. - -**A whole folder of them** — press **i**, point it at a parent folder, and let -Crabby find them. It scans the tree for folders that contain a `CLAUDE.md` and -shows what it found — a git repository isn't required. Pick the ones you want and -press **Enter**: +Press **i** and Crabby **scans your machine straight away** — no path to type. It +looks through your WSL home and the common project folders under each Windows user +on your mounted drives (`Desktop`, `Documents`, `source`, `repos`, `projects`, …), +finds every folder with a `CLAUDE.md` (or `claude.md`), and lists them: ``` ──────────────────────────────────────────────────── - Import workspaces + Import workspaces - Found 4 workspaces. + Found 4 Claude workspaces · Selected: 2 - ▌ [x] ai-security main - [x] frontend main - [✓] payments already imported - [ ] old-test wip/spike + ▌ [✔] payments + ✔ already imported · /repos/payments + [✔] frontend + ✔ already imported · /mnt/c/Users/you/repos/frontend + [x] docs + main · /repos/docs + [x] ai-security + wip/spike · /repos/ai-security ──────────────────────────────────────────────────── - space select a all enter import esc back + ↑↓ move space toggle a all n none i import esc back ``` -- **Space** toggles a workspace; **a** toggles all. -- Projects you've already imported show `[✓]` and can't be added twice. -- Everything importable starts checked, so adopting a folder is usually just **i**, - the path, and **Enter**. The scan runs in the background, so a large folder - never freezes the interface. +- The scan runs off the UI thread, so it stays responsive; press **r** to rescan. +- **Space** toggles a workspace; **a** selects all, **n** selects none. The + `Selected` count is always visible. Everything not-yet-imported starts checked, + so adopting a machine full of projects is often just **i** then **i** again. +- Projects you've already imported show `✔` and can't be added twice. +- Press **i** (or **Enter**) to import the selection; the workspaces appear on the + dashboard immediately. Import never edits your repositories — it registers them and writes Crabby's own `.claude/crabby.yaml`, leaving your existing `CLAUDE.md` and workflow untouched. -Scanning skips the obvious noise — `.git`, `node_modules`, `.venv`, `vendor`, and -similar — and stops at the first `CLAUDE.md` on each branch of the tree, so a -project's own subdirectory context files aren't imported as separate workspaces. -When a folder is a git repository its current branch is shown for context; when -it isn't, it's still importable. +Scanning skips the obvious noise — `.git`, `node_modules`, `.venv`, `vendor`, +`build`, `dist`, and hidden config folders like `~/.claude` — and stops at the +first context file on each branch of the tree, so a project's own subdirectory +context files aren't imported as separate workspaces. When a folder is a git +repository its current branch is shown for context; when it isn't, it's still +importable. + +## Mission Control + +Press **F12** to open **Mission Control** — a live overview of *every* Claude task +at once, so you can see what all your Claude workers are doing without attaching to +each one. **F12** again returns to the dashboard. + +``` +──────────────────────────────────────────────────── + + Mission Control + +╭──────────────────────────╮ ╭──────────────────────────╮ +│ ● payments:refactor │ │ ● frontend │ +│ Editing files │ │ Waiting for input │ +│ feature/refunds · Opus… │ │ main · up 3h · 42k tok │ +│ ─────────────────────────│ │ ─────────────────────────│ +│ Updated auth.go │ │ ❯ add a dark theme │ +│ ✻ Churned for 2m 15s │ │ — │ +│ ❯ now run the tests │ │ │ +╰──────────────────────────╯ ╰──────────────────────────╯ +╭──────────────────────────╮ ╭──────────────────────────╮ +│ ● docs │ │ ○ ai-security │ +│ Running command │ │ Stopped │ +│ main · up 1h · 12k tok │ │ │ +│ ─────────────────────────│ │ ─────────────────────────│ +│ Writing summary... │ │ — │ +│ Executing grep... │ │ │ +╰──────────────────────────╯ ╰──────────────────────────╯ + + ↑↓←→ move enter open r refresh F12 dashboard q quit +``` + +Each card is one task. It shows the status Crabby can read reliably — **Working** +(with the current activity, when it can tell), **Waiting for input**, **Attached**, +**Idle** or **Stopped** — plus the branch, uptime, model and token count when a +transcript is available, and a short **live preview** of the session's screen. + +The preview comes from `tmux capture-pane`, reduced to the last few lines that +actually carry content: Crabby strips the box art, blank lines and Claude's +persistent status bar, so the card shows recent output and the current prompt +rather than the whole terminal. Mission Control is an **observation surface**, not +a terminal emulator — it never lets you type into multiple sessions at once. + +It refreshes automatically and stays cheap: a session's preview is only +re-captured when that session has actually produced new output, so watching dozens +of tasks costs almost nothing. Move between cards with the arrows or **Tab**; press +**Enter** to attach to the highlighted task, and leaving it drops you back in +Mission Control. ## Commands diff --git a/VERSION b/VERSION index a3df0a6..ac39a10 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.0 +0.9.0 From e51f8a469e681a74a3933255f057a6601b5f15db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20L=C3=B3pez?= Date: Wed, 15 Jul 2026 19:57:52 +0200 Subject: [PATCH 14/15] tui: open a workspace's folder in the file manager (o) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Press o on the dashboard to open the selected workspace's directory in the file manager — Windows Explorer on WSL, via wslpath (a Linux path opens over \\wsl.localhost). Launched fire-and-forget off the UI thread, with the result shown in the status bar. Saves copying and pasting the path when adding files to a workspace. --- CHANGELOG.md | 3 +++ README.md | 2 ++ internal/tui/dashboard.go | 7 ++++++- internal/tui/help.go | 1 + internal/tui/openfolder.go | 39 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 internal/tui/openfolder.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 05bebdc..4dedcb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 shows `Found N` / `Selected N` with space to toggle, `a`/`n` to select all/none, `i`/enter to import, and `r` to rescan; the scan runs off the UI thread. +- **Open a workspace's folder** (o) in the file manager (Windows Explorer on + WSL), so you can drop files into a workspace without copying its path. + ### Changed - Import pruning now also skips `build`, `dist` and hidden config folders (so diff --git a/README.md b/README.md index 7a076a8..29776d8 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,8 @@ and [Workspace insights](#workspace-insights) for where the Details come from. - **i** [imports existing Claude workspaces](#adopting-existing-projects) — it scans your machine automatically. - **t** starts a new task in the selected workspace. +- **o** opens the selected workspace's folder in the file manager (Windows + Explorer on WSL), so you can drop files straight into it. - **x** stops the highlighted task (asks first) — no need to exit Claude. - **d** removes the highlighted task, or the whole workspace (asks first). Your files are kept; only Crabby forgets it. diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index e56d879..6b31be1 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -181,6 +181,10 @@ func (m model) updateDashboard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "n": return m.startNewWorkspace() + case "o": + if ws, ok := m.selectedWs(); ok { + return m, openFolderCmd(ws.proj.Path) + } case "i": return m.startImport() case "t": @@ -529,7 +533,8 @@ func (m model) dashboardFooter() string { line1 := strings.Join([]string{ actionKey("↑↓", "move"), actionKey("tab", "pane"), actionKey("enter", "open"), actionKey("n", "new"), - actionKey("t", "task"), actionKey("x", "stop"), actionKey("d", "remove"), + actionKey("t", "task"), actionKey("o", "folder"), + actionKey("x", "stop"), actionKey("d", "remove"), }, " ") line2 := strings.Join([]string{ actionKey("F12", "mission control"), actionKey("i", "import"), diff --git a/internal/tui/help.go b/internal/tui/help.go index d77f036..ac4f2f7 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -36,6 +36,7 @@ var helpSections = []helpSection{ {"n", "new workspace (point it at a folder)"}, {"i", "import workspaces found under a folder"}, {"t", "new task in the selected workspace"}, + {"o", "open the workspace's folder in the file manager"}, {"x", "stop the selected task's session"}, {"d", "remove the selected task or workspace"}, {"r", "refresh now"}, diff --git a/internal/tui/openfolder.go b/internal/tui/openfolder.go new file mode 100644 index 0000000..7d28c19 --- /dev/null +++ b/internal/tui/openfolder.go @@ -0,0 +1,39 @@ +package tui + +import ( + "fmt" + "os/exec" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// openFolderCmd opens a workspace's directory in the file manager, so files can +// be dropped into it without copying paths around. It runs off the UI thread and +// reports the result in the status bar. +func openFolderCmd(path string) tea.Cmd { + return func() tea.Msg { + if err := openInFileManager(path); err != nil { + return noticeMsg{"couldn't open the folder: " + err.Error(), true} + } + return noticeMsg{"Opened " + path + " in the file manager", false} + } +} + +// openInFileManager launches the platform's file manager at path. On WSL — the +// only environment Crabby targets — that is Windows Explorer, reached by +// converting the path with wslpath (a Linux path opens over \\wsl.localhost). +// explorer.exe is launched fire-and-forget: it returns a non-zero exit status +// even on success, so its result is deliberately not awaited. A couple of Linux +// openers are tried as a fallback for non-WSL use. +func openInFileManager(path string) error { + if win, err := exec.Command("wslpath", "-w", path).Output(); err == nil { + return exec.Command("explorer.exe", strings.TrimSpace(string(win))).Start() + } + for _, opener := range []string{"wslview", "xdg-open", "open"} { + if _, err := exec.LookPath(opener); err == nil { + return exec.Command(opener, path).Start() + } + } + return fmt.Errorf("no file manager found") +} From e0ba4c0996895bcc111fd97bde19bac1e9c116f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20L=C3=B3pez?= Date: Wed, 15 Jul 2026 20:11:36 +0200 Subject: [PATCH 15/15] tui: relink moved/renamed workspaces, un-import, flag missing folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crabby can't auto-detect a folder rename — the old path is gone and nothing links it to the new location — so instead: - refresh() flags a workspace whose directory no longer exists as missing; the dashboard shows '⚠ missing' and o explains it rather than failing. - e opens a relink flow (same path completion as new-workspace) that points the workspace at its new folder via project.Relink, updating name and path while keeping tasks (their session names are stored, so they still match). - d in the import screen un-imports an already-imported workspace, so a mistaken or stale import can be undone without leaving the view. The space toggle in import was verified working; the confusion was that already-imported rows are locked — d now gives them an action. --- CHANGELOG.md | 6 +++ README.md | 7 +++- internal/project/project.go | 36 ++++++++++++++++ internal/project/relink_test.go | 71 +++++++++++++++++++++++++++++++ internal/tui/app.go | 8 ++++ internal/tui/dashboard.go | 28 +++++++++++-- internal/tui/help.go | 1 + internal/tui/imports.go | 36 +++++++++++++++- internal/tui/relink.go | 74 +++++++++++++++++++++++++++++++++ internal/tui/screens_test.go | 6 +++ 10 files changed, 267 insertions(+), 6 deletions(-) create mode 100644 internal/project/relink_test.go create mode 100644 internal/tui/relink.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dedcb4..6b433e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Open a workspace's folder** (o) in the file manager (Windows Explorer on WSL), so you can drop files into a workspace without copying its path. +- **Relink a moved or renamed workspace** (e). When a workspace's folder is + renamed or moved, the dashboard now flags it as `⚠ missing` and `o` explains + why instead of failing; pressing `e` points it at its new folder (with the same + Tab completion) and updates both its name and path, keeping its tasks. +- **Un-import from the import screen** (d): remove an already-imported workspace + from Crabby without leaving the import view (its files are kept). ### Changed diff --git a/README.md b/README.md index 29776d8..bab3714 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,10 @@ and [Workspace insights](#workspace-insights) for where the Details come from. - **t** starts a new task in the selected workspace. - **o** opens the selected workspace's folder in the file manager (Windows Explorer on WSL), so you can drop files straight into it. +- **e** relinks a workspace whose folder you've moved or renamed: Crabby flags it + as `⚠ missing`, and **e** points it at the new folder (updating its name and + path, keeping its tasks). Crabby can't guess where a folder went, so this is how + you tell it. - **x** stops the highlighted task (asks first) — no need to exit Claude. - **d** removes the highlighted task, or the whole workspace (asks first). Your files are kept; only Crabby forgets it. @@ -243,7 +247,8 @@ finds every folder with a `CLAUDE.md` (or `claude.md`), and lists them: - **Space** toggles a workspace; **a** selects all, **n** selects none. The `Selected` count is always visible. Everything not-yet-imported starts checked, so adopting a machine full of projects is often just **i** then **i** again. -- Projects you've already imported show `✔` and can't be added twice. +- Projects you've already imported show `✔` and can't be added twice — press **d** + on one to un-import it (Crabby forgets it; your files are kept). - Press **i** (or **Enter**) to import the selection; the workspaces appear on the dashboard immediately. diff --git a/internal/project/project.go b/internal/project/project.go index e675ca1..3f75484 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -142,6 +142,42 @@ func Register(p Project) error { return Save(projects) } +// Relink points an existing workspace at a new directory — for when its folder +// has been moved or renamed — updating both its path and its name (to the new +// folder's base name). Its tasks are kept as they are: their tmux session names +// are stored, so the running sessions still match after the rename. Returns +// ErrNotFound if oldName isn't registered, or an error if newPath isn't an +// existing directory or its name is already taken by another workspace. +func Relink(oldName, newPath string) error { + abs, err := filepath.Abs(newPath) + if err != nil { + return err + } + if info, statErr := os.Stat(abs); statErr != nil || !info.IsDir() { + return fmt.Errorf("%q is not a directory", newPath) + } + newName := filepath.Base(abs) + + projects, err := Load() + if err != nil { + return err + } + idx := -1 + for i := range projects { + if projects[i].Name == oldName { + idx = i + } else if projects[i].Name == newName { + return fmt.Errorf("a workspace named %q already exists", newName) + } + } + if idx < 0 { + return ErrNotFound + } + projects[idx].Name = newName + projects[idx].Path = abs + return Save(projects) +} + // Remove deletes a project from the registry. It does not touch any files on // disk. Returns ErrNotFound if no such project is registered. func Remove(name string) error { diff --git a/internal/project/relink_test.go b/internal/project/relink_test.go new file mode 100644 index 0000000..f2e3f5f --- /dev/null +++ b/internal/project/relink_test.go @@ -0,0 +1,71 @@ +package project + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRelinkUpdatesPathAndName(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + // Register a workspace with a task, then move its folder. + oldDir := filepath.Join(home, "old") + newDir := filepath.Join(home, "renamed") + if err := os.MkdirAll(newDir, 0o755); err != nil { + t.Fatal(err) + } + p := Project{ + Name: "old", + Path: oldDir, + Tasks: []Task{{Name: DefaultTask, Session: "crabby_old"}}, + } + if err := Register(p); err != nil { + t.Fatal(err) + } + + if err := Relink("old", newDir); err != nil { + t.Fatalf("Relink: %v", err) + } + + got, err := Find("renamed") + if err != nil { + t.Fatalf("Find renamed: %v", err) + } + if got.Path != newDir { + t.Fatalf("path = %q, want %q", got.Path, newDir) + } + // The task (and its stored session) must survive the relink. + if len(got.Tasks) != 1 || got.Tasks[0].Session != "crabby_old" { + t.Fatalf("tasks not preserved: %+v", got.Tasks) + } + // The old name is gone. + if _, err := Find("old"); err != ErrNotFound { + t.Fatalf("old name still resolves (err %v)", err) + } +} + +func TestRelinkRejectsMissingDirAndNameClash(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + a := filepath.Join(home, "a") + b := filepath.Join(home, "b") + for _, d := range []string{a, b} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + _ = Register(Project{Name: "a", Path: a, Tasks: []Task{{Name: DefaultTask, Session: "crabby_a"}}}) + _ = Register(Project{Name: "b", Path: b, Tasks: []Task{{Name: DefaultTask, Session: "crabby_b"}}}) + + // Relinking to a non-existent directory fails. + if err := Relink("a", filepath.Join(home, "nope")); err == nil { + t.Fatal("expected error relinking to a missing directory") + } + // Relinking "a" onto b's folder would collide with the existing "b". + if err := Relink("a", b); err == nil { + t.Fatal("expected error on name clash") + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go index 1107e6c..77d34b9 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -41,6 +41,7 @@ const ( scrDashboard screen = iota scrMission scrNewWorkspace + scrRelink scrImport scrNewTask scrPacks @@ -104,6 +105,9 @@ type model struct { importSel []bool importCursor int + // --- Relink flow ------------------------------------------------------- + relinkProj project.Project + // --- New-task flow ----------------------------------------------------- formProj project.Project @@ -285,6 +289,8 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.updateMission(msg) case scrNewWorkspace: return m.updateNewWorkspace(msg) + case scrRelink: + return m.updateRelink(msg) case scrImport: return m.updateImport(msg) case scrNewTask: @@ -306,6 +312,8 @@ func (m model) View() string { return m.viewMission() case scrNewWorkspace: return m.viewNewWorkspace() + case scrRelink: + return m.viewRelink() case scrImport: return m.viewImport() case scrNewTask: diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index 6b31be1..6fb9c6f 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "os" "sort" "strings" "time" @@ -14,6 +15,13 @@ import ( "github.com/marioolf/crabby/internal/session" ) +// dirExists reports whether path is an existing directory, used to flag a +// workspace whose folder has been moved or renamed. +func dirExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + // taskRow is one task's live state within a workspace. type taskRow struct { task project.Task @@ -29,7 +37,8 @@ type wsRow struct { branch string insight insights.Insight tasks []taskRow - best int // best (lowest) task rank, for ordering workspaces + best int // best (lowest) task rank, for ordering workspaces + missing bool // the workspace's directory no longer exists on disk } // repr picks a representative state and working flag for the workspace, used for @@ -69,7 +78,7 @@ func (m *model) refresh() { rising := false rows := make([]wsRow, 0, len(projects)) for _, p := range projects { - r := wsRow{proj: p, branch: p.Branch(), insight: m.insights.For(p.Path), best: 99} + r := wsRow{proj: p, branch: p.Branch(), insight: m.insights.For(p.Path), best: 99, missing: !dirExists(p.Path)} for _, tk := range p.Tasks { info, present := sessions[tk.Session] tr := taskRow{task: tk, state: session.Classify(present, info.Attached)} @@ -183,8 +192,16 @@ func (m model) updateDashboard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.startNewWorkspace() case "o": if ws, ok := m.selectedWs(); ok { + if ws.missing { + m.notice, m.noticeErr = "Folder not found: "+ws.proj.Path+" — moved or renamed? Press e to relink.", true + return m, nil + } return m, openFolderCmd(ws.proj.Path) } + case "e": + if ws, ok := m.selectedWs(); ok { + return m.startRelink(ws.proj) + } case "i": return m.startImport() case "t": @@ -469,6 +486,9 @@ func (m model) detailColumn(w, h int) string { } add("status", stateLabel(tr.state, tr.working)) add("task", nameStyle.Render(tr.task.Name)) + if ws.missing { + add("folder", errorStyle.Render("⚠ missing — moved or renamed (e to relink)")) + } add("branch", ws.branch) if ins.Model != "" { add("model", ins.Model) @@ -537,8 +557,8 @@ func (m model) dashboardFooter() string { actionKey("x", "stop"), actionKey("d", "remove"), }, " ") line2 := strings.Join([]string{ - actionKey("F12", "mission control"), actionKey("i", "import"), - actionKey("p", "packs"), actionKey("s", "settings"), + actionKey("e", "relink"), actionKey("F12", "mission control"), + actionKey("i", "import"), actionKey("p", "packs"), actionKey("s", "settings"), actionKey("?", "help"), actionKey("q", "quit"), }, " ") return line1 + "\n" + line2 diff --git a/internal/tui/help.go b/internal/tui/help.go index ac4f2f7..13696f0 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -37,6 +37,7 @@ var helpSections = []helpSection{ {"i", "import workspaces found under a folder"}, {"t", "new task in the selected workspace"}, {"o", "open the workspace's folder in the file manager"}, + {"e", "relink a moved/renamed workspace to its new folder"}, {"x", "stop the selected task's session"}, {"d", "remove the selected task or workspace"}, {"r", "refresh now"}, diff --git a/internal/tui/imports.go b/internal/tui/imports.go index 886e6dc..fa44750 100644 --- a/internal/tui/imports.go +++ b/internal/tui/imports.go @@ -8,6 +8,7 @@ import ( "github.com/marioolf/crabby/internal/importcmd" "github.com/marioolf/crabby/internal/initcmd" + "github.com/marioolf/crabby/internal/project" ) // importStep is which step of the import flow is on screen: the machine scan, or @@ -71,6 +72,12 @@ func (m model) updateImport(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.setAllImports(true) case "n": m.setAllImports(false) + case "d": + // Un-import: remove an already-imported workspace from Crabby, so a + // mistaken import (or a stale one) can be undone without leaving here. + if len(m.importList) > 0 && m.importList[m.importCursor].Imported { + return m.unimportAt(m.importCursor) + } case "r": m.importStep = importStepScanning m.importList = nil @@ -107,6 +114,32 @@ func (m model) applyDiscover(msg discoverDoneMsg) (tea.Model, tea.Cmd) { return m, nil } +// unimportAt removes the workspace registered at the item's path, flipping the +// row back to an importable state. +func (m model) unimportAt(i int) (tea.Model, tea.Cmd) { + w := m.importList[i] + name := "" + if projects, err := project.Load(); err == nil { + for _, p := range projects { + if p.Path == w.Path { + name = p.Name + } + } + } + if name == "" { + m.notice, m.noticeErr = "That workspace isn't registered.", true + return m, nil + } + if err := project.Remove(name); err != nil { + m.notice, m.noticeErr = err.Error(), true + return m, nil + } + m.importList[i].Imported = false + m.importSel[i] = true + m.notice, m.noticeErr = "Removed "+name+" from Crabby (files kept)", false + return m, nil +} + // setAllImports selects or clears every importable workspace at once. func (m *model) setAllImports(on bool) { for i, w := range m.importList { @@ -195,7 +228,8 @@ func (m model) viewImport() string { footer := strings.Join([]string{ actionKey("↑↓", "move"), actionKey("space", "toggle"), actionKey("a", "all"), - actionKey("n", "none"), actionKey("i", "import"), actionKey("esc", "back"), + actionKey("n", "none"), actionKey("i", "import"), actionKey("d", "un-import"), + actionKey("esc", "back"), }, " ") return m.frame("Import workspaces", b.String(), footer) } diff --git a/internal/tui/relink.go b/internal/tui/relink.go new file mode 100644 index 0000000..4c1e0dc --- /dev/null +++ b/internal/tui/relink.go @@ -0,0 +1,74 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/marioolf/crabby/internal/project" +) + +// The relink flow points an existing workspace at a new folder — for when its +// directory has been moved or renamed. It reuses the directory input and Tab +// completion from the new-workspace flow. + +func (m model) startRelink(p project.Project) (tea.Model, tea.Cmd) { + m.screen = scrRelink + m.relinkProj = p + m.input = newTextInput(p.Path) + _, m.wsMatches = dirCandidates(p.Path) + m.notice = "" + return m, nil +} + +func (m model) updateRelink(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.goDashboard() + case "tab": + m.input = newTextInput(completePath(m.input.Value())) + _, m.wsMatches = dirCandidates(m.input.Value()) + case "ctrl+o": + m.notice, m.noticeErr = "Opening the Windows folder picker…", false + return m, pickWindowsFolder + case "enter": + path := strings.TrimSpace(m.input.Value()) + if path == "" { + return m, nil + } + if err := project.Relink(m.relinkProj.Name, expandHome(path)); err != nil { + m.notice, m.noticeErr = err.Error(), true + return m, nil + } + newName := m.relinkProj.Name + m.goDashboard() + m.refresh() + // The workspace's name follows its new folder; select it by its path. + for i, r := range m.rows { + if r.proj.Path == expandHome(path) { + m.wsCursor, m.taskCursor = i, 0 + newName = r.proj.Name + } + } + m.notice, m.noticeErr = "Relinked to "+newName, false + default: + m.input = m.input.update(msg) + _, m.wsMatches = dirCandidates(m.input.Value()) + } + return m, nil +} + +func (m model) viewRelink() string { + body := fmt.Sprintf("Point %q at its new folder:\n\n ", m.relinkProj.Name) + + m.input.view("path to the moved/renamed folder") + if len(m.wsMatches) > 0 { + body += "\n\n " + metaStyle.Render("subdirectories:") + "\n " + + metaStyle.Render(wrapMatches(m.wsMatches, 60)) + } + footer := strings.Join([]string{ + actionKey("tab", "complete"), actionKey("ctrl+o", "windows folder"), + actionKey("enter", "relink"), actionKey("esc", "cancel"), + }, " ") + return m.frame("Relink workspace", body, footer) +} diff --git a/internal/tui/screens_test.go b/internal/tui/screens_test.go index 8884d1f..a35c402 100644 --- a/internal/tui/screens_test.go +++ b/internal/tui/screens_test.go @@ -11,6 +11,7 @@ import ( "github.com/marioolf/crabby/internal/importcmd" "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" ) @@ -57,6 +58,11 @@ func TestEveryScreenRenders(t *testing.T) { m.wsDir = "/tmp/x" m.packList = []pack.Pack{{Name: "go", Description: "Go"}} }}, + {"relink", func(m *model) { + m.screen = scrRelink + m.relinkProj = project.Project{Name: "payments", Path: "/repos/payments"} + m.input = newTextInput("/repos/payments-new") + }}, {"import-scanning", func(m *model) { m.screen = scrImport m.importStep = importStepScanning