Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ ccbit answers, at a glance: *is anything working, done, waiting, broken, or stop

```
(つ•‿•)つ 4 files edited, line changes: +885/-99. Build succeeded. Tests succeeded.
~/ccbit · Opus · ctx 38% ↑ · 5h 3% (4h37m) · 7d 0% (6d20h)
~/ccbit · main +1 ~2 -1 ↑1 · Opus 4.8 (high) · ctx 38% ↑ · 5h 3% (4h37m) · 7d 0% (6d20h)
```

It is a single Go binary. **No hooks, no daemons.** The transcript is the source of truth; Claude Code already writes it and owns its lifecycle. ccbit only reads it (plus two small, disposable state dirs of its own — see [How it works](#how-it-works)).
Expand Down Expand Up @@ -113,10 +113,10 @@ To see every face before a real session happens to hit each state, run `ccbit de
### Line 2 — the ambient line

```
~/ccbit · Opus · ctx 38% ↑ · 5h 3% (4h37m) · 7d 0% (6d20h)
~/ccbit · main +1 ~2 -1 ↑1 · Opus 4.8 (high) · ctx 38% ↑ · 5h 3% (4h37m) · 7d 0% (6d20h)
```

Current directory, model, context-window usage, and rate-limit windows with their reset countdowns. `ctx%` colors only when it warrants attention (≥70 yellow, ≥90 red) and carries a velocity arrow — `↑` while context is climbing, `↓` after a `/compact`.
Current directory, git branch, model (with its reasoning effort), context-window usage, and rate-limit windows with their reset countdowns. The git segment breaks the worktree down as `+new ~modified -deleted` (each shown only when nonzero), followed by `↑ahead ↓behind`. `ctx%` colors only when it warrants attention (≥70 yellow, ≥90 red) and carries a velocity arrow — `↑` while context is climbing, `↓` after a `/compact`. Several segments have optional color/icon upgrades — see [Configuration](#configuration).

## Bit gets smarter over time

Expand All @@ -136,6 +136,18 @@ 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 |

### Visual features (optional, opt-in)

Off by default and set independently — none can be safely auto-detected (Nerd Font glyphs show as tofu without a patched font; color and gauges are a matter of taste). Set any to `1` to enable. While idle, ccbit quietly rotates a one-line hint for whichever are still off, so you can discover them without reading this table.

| Var | Effect |
|---|---|
| `CCBIT_NERD_FONT` | Nerd Font glyphs for git change marks — plus / pencil / trash instead of `+ ~ -` (requires a patched Nerd Font) |
| `CCBIT_ICONS` | a leading Nerd Font icon on each ambient segment — folder, branch, chip, gauge, clock (requires a patched Nerd Font) |
| `CCBIT_GIT_COLOR` | color the git change marks: green new, yellow modified, red deleted |
| `CCBIT_CTX_GAUGE` | a small fill bar beside the context percentage (`ctx ▆▆▁▁▁ 38%`) |
| `CCBIT_RATE_COLOR` | escalate the 5h / 7d rate-limit meters to yellow (≥70%) then red (≥90%), like `ctx%` |

### 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.
Expand Down
2 changes: 1 addition & 1 deletion cmd/ccbit/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ func statusLine() {
func gitInfo(root string, now time.Time) gitx.Info {
g := gitx.Info{Branch: gitx.Branch(root)}
if g.Branch != "" {
g.Dirty, g.Ahead, g.Behind = gitx.Status(root, now)
g.New, g.Modified, g.Deleted, g.Ahead, g.Behind = gitx.Status(root, now)
}
return g
}
Expand Down
61 changes: 40 additions & 21 deletions internal/gitx/gitx.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,17 @@ import (

// Info is the rendered subset of git state.
type Info struct {
Branch string // branch name, or short detached hash, "" if unknown
Dirty int // modified/untracked paths in the worktree
Ahead int // commits ahead of upstream
Behind int // commits behind upstream
Branch string // branch name, or short detached hash, "" if unknown
New int // untracked or newly-added paths
Modified int // modified/renamed/copied paths
Deleted int // deleted paths
Ahead int // commits ahead of upstream
Behind int // commits behind upstream
}

// Dirty is the total count of changed paths in the worktree.
func (i Info) Dirty() int { return i.New + i.Modified + i.Deleted }

// statusTTL bounds how often the git status subprocess may run per repo.
const statusTTL = 45 * time.Second

Expand Down Expand Up @@ -69,41 +74,44 @@ func Branch(root string) string {
return ""
}

// Status returns dirty/ahead/behind, refreshed via `git status` at most once per
// statusTTL (cached on disk per repo, like the repo-root cache: disposable,
// never authoritative). Returns zeros when git is slow, absent, or upstream-less.
func Status(root string, now time.Time) (dirty, ahead, behind int) {
// Status returns the changed-path breakdown plus ahead/behind, refreshed via
// `git status` at most once per statusTTL (cached on disk per repo, like the
// repo-root cache: disposable, never authoritative). Returns zeros when git is
// slow, absent, or upstream-less.
func Status(root string, now time.Time) (new_, modified, deleted, ahead, behind int) {
if root == "" {
return 0, 0, 0
return 0, 0, 0, 0, 0
}
cache := cachePath(root)
if fi, err := os.Stat(cache); err == nil && now.Sub(fi.ModTime()) < statusTTL {
if b, err := os.ReadFile(cache); err == nil {
return parseCache(b)
}
}
dirty, ahead, behind = liveStatus(root)
new_, modified, deleted, ahead, behind = liveStatus(root)
if err := os.MkdirAll(filepath.Dir(cache), 0o755); err == nil {
_ = os.WriteFile(cache, fmt.Appendf(nil, "%d %d %d", dirty, ahead, behind), 0o644)
_ = os.WriteFile(cache, fmt.Appendf(nil, "%d %d %d %d %d", new_, modified, deleted, ahead, behind), 0o644)
}
return dirty, ahead, behind
return new_, modified, deleted, ahead, behind
}

func liveStatus(root string) (dirty, ahead, behind int) {
func liveStatus(root string) (new_, modified, deleted, ahead, behind int) {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
out, err := exec.CommandContext(ctx, "git", "-C", root, "status", "--porcelain=v1", "-b").Output()
if err != nil {
return 0, 0, 0
return 0, 0, 0, 0, 0
}
return parsePorcelain(out)
}

var aheadBehindRe = regexp.MustCompile(`\[(?:ahead (\d+))?(?:, )?(?:behind (\d+))?\]`)

// parsePorcelain reads `git status --porcelain=v1 -b` output: the "## branch"
// header carries [ahead N, behind M]; every following line is a dirty path.
func parsePorcelain(out []byte) (dirty, ahead, behind int) {
// header carries [ahead N, behind M]; every following line is a dirty path
// whose two-char XY code sorts it into new (untracked/added), deleted, or
// modified (everything else — modify, rename, copy, type change).
func parsePorcelain(out []byte) (new_, modified, deleted, ahead, behind int) {
for _, line := range bytes.Split(out, []byte("\n")) {
if len(bytes.TrimSpace(line)) == 0 {
continue
Expand All @@ -115,14 +123,25 @@ func parsePorcelain(out []byte) (dirty, ahead, behind int) {
}
continue
}
dirty++
if len(line) < 2 {
continue
}
x, y := line[0], line[1]
switch {
case x == '?' || x == 'A' || y == 'A':
new_++
case x == 'D' || y == 'D':
deleted++
default:
modified++
}
}
return dirty, ahead, behind
return new_, modified, deleted, ahead, behind
}

func parseCache(b []byte) (dirty, ahead, behind int) {
_, _ = fmt.Sscanf(string(b), "%d %d %d", &dirty, &ahead, &behind)
return dirty, ahead, behind
func parseCache(b []byte) (new_, modified, deleted, ahead, behind int) {
_, _ = fmt.Sscanf(string(b), "%d %d %d %d %d", &new_, &modified, &deleted, &ahead, &behind)
return new_, modified, deleted, ahead, behind
}

func cachePath(root string) string {
Expand Down
20 changes: 10 additions & 10 deletions internal/gitx/gitx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,21 +47,21 @@ func TestBranchWorktreeFile(t *testing.T) {
}

func TestParsePorcelain(t *testing.T) {
out := []byte("## main...origin/main [ahead 2, behind 1]\n M cmd/main.go\n?? new.txt\n")
dirty, ahead, behind := parsePorcelain(out)
if dirty != 2 || ahead != 2 || behind != 1 {
t.Fatalf("got %d/%d/%d, want 2/2/1", dirty, ahead, behind)
out := []byte("## main...origin/main [ahead 2, behind 1]\n M cmd/main.go\n?? new.txt\n D gone.go\nA staged.go\n")
new_, mod, del, ahead, behind := parsePorcelain(out)
if new_ != 2 || mod != 1 || del != 1 || ahead != 2 || behind != 1 {
t.Fatalf("got new=%d mod=%d del=%d ahead=%d behind=%d, want 2/1/1/2/1", new_, mod, del, ahead, behind)
}

out = []byte("## main...origin/main\n")
dirty, ahead, behind = parsePorcelain(out)
if dirty != 0 || ahead != 0 || behind != 0 {
t.Fatalf("clean synced repo: got %d/%d/%d, want zeros", dirty, ahead, behind)
new_, mod, del, ahead, behind = parsePorcelain(out)
if new_ != 0 || mod != 0 || del != 0 || ahead != 0 || behind != 0 {
t.Fatalf("clean synced repo: got new=%d mod=%d del=%d ahead=%d behind=%d, want zeros", new_, mod, del, ahead, behind)
}

out = []byte("## main...origin/main [ahead 3]\n M a\n")
dirty, ahead, behind = parsePorcelain(out)
if dirty != 1 || ahead != 3 || behind != 0 {
t.Fatalf("ahead-only: got %d/%d/%d, want 1/3/0", dirty, ahead, behind)
new_, mod, del, ahead, behind = parsePorcelain(out)
if new_ != 0 || mod != 1 || del != 0 || ahead != 3 || behind != 0 {
t.Fatalf("ahead-only: got new=%d mod=%d del=%d ahead=%d behind=%d, want 0/1/0/3/0", new_, mod, del, ahead, behind)
}
}
20 changes: 19 additions & 1 deletion internal/input/stdin.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"bytes"
"encoding/json"
"io"
"os"
"strings"
"time"
)

Expand All @@ -21,6 +23,7 @@ type Stdin struct {
TranscriptPath string
Cwd string
ModelName string
Effort string // reasoning effort (e.g. "high"), from CLAUDE_EFFORT
CurrentDir string
ProjectDir string
Version string
Expand Down Expand Up @@ -73,7 +76,8 @@ func Parse(r io.Reader) Stdin {
SessionID: raw.SessionID,
TranscriptPath: raw.TranscriptPath,
Cwd: raw.Cwd,
ModelName: raw.Model.DisplayName,
ModelName: cleanModelName(raw.Model.DisplayName),
Effort: strings.ToLower(strings.TrimSpace(os.Getenv("CLAUDE_EFFORT"))),
CurrentDir: raw.Workspace.CurrentDir,
ProjectDir: raw.Workspace.ProjectDir,
Version: raw.Version,
Expand All @@ -92,6 +96,20 @@ func Parse(r io.Reader) Stdin {
return s
}

// cleanModelName drops a trailing context-window parenthetical that Claude Code
// appends to some display names (e.g. "Opus 4.8 (1M context)" → "Opus 4.8"), so
// the status line can present its own effort parenthetical instead.
func cleanModelName(name string) string {
name = strings.TrimSpace(name)
if i := strings.LastIndex(name, "("); i > 0 {
tail := name[i:]
if strings.Contains(strings.ToLower(tail), "context") {
name = strings.TrimSpace(name[:i])
}
}
return name
}

func convertRate(r *rawRate) *RateLimit {
if r == nil {
return nil
Expand Down
2 changes: 1 addition & 1 deletion internal/render/demo.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ func Demo(colorOn bool) []string {
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.Git = gitx.Info{Branch: "main", New: 1, Modified: 2, Deleted: 1, Ahead: 1}
full.Trend = sessions.TrendUp
full.Siblings = []sessions.Beat{
{State: "failed", Project: "api", Title: "Fix login bug", UpdatedAt: now.Unix()},
Expand Down
100 changes: 96 additions & 4 deletions internal/render/insights_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"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"
Expand Down Expand Up @@ -119,20 +120,44 @@ func TestWaitingAge(t *testing.T) {
}
}

// clearFeatureEnv turns every opt-in visual feature off for a test, so its
// assertions don't depend on ambient CCBIT_* set in the developer's shell.
func clearFeatureEnv(t *testing.T) {
for _, k := range []string{"CCBIT_NERD_FONT", "CCBIT_ICONS", "CCBIT_GIT_COLOR", "CCBIT_CTX_GAUGE", "CCBIT_RATE_COLOR"} {
t.Setenv(k, "")
}
}

func TestGitSegment(t *testing.T) {
clearFeatureEnv(t)
c := ctx()
c.Git = gitx.Info{Branch: "main", Dirty: 3, Ahead: 2}
c.Git = gitx.Info{Branch: "main", New: 1, Modified: 3, Deleted: 2, Ahead: 2}
l2 := Render(state.View{State: state.Idle}, c)[1]
if !strings.Contains(l2, "main* ↑2") {
t.Fatalf("line2 = %q, want git segment main* ↑2", l2)
if !strings.Contains(l2, "main +1 ~3 -2 ↑2") {
t.Fatalf("line2 = %q, want git segment main +1 ~3 -2 ↑2", l2)
}
c.Git = gitx.Info{Branch: "main"}
l2 = Render(state.View{State: state.Idle}, c)[1]
if !strings.Contains(l2, "main") || strings.Contains(l2, "main*") {
if !strings.Contains(l2, "main") || strings.ContainsAny(l2, "+~") {
t.Fatalf("clean repo line2 = %q, want bare branch", l2)
}
}

func TestGitSegmentNerdFont(t *testing.T) {
clearFeatureEnv(t)
t.Setenv("CCBIT_NERD_FONT", "1")
c := ctx()
c.Git = gitx.Info{Branch: "main", New: 1, Modified: 3, Deleted: 2, Ahead: 2}
l2 := Render(state.View{State: state.Idle}, c)[1]
// plus , pencil , trash  — each icon spaced from its count.
if want := "main  1  3  2 ↑2"; !strings.Contains(l2, want) {
t.Fatalf("line2 = %q, want git segment %q", l2, want)
}
if strings.ContainsAny(l2, "+~") {
t.Fatalf("nerd-font line2 = %q, should not carry ASCII marks", l2)
}
}

func TestDoneShipClauses(t *testing.T) {
v := state.View{State: state.DoneNormal, Turn: transcript.Turn{
Edited: []string{"a.go"}, Pushed: true, Deployed: true,
Expand Down Expand Up @@ -201,3 +226,70 @@ func TestSameRepoCollision(t *testing.T) {
t.Fatalf("line1 = %q, no collision expected", got)
}
}

func TestGitSegmentColor(t *testing.T) {
clearFeatureEnv(t)
t.Setenv("CCBIT_GIT_COLOR", "1")
c := ctx()
c.ColorOn = true
c.Git = gitx.Info{Branch: "main", New: 1, Modified: 3, Deleted: 2}
l2 := Render(state.View{State: state.Idle}, c)[1]
// New green, modified yellow, deleted red — each mark wrapped in its color.
if !strings.Contains(l2, colorize(" +1", green)) ||
!strings.Contains(l2, colorize(" ~3", yellow)) ||
!strings.Contains(l2, colorize(" -2", red)) {
t.Fatalf("colored git line2 = %q, want green/yellow/red marks", l2)
}
}

func TestCtxGauge(t *testing.T) {
clearFeatureEnv(t)
t.Setenv("CCBIT_CTX_GAUGE", "1")
c := ctx()
pct := 38.0
c.In.CtxPct = &pct
if got := ctxSegment(c); !strings.Contains(got, "▆▆▁▁▁") || !strings.Contains(got, "38%") {
t.Fatalf("ctx gauge = %q, want a 2/5 bar and 38%%", got)
}
}

func TestRateSegmentColor(t *testing.T) {
clearFeatureEnv(t)
t.Setenv("CCBIT_RATE_COLOR", "1")
hot := 94.0
rl := &input.RateLimit{UsedPercentage: &hot}
if got := rateSegment("7d", rl, time.Time{}, true); !strings.Contains(got, red) {
t.Fatalf("hot rate segment = %q, want red", got)
}
warm := 78.0
rl.UsedPercentage = &warm
if got := rateSegment("5h", rl, time.Time{}, true); !strings.Contains(got, yellow) {
t.Fatalf("warm rate segment = %q, want yellow", got)
}
cool := 12.0
rl.UsedPercentage = &cool
if got := rateSegment("5h", rl, time.Time{}, true); strings.Contains(got, "\x1b[") {
t.Fatalf("healthy rate segment = %q, want no color", got)
}
}

func TestIdleTipRotates(t *testing.T) {
clearFeatureEnv(t) // all features off → all tips eligible
c := ctx()
// A showing slot (slot%tipShowEvery==0): first tip is the Nerd Font hint.
c.Now = time.Unix(0, 0)
if l1 := Render(state.View{State: state.Idle}, c)[0]; !strings.Contains(l1, "tip: set CCBIT_NERD_FONT=1") {
t.Fatalf("idle line = %q, want Nerd Font tip", l1)
}
// A resting slot shows no tip.
c.Now = time.Unix(tipPeriodSecs, 0) // slot 1, 1%3 != 0
if l1 := Render(state.View{State: state.Idle}, c)[0]; strings.Contains(l1, "tip:") {
t.Fatalf("resting-slot idle line = %q, want no tip", l1)
}
// A sibling to talk about preempts the tip entirely.
c.Now = time.Unix(0, 0)
c.Siblings = []sessions.Beat{{State: "failed", Project: "api"}}
if l1 := Render(state.View{State: state.Idle}, c)[0]; strings.Contains(l1, "tip:") {
t.Fatalf("idle-with-sibling line = %q, want no tip", l1)
}
}
Loading
Loading