diff --git a/CHANGELOG.md b/CHANGELOG.md index b418bcc..6b433e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,88 @@ 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. + +- **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 + +- 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 + +- **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. 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). +- 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. +- 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 + +- 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..bab3714 100644 --- a/README.md +++ b/README.md @@ -76,101 +76,99 @@ 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 + F12 mission control i import p packs s settings ? help q quit ``` -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** 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 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. +- **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 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 @@ -216,82 +214,121 @@ 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. +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** — point `init` at it (no need to `cd` first): +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: -```bash -crabby init ~/repos/payments ``` +──────────────────────────────────────────────────── -**A whole folder of them** — let Crabby find them: + Import workspaces -```bash -crabby import ~/repos -``` + Found 4 Claude workspaces · Selected: 2 -`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: + ▌ [✔] 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 +──────────────────────────────────────────────────── + ↑↓ move space toggle a all n none i import esc back ``` - (\/) (\/) - \(o..o)/ - /`----'\ - C R A B B Y -Many Claudes. One shell. +- 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 **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. -Import Claude workspaces -Found 4 workspaces. +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`, +`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. -▌ [x] ai-security main - [x] frontend main - [✓] payments already imported - [ ] old-test wip/spike +## 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. -──────────────────────────────────── -space select a all enter import q cancel ``` +──────────────────────────────────────────────────── -- **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**. + 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 +``` -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`. +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. -`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. +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 +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 +366,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 +441,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) diff --git a/VERSION b/VERSION index f38fc53..ac39a10 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.3 +0.9.0 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/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/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/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/tmux/tmux.go b/internal/tmux/tmux.go index 143bd14..98ba204 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -148,15 +148,32 @@ 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 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 cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - return cmd.Run() + 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: diff --git a/internal/tui/app.go b/internal/tui/app.go new file mode 100644 index 0000000..77d34b9 --- /dev/null +++ b/internal/tui/app.go @@ -0,0 +1,478 @@ +// 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" + "io" + "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/importcmd" + "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 + +// 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 + +const ( + scrDashboard screen = iota + scrMission + scrNewWorkspace + scrRelink + scrImport + 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 + // 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 + 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 + // 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 + wsDir string + wsMatches []string // sub-directories matching the typed path, for completion + packList []pack.Pack + packCursor int + + // --- Import flow ------------------------------------------------------- + importStep importStep + importList []importcmd.Workspace + importSel []bool + importCursor int + + // --- Relink flow ------------------------------------------------------- + relinkProj project.Project + + // --- 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 + + // 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 +} + +// 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 + +// 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 { + 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) }) +} + +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 tea.Batch(tick(), animTick()) } + +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 == scrMission { + m.refreshMission() + } + if m.screen == scrDashboard && m.ringBell { + return m, tea.Batch(tick(), bellCmd) + } + return m, tick() + + case animMsg: + m.anim++ + return m, animTick() + + case noticeMsg: + m.notice, m.noticeErr = msg.text, msg.isErr + return m, nil + + case openTaskMsg: + return m.attach(msg.proj, msg.task) + + 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) + + case execDoneMsg: + 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 + } + 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. 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 scrRelink: + return m.updateRelink(msg) + case scrImport: + return m.updateImport(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 scrMission: + return m.viewMission() + case scrNewWorkspace: + return m.viewNewWorkspace() + case scrRelink: + return m.viewRelink() + case scrImport: + return m.viewImport() + 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) { + // 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) + 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 + 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 +// 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, BannerFrame(m.anim))) + 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..6fb9c6f --- /dev/null +++ b/internal/tui/dashboard.go @@ -0,0 +1,565 @@ +package tui + +import ( + "fmt" + "os" + "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" +) + +// 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 + 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 + missing bool // the workspace's directory no longer exists on disk +} + +// 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() + m.sessions = sessions + + 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, missing: !dirExists(p.Path)} + 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 "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": + 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, BannerFrame(m.anim))) + 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)) + 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) + } + 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("o", "folder"), + actionKey("x", "stop"), actionKey("d", "remove"), + }, " ") + line2 := strings.Join([]string{ + 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/forms.go b/internal/tui/forms.go new file mode 100644 index 0000000..0d98551 --- /dev/null +++ b/internal/tui/forms.go @@ -0,0 +1,361 @@ +package tui + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "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.wsMatches = dirCandidates(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 "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 == "" { + 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) + _, m.wsMatches = dirCandidates(m.input.Value()) + } + 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") + 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 { + 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/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/header.go b/internal/tui/header.go index d2bb340..59226e7 100644 --- a/internal/tui/header.go +++ b/internal/tui/header.go @@ -1,6 +1,8 @@ package tui import ( + "strings" + "github.com/charmbracelet/lipgloss" ) @@ -17,41 +19,113 @@ 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)/ - /` + "`" + `----'\` - -// 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 { - return lipgloss.JoinVertical(lipgloss.Center, - crabStyle.Render(crabArt), - "", - wordmarkStyle.Render(Wordmark), - taglineStyle.Render(Tagline), - subStyle.Render(Subtitle), - ) +// 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; 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. 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, 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 +// centred within it, and the crab sways around its centre. +func bannerWidth() int { return lipgloss.Width(Subtitle) } + +// 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 } -// BannerWidth is the display width of the banner (its widest line). -func BannerWidth() int { return lipgloss.Width(Banner()) } +// 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 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. 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] + left := (bw-blockWidth(crab))/2 + sway -// 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 + var lines []string + for _, l := range strings.Split(crab, "\n") { + lines = append(lines, placeIn(bw, left, crabStyle.Render(l))) } - return lipgloss.Place(width, lipgloss.Height(block)+2, - lipgloss.Center, lipgloss.Center, block) + lines = append(lines, + strings.Repeat(" ", bw), + 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, 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. +func BannerFrame(t int) string { + step := crabCycle[t%len(crabCycle)] + 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 bannerWidth() } diff --git a/internal/tui/header_test.go b/internal/tui/header_test.go index 0c35f5d..8c93f9a 100644 --- a/internal/tui/header_test.go +++ b/internal/tui/header_test.go @@ -7,14 +7,15 @@ 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) } } 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." { diff --git a/internal/tui/help.go b/internal/tui/help.go new file mode 100644 index 0000000..13696f0 --- /dev/null +++ b/internal/tui/help.go @@ -0,0 +1,78 @@ +package tui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +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)"}, + {"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"}, + }}, + {"Elsewhere", []helpEntry{ + {"F12", "Mission Control — live view of every task"}, + {"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 w := lipgloss.Width(e.key); w > width { + width = w + } + } + } + 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-lipgloss.Width(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/imports.go b/internal/tui/imports.go new file mode 100644 index 0000000..fa44750 --- /dev/null +++ b/internal/tui/imports.go @@ -0,0 +1,263 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "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 +// the pick list of what it found. +type importStep int + +const ( + importStepScanning importStep = iota + importStepPick +) + +// 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) { + m.screen = scrImport + m.importStep = importStepScanning + m.importList = nil + m.importSel = nil + m.importCursor = 0 + m.notice = "" + 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 importStepScanning: + if msg.String() == "esc" { + m.goDashboard() + } + return m, nil + case importStepPick: + switch msg.String() { + case "esc", "q": + m.goDashboard() + case "up", "k": + if m.importCursor > 0 { + m.importCursor-- + } + case "down", "j": + if m.importCursor < len(m.importList)-1 { + m.importCursor++ + } + case " ": + if len(m.importList) > 0 && !m.importList[m.importCursor].Imported { + m.importSel[m.importCursor] = !m.importSel[m.importCursor] + } + case "a": + 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 + 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, 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.goDashboard() + m.notice, m.noticeErr = msg.err.Error(), true + return m, nil + } + m.importList = msg.found + m.importSel = make([]bool, len(msg.found)) + for i, w := range msg.found { + // Everything not-yet-imported starts checked, so adopting a machine full + // of projects is one keypress. + m.importSel[i] = !w.Imported + } + m.importCursor = 0 + m.importStep = importStepPick + 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 { + 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 m.importSel[i] && !w.Imported { + n++ + } + } + return n +} + +// performImport registers each chosen workspace with init (no pack, so existing +// 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 { + 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 { + 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 + 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 + "\n") + detail := w.Path + if w.Imported { + detail = "✔ already imported · " + w.Path + } else if w.Branch != "" { + detail = w.Branch + " · " + w.Path + } + b.WriteString(" " + metaStyle.Render(detail) + "\n") + } + if end < len(m.importList) { + b.WriteString(metaStyle.Render(" ↓ more") + "\n") + } + + footer := strings.Join([]string{ + actionKey("↑↓", "move"), actionKey("space", "toggle"), actionKey("a", "all"), + actionKey("n", "none"), actionKey("i", "import"), actionKey("d", "un-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 okStyle.Render("[✔]") + case selected: + return barStyle.Render("[x]") + default: + return "[ ]" + } +} 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/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/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") +} 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/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 new file mode 100644 index 0000000..a35c402 --- /dev/null +++ b/internal/tui/screens_test.go @@ -0,0 +1,157 @@ +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/project" + "github.com/marioolf/crabby/internal/session" + "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 }}, + {"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 + 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"}} + }}, + {"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 + }}, + {"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) + } + } +} + +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) + } +} 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) -} 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))} +}