diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..06bb8c4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... -count=1 + + - name: Test (race) + run: go test -race ./... -count=1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f53d2fe --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,6 @@ +repos: + - repo: https://github.com/dnephin/pre-commit-golang + rev: v0.5.1 + hooks: + - id: go-fmt + - id: go-vet diff --git a/CLAUDE.md b/CLAUDE.md index bd6275d..3dc2c8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,17 +6,17 @@ vitals is a Go binary that renders a terminal statusline for Claude Code session ## Build & Test -Uses `just` (justfile) for all tasks: +Uses `make` for all tasks: ```sh -just # default: run tests -just build # go install ./cmd/vitals -just test # go test ./... -count=1 -just test-race # go test -race ./... -count=1 -just bench # go test -bench=. -benchmem ./internal/... -count=1 -just check # fmt + vet + test -just dump # build + render from current session's transcript -just run-sample # pipe testdata/sample-stdin.json through the binary +make # default: run tests +make build # go install ./cmd/vitals +make test # go test ./... -count=1 +make test-race # go test -race ./... -count=1 +make bench # go test -bench=. -benchmem ./internal/... -count=1 +make check # fmt + vet + test +make dump # build + render from current session's transcript +make run-sample # pipe testdata/sample-stdin.json through the binary ``` Run a single test: @@ -38,7 +38,7 @@ stdin → gather → render → stdout 3. **render** (`internal/render`): Walks configured lines, calls each widget's `RenderFunc` from the registry, joins non-empty results with the separator, and ANSI-truncates to terminal width. -4. **widget** (`internal/render/widget`): 16 registered widgets (model, context, cost, directory, git, project, duration, tools, agents, todos, tokens, lines, messages, speed, permission, usage). Each is a pure function: `(RenderContext, Config) -> WidgetResult`. Returns empty when it has nothing to show. +4. **widget** (`internal/render/widget`): 18 registered widgets (model, context, cost, directory, git, project, service, duration, tools, agents, todos, tokens, lines, messages, speed, permission, usage, worktree). Each is a pure function: `(RenderContext, Config) -> WidgetResult`. Returns empty when it has nothing to show. ## Key Design Decisions diff --git a/Justfile b/Justfile deleted file mode 100644 index a4b6f61..0000000 --- a/Justfile +++ /dev/null @@ -1,118 +0,0 @@ -# vitals - -# Default: run tests -default: test - -# Build and install the binary to ~/go/bin -build: - go install ./cmd/vitals - -# Run all tests -test: - go test ./... -count=1 - -# Run tests with race detector -test-race: - go test -race ./... -count=1 - -# Run benchmarks -bench: - go test -bench=. -benchmem ./internal/... -count=1 - -# Format code -fmt: - go fmt ./... - -# Vet code -vet: - go vet ./... - -# Format, vet, and test -check: fmt vet test - -# Render the statusline from the current session's transcript -dump: build - vitals --dump-current - -# Pipe sample stdin through the binary (no transcript) -run-sample: - cat testdata/sample-stdin.json | go run ./cmd/vitals - -# Run design evaluation -eval: - go test ./internal/eval/ -run TestDesignEval -v -count=1 - -# Clean build artifacts -clean: - rm -rf bin/ - rm -f $(go env GOPATH)/bin/vitals - -# Bump the version. Usage: just bump patch|minor|major -bump level: - #!/usr/bin/env zsh - set -e - - v=$(cat VERSION) - M=${v%%.*}; rest=${v#*.}; m=${rest%%.*}; p=${rest#*.} - - case "{{level}}" in - patch) new="$M.$m.$((p+1))" ;; - minor) new="$M.$((m+1)).0" ;; - major) new="$((M+1)).0.0" ;; - *) echo "Usage: just bump patch|minor|major" && exit 1 ;; - esac - - echo "Bumping $v → $new" - echo "$new" > VERSION - echo "$new" > internal/version/VERSION - git add VERSION internal/version/VERSION - echo "Version bumped to $new. Run 'just release' to commit, tag, and push." - -# Commit, tag, push, and create a GitHub release. Pass a notes file for custom release notes. -release notes="": - #!/usr/bin/env zsh - set -e - - v=$(cat VERSION) - - # Safety: must be on main and up to date - branch=$(git branch --show-current) - if [[ "$branch" != "main" ]]; then - echo "Error: must be on main branch (currently on $branch)" - exit 1 - fi - - git fetch origin main - behind=$(git rev-list HEAD..origin/main --count) - if [[ "$behind" -gt 0 ]]; then - echo "Error: $behind commit(s) behind origin/main" - echo "Run 'git pull --rebase' first" - exit 1 - fi - - if git diff --cached --quiet; then - echo "Error: nothing staged. Run 'just bump' first." - exit 1 - fi - - tag="v$v" - - git commit -m "chore: bump version to $v" - git tag "$tag" - git push && git push --tags - - # Create GitHub release - notes="{{notes}}" - if [[ -n "$notes" && -f "$notes" ]]; then - gh release create "$tag" --title "$tag" --notes-file "$notes" --latest - else - gh release create "$tag" --title "$tag" --generate-notes --latest - fi - - # Prime the Go module proxy so `go install ...@latest` resolves immediately. - # Uses the /lookup endpoint which only needs the tag to exist on GitHub — - # no local auth required. - curl -sf "https://proxy.golang.org/github.com/Jason-Adam/vitals/@v/${tag}.info" > /dev/null || true - curl -sf "https://proxy.golang.org/github.com/Jason-Adam/vitals/@latest" > /dev/null || true - - echo "Released $tag" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..21d3e54 --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +.DEFAULT_GOAL := test + +.PHONY: build test test-race bench fmt vet check dump run-sample eval clean + +build: + go install ./cmd/vitals + +test: + go test ./... -count=1 + +test-race: + go test -race ./... -count=1 + +bench: + go test -bench=. -benchmem ./internal/... -count=1 + +fmt: + go fmt ./... + +vet: + go vet ./... + +check: fmt vet test + +dump: build + vitals --dump-current + +run-sample: + cat testdata/sample-stdin.json | go run ./cmd/vitals + +eval: + go test ./internal/eval/ -run TestDesignEval -v -count=1 + +clean: + rm -rf bin/ + rm -f $$(go env GOPATH)/bin/vitals diff --git a/README.md b/README.md index 2cb2601..5281986 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ A terminal statusline for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) sessions. -![vitals demo](demo.gif) - ## Install ```bash @@ -25,6 +23,17 @@ Add to `~/.claude/settings.json`: To customize, run `vitals --init` to generate a config at `~/.config/vitals/config.toml`. +## Development + +```bash +# Install pre-commit hooks +pip install pre-commit +pre-commit install + +# Run tests +make test +``` + ## License [MIT](LICENSE) diff --git a/VERSION b/VERSION deleted file mode 100644 index 1c09c74..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.3.3 diff --git a/cmd/vitals/main.go b/cmd/vitals/main.go index 1f43f3e..39f87f6 100644 --- a/cmd/vitals/main.go +++ b/cmd/vitals/main.go @@ -22,7 +22,6 @@ import ( "github.com/Jason-Adam/vitals/internal/preset" "github.com/Jason-Adam/vitals/internal/render" "github.com/Jason-Adam/vitals/internal/stdin" - "github.com/Jason-Adam/vitals/internal/version" "github.com/lucasb-eyer/go-colorful" ) @@ -37,9 +36,6 @@ func main() { case "hook": runHook() return - case "version", "--version", "-version", "-v": - fmt.Println(version.String()) - return case "update": runUpdate() return @@ -428,7 +424,7 @@ func isLightColor(c color.Color) bool { // interpreted by the terminal. Useful for verifying that threshold colors, // powerline backgrounds, and other ANSI styling are actually present. func printRaw(w io.Writer, data []byte) { - for i := 0; i < len(data); i++ { + for i := range len(data) { if data[i] == 0x1b { fmt.Fprint(w, `\x1b`) } else { @@ -467,8 +463,6 @@ func runHook() { // runUpdate installs the latest version via go install. func runUpdate() { - current := version.String() - fmt.Printf("Current version: %s\n", current) fmt.Printf("Installing latest from %s...\n", installPath) cmd := exec.Command("go", "install", installPath) @@ -479,18 +473,7 @@ func runUpdate() { os.Exit(1) } - // Run the newly installed binary to get its version. - out, err := exec.Command("vitals", "version").Output() - if err != nil { - fmt.Println("Updated successfully.") - return - } - newVersion := strings.TrimSpace(string(out)) - if newVersion == current { - fmt.Printf("Already up to date (%s).\n", current) - } else { - fmt.Printf("Updated %s -> %s\n", current, newVersion) - } + fmt.Println("Updated successfully.") } // usage prints help with -- prefixed flags (Go's flag package defaults to single -). @@ -499,7 +482,6 @@ func usage() { fmt.Fprintf(os.Stderr, "Commands:\n") fmt.Fprintf(os.Stderr, " hook handle a Claude Code hook event\n") fmt.Fprintf(os.Stderr, " update install the latest version via go install\n") - fmt.Fprintf(os.Stderr, " version print the current version\n") fmt.Fprintf(os.Stderr, "\nFlags:\n") flag.VisitAll(func(f *flag.Flag) { name := f.Name diff --git a/demo.gif b/demo.gif deleted file mode 100644 index 7b4efcd..0000000 Binary files a/demo.gif and /dev/null differ diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index 9713208..0000000 --- a/docs/architecture.md +++ /dev/null @@ -1,32 +0,0 @@ -# Architecture - -## Four-stage pipeline - -Every invocation follows a strict linear pipeline. Each stage is a separate package with no backward dependencies. - -``` -stdin -> gather -> render -> stdout -``` - -1. **stdin** (`internal/stdin`) -- Decode JSON from Claude Code, persist snapshot to disk. -2. **gather** (`internal/gather`) -- Spawn goroutines only for data sources active widgets need. -3. **render** (`internal/render`) -- Walk configured lines, call each widget's render function, ANSI-truncate to terminal width. -4. **widget** (`internal/render/widget`) -- Pure functions: `(RenderContext, Config) -> WidgetResult`. - -## Transcript processing - -Three layers in `internal/transcript/`: - -- **transcript.go** -- Parses JSONL entries and classifies content blocks (tool_use, tool_result, thinking, text). Filters sidechain sub-agent messages. -- **extractor.go** -- Stateful processor that accumulates tools, agents, and todos across entries. Handles agent lifecycle, todo mutations, and the scrolling divider counter. -- **state.go** -- Byte-offset persistence for incremental reads. Embeds the extraction snapshot so state survives across ticks. - -Reads are incremental (O(delta) not O(n)) by tracking byte offsets per file. - -## Design decisions - -- **Fail-open config**: Missing or corrupt TOML yields defaults. The statusline must always render. -- **Conditional goroutines**: The gather stage checks which widgets are configured before spawning work. No transcript widgets active = no transcript parsing. -- **Never write to stderr**: Claude Code owns the terminal. Debug logging goes to `~/.claude/plugins/vitals/debug.log`, gated behind `VITALS_DEBUG=1`. -- **Hook-based permission detection**: The binary doubles as a Claude Code hook handler (`vitals hook `). PermissionRequest writes a breadcrumb file; PostToolUse and Stop remove it. The gather stage scans breadcrumb files instead of the process table, keeping the project free of cgo. -- **Single-digit millisecond budget**: The full cycle runs on every tick. No work that doesn't contribute to the current frame. diff --git a/docs/cli.md b/docs/cli.md deleted file mode 100644 index d7e3d2d..0000000 --- a/docs/cli.md +++ /dev/null @@ -1,30 +0,0 @@ -# CLI Reference - -``` -vitals [flags] - --init Generate default config and register Claude Code hooks - --preset NAME Apply a built-in or custom preset - --theme NAME Override color theme - --list-presets Print available preset names - --dump-current Render from the current session's transcript snapshot - --dump-raw Like --dump-current but print ANSI escapes as visible text - --preview PATH Render from a transcript file with mock stdin data - --watch Continuously re-render on transcript changes (with --preview) -``` - -## Hook subcommand - -The binary handles Claude Code hook events directly: - -``` -vitals hook permission-request # write breadcrumb (PermissionRequest hook) -vitals hook cleanup # remove breadcrumb (PostToolUse, Stop hooks) -``` - -`--init` registers these hooks in `~/.claude/settings.json`. Re-running is idempotent. - -## Notes - -`--dump-current` auto-discovers the most recent `.jsonl` transcript for the current directory. Useful for testing outside a live session. - -`--preview` with `--watch` polls the transcript every 500ms and re-renders on change, for live development iteration. diff --git a/docs/configuration.md b/docs/configuration.md deleted file mode 100644 index e2f2766..0000000 --- a/docs/configuration.md +++ /dev/null @@ -1,95 +0,0 @@ -# Configuration - -TOML at `~/.config/vitals/config.toml`. Generate defaults with `vitals --init`. - -## Layout - -Each `[[line]]` defines a row of widgets. Widgets render left to right in array order -- reorder the array to change the layout: - -```toml -[[line]] -widgets = ["model", "context", "project", "todos", "duration"] - -[[line]] -widgets = ["tools"] -mode = "powerline" # per-line mode override -``` - -## Style - -```toml -[style] -separator = " | " -icons = "nerdfont" # nerdfont, unicode, ascii -mode = "plain" # plain, powerline, minimal -theme = "default" -``` - -## Widget options - -```toml -[context] -display = "both" # text, bar, percent, both -bar_width = 10 - -[directory] -style = "fish" # full, fish, basename -levels = 2 - -[git] -dirty = true -ahead_behind = true -``` - -## Usage (rate limits) - -```toml -[usage] -five_hour_threshold = 0 # show when 5h usage >= this % (0 = always) -seven_day_threshold = 80 # append 7d window when >= this % -cache_ttl_seconds = 180 # how long to cache successful API responses -``` - -Requires OAuth credentials (macOS Keychain or `~/.claude/.credentials.json`). Only applies to plan subscribers (Pro, Max, Team). Returns empty for API users. - -## Thresholds - -```toml -[thresholds] -context_warning = 70 -context_critical = 85 -cost_warning = 5.00 -cost_critical = 10.00 -``` - -## Render modes - -Three rendering styles, set globally or per-line: - -- **`plain`** (default) -- Separator-joined, widgets apply their own ANSI styling. -- **`powerline`** -- Nerd Font arrow transitions between colored background segments. Auto-detects light/dark terminal background. -- **`minimal`** -- Space-separated, foreground color only, no backgrounds. - -Modes can be mixed. The `powerline` preset uses powerline for line 1 and plain for the tools activity feed on line 2. - -## Themes - -Seven built-in color themes: `default`, `dark`, `light`, `nord`, `gruvbox`, `tokyo-night`, `rose-pine`. - -```bash -vitals --theme nord -``` - -In powerline mode without an explicit `--theme`, the terminal background is auto-detected (via OSC 11) and the theme switches between `light` and `dark` automatically. - -Per-widget color overrides: - -```toml -[theme.overrides] -model = { fg = "#ffffff", bg = "#2d2d2d" } -tools = { fg = "#87ceeb", bg = "#2a2a2a" } -``` - -## Fail-open - -Config loading never fails. Missing or corrupt TOML yields defaults. The statusline always renders something. diff --git a/docs/development.md b/docs/development.md deleted file mode 100644 index d77789a..0000000 --- a/docs/development.md +++ /dev/null @@ -1,29 +0,0 @@ -# Development - -Requires Go 1.25+ and [just](https://github.com/casey/just) for task running. - -## Commands - -```bash -just # run tests -just build # go build -o bin/vitals ./cmd/vitals -just test # go test ./... -count=1 -just test-race # race detector -just bench # benchmarks -just check # fmt + vet + test -just dump # build + render from current session -just run-sample # pipe testdata through the binary -``` - -## Running a single test - -```bash -go test ./internal/transcript/ -run TestExtractContentBlocks -count=1 -``` - -## Releasing - -```bash -just bump patch # or minor, major -just release # commit, tag, push, create GitHub release -``` diff --git a/docs/widgets.md b/docs/widgets.md deleted file mode 100644 index 80fa10e..0000000 --- a/docs/widgets.md +++ /dev/null @@ -1,73 +0,0 @@ -# Widgets - -16 widgets, each a pure function that returns a styled string or `""` when it has nothing to show. - -## Session data - -From Claude Code's stdin JSON: - -| Widget | Shows | Config | -|---|---|---| -| `model` | Model name, optional context window size | `show_context_size` (bool, default: true) | -| `context` | Context usage, color-coded by threshold | `display` (text/bar/percent/both), `bar_width` (int), `value` (percent/tokens), `show_breakdown` (bool) | -| `cost` | Session cost in USD, color-coded by threshold | -- | -| `tokens` | Token count and cache hit ratio | -- | -| `duration` | Elapsed session time | -- | -| `lines` | Lines added/removed (green +N, red -N) | -- | -| `speed` | Rolling tokens/sec | `window_secs` (int, default: 30) | -| `messages` | Conversation turn count | -- | - -## Transcript data - -Parsed incrementally from the JSONL transcript: - -| Widget | Shows | Config | -|---|---|---| -| `tools` | Running/completed tool invocations as a scrolling activity feed | -- | -| `agents` | Sub-agents with elapsed time (running) or duration (completed) | -- | -| `todos` | Task completion count, color-coded | -- | - -## Environment - -Gathered from the filesystem: - -| Widget | Shows | Config | -|---|---|---| -| `directory` | Working directory | `style` (full/fish/basename), `levels` (int) | -| `git` | Branch, dirty indicator, ahead/behind counts | `dirty` (bool), `ahead_behind` (bool), `file_stats` (bool) | -| `project` | Composite of directory + git in a single segment | inherits `[directory]` and `[git]` config | -| `permission` | Red alert when another session needs approval (requires hooks via `--init`) | `show_project` (bool, default: true) | -| `usage` | Anthropic 5-hour and 7-day rate-limit utilization | `five_hour_threshold` (int, default: 0), `seven_day_threshold` (int, default: 80), `cache_ttl_seconds` (int, default: 180) | - -## Config sections - -Widget options live under a TOML section matching the widget name: - -```toml -[model] -show_context_size = false - -[context] -display = "bar" -bar_width = 15 - -[speed] -window_secs = 10 - -[directory] -style = "fish" -levels = 2 - -[git] -dirty = true -ahead_behind = true -file_stats = false - -[permission] -show_project = false # show only the bell icon - -[usage] -five_hour_threshold = 0 # always show when credentials are available -seven_day_threshold = 80 # only show 7-day window when >= 80% -cache_ttl_seconds = 180 # cache successful API responses for 3 minutes -``` diff --git a/internal/config/config.go b/internal/config/config.go index c4be3ee..edd7202 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -118,7 +118,7 @@ type Config struct { // TOML decode) cannot mutate the canonical definition. func DefaultLines() []Line { return []Line{ - {Widgets: []string{"model", "context", "project", "worktree", "todos", "duration", "permission"}}, + {Widgets: []string{"model", "context", "service", "worktree", "project", "todos", "duration", "permission"}}, {Widgets: []string{"agents"}}, } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d3d480c..0c55a78 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -33,7 +33,7 @@ func TestDefaultsWhenNoFile(t *testing.T) { if len(cfg.Lines) != 2 { t.Fatalf("expected 2 lines, got %d", len(cfg.Lines)) } - assertWidgets(t, cfg.Lines[0].Widgets, []string{"model", "context", "project", "worktree", "todos", "duration", "permission"}) + assertWidgets(t, cfg.Lines[0].Widgets, []string{"model", "context", "service", "worktree", "project", "todos", "duration", "permission"}) assertWidgets(t, cfg.Lines[1].Widgets, []string{"agents"}) // Spec 4: default Icons @@ -286,7 +286,7 @@ func TestDefaultLayoutIsTwoLines(t *testing.T) { t.Fatalf("default layout: want 2 lines, got %d", len(cfg.Lines)) } - assertWidgets(t, cfg.Lines[0].Widgets, []string{"model", "context", "project", "worktree", "todos", "duration", "permission"}) + assertWidgets(t, cfg.Lines[0].Widgets, []string{"model", "context", "service", "worktree", "project", "todos", "duration", "permission"}) assertWidgets(t, cfg.Lines[1].Widgets, []string{"agents"}) } diff --git a/internal/gather/gather.go b/internal/gather/gather.go index 6dbed13..7ed9736 100644 --- a/internal/gather/gather.go +++ b/internal/gather/gather.go @@ -13,13 +13,13 @@ import ( "sync" "time" - "github.com/charmbracelet/x/term" "github.com/Jason-Adam/vitals/internal/breadcrumb" "github.com/Jason-Adam/vitals/internal/config" "github.com/Jason-Adam/vitals/internal/extracmd" "github.com/Jason-Adam/vitals/internal/git" "github.com/Jason-Adam/vitals/internal/model" "github.com/Jason-Adam/vitals/internal/transcript" + "github.com/charmbracelet/x/term" ) // transcriptWidgets are the widget names that require transcript data. @@ -66,36 +66,40 @@ func Gather(input *model.StdinData, cfg *config.Config) *model.RenderContext { // Determine which widget names are active across all configured lines. active := activeWidgets(cfg) + // Service name: repo basename, derived from the original cwd (worktree) + // or current cwd (non-worktree). Only populated when the widget is active. + if active["service"] { + cwd := input.Cwd + if input.Worktree != nil && input.Worktree.OriginalCwd != "" { + cwd = input.Worktree.OriginalCwd + } + ctx.ServiceName = filepath.Base(cwd) + } + var wg sync.WaitGroup // Transcript goroutine: needed when any of tools/agents/todos are active // and a transcript path is available. if needsTranscript(active) && input.TranscriptPath != "" { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { ctx.Transcript = gatherTranscript(input.TranscriptPath) - }() + }) } // Git goroutine: needed when the "git" or "project" widget is active. // "project" renders the project name with optional ahead/behind counts, // so it requires the same git data as the "git" widget. if active["git"] || active["project"] { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { ctx.Git = git.GetStatus(input.Cwd) - }() + }) } // Extra command goroutine: runs user-configured command when set. if cfg.Extra.Command != "" { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { ctx.ExtraOutput = extracmd.Run(cfg.Extra.Command) - }() + }) } // Usage: populated from stdin rate_limits (zero-cost, no network). @@ -106,19 +110,17 @@ func Gather(input *model.StdinData, cfg *config.Config) *model.RenderContext { // Permission detection goroutine: scans breadcrumb files written by // Claude Code hooks. Only runs when the widget is active. if active["permission"] { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { if b := breadcrumb.FindWaiting(input.SessionID); b != nil { ctx.PermissionProject = b.Project } - }() + }) } wg.Wait() // Post-gather: compute derived fields that depend on gathered data. - ctx.SessionStart = sessionStart(ctx.Transcript, input.TranscriptPath) + ctx.SessionStart = sessionStart(input.TranscriptPath) ctx.TerminalWidth = terminalWidth() // Claude Code's pseudo-TTY reports 80 columns regardless of actual @@ -231,7 +233,7 @@ func readFirstLine(path string) []byte { // transcript file, which the duration widget uses as the session start time. // Falls back to "" when the transcript path is empty, unreadable, or has no // parseable entries. -func sessionStart(td *model.TranscriptData, path string) string { +func sessionStart(path string) string { if path == "" { return "" } diff --git a/internal/gather/gather_bench_test.go b/internal/gather/gather_bench_test.go index e363759..1d6cc06 100644 --- a/internal/gather/gather_bench_test.go +++ b/internal/gather/gather_bench_test.go @@ -201,7 +201,7 @@ func BenchmarkRender_FullContext(b *testing.B) { SessionStart: "2026-03-15T09:00:00Z", TerminalWidth: 200, Transcript: &model.TranscriptData{ - Path: "/tmp/bench-session.jsonl", + Path: "/tmp/bench-session.jsonl", Tools: []model.ToolEntry{ {Name: "Bash", Completed: true}, {Name: "Read", Completed: true}, diff --git a/internal/gather/gather_test.go b/internal/gather/gather_test.go index 90c586c..93a900f 100644 --- a/internal/gather/gather_test.go +++ b/internal/gather/gather_test.go @@ -366,8 +366,12 @@ func TestGather_GitSpawnedForProjectWidget(t *testing.T) { // We can only observe this indirectly: Git field must be non-nil when // the cwd is inside a real git repository. input := minimalInput() - // Use a real directory that is inside a git repo so git.GetStatus returns data. - input.Cwd = "/Users/kyle/Code/my-projects/vitals" + // Use the current working directory (which is inside this git repo). + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get cwd: %v", err) + } + input.Cwd = cwd cfg := cfgWithWidgets("project") // "git" widget NOT listed ctx := Gather(input, cfg) diff --git a/internal/model/model.go b/internal/model/model.go index ded1a58..95b7a6a 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -40,6 +40,10 @@ type RenderContext struct { // Empty when not running inside a worktree. WorktreeName string + // ServiceName is the repository/project basename (e.g. "vitals"). + // Populated only when the "service" widget is active. + ServiceName string + // ExtraOutput is the label returned by the user's extra command. // Empty when no extra command is configured or the command fails/times out. ExtraOutput string diff --git a/internal/render/powerline_test.go b/internal/render/powerline_test.go index 4c113be..d655b1e 100644 --- a/internal/render/powerline_test.go +++ b/internal/render/powerline_test.go @@ -5,12 +5,12 @@ import ( "strings" "testing" - "github.com/charmbracelet/x/ansi" "github.com/Jason-Adam/vitals/internal/config" "github.com/Jason-Adam/vitals/internal/model" "github.com/Jason-Adam/vitals/internal/preset" "github.com/Jason-Adam/vitals/internal/render/widget" "github.com/Jason-Adam/vitals/internal/theme" + "github.com/charmbracelet/x/ansi" ) // makeTestConfig builds a Config with the given lines and a dark theme diff --git a/internal/render/render.go b/internal/render/render.go index b97b525..114f419 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -8,13 +8,13 @@ import ( "strings" "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" "github.com/Jason-Adam/vitals/internal/color" "github.com/Jason-Adam/vitals/internal/config" "github.com/Jason-Adam/vitals/internal/logging" "github.com/Jason-Adam/vitals/internal/model" "github.com/Jason-Adam/vitals/internal/render/widget" "github.com/Jason-Adam/vitals/internal/theme" + "github.com/charmbracelet/x/ansi" ) // truncateSuffix is appended when a line is truncated to fit terminal width. @@ -165,7 +165,7 @@ func renderPowerline(results []widget.WidgetResult, names []string, cfg *config. // no background colors — just the widget text with its fg color applied. // // When results is empty the function returns "". -func renderMinimal(results []widget.WidgetResult, line config.Line, cfg *config.Config) string { +func renderMinimal(results []widget.WidgetResult, _ config.Line, _ *config.Config) string { var parts []string for _, r := range results { if r.IsEmpty() { diff --git a/internal/render/render_test.go b/internal/render/render_test.go index d424e57..e5be957 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -7,12 +7,12 @@ import ( "time" "charm.land/lipgloss/v2" - "github.com/charmbracelet/colorprofile" - "github.com/charmbracelet/x/ansi" "github.com/Jason-Adam/vitals/internal/config" "github.com/Jason-Adam/vitals/internal/model" "github.com/Jason-Adam/vitals/internal/render/widget" "github.com/Jason-Adam/vitals/internal/theme" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" ) func TestRender_ProducesOutput(t *testing.T) { diff --git a/internal/render/widget/agents.go b/internal/render/widget/agents.go index 24add70..7c2b9a2 100644 --- a/internal/render/widget/agents.go +++ b/internal/render/widget/agents.go @@ -5,9 +5,9 @@ import ( "strings" "time" - "github.com/charmbracelet/x/ansi" "github.com/Jason-Adam/vitals/internal/config" "github.com/Jason-Adam/vitals/internal/model" + "github.com/charmbracelet/x/ansi" ) // agentSeparator is the text between agent entries. diff --git a/internal/render/widget/agents_test.go b/internal/render/widget/agents_test.go index b76c822..047ab88 100644 --- a/internal/render/widget/agents_test.go +++ b/internal/render/widget/agents_test.go @@ -5,8 +5,8 @@ import ( "testing" "time" - "github.com/charmbracelet/x/ansi" "github.com/Jason-Adam/vitals/internal/model" + "github.com/charmbracelet/x/ansi" ) func longAgent(name string, status string) model.AgentEntry { diff --git a/internal/render/widget/project.go b/internal/render/widget/project.go index de6eccc..a88edc8 100644 --- a/internal/render/widget/project.go +++ b/internal/render/widget/project.go @@ -19,8 +19,9 @@ func Project(ctx *model.RenderContext, cfg *config.Config) WidgetResult { dir := Directory(ctx, cfg) git := Git(ctx, cfg) - // In a worktree the project name is redundant — just show branch info. - if ctx.WorktreeName != "" { + // When the service widget is active or we're in a worktree, the directory + // name is handled elsewhere — just show branch info. + if ctx.WorktreeName != "" || ctx.ServiceName != "" { if git.IsEmpty() { return WidgetResult{} } diff --git a/internal/render/widget/service.go b/internal/render/widget/service.go new file mode 100644 index 0000000..ba50052 --- /dev/null +++ b/internal/render/widget/service.go @@ -0,0 +1,22 @@ +package widget + +import ( + "github.com/Jason-Adam/vitals/internal/config" + "github.com/Jason-Adam/vitals/internal/model" +) + +// Service renders the repository name (e.g. "vitals") so the user always +// knows which project they are working in. Returns empty when the name +// is not available. +func Service(ctx *model.RenderContext, cfg *config.Config) WidgetResult { + if ctx.ServiceName == "" { + return WidgetResult{} + } + icons := IconsFor(cfg.Style.Icons) + plain := icons.Folder + " " + ctx.ServiceName + return WidgetResult{ + Text: dirStyle.Render(plain), + PlainText: plain, + FgColor: "13", + } +} diff --git a/internal/render/widget/tools.go b/internal/render/widget/tools.go index 723dc73..20c6341 100644 --- a/internal/render/widget/tools.go +++ b/internal/render/widget/tools.go @@ -254,7 +254,7 @@ func renderToolEntryWithMultiplier(icons Icons, t model.ToolEntry, count int) st if t.Category == "Thinking" { return fmt.Sprintf("%s %s%s", yellowStyle.Render(catIcon), DimStyle.Render(t.Name), DimStyle.Render(mult)) } - return yellowStyle.Bold(true).Render(toolLabel(catIcon, t.Name)+mult) + return yellowStyle.Bold(true).Render(toolLabel(catIcon, t.Name) + mult) } if t.HasError { diff --git a/internal/render/widget/usage.go b/internal/render/widget/usage.go index 6aebf77..141014f 100644 --- a/internal/render/widget/usage.go +++ b/internal/render/widget/usage.go @@ -97,11 +97,6 @@ func usageWindow(label string, pct int, resetAt time.Time, cfg *config.Config) u // Per-element helpers. Each returns (styled, plain). Return ("", "") to omit. // --------------------------------------------------------------------------- -// usageLabel renders the window identifier: "5h" or "7d". -func usageLabel(label string) (string, string) { - return DimStyle.Render(label), label -} - // usageIcon renders the circle-slice fill icon colored by severity. func usageIcon(pct int, style lipgloss.Style) (string, string) { icon := percentToIcon(pct) diff --git a/internal/render/widget/widget.go b/internal/render/widget/widget.go index 1bd1e0b..e37fdb8 100644 --- a/internal/render/widget/widget.go +++ b/internal/render/widget/widget.go @@ -36,23 +36,24 @@ type RenderFunc func(ctx *model.RenderContext, cfg *config.Config) WidgetResult // Registry maps widget names to their render functions. var Registry = map[string]RenderFunc{ - "model": Model, - "context": Context, - "cost": Cost, - "directory": Directory, - "git": Git, - "project": Project, - "duration": Duration, - "tools": Tools, - "agents": Agents, - "todos": Todos, - "tokens": Tokens, - "lines": Lines, - "messages": Messages, - "speed": Speed, - "permission": Permission, - "usage": Usage, - "worktree": Worktree, + "model": Model, + "context": Context, + "cost": Cost, + "directory": Directory, + "git": Git, + "project": Project, + "duration": Duration, + "tools": Tools, + "agents": Agents, + "todos": Todos, + "tokens": Tokens, + "lines": Lines, + "messages": Messages, + "speed": Speed, + "permission": Permission, + "service": Service, + "usage": Usage, + "worktree": Worktree, } // Icons holds the icon strings for a given display mode (nerdfont, unicode, ascii). diff --git a/internal/render/widget/widget_test.go b/internal/render/widget/widget_test.go index f3ed2b2..ec49e3b 100644 --- a/internal/render/widget/widget_test.go +++ b/internal/render/widget/widget_test.go @@ -508,7 +508,7 @@ func TestDirectoryWidget_EmptyCwd(t *testing.T) { } func TestRegistryHasAllWidgets(t *testing.T) { - expected := []string{"model", "context", "directory", "git", "project", "duration", "tools", "agents", "todos", "tokens", "cost", "lines", "messages", "speed", "permission", "usage", "worktree"} + expected := []string{"model", "context", "directory", "git", "project", "duration", "tools", "agents", "todos", "tokens", "cost", "lines", "messages", "speed", "permission", "service", "usage", "worktree"} for _, name := range expected { if _, ok := Registry[name]; !ok { t.Errorf("Registry missing widget %q", name) @@ -1489,9 +1489,12 @@ func TestDirectoryWidget_StyleFull(t *testing.T) { func TestDirectoryWidget_StyleFish(t *testing.T) { // Fish style abbreviates intermediate segments to first char. - // Home dir (/Users/kyle) is replaced with ~ first, so the path becomes - // ~/Code/my-projects/vitals, then fish gives ~/C/m/vitals. - ctx := &model.RenderContext{Cwd: "/Users/kyle/Code/my-projects/vitals"} + // Home dir is replaced with ~ first, then fish abbreviates intermediates. + home, err := os.UserHomeDir() + if err != nil { + t.Fatalf("failed to get home dir: %v", err) + } + ctx := &model.RenderContext{Cwd: home + "/Code/my-projects/vitals"} cfg := defaultCfg() cfg.Directory.Style = "fish" @@ -1953,7 +1956,6 @@ func TestWidgetResult_IsEmpty(t *testing.T) { } } - // -- percentToIcon ------------------------------------------------------------ // TestPercentToIcon_BoundaryValues checks that each of the 8 icon levels is diff --git a/internal/stdin/mock_test.go b/internal/stdin/mock_test.go index c385aa7..711382c 100644 --- a/internal/stdin/mock_test.go +++ b/internal/stdin/mock_test.go @@ -86,4 +86,3 @@ func TestMockStdinData_ContextWindowValues(t *testing.T) { t.Errorf("CacheReadInputTokens: got %d, want 8000", data.ContextWindow.CurrentUsage.CacheReadInputTokens) } } - diff --git a/internal/theme/theme.go b/internal/theme/theme.go index b2965e1..4802e51 100644 --- a/internal/theme/theme.go +++ b/internal/theme/theme.go @@ -42,12 +42,12 @@ var defaultTheme = Theme{ "context": {Fg: "42", Bg: ""}, // green (normal usage) "directory": {Fg: "110", Bg: ""}, "git": {Fg: "87", Bg: ""}, // cyan - "project": {Fg: "75", Bg: ""}, // blue - "duration": {Fg: "244", Bg: ""}, - "tools": {Fg: "75", Bg: ""}, - "agents": {Fg: "114", Bg: ""}, - "todos": {Fg: "220", Bg: ""}, - "cost": {Fg: "87", Bg: ""}, + "project": {Fg: "75", Bg: ""}, // blue + "duration": {Fg: "244", Bg: ""}, + "tools": {Fg: "75", Bg: ""}, + "agents": {Fg: "114", Bg: ""}, + "todos": {Fg: "220", Bg: ""}, + "cost": {Fg: "87", Bg: ""}, } // darkTheme is a high-contrast dark terminal palette derived from claude-powerline's @@ -58,12 +58,12 @@ var darkTheme = Theme{ "context": {Fg: "#cbd5e0", Bg: "#4a5568"}, "directory": {Fg: "#ffffff", Bg: "#8b4513"}, "git": {Fg: "#ffffff", Bg: "#404040"}, - "project": {Fg: "#87ceeb", Bg: "#2d3a4a"}, // sky blue text on navy - "duration": {Fg: "#d1d5db", Bg: "#374151"}, - "tools": {Fg: "#87ceeb", Bg: "#2a2a2a"}, - "agents": {Fg: "#87ceeb", Bg: "#2f4f2f"}, - "todos": {Fg: "#98fb98", Bg: "#1a1a1a"}, - "cost": {Fg: "#00ffff", Bg: "#1a3a2a"}, + "project": {Fg: "#87ceeb", Bg: "#2d3a4a"}, // sky blue text on navy + "duration": {Fg: "#d1d5db", Bg: "#374151"}, + "tools": {Fg: "#87ceeb", Bg: "#2a2a2a"}, + "agents": {Fg: "#87ceeb", Bg: "#2f4f2f"}, + "todos": {Fg: "#98fb98", Bg: "#1a1a1a"}, + "cost": {Fg: "#00ffff", Bg: "#1a3a2a"}, } // lightTheme uses pale, distinct backgrounds for light terminal themes. @@ -75,12 +75,12 @@ var lightTheme = Theme{ "context": {Fg: "#2d3748", Bg: "#c8dcb0"}, // dark text on sage green "directory": {Fg: "#3b2006", Bg: "#e0c8a8"}, // brown text on warm tan "git": {Fg: "#1a3a4a", Bg: "#a8c8d8"}, // navy text on sky blue - "project": {Fg: "#2d2066", Bg: "#c8b8d8"}, // indigo text on lavender - "duration": {Fg: "#374151", Bg: "#c8ccd0"}, // gray text on cool silver - "tools": {Fg: "#1a3a4a", Bg: "#b8c8cc"}, // navy text on pale steel - "agents": {Fg: "#1a4a2a", Bg: "#b0d0b8"}, // forest text on mint - "todos": {Fg: "#4a3800", Bg: "#dcd0a8"}, // amber text on cream - "cost": {Fg: "#1a4a2a", Bg: "#a8d0b8"}, // forest text on mint green + "project": {Fg: "#2d2066", Bg: "#c8b8d8"}, // indigo text on lavender + "duration": {Fg: "#374151", Bg: "#c8ccd0"}, // gray text on cool silver + "tools": {Fg: "#1a3a4a", Bg: "#b8c8cc"}, // navy text on pale steel + "agents": {Fg: "#1a4a2a", Bg: "#b0d0b8"}, // forest text on mint + "todos": {Fg: "#4a3800", Bg: "#dcd0a8"}, // amber text on cream + "cost": {Fg: "#1a4a2a", Bg: "#a8d0b8"}, // forest text on mint green } // nordTheme uses the Nord color palette (https://www.nordtheme.com/). @@ -90,12 +90,12 @@ var nordTheme = Theme{ "context": {Fg: "#eceff4", Bg: "#5e81ac"}, "directory": {Fg: "#d8dee9", Bg: "#434c5e"}, "git": {Fg: "#a3be8c", Bg: "#3b4252"}, - "project": {Fg: "#88c0d0", Bg: "#434c5e"}, - "duration": {Fg: "#d8dee9", Bg: "#3b4252"}, - "tools": {Fg: "#81a1c1", Bg: "#3b4252"}, - "agents": {Fg: "#88c0d0", Bg: "#2e3440"}, - "todos": {Fg: "#8fbcbb", Bg: "#2e3440"}, - "cost": {Fg: "#b48ead", Bg: "#2e3440"}, + "project": {Fg: "#88c0d0", Bg: "#434c5e"}, + "duration": {Fg: "#d8dee9", Bg: "#3b4252"}, + "tools": {Fg: "#81a1c1", Bg: "#3b4252"}, + "agents": {Fg: "#88c0d0", Bg: "#2e3440"}, + "todos": {Fg: "#8fbcbb", Bg: "#2e3440"}, + "cost": {Fg: "#b48ead", Bg: "#2e3440"}, } // gruvboxTheme uses the Gruvbox color palette (https://github.com/morhetz/gruvbox). @@ -105,12 +105,12 @@ var gruvboxTheme = Theme{ "context": {Fg: "#ebdbb2", Bg: "#458588"}, "directory": {Fg: "#ebdbb2", Bg: "#504945"}, "git": {Fg: "#b8bb26", Bg: "#3c3836"}, - "project": {Fg: "#8ec07c", Bg: "#504945"}, - "duration": {Fg: "#ebdbb2", Bg: "#3c3836"}, - "tools": {Fg: "#83a598", Bg: "#3c3836"}, - "agents": {Fg: "#8ec07c", Bg: "#282828"}, - "todos": {Fg: "#fabd2f", Bg: "#282828"}, - "cost": {Fg: "#fabd2f", Bg: "#282828"}, + "project": {Fg: "#8ec07c", Bg: "#504945"}, + "duration": {Fg: "#ebdbb2", Bg: "#3c3836"}, + "tools": {Fg: "#83a598", Bg: "#3c3836"}, + "agents": {Fg: "#8ec07c", Bg: "#282828"}, + "todos": {Fg: "#fabd2f", Bg: "#282828"}, + "cost": {Fg: "#fabd2f", Bg: "#282828"}, } // tokyoNightTheme uses the Tokyo Night color palette @@ -121,12 +121,12 @@ var tokyoNightTheme = Theme{ "context": {Fg: "#c0caf5", Bg: "#414868"}, "directory": {Fg: "#82aaff", Bg: "#2f334d"}, "git": {Fg: "#c3e88d", Bg: "#1e2030"}, - "project": {Fg: "#bb9af7", Bg: "#292e42"}, - "duration": {Fg: "#c0caf5", Bg: "#3d59a1"}, - "tools": {Fg: "#7aa2f7", Bg: "#2d3748"}, - "agents": {Fg: "#86e1fc", Bg: "#222436"}, - "todos": {Fg: "#4fd6be", Bg: "#1a202c"}, - "cost": {Fg: "#4fd6be", Bg: "#1a202c"}, + "project": {Fg: "#bb9af7", Bg: "#292e42"}, + "duration": {Fg: "#c0caf5", Bg: "#3d59a1"}, + "tools": {Fg: "#7aa2f7", Bg: "#2d3748"}, + "agents": {Fg: "#86e1fc", Bg: "#222436"}, + "todos": {Fg: "#4fd6be", Bg: "#1a202c"}, + "cost": {Fg: "#4fd6be", Bg: "#1a202c"}, } // rosePineTheme uses the Rosé Pine color palette (https://rosepinetheme.com/). @@ -136,12 +136,12 @@ var rosePineTheme = Theme{ "context": {Fg: "#e0def4", Bg: "#393552"}, "directory": {Fg: "#c4a7e7", Bg: "#26233a"}, "git": {Fg: "#9ccfd8", Bg: "#1f1d2e"}, - "project": {Fg: "#c4a7e7", Bg: "#2a273f"}, - "duration": {Fg: "#e0def4", Bg: "#524f67"}, - "tools": {Fg: "#eb6f92", Bg: "#2a273f"}, - "agents": {Fg: "#f6c177", Bg: "#26233a"}, - "todos": {Fg: "#9ccfd8", Bg: "#232136"}, - "cost": {Fg: "#9ccfd8", Bg: "#232136"}, + "project": {Fg: "#c4a7e7", Bg: "#2a273f"}, + "duration": {Fg: "#e0def4", Bg: "#524f67"}, + "tools": {Fg: "#eb6f92", Bg: "#2a273f"}, + "agents": {Fg: "#f6c177", Bg: "#26233a"}, + "todos": {Fg: "#9ccfd8", Bg: "#232136"}, + "cost": {Fg: "#9ccfd8", Bg: "#232136"}, } // Load returns the named built-in theme. If the name is not recognized, diff --git a/internal/version/VERSION b/internal/version/VERSION deleted file mode 100644 index 1c09c74..0000000 --- a/internal/version/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.3.3 diff --git a/internal/version/version.go b/internal/version/version.go deleted file mode 100644 index 63c4df1..0000000 --- a/internal/version/version.go +++ /dev/null @@ -1,15 +0,0 @@ -// Package version embeds the project version from the VERSION file. -package version - -import ( - _ "embed" - "strings" -) - -//go:embed VERSION -var raw string - -// String returns the current version, e.g. "0.3.1". -func String() string { - return strings.TrimSpace(raw) -}