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 ./...
diff --git a/README.md b/README.md
index 4fb688f..3d784d6 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# ccbit
+[](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.

@@ -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/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"
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)
+ }
+}
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)
+ }
+ }
+}