From 7b48cc66db11ca7b784dc846c79bf7edc27b097e Mon Sep 17 00:00:00 2001 From: livlign Date: Sun, 14 Jun 2026 19:29:48 +0700 Subject: [PATCH 1/4] Verify the release binary checksum in the installer The curl|bash installer downloaded the release tarball with no integrity check. Fetch the release's checksums.txt and verify the asset's SHA-256 before installing, refusing to install on a mismatch. Skips gracefully (with a warning) only when verification genuinely can't run: checksums unreachable, asset unlisted, or no sha256 tool present. Co-Authored-By: Claude Opus 4.8 (1M context) --- install.sh | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/install.sh b/install.sh index dada4af..89744bd 100755 --- a/install.sh +++ b/install.sh @@ -25,6 +25,48 @@ esac ASSET="ccbit_${OS}_${ARCH}.tar.gz" URL="https://github.com/$REPO/releases/latest/download/$ASSET" +CHECKSUMS_URL="https://github.com/$REPO/releases/latest/download/checksums.txt" + +# sha256_of prints the SHA-256 of a file, using whichever tool is present. +# Returns non-zero when neither is available (caller skips verification). +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + return 1 + fi +} + +# verify_checksum checks the downloaded asset against the release's +# checksums.txt. A mismatch is fatal (return 1). When verification genuinely +# can't run — checksums.txt unreachable, asset unlisted, or no sha256 tool — it +# warns and returns 0 so a release predating checksums still installs. +verify_checksum() { + local asset_path="$1" sums want got + sums="$(dirname "$asset_path")/checksums.txt" + if ! curl -fsSL "$CHECKSUMS_URL" -o "$sums" 2>/dev/null; then + echo "ccbit: WARNING — could not fetch checksums.txt; skipping integrity check" >&2 + return 0 + fi + want=$(awk -v f="$ASSET" '$2 == f {print $1}' "$sums") + if [ -z "$want" ]; then + echo "ccbit: WARNING — no checksum listed for $ASSET; skipping integrity check" >&2 + return 0 + fi + if ! got=$(sha256_of "$asset_path"); then + echo "ccbit: WARNING — no sha256 tool (sha256sum/shasum); skipping integrity check" >&2 + return 0 + fi + if [ "$got" != "$want" ]; then + echo "ccbit: ERROR — checksum mismatch for $ASSET (refusing to install)" >&2 + echo " expected $want" >&2 + echo " got $got" >&2 + return 1 + fi + echo "ccbit: checksum verified" +} fetch_release() { [ -n "$OS" ] && [ -n "$ARCH" ] || return 1 @@ -35,6 +77,7 @@ fetch_release() { trap "rm -rf '$tmp'" RETURN 2>/dev/null || true echo "ccbit: downloading $ASSET from the latest release" curl -fsSL "$URL" -o "$tmp/$ASSET" || { rm -rf "$tmp"; return 1; } + verify_checksum "$tmp/$ASSET" || { rm -rf "$tmp"; return 1; } tar -xzf "$tmp/$ASSET" -C "$tmp" ccbit || { rm -rf "$tmp"; return 1; } mkdir -p "$DEST_DIR" install -m 0755 "$tmp/ccbit" "$DEST" From 3638bd4d853ec5cbf25326bed964742b2c1aa13d Mon Sep 17 00:00:00 2001 From: livlign Date: Sun, 14 Jun 2026 19:29:48 +0700 Subject: [PATCH 2/4] Add CI workflow: gofmt/vet lint and cross-OS tests Runs on push to main and PRs. A lint job gates gofmt and go vet; a test matrix builds and tests on ubuntu, macOS, and windows (the three platforms the release ships). Previously only release.yml existed, so tests never ran on PRs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4ae05b2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: gofmt + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "::error::these files are not gofmt-clean:" + echo "$unformatted" + exit 1 + fi + - run: go vet ./... + + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: go build ./... + - run: go test ./... From 27bf14eea67c9161cbf921e73ac4ef3737d97686 Mon Sep 17 00:00:00 2001 From: livlign Date: Sun, 14 Jun 2026 19:29:48 +0700 Subject: [PATCH 3/4] Add diag package for build-failure diagnosis Extracts a short, glanceable reason from a failed build/test's captured output: a compiler/linter file:line location, a named failing test, an error: line, else the bare exit code. High-signal only, so a miss yields nothing rather than a guess. A user-maintained error-signatures file (pattern->message) overrides the heuristic. No network, no model call. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/diag/diag.go | 138 +++++++++++++++++++++++++++++++++++++ internal/diag/diag_test.go | 66 ++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 internal/diag/diag.go create mode 100644 internal/diag/diag_test.go diff --git a/internal/diag/diag.go b/internal/diag/diag.go new file mode 100644 index 0000000..3161fb4 --- /dev/null +++ b/internal/diag/diag.go @@ -0,0 +1,138 @@ +// Package diag turns a failed build/test's captured output into a short, +// glanceable reason for the status line — the first concrete error it can find, +// or a user-defined signature message. Pure local text parsing: no network and +// no model call (PRD §10 tier 3 is deliberately omitted to keep ccbit a +// transcript-only, no-daemon tool). +package diag + +import ( + "bufio" + "os" + "path/filepath" + "regexp" + "strings" +) + +// Diagnose returns a one-line reason for a failure, or "" when nothing +// trustworthy can be extracted (the caller then shows a plain "build failed"). +// A user signature (tier 2) wins over the built-in heuristic (tier 1.5). +func Diagnose(text string) string { + if strings.TrimSpace(text) == "" { + return "" + } + if m := matchSignature(text); m != "" { + return m + } + return reason(text) +} + +var ( + // "path/file.ext:line[:col]: message" — Go, gcc/clang, tsc, eslint, etc. + compilerRe = regexp.MustCompile(`([\w./\\-]+\.\w+:\d+(?::\d+)?:\s+\S.*)$`) + // A named Go test failure. + goFailRe = regexp.MustCompile(`^\s*--- FAIL:\s+(\S+)`) + // A generic "error: " line (most build tools emit one). + errorLineRe = regexp.MustCompile(`(?i)\berror:\s*(\S.*)$`) + // The exit-code line, always present on a failed Bash result (§6.3). + exitCodeRe = regexp.MustCompile(`(?i)\bexit code\s+\d+`) +) + +// reason is the built-in heuristic: prefer a concrete error location or named +// test failure, then an explicit error message, then the bare exit code. Each +// tier is high-signal, so a miss returns "" rather than a guess. +func reason(text string) string { + lines := strings.Split(text, "\n") + for _, ln := range lines { + if m := goFailRe.FindStringSubmatch(ln); m != nil { + return m[1] + " failed" + } + if m := compilerRe.FindStringSubmatch(ln); m != nil { + return clean(m[1]) + } + } + for _, ln := range lines { + if m := errorLineRe.FindStringSubmatch(ln); m != nil { + if msg := clean(m[1]); msg != "" { + return msg + } + } + } + if loc := exitCodeRe.FindString(text); loc != "" { + return clean(loc) + } + return "" +} + +// clean collapses runs of whitespace so a wrapped or tab-indented error reads +// as one tidy line. +func clean(s string) string { + return strings.Join(strings.Fields(s), " ") +} + +type signature struct { + re *regexp.Regexp + msg string +} + +// matchSignature returns the message of the first user signature whose pattern +// matches the failure text, or "". +func matchSignature(text string) string { + for _, s := range loadSignatures() { + if s.re.MatchString(text) { + return s.msg + } + } + return "" +} + +// signaturePath is the user's signature file: $XDG_CONFIG_HOME/ccbit/ +// error-signatures, else ~/.config/ccbit/error-signatures. Absent is fine — +// tier 2 is opt-in. +func signaturePath() string { + if x := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); x != "" { + return filepath.Join(x, "ccbit", "error-signatures") + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + return filepath.Join(home, ".config", "ccbit", "error-signatures") + } + return "" +} + +// loadSignatures parses the signature file: one "patternmessage" per line, +// "#" comments and blanks ignored. The pattern is a case-insensitive regexp; +// unparsable lines are skipped so one typo can't break the whole file. +func loadSignatures() []signature { + p := signaturePath() + if p == "" { + return nil + } + f, err := os.Open(p) + if err != nil { + return nil + } + defer f.Close() + + var out []signature + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + tab := strings.IndexByte(line, '\t') + if tab < 0 { + continue + } + pat := strings.TrimSpace(line[:tab]) + msg := strings.TrimSpace(line[tab+1:]) + if pat == "" || msg == "" { + continue + } + re, err := regexp.Compile("(?i)" + pat) + if err != nil { + continue + } + out = append(out, signature{re: re, msg: msg}) + } + return out +} diff --git a/internal/diag/diag_test.go b/internal/diag/diag_test.go new file mode 100644 index 0000000..b68df54 --- /dev/null +++ b/internal/diag/diag_test.go @@ -0,0 +1,66 @@ +package diag + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReasonHeuristics(t *testing.T) { + cases := []struct { + name, text, want string + }{ + {"empty", "", ""}, + {"whitespace", " \n\t", ""}, + {"go compiler", "Exit code 1\n# pkg\ninternal/render/render.go:42:3: undefined: foo", + "internal/render/render.go:42:3: undefined: foo"}, + {"go test fail", "Exit code 1\n--- FAIL: TestThing (0.00s)\n x_test.go:9: boom\nFAIL", + "TestThing failed"}, + {"named test beats compiler order", "--- FAIL: TestA\nfoo.go:1:1: nope", "TestA failed"}, + {"rust error line", "Exit code 101\nerror: could not compile `app` due to 2 errors", + "could not compile `app` due to 2 errors"}, + {"collapses whitespace", "error: too many\t spaces", "too many spaces"}, + {"exit code fallback", "Error: Exit code 2\n(no recognizable detail)", "Exit code 2"}, + {"nothing recognizable", "something went sideways", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := Diagnose(c.text); got != c.want { + t.Errorf("Diagnose(%q) = %q, want %q", c.text, got, c.want) + } + }) + } +} + +func TestSignatureOverridesHeuristic(t *testing.T) { + dir := t.TempDir() + cfg := filepath.Join(dir, "ccbit") + if err := os.MkdirAll(cfg, 0o755); err != nil { + t.Fatal(err) + } + // patternmessage, plus a comment and a malformed line to skip. + body := "# my signatures\n" + + "401|codeartifact\trenew AWS SSO for codeartifact\n" + + "no-tab-here-is-ignored\n" + if err := os.WriteFile(filepath.Join(cfg, "error-signatures"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("XDG_CONFIG_HOME", dir) + + // A signature match wins even though the text also has a compiler line. + got := Diagnose("Exit code 1\nfoo.go:1:1: bad\n401 Unauthorized from codeartifact") + if got != "renew AWS SSO for codeartifact" { + t.Fatalf("signature should win, got %q", got) + } + // No signature match falls back to the heuristic. + if got := Diagnose("Exit code 1\nfoo.go:1:1: bad"); got != "foo.go:1:1: bad" { + t.Fatalf("non-matching text should fall back to heuristic, got %q", got) + } +} + +func TestNoSignatureFileIsFine(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) // exists, but no ccbit/error-signatures + if got := Diagnose("error: boom"); got != "boom" { + t.Fatalf("missing signature file should not break diagnosis, got %q", got) + } +} From 6edab9e7c4d4e9ece595443fab804c5729027bf8 Mon Sep 17 00:00:00 2001 From: livlign Date: Sun, 14 Jun 2026 19:30:00 +0700 Subject: [PATCH 4/4] Add sessions/demo CLI, surface failure reasons, rotate faces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI: a dispatcher routes explicit subcommands (Claude Code still invokes ccbit bare for the status line). `ccbit sessions` prints the full live-session roster the heartbeat dir already holds; `ccbit demo` previews every state and the face parts from synthetic data; `ccbit version`/`help` round it out. Faces: the Working and Idle faces now assemble per turn from shared parts — a pool of eye cores and a per-state pool of hands — chosen by a turn-derived seed, so a face is steady through a turn's repaints and varies between turns. Working hands are animated 2-frame gestures; the cup idle prop keeps its narrow fallback. Failure line: the Failed state appends diag's concrete reason when the build output gives one up. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 39 +++++++++-- cmd/ccbit/main.go | 98 ++++++++++++++++++++++++-- docs/ccbit-PRD-v2.md | 9 ++- docs/ccbit-WISHLIST.md | 9 ++- internal/render/demo.go | 119 ++++++++++++++++++++++++++++++++ internal/render/demo_test.go | 30 ++++++++ internal/render/faces_test.go | 98 ++++++++++++++++++++++++++ internal/render/failed_test.go | 48 +++++++++++++ internal/render/line2_test.go | 8 ++- internal/render/render.go | 120 ++++++++++++++++++++++++++++---- internal/render/render_test.go | 20 +++--- internal/render/roster.go | 122 +++++++++++++++++++++++++++++++++ internal/render/roster_test.go | 69 +++++++++++++++++++ 13 files changed, 747 insertions(+), 42 deletions(-) create mode 100644 internal/render/demo.go create mode 100644 internal/render/demo_test.go create mode 100644 internal/render/faces_test.go create mode 100644 internal/render/failed_test.go create mode 100644 internal/render/roster.go create mode 100644 internal/render/roster_test.go diff --git a/README.md b/README.md index 4fb688f..3d784d6 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # ccbit +[![ci](https://github.com/livlign/ccbit/actions/workflows/ci.yml/badge.svg)](https://github.com/livlign/ccbit/actions/workflows/ci.yml) + A session-awareness status line for [Claude Code](https://claude.com/claude-code), led by a stateful kaomoji face named **Bit**. On every render Bit reads the session transcript, works out what's happening, and narrates it in a line or two — like a small secretary sitting on the bottom row of your terminal. ![ccbit — a status line that reads your session. One coding turn narrated by Bit: working in cyan, a build failure with a table-flip face, recovery with "Build green again.", a done summary with files edited and tests passed, idle with alerts from other sessions — ending on all 8 states: working, agents running, waiting on you, failed, done, done-recovered, stopped, idle.](docs/hero.gif) @@ -21,6 +23,8 @@ Requires Claude Code ≥ v2.1.153 (for the `COLUMNS` width hint). No Go toolchai curl -fsSL https://raw.githubusercontent.com/livlign/ccbit/main/install.sh | bash ``` +The installer verifies the downloaded binary's SHA-256 against the release's `checksums.txt` before installing, and refuses to install on a mismatch. + Open a new Claude Code session to see Bit. A settings file may only define **one** `statusLine`; if you already have one, the installer replaces it (your previous `settings.json` is backed up first). On Windows, grab `ccbit_windows_amd64.zip` (or `arm64`) from the [latest release](https://github.com/livlign/ccbit/releases/latest) and point your status line at the extracted `ccbit.exe`. @@ -57,13 +61,13 @@ Bit's face maps to the session state (first match wins, in priority order), and | Priority | State | Face | When | |---|---|---|---| | 1 | Stopped | `(¬°-°)¬` | a quiet open turn with a dispatched tool that never answered (hang or unnoticed permission prompt), or silence past any plausible thinking stretch (~15m) | -| 2 | Failed | (╯°□°)╯︵ ┻━┻ | the latest build/test errored | +| 2 | Failed | (╯°□°)╯︵ ┻━┻ | the latest build/test errored — Bit appends the concrete reason when the output gives one up (a compiler location, a named failing test, a known signature) | | 3 | Waiting on you | `(◕_◕)?` | a question or plan is awaiting your answer | | 4 | Agents running | ┏(•_•)┛ ⇄ ┗(•_•)┓ | subagents are in flight | -| 5 | Working | -(๏_๏)- ⇄ ৲(๏_๏)৲ | a turn is in progress; long silent stretches with nothing pending show as `thinking (2m31s)` rather than decaying into Stopped | +| 5 | Working | \(•_•)/ ⇄ /(•_•)\ … | a turn is in progress; the working face is a hand gesture picked per turn (raise-the-roof, pumping, flexing, …) that animates between two frames. Long silent stretches with nothing pending show as `thinking (2m31s)` rather than decaying into Stopped | | 6 | Done (recovered) | `(→_←")` | idle, and a build/test passed this turn after an earlier failure | | 7 | Done | `(つ•‿•)つ` | the turn finished having edited, committed/pushed/deployed, or passed a build/test | -| 8 | Idle | `(•_•)` | nothing else applies (e.g. a turn that only read or answered) | +| 8 | Idle | `(•_•)` `(°.°)` `(◕_◕)` … | nothing else applies (e.g. a turn that only read or answered). The idle face rotates per turn through a small set of resting moods — picked deterministically from the turn, so it's steady through a turn's repaints and just varies turn to turn | Bit recaps in plain sentences rather than a row of glyphs: @@ -71,11 +75,11 @@ Bit recaps in plain sentences rather than a row of glyphs: (つ•‿•)つ 4 files edited, line changes: +885/-99. Build succeeded. Tests succeeded. (→_←") 2 files edited. Build green again. -(๏_๏)- editing ccbit (3 files) · 1/4 todos · 2m14s -(╯°□°)╯︵ ┻━┻ ccbit build failed +(╯°□°)╯︵ ┻━┻ ccbit build failed · internal/render/render.go:42:3: undefined: foo (¬°-°)¬ stopped · last: editing render.go · 2m ago ``` -Motion exists only in Working and Agents — a 2-frame swap on a ~2s wall-clock cycle, time-derived so concurrent repaints never jitter. The felt liveness during a long turn comes from the **numbers** ticking, not the face. +Motion exists only in Working and Agents — a 2-frame swap on a ~2s wall-clock cycle, time-derived so concurrent repaints never jitter. The Working and Idle faces are also assembled per turn from shared parts — a pool of eyes and a per-state pool of hands — so which gesture (or resting pose) you see is picked once per turn and held steady through that turn's repaints, varying turn to turn. The felt liveness during a long turn comes from the **numbers** ticking and the gesture animating, not from the face changing under you. ### Other sessions @@ -91,6 +95,21 @@ When you run several sessions at once, Bit speaks up about the others on line 1 - A session that **crashed**, **needs you**, or **stalled** is named with its state. - Several merely-busy sessions become a light count; a single idle one isn't mentioned at all. +Line 1 can only afford a fragment. For the full picture, run `ccbit sessions` in any shell — it prints every live session as a table, the actionable ones first: + +``` +$ ccbit sessions +STATE AGE PROJECT SESSION +failed 2m api Fix login bug +needs you 5s web Redesign nav +working 1s ccbit Refactor parser +idle 30s docs — +``` + +Same data, no new source — just a full read of the heartbeats line 1 hints at. Add `--json` to pipe it. + +To see every face before a real session happens to hit each state, run `ccbit demo` — it prints Bit in all eight states from synthetic data (reads nothing). `ccbit version` and `ccbit help` round out the CLI. + ### Line 2 — the ambient line ``` @@ -117,6 +136,16 @@ Everything stays silent until there's enough history to be trustworthy — a wro | `NO_COLOR` | unset | set to disable all ANSI color | | `COLUMNS` | — | width hint; below ~60 columns, risky wide glyphs fall back to ASCII-safe faces | +### Custom error signatures (optional) + +When a build fails, Bit shows the first concrete reason it can extract from the output. To map a recurring failure to your own message, create `~/.config/ccbit/error-signatures` (or `$XDG_CONFIG_HOME/ccbit/error-signatures`) with one `pattern⇥message` per line — a case-insensitive regular expression, a **tab**, then the message. A matching signature wins over the built-in extraction; `#` lines are comments. + +``` +# patternmessage +401|codeartifact renew AWS SSO for codeartifact +no space left on device disk full — clear build cache +``` + ## How it works ``` diff --git a/cmd/ccbit/main.go b/cmd/ccbit/main.go index cea8ac0..5302bc8 100644 --- a/cmd/ccbit/main.go +++ b/cmd/ccbit/main.go @@ -8,8 +8,10 @@ package main import ( "context" + "encoding/json" "fmt" "hash/fnv" + "io" "os" "os/exec" "path/filepath" @@ -31,6 +33,90 @@ import ( var version = "dev" func main() { + // Claude Code invokes ccbit with no arguments to paint the status line. Any + // argument means a human ran it directly — dispatch to a subcommand. + if len(os.Args) > 1 { + os.Exit(runCLI(os.Args[1:])) + } + statusLine() +} + +// runCLI handles the explicit subcommands. Returns a process exit code. +func runCLI(args []string) int { + switch args[0] { + case "sessions": + return cmdSessions(args[1:]) + case "demo": + for _, line := range render.Demo(os.Getenv("NO_COLOR") == "") { + fmt.Println(line) + } + return 0 + case "version", "-v", "--version": + fmt.Println("ccbit " + version) + return 0 + case "help", "-h", "--help": + printUsage(os.Stdout) + return 0 + default: + fmt.Fprintf(os.Stderr, "ccbit: unknown command %q\n\n", args[0]) + printUsage(os.Stderr) + return 2 + } +} + +// cmdSessions prints the roster of live sessions across all terminals — a full +// read of the heartbeat dir the status line otherwise only hints at on line 1. +func cmdSessions(args []string) int { + jsonOut := false + for _, a := range args { + switch a { + case "--json": + jsonOut = true + default: + fmt.Fprintf(os.Stderr, "ccbit sessions: unknown flag %q\n", a) + return 2 + } + } + now := time.Now() + beats := sessions.Active("", now) // selfID "" excludes nothing: list them all + if jsonOut { + if beats == nil { + beats = []sessions.Beat{} + } + b, err := json.MarshalIndent(beats, "", " ") + if err != nil { + return 1 + } + fmt.Println(string(b)) + return 0 + } + for _, line := range render.Roster(beats, now, os.Getenv("NO_COLOR") == "") { + fmt.Println(line) + } + return 0 +} + +func printUsage(w io.Writer) { + fmt.Fprint(w, `ccbit — a session-awareness status line for Claude Code. + +Usage: + ccbit Render the status line (reads Claude Code's stdin JSON). + ccbit sessions List every live Claude Code session across your terminals. + ccbit demo Preview Bit in every state (synthetic; reads nothing). + ccbit version Print the version. + ccbit help Show this help. + +Flags: + ccbit sessions --json Machine-readable roster, for scripting. + +ccbit is normally invoked by Claude Code as the statusLine command; the +subcommands above are for running it yourself. +`) +} + +// statusLine is the default invocation: read Claude Code's stdin JSON, derive +// this session's state, and print the two ambient lines. +func statusLine() { now := time.Now() in := input.Parse(os.Stdin) @@ -106,12 +192,12 @@ func main() { RepoRoot: root, // RepoRoot is resolved with a disposable cache so a 1s refresh interval // does not spawn git every render. - Cols: cols, - Narrow: cols > 0 && cols < render.NarrowCols, - Frame: int((now.Unix() / 2) % 2), - FrameFast: int(now.Unix() % 2), - ColorOn: os.Getenv("NO_COLOR") == "", - Now: now, + Cols: cols, + Narrow: cols > 0 && cols < render.NarrowCols, + Frame: int((now.Unix() / 2) % 2), + FrameFast: int(now.Unix() % 2), + ColorOn: os.Getenv("NO_COLOR") == "", + Now: now, Trend: trend, Siblings: sessions.Active(in.SessionID, now), TypicalTurn: stats.TypicalTurn(), diff --git a/docs/ccbit-PRD-v2.md b/docs/ccbit-PRD-v2.md index 6669f70..c80a82d 100644 --- a/docs/ccbit-PRD-v2.md +++ b/docs/ccbit-PRD-v2.md @@ -276,11 +276,10 @@ The only Claude Code config ccbit needs is the `statusLine` block (§11.2). The v1 stdout `[VERIFY]` is resolved: the failure text is in the transcript `tool_result` (§6.3). All tiers read the same entry. -- **Tier 1 (build now):** `is_error` → `build ✓` / `build failed`. No text parsing. -- **Tier 2 (signature dictionary):** on failure, grep the result `content` text against a user-maintained pattern→message map (`~/.config/ccbit/error-signatures` or in-repo). Example: text containing `401`/`codeartifact` → `renew AWS SSO for codeartifact`. Unknown → fall back to tier 1. -- **Tier 3 (model-assisted, opt-in, default off):** on failure with no tier-2 match, send the result text to a cheap model call, write a one-line diagnosis. Adds an API call + latency per failure; gate behind `CCBIT_TIER3=1`. - -Tiers 2–3 are post-v1 but no longer blocked by a verification. +- **Tier 1 (SHIPPED):** `is_error` → `build ✓` / `build failed`. No text parsing. +- **Tier 1.5 (SHIPPED — `internal/diag`):** on failure, extract the first concrete reason from the captured result text with no config — a compiler/linter `file:line[:col]: message`, a named `--- FAIL: Test…`, an explicit `error:` line, else the bare `Exit code N`. High-signal only; a miss surfaces nothing (plain "build failed") rather than a guess. The Failed line appends it, ellipsized. +- **Tier 2 (SHIPPED — `internal/diag`):** a user-maintained `pattern⇥message` map at `~/.config/ccbit/error-signatures` (or `$XDG_CONFIG_HOME/ccbit/...`). A matching case-insensitive regexp's message overrides tier 1.5; unparsable lines are skipped so one typo can't break the file. +- **Tier 3 (model-assisted) — NOT BUILT, by design.** A per-failure model call adds network, latency, and cost, which cuts against ccbit being a transcript-only, no-daemon tool. Left out deliberately rather than gated behind a flag. --- diff --git a/docs/ccbit-WISHLIST.md b/docs/ccbit-WISHLIST.md index 8a88450..bdaf0a2 100644 --- a/docs/ccbit-WISHLIST.md +++ b/docs/ccbit-WISHLIST.md @@ -1,8 +1,15 @@ -## `ccbit sessions` subcommand +## `ccbit sessions` subcommand — SHIPPED Standalone command that prints the full roster of live sessions, not just the width-squeezed inline hint on line 1. +**As built:** `ccbit sessions` reads the heartbeat dir and prints an aligned +table (STATE / AGE / PROJECT / SESSION), actionable states first — reusing the +status line's own ordering, state vocabulary, and per-state colors. The open +questions resolved as: human table by default with `--json` for piping; +live-only (same 3-min `activeWindow` the line uses), no dimmed-expired rows; +invoked manually. Adding the dispatcher also wired `ccbit version` / `help`. + **Why:** cross-session awareness is ccbit's differentiator vs. other statuslines. Line 1 can only afford a terse fragment ("3 other sessions running"); the heartbeat dir already holds every live session's state + title, so a full roster is near-free. diff --git a/internal/render/demo.go b/internal/render/demo.go new file mode 100644 index 0000000..969f7fe --- /dev/null +++ b/internal/render/demo.go @@ -0,0 +1,119 @@ +package render + +import ( + "fmt" + "strings" + "time" + + "github.com/livlign/ccbit/internal/gitx" + "github.com/livlign/ccbit/internal/input" + "github.com/livlign/ccbit/internal/sessions" + "github.com/livlign/ccbit/internal/state" + "github.com/livlign/ccbit/internal/transcript" +) + +// Demo renders a representative line for every state, plus one full two-line +// sample with an ambient line and sibling sessions. It lets a user preview Bit +// without waiting for a real session to hit each state, and is the harness the +// README's hero visual is built from. Pure synthetic data: no transcript, +// stdin, or heartbeat is read. +func Demo(colorOn bool) []string { + now := time.Unix(1_700_000_000, 0) + root := "/work/ccbit" + f := func(p string) string { return root + "/" + p } + base := Ctx{ColorOn: colorOn, Now: now, RepoRoot: root, ProjectLabel: "ccbit"} + + withElapsed := func(v state.View, d time.Duration) state.View { + v.Elapsed, v.HasElapsed = d, true + return v + } + withAge := func(v state.View, d time.Duration) state.View { + v.LastAge, v.HasLastAge = d, true + return v + } + + type scene struct { + label string + v state.View + c Ctx + } + scenes := []scene{ + {"working", withElapsed(state.View{State: state.Working, + Turn: transcript.Turn{Open: true, Edited: []string{f("render.go"), f("state.go"), f("main.go")}}}, + 134*time.Second), withTasks(base, 4, 1)}, + {"agents", withElapsed(state.View{State: state.Agents, AgentsRunning: 2, AgentsDone: 1, + Turn: transcript.Turn{Open: true}}, 48*time.Second), base}, + {"waiting", withAge(state.View{State: state.Waiting, + Turn: transcript.Turn{Pending: "AskUserQuestion"}}, 90*time.Second), base}, + {"failed", state.View{State: state.Failed, + Turn: transcript.Turn{Edited: []string{f("render.go")}, + Builds: []transcript.BuildResult{{Kind: "build", IsError: true, + Text: "Exit code 1\ninternal/render/render.go:42:3: undefined: foo"}}}}, base}, + {"done", state.View{State: state.DoneNormal, + Turn: transcript.Turn{Edited: []string{f("a.go"), f("b.go"), f("c.go"), f("d.go")}, + Builds: []transcript.BuildResult{{Kind: "build"}, {Kind: "test"}}, Pushed: true}}, + withLines(base, 885, 99)}, + {"redeemed", state.View{State: state.DoneRedeemed, + Turn: transcript.Turn{Edited: []string{f("render.go"), f("state.go")}, + Builds: []transcript.BuildResult{{Kind: "build", IsError: true}, {Kind: "build"}}}}, + withLines(base, 12, 3)}, + {"stopped", withAge(state.View{State: state.Stopped, + Turn: transcript.Turn{Edited: []string{f("render.go")}, HasLast: true}}, 120*time.Second), base}, + {"idle", state.View{State: state.Idle}, base}, + } + + out := []string{"Bit — state preview", ""} + for _, s := range scenes { + line1 := Render(s.v, s.c)[0] // [0] is the reactive line; [1] is ambient + out = append(out, fmt.Sprintf(" %-9s %s", s.label, line1)) + } + + // Working and Idle faces are assembled per turn from shared parts; print the + // parts so the rotation (and how each glyph renders in this terminal) is + // visible at a glance. + sample := eyes[0] + var ih []string + for _, h := range idleHands { + ih = append(ih, fmt.Sprintf(h, sample)) + } + var wh []string + for _, h := range workingHands { + wh = append(wh, fmt.Sprintf(h.a, sample)+"⇄"+fmt.Sprintf(h.b, sample)) + } + out = append(out, + "", + "Faces assemble per turn from shared parts:", + " eyes: "+strings.Join(eyes, " "), + " idle hands: "+strings.Join(ih, " "), + " working hands: "+strings.Join(wh, " "), + ) + + // One full render so the ambient line and the cross-session digest show too. + out = append(out, "", "Full status line (ambient line + other sessions):", "") + ctxPct := 38.0 + full := withLines(base, 885, 99) + full.In = input.Stdin{CurrentDir: root, ModelName: "Opus", CtxPct: &ctxPct} + full.Git = gitx.Info{Branch: "main", Dirty: 2, Ahead: 1} + full.Trend = sessions.TrendUp + full.Siblings = []sessions.Beat{ + {State: "failed", Project: "api", Title: "Fix login bug", UpdatedAt: now.Unix()}, + {State: "waiting", Project: "web", UpdatedAt: now.Unix()}, + } + done := state.View{State: state.DoneNormal, + Turn: transcript.Turn{Edited: []string{f("a.go"), f("b.go"), f("c.go"), f("d.go")}, + Builds: []transcript.BuildResult{{Kind: "build"}, {Kind: "test"}}, Pushed: true}} + for _, line := range Render(done, full) { + out = append(out, " "+line) + } + return out +} + +func withTasks(c Ctx, total, done int) Ctx { + c.Tasks = transcript.TaskSummary{Total: total, Done: done} + return c +} + +func withLines(c Ctx, added, removed int) Ctx { + c.TurnLinesAdded, c.TurnLinesRemoved = added, removed + return c +} diff --git a/internal/render/demo_test.go b/internal/render/demo_test.go new file mode 100644 index 0000000..5666b9c --- /dev/null +++ b/internal/render/demo_test.go @@ -0,0 +1,30 @@ +package render + +import ( + "strings" + "testing" +) + +func TestDemoCoversEveryState(t *testing.T) { + out := strings.Join(Demo(false), "\n") + for _, label := range []string{"working", "agents", "waiting", "failed", "done", "redeemed", "stopped", "idle"} { + if !strings.Contains(out, label) { + t.Errorf("demo output missing state %q", label) + } + } + // The full sample exercises the ambient line and the sibling digest. + for _, want := range []string{"ctx 38%", "main", "Elsewhere", "needs you"} { + if !strings.Contains(out, want) { + t.Errorf("demo full sample missing %q", want) + } + } +} + +func TestDemoColorIsOptional(t *testing.T) { + if strings.Contains(strings.Join(Demo(false), "\n"), "\x1b[") { + t.Error("NO_COLOR demo should contain no ANSI escapes") + } + if !strings.Contains(strings.Join(Demo(true), "\n"), "\x1b[") { + t.Error("colored demo should contain ANSI escapes") + } +} diff --git a/internal/render/faces_test.go b/internal/render/faces_test.go new file mode 100644 index 0000000..e537538 --- /dev/null +++ b/internal/render/faces_test.go @@ -0,0 +1,98 @@ +package render + +import ( + "strings" + "testing" + + "github.com/livlign/ccbit/internal/transcript" +) + +func TestEyeFromPool(t *testing.T) { + for i := uint64(0); i < 200; i++ { + if !contains(eyes, eye(i)) { + t.Fatalf("eye(%d)=%q not in pool", i, eye(i)) + } + } +} + +func TestIdleFaceStableAndCarriesEye(t *testing.T) { + for i := uint64(0); i < 100; i++ { + f := idleFace(i, false) + if f != idleFace(i, false) { + t.Fatalf("idle face not stable for seed %d", i) + } + if !strings.Contains(f, eye(i)) { + t.Errorf("idleFace(%d)=%q should carry its eye %q", i, f, eye(i)) + } + } +} + +func TestWorkingFaceAnimatesAndCarriesEye(t *testing.T) { + for i := uint64(0); i < 100; i++ { + a, b := workingFace(i, 0), workingFace(i, 1) + if a == b { + t.Errorf("working frames identical for seed %d: %q", i, a) + } + if !strings.Contains(a, eye(i)) || !strings.Contains(b, eye(i)) { + t.Errorf("working face should carry its eye %q: %q / %q", eye(i), a, b) + } + } +} + +// Eyes and hands must vary independently across turns — sequential seeds should +// reach every eye and every hand. (Hands are counted by index: two working +// gestures intentionally share a first frame and differ only in the second.) +func TestPartsCoverage(t *testing.T) { + seenEye := map[string]bool{} + seenIdleHand := map[uint64]bool{} + seenWorkHand := map[uint64]bool{} + for i := uint64(0); i < 1000; i++ { + seenEye[eye(i)] = true + seenIdleHand[handIndex(i, len(idleHands))] = true + seenWorkHand[handIndex(i, len(workingHands))] = true + } + if len(seenEye) != len(eyes) { + t.Errorf("eyes covered %d/%d", len(seenEye), len(eyes)) + } + if len(seenIdleHand) != len(idleHands) { + t.Errorf("idle hands covered %d/%d", len(seenIdleHand), len(idleHands)) + } + if len(seenWorkHand) != len(workingHands) { + t.Errorf("working hands covered %d/%d", len(seenWorkHand), len(workingHands)) + } +} + +func TestIdleCupDropsPropWhenNarrow(t *testing.T) { + for i := uint64(0); i < 1000; i++ { + if strings.Contains(idleFace(i, false), "旦") { + if got := idleFace(i, true); strings.Contains(got, "旦") { + t.Fatalf("cup face should lose its prop when narrow, got %q", got) + } + return + } + } + t.Fatal("expected a cup face across seeds") +} + +func TestFaceSeedPerTurn(t *testing.T) { + a := transcript.Turn{PromptID: "turn-a"} + b := transcript.Turn{PromptID: "turn-b"} + if faceSeed("s", a) != faceSeed("s", a) { + t.Fatal("seed must be stable for the same session+turn") + } + if faceSeed("s", a) == faceSeed("s", b) { + t.Error("different turns should produce different seeds") + } + if faceSeed("s1", a) == faceSeed("s2", a) { + t.Error("different sessions should produce different seeds") + } +} + +func contains(ss []string, s string) bool { + for _, x := range ss { + if x == s { + return true + } + } + return false +} diff --git a/internal/render/failed_test.go b/internal/render/failed_test.go new file mode 100644 index 0000000..d9fef0f --- /dev/null +++ b/internal/render/failed_test.go @@ -0,0 +1,48 @@ +package render + +import ( + "strings" + "testing" + + "github.com/livlign/ccbit/internal/state" + "github.com/livlign/ccbit/internal/transcript" +) + +func TestFailedLineSurfacesDiagnosis(t *testing.T) { + c := ctx() + v := state.View{State: state.Failed, Turn: transcript.Turn{ + Builds: []transcript.BuildResult{{Kind: "build", IsError: true, + Text: "Exit code 1\nsvc/render.go:42:3: undefined: foo"}}, + }} + got := Render(v, c)[0] + if !strings.Contains(got, "build failed") { + t.Fatalf("missing base failed text: %q", got) + } + if !strings.Contains(got, "svc/render.go:42:3: undefined: foo") { + t.Errorf("failed line should surface the concrete reason: %q", got) + } +} + +func TestFailedLineNoDiagnosisStaysPlain(t *testing.T) { + c := ctx() + v := state.View{State: state.Failed, Turn: transcript.Turn{ + Builds: []transcript.BuildResult{{Kind: "build", IsError: true, Text: "something opaque"}}, + }} + got := Render(v, c)[0] + if strings.Contains(got, " · ") { + t.Errorf("no extractable reason should leave the line plain, got %q", got) + } +} + +func TestFailedLineDiagnosisIsBounded(t *testing.T) { + c := ctx() + long := "error: " + strings.Repeat("verylongtoken ", 20) + v := state.View{State: state.Failed, Turn: transcript.Turn{ + Builds: []transcript.BuildResult{{Kind: "build", IsError: true, Text: long}}, + }} + got := Render(v, c)[0] + // The reason clause is ellipsized to keep line 1 from blowing out. + if !strings.Contains(got, "…") { + t.Errorf("an overlong reason should be ellipsized, got %q", got) + } +} diff --git a/internal/render/line2_test.go b/internal/render/line2_test.go index 1155ad8..6128fc4 100644 --- a/internal/render/line2_test.go +++ b/internal/render/line2_test.go @@ -77,8 +77,12 @@ func TestSiblingClauseEmpty(t *testing.T) { func TestLine1ShowsSiblings(t *testing.T) { c := ctx() c.Siblings = []sessions.Beat{{State: "failed", Project: "api"}} - l1 := Render(state.View{State: state.Idle}, c)[0] - want := "(•_•) idle · The session api crashed" + v := state.View{State: state.Idle} + l1 := Render(v, c)[0] + // The idle face rotates per turn, so assert on the face this turn's seed + // selects plus the sibling clause that should ride line 1 after it. + face := idleFace(faceSeed(c.In.SessionID, v.Turn), c.Narrow) + want := face + " idle · The session api crashed" if l1 != want { t.Fatalf("line1 with sibling = %q, want %q", l1, want) } diff --git a/internal/render/render.go b/internal/render/render.go index bedd09a..e536273 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -5,11 +5,13 @@ package render import ( "fmt" + "hash/fnv" "os" "path/filepath" "strings" "time" + "github.com/livlign/ccbit/internal/diag" "github.com/livlign/ccbit/internal/gitx" "github.com/livlign/ccbit/internal/input" "github.com/livlign/ccbit/internal/sessions" @@ -23,9 +25,9 @@ const NarrowCols = 60 // Ctx carries per-render environment the line builders need. type Ctx struct { - In input.Stdin - RepoRoot string // git toplevel, or "" if unknown - Cols int // terminal width from COLUMNS, 0 if unset + In input.Stdin + RepoRoot string // git toplevel, or "" if unknown + Cols int // terminal width from COLUMNS, 0 if unset Narrow bool Frame int // 0 or 1, wall-clock selected (~2s swap) — Agents shimmy FrameFast int // 0 or 1, wall-clock selected (~1s swap) — Working face @@ -64,18 +66,86 @@ type Ctx struct { Git gitx.Info } +// The Working and Idle faces are assembled per turn from shared parts: a pool +// of eye cores, and a per-state pool of "hands" (wrappers, %s = the eyes). A +// turn-derived seed (faceSeed) picks one eye and one hand, so the face is steady +// through a turn's repaints and varies between turns. Eye index uses the seed's +// low end, hand index the next radix up, so the two vary independently. + +// eyes is the shared eye pool. All glyphs are single-width. +var eyes = []string{ + "•_•", "°.°", "-_-", "◔_◔", "ʘ_ʘ", "◕_◕", "^_^", ">_<", "*_*", "o_o", +} + +// idleHands are the resting face's static wrappers; %s is where the eyes slot. +var idleHands = []string{ + "(%s)", // bare + "(%s)旦", // sipping a cup, on a break + "d(%s)b", // thumbs up +} + +// workingHand is one animated gesture: the two frame templates the face +// alternates between (~1s) while a turn runs. The frames are distinct poses, so +// the motion reads as action rather than a mirror flip. +type workingHand struct{ a, b string } + +var workingHands = []workingHand{ + {`\(%s)/`, `/(%s)\`}, // raise-the-roof + {`>(%s)<`, `<(%s)>`}, // elbows pumping + {`ᕙ(%s)ᕗ`, `\(%s)/`}, // march-arm -> arms up + {`ᕦ(%s)ᕤ`, `\(%s)/`}, // flex -> arms up + {`ง(%s)ง`, `-(%s)-`}, // fists -> arms out + {`-(%s)-`, `৲(%s)৲`}, // arm swing (the original Working face) + {`-(%s)-`, `\(%s)/`}, // wind-up + {`\(%s)-`, `-(%s)/`}, // alternating arms + {`/(%s)/`, `\(%s)\`}, // swaying / rowing + {`ง(%s)ง`, `ว(%s)ว`}, // fists shaking +} + +// eye picks this turn's shared eye core. +func eye(seed uint64) string { return eyes[seed%uint64(len(eyes))] } + +// handIndex picks a hand for a pool of size n, from a different slice of the +// seed than eye() uses so the two rotate independently. +func handIndex(seed uint64, n int) uint64 { return (seed / uint64(len(eyes))) % uint64(n) } + +// idleFace assembles the resting face: a per-turn eye in a per-turn static hand. +// The cup is the one width-risky glyph, so a narrow terminal drops the prop. +func idleFace(seed uint64, narrow bool) string { + hand := idleHands[handIndex(seed, len(idleHands))] + if narrow && strings.Contains(hand, "旦") { + hand = "(%s)" + } + return fmt.Sprintf(hand, eye(seed)) +} + +// workingFace assembles the working face: a per-turn eye in a per-turn gesture, +// alternating between the gesture's two frames as frame flips. +func workingFace(seed uint64, frame int) string { + h := workingHands[handIndex(seed, len(workingHands))] + tmpl := h.a + if frame == 1 { + tmpl = h.b + } + return fmt.Sprintf(tmpl, eye(seed)) +} + +// faceSeed derives a stable per-turn seed for face rotation: identical across a +// turn's ~1×/s repaints (so the face never flickers) yet different from turn to +// turn and session to session. +func faceSeed(sessionID string, t transcript.Turn) uint64 { + h := fnv.New64a() + fmt.Fprintf(h, "%s|%s|%d", sessionID, t.PromptID, t.Start.Unix()) + return h.Sum64() +} + // Face returns the kaomoji for a state. Working and Agents animate via Frame; -// every other face is static. Narrow swaps risky glyphs for safe fallbacks. -func Face(s state.State, frame int, narrow bool) string { +// Idle rotates per turn via seed; the rest are static. Narrow swaps risky glyphs +// for safe fallbacks. +func Face(s state.State, frame int, narrow bool, seed uint64) string { switch s { case state.Working: - // Arms flat -> raised + mouth opening: pure-ASCII body language that reads - // as active work and renders on every font (the v1 triangles ◣◢ are - // Geometric-Shapes glyphs that tofu/mangle on Windows). - if frame == 0 { - return "-(๏_๏)-" - } - return "৲(๏_๏)৲" + return workingFace(seed, frame) case state.Agents: if narrow { if frame == 0 { @@ -103,8 +173,10 @@ func Face(s state.State, frame int, narrow bool) string { return "(╯°□°)╯︵ ┻━┻" case state.Stopped: return "(¬°-°)¬" - default: // Idle - return "(•_•)" + case state.Idle: + return idleFace(seed, narrow) + default: // unknown -> neutral resting face + return fmt.Sprintf("(%s)", eyes[0]) } } @@ -116,7 +188,8 @@ func Render(v state.View, c Ctx) []string { if v.State == state.Working { frame = c.FrameFast // Working swaps every ~1s; Agents stays at ~2s } - l1 := Face(v.State, frame, c.Narrow) + " " + line1(v, c) + seed := faceSeed(c.In.SessionID, v.Turn) + l1 := Face(v.State, frame, c.Narrow, seed) + " " + line1(v, c) if c.ColorOn { l1 = colorize(l1, line1Color(v.State)) } @@ -203,6 +276,12 @@ func line1(v state.View, c Ctx) string { if v.Turn.FailStreak >= 3 { s += fmt.Sprintf(" (%d× in a row)", v.Turn.FailStreak) } + // What actually broke, when the failure text gives up a concrete reason + // (a compiler location, a named test, a signature) — turning the alarm + // into a signpost. + if d := diag.Diagnose(lastFailedText(v.Turn.Builds)); d != "" { + s += " · " + ellipsize(d, 48) + } return s case state.DoneNormal, state.DoneRedeemed: @@ -593,6 +672,17 @@ func countFiles(n int) string { return fmt.Sprintf("%d files", n) } +// lastFailedText is the captured output of the most recent failing build/test — +// the raw material diag.Diagnose mines for a concise reason. +func lastFailedText(builds []transcript.BuildResult) string { + for i := len(builds) - 1; i >= 0; i-- { + if builds[i].IsError { + return builds[i].Text + } + } + return "" +} + func failedKind(builds []transcript.BuildResult) (string, bool) { for i := len(builds) - 1; i >= 0; i-- { if builds[i].IsError { diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 6fbb7ce..124e8e4 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -12,22 +12,22 @@ import ( func TestFaceRules(t *testing.T) { // Working frames must differ from each other and from idle. - a, b := Face(state.Working, 0, false), Face(state.Working, 1, false) - idle := Face(state.Idle, 0, false) + a, b := Face(state.Working, 0, false, 0), Face(state.Working, 1, false, 0) + idle := Face(state.Idle, 0, false, 0) if a == b { t.Fatalf("working frames identical: %q", a) } if a == idle || b == idle { t.Fatalf("working frame equals idle face %q", idle) } - if idle != "(•_•)" { - t.Fatalf("idle face = %q", idle) + if idle != "(•_•)" { // seed 0 -> the neutral default + t.Fatalf("idle face (seed 0) = %q", idle) } // Narrow fallbacks. - if got := Face(state.Failed, 0, true); got != "(>_<) FAILED" { + if got := Face(state.Failed, 0, true, 0); got != "(>_<) FAILED" { t.Fatalf("narrow failed = %q", got) } - if got := Face(state.DoneNormal, 0, true); got != "(•‿•)" { + if got := Face(state.DoneNormal, 0, true, 0); got != "(•‿•)" { t.Fatalf("narrow done = %q", got) } } @@ -71,8 +71,12 @@ func TestLine1Working(t *testing.T) { HasElapsed: true, Elapsed: 2*time.Minute + 14*time.Second, } - got := Render(v, ctx())[0] - want := "-(๏_๏)- editing svcA (2 files) · svcB (1 file) · 2m14s" + c := ctx() + got := Render(v, c)[0] + // The working face is assembled per turn from parts; assert the narration + // after it rather than a fixed face. + face := workingFace(faceSeed(c.In.SessionID, v.Turn), c.FrameFast) + want := face + " editing svcA (2 files) · svcB (1 file) · 2m14s" if got != want { t.Fatalf("line1 = %q, want %q", got, want) } diff --git a/internal/render/roster.go b/internal/render/roster.go new file mode 100644 index 0000000..7e14073 --- /dev/null +++ b/internal/render/roster.go @@ -0,0 +1,122 @@ +package render + +import ( + "strings" + "time" + + "github.com/livlign/ccbit/internal/sessions" + "github.com/livlign/ccbit/internal/state" +) + +// Roster renders the full live-session list for the `ccbit sessions` subcommand: +// one row per sibling heartbeat as an aligned table, actionable states first. +// beats is taken as-is from sessions.Active, which already filters to live +// sessions and sorts them (failed, stopped, waiting, then the rest). +func Roster(beats []sessions.Beat, now time.Time, colorOn bool) []string { + if len(beats) == 0 { + return []string{"No live Claude Code sessions."} + } + + type row struct{ status, age, project, session string } + header := row{"STATE", "AGE", "PROJECT", "SESSION"} + rows := make([]row, len(beats)) + for i, b := range beats { + rows[i] = row{ + status: rosterStatus(b.State), + age: fmtAge(now.Sub(time.Unix(b.UpdatedAt, 0))), + project: dash(b.Project), + session: dash(b.Title), + } + } + + // Column widths from the plain (uncolored) text; the trailing SESSION column + // is never padded. + w := []int{len(header.status), len(header.age), len(header.project)} + for _, r := range rows { + w[0] = max(w[0], len(r.status)) + w[1] = max(w[1], len(r.age)) + w[2] = max(w[2], len(r.project)) + } + + out := []string{rosterLine(header, w, "")} + for i, r := range rows { + col := "" + if colorOn { + col = line1Color(stateFromString(beats[i].State)) + } + out = append(out, rosterLine(r, w, col)) + } + return out +} + +// rosterLine lays out one table row. The STATE cell is colored (when statusColor +// is set) but padded by its plain width so columns still align under the color +// codes. +func rosterLine(r struct{ status, age, project, session string }, w []int, statusColor string) string { + status := r.status + if statusColor != "" { + status = colorize(status, statusColor) + } + var b strings.Builder + b.WriteString(status) + b.WriteString(strings.Repeat(" ", w[0]-len(r.status)+2)) + b.WriteString(r.age) + b.WriteString(strings.Repeat(" ", w[1]-len(r.age)+2)) + b.WriteString(r.project) + b.WriteString(strings.Repeat(" ", w[2]-len(r.project)+2)) + b.WriteString(r.session) + return b.String() +} + +// rosterStatus is the human word for a heartbeat state in the roster — the same +// vocabulary line 1 uses for siblings ("stalled", "needs you"), extended to the +// benign states a full roster also lists. +func rosterStatus(s string) string { + switch s { + case "failed": + return "failed" + case "stopped": + return "stalled" + case "waiting": + return "needs you" + case "working": + return "working" + case "agents": + return "agents" + case "done", "redeemed": + return "done" + default: + return "idle" + } +} + +// stateFromString maps a heartbeat's state string back to a state.State so the +// roster can reuse line1Color across every state (siblingState only covers the +// actionable ones). +func stateFromString(s string) state.State { + switch s { + case "working": + return state.Working + case "agents": + return state.Agents + case "done": + return state.DoneNormal + case "redeemed": + return state.DoneRedeemed + case "waiting": + return state.Waiting + case "failed": + return state.Failed + case "stopped": + return state.Stopped + default: + return state.Idle + } +} + +func dash(s string) string { + if s == "" { + return "—" + } + return s +} diff --git a/internal/render/roster_test.go b/internal/render/roster_test.go new file mode 100644 index 0000000..4122e6b --- /dev/null +++ b/internal/render/roster_test.go @@ -0,0 +1,69 @@ +package render + +import ( + "strings" + "testing" + "time" + + "github.com/livlign/ccbit/internal/sessions" +) + +func TestRosterEmpty(t *testing.T) { + got := Roster(nil, time.Unix(1000, 0), false) + if len(got) != 1 || !strings.Contains(got[0], "No live") { + t.Fatalf("empty roster = %q, want a single 'No live...' line", got) + } +} + +func TestRosterRows(t *testing.T) { + now := time.Unix(10_000, 0) + beats := []sessions.Beat{ + {State: "failed", Project: "api", Title: "Fix login bug", UpdatedAt: now.Add(-2 * time.Minute).Unix()}, + {State: "waiting", Project: "web", Title: "Redesign nav", UpdatedAt: now.Add(-5 * time.Second).Unix()}, + {State: "idle", Project: "docs", UpdatedAt: now.Add(-30 * time.Second).Unix()}, + } + got := Roster(beats, now, false) + if len(got) != 4 { // header + 3 rows + t.Fatalf("got %d lines, want 4:\n%s", len(got), strings.Join(got, "\n")) + } + if !strings.HasPrefix(got[0], "STATE") { + t.Fatalf("first line should be the header, got %q", got[0]) + } + // State words use the line-1 vocabulary; ages and titles are present. + if !strings.Contains(got[1], "failed") || !strings.Contains(got[1], "2m") || !strings.Contains(got[1], "Fix login bug") { + t.Errorf("failed row = %q", got[1]) + } + if !strings.Contains(got[2], "needs you") { + t.Errorf("waiting row should read 'needs you', got %q", got[2]) + } + // A titleless session shows a dash, not an empty cell. + if !strings.Contains(got[3], "—") { + t.Errorf("titleless row should show a dash, got %q", got[3]) + } +} + +func TestRosterColorsStateCellOnly(t *testing.T) { + now := time.Unix(10_000, 0) + beats := []sessions.Beat{{State: "failed", Project: "api", Title: "x", UpdatedAt: now.Unix()}} + row := Roster(beats, now, true)[1] + if !strings.Contains(row, red) { + t.Fatalf("colored roster row should color the state cell, got %q", row) + } + // Exactly one color span: the rest of the row is plain. + if n := strings.Count(row, reset); n != 1 { + t.Fatalf("want exactly one reset (state cell only), got %d in %q", n, row) + } +} + +func TestRosterStatusWords(t *testing.T) { + cases := map[string]string{ + "failed": "failed", "stopped": "stalled", "waiting": "needs you", + "working": "working", "agents": "agents", "done": "done", + "redeemed": "done", "idle": "idle", "unknown-thing": "idle", + } + for in, want := range cases { + if got := rosterStatus(in); got != want { + t.Errorf("rosterStatus(%q) = %q, want %q", in, got, want) + } + } +}