Compare commits

...

6 Commits

Author SHA1 Message Date
99ad5b9d7f Format markdown and YAML files with deno fmt 2025-12-18 19:42:05 +01:00
0638707349 Use golang.org/x/term for terminal width detection
Replace direct unix.IoctlGetWinsize() call with term.GetSize() for
cleaner API. No binary size change as x/sys remains an indirect
dependency.
2025-12-18 18:36:54 +01:00
58aaad4c9c Add comprehensive edge case tests for status line functions 2025-12-18 11:31:35 +01:00
52d6bbaf84 Add OpenCode config with golangci-lint LSP 2025-12-18 11:31:35 +01:00
40452e100e Refactor: Extract testable functions and improve code organization
- Extract TokenUsage as named type (eliminates inline struct repetition)
- Refactor main() into testable functions:
  - readInputFromStdin: Read JSON from stdin
  - parseStatusInput: Validate and parse JSON
  - buildStatusLine: Construct left and right statusline parts
  - calculatePadding: Compute padding for alignment
  - formatOutput: Combine components into final output
- Add comprehensive tests for extracted functions
- Improve coverage from 45% to 71% (+26 percentage points)
- All new functions have 100% test coverage
- Clean linting with zero issues
2025-12-18 11:31:35 +01:00
47ea4eb509 Add AGENTS.md and bump to Go 1.25 2025-12-18 11:31:35 +01:00
8 changed files with 766 additions and 72 deletions

23
.opencode/opencode.jsonc Normal file
View File

@ -0,0 +1,23 @@
{
"lsp": {
"golangci-lint": {
"command": [
"golangci-lint-langserver"
],
"extensions": [
".go"
],
"initialization": {
"command": [
"golangci-lint",
"run",
"--output.json.path",
"stdout",
"--show-stats=false",
"--issues-exit-code=1"
]
}
}
},
"$schema": "https://opencode.ai/config.json"
}

28
AGENTS.md Normal file
View File

@ -0,0 +1,28 @@
# AGENTS
- Go 1.25; modules in `go.mod`; binary name `statusline`.
- Build: `task build` (stripped) or `go build -o bin/statusline .`.
- Run fixture: `task run` or `cat test/fixture.json | ./bin/statusline`.
- Tests: `task test` (`go test -v ./...`); single test:
`go test -v -run 'TestName' ./...`.
- Coverage: `task test:cover`; benchmarks: `task bench` or `task bench:go`.
- Lint: `task lint` (`golangci-lint run`); auto-fix: `task lint:fix`.
- Formatting: `gofumpt` + `goimports` via golangci-lint; keep `go fmt`/gofumpt
style.
- Imports: organize with `goimports`; stdlib first, then third-party, then
local.
- Types: prefer explicit types; avoid unused code (lint-enforced).
- Naming: follow Go conventions (ExportedCamelCase for exported, lowerCamelCase
for unexported); no stutter.
- Errors: check and return errors; wrap or format with context; no silent
ignores.
- Security: `gosec` enabled; avoid leaking secrets; handle paths carefully.
- Tests should avoid external deps; skip when environment-dependent.
- Modernization helpers: `task modernize` / `task modernize:test` (gopls).
- Clean artifacts: `task clean`; binary lives in `bin/`.
- Git info and gitea checks rely on `go-git` and `gopsutil`; keep deps updated
via `go mod tidy`.
- Keep ANSI handling via `stripANSI` regex in `main.go`; adjust carefully if
changing.
- No Cursor/Copilot rules present as of this file.
- No emojis in code or docs unless explicitly requested.

View File

@ -1,10 +1,14 @@
# CLAUDE.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with
code in this repository.
## Project Overview ## Project Overview
This is a custom status line binary for Claude Code, written in Go. It replaces shell-based status line scripts with a compiled binary for better performance. The binary reads JSON from stdin (provided by Claude Code) and outputs a formatted status line with ANSI colors. This is a custom status line binary for Claude Code, written in Go. It replaces
shell-based status line scripts with a compiled binary for better performance.
The binary reads JSON from stdin (provided by Claude Code) and outputs a
formatted status line with ANSI colors.
## Build and Development Commands ## Build and Development Commands
@ -35,7 +39,8 @@ task clean # Remove bin/ directory
Single-file Go application (`main.go`) that: Single-file Go application (`main.go`) that:
1. **Reads JSON from stdin** - Parses Claude Code's status hook payload (`StatusInput` struct) 1. **Reads JSON from stdin** - Parses Claude Code's status hook payload
(`StatusInput` struct)
2. **Gathers system state**: 2. **Gathers system state**:
- Gitea process status via gopsutil (cross-platform process listing) - Gitea process status via gopsutil (cross-platform process listing)
- Git repository info via go-git (branch name, dirty state) - Git repository info via go-git (branch name, dirty state)
@ -43,6 +48,7 @@ Single-file Go application (`main.go`) that:
3. **Outputs formatted status line** with ANSI colors, padding to terminal width 3. **Outputs formatted status line** with ANSI colors, padding to terminal width
Key functions: Key functions:
- `formatContextInfo()` - Formats token usage as "Xk/Yk" - `formatContextInfo()` - Formats token usage as "Xk/Yk"
- `getGiteaStatus()` - Returns green/red dot based on gitea process running - `getGiteaStatus()` - Returns green/red dot based on gitea process running
- `getGitInfo()` - Returns git branch and dirty indicator - `getGitInfo()` - Returns git branch and dirty indicator
@ -50,7 +56,9 @@ Key functions:
## JSON Input Format ## JSON Input Format
The binary expects Claude Code's status hook JSON via stdin. See `test/fixture.json` for the complete structure. Key fields used: The binary expects Claude Code's status hook JSON via stdin. See
`test/fixture.json` for the complete structure. Key fields used:
- `model.display_name` - Model name to display - `model.display_name` - Model name to display
- `workspace.current_dir` - Current directory path - `workspace.current_dir` - Current directory path
- `context_window.context_window_size` - Total context window tokens - `context_window.context_window_size` - Total context window tokens

View File

@ -1,6 +1,6 @@
# https://taskfile.dev # https://taskfile.dev
version: '3' version: "3"
vars: vars:
BINARY: statusline BINARY: statusline
@ -59,11 +59,11 @@ tasks:
deps: [build] deps: [build]
cmds: cmds:
- | - |
echo "=== Pure Go (100 runs) ===" echo "=== Pure Go (100 runs) ==="
time for i in $(seq 1 100); do cat test/fixture.json | ./bin/{{.BINARY}} >/dev/null; done time for i in $(seq 1 100); do cat test/fixture.json | ./bin/{{.BINARY}} >/dev/null; done
# echo "" # echo ""
# echo "=== Shell (100 runs) ===" # echo "=== Shell (100 runs) ==="
# time for i in $(seq 1 100); do cat test/fixture.json | ./statusline.sh >/dev/null; done # time for i in $(seq 1 100); do cat test/fixture.json | ./statusline.sh >/dev/null; done
silent: false silent: false
bench:go: bench:go:
@ -71,8 +71,8 @@ tasks:
deps: [build] deps: [build]
cmds: cmds:
- | - |
echo "=== Pure Go (100 runs) ===" echo "=== Pure Go (100 runs) ==="
time for i in $(seq 1 100); do cat test/fixture.json | ./bin/{{.BINARY}} >/dev/null; done time for i in $(seq 1 100); do cat test/fixture.json | ./bin/{{.BINARY}} >/dev/null; done
tidy: tidy:
desc: Run go mod tidy desc: Run go mod tidy

11
go.mod
View File

@ -1,11 +1,11 @@
module gitea.kajkowalski.nl/kjanat/claude-statusline module gitea.kajkowalski.nl/kjanat/claude-statusline
go 1.24.11 go 1.25.5
require ( require (
github.com/go-git/go-git/v6 v6.0.0-20251216093047-22c365fcee9c github.com/go-git/go-git/v6 v6.0.0-20251216093047-22c365fcee9c
github.com/shirou/gopsutil/v4 v4.25.11 github.com/shirou/gopsutil/v4 v4.25.11
golang.org/x/sys v0.39.0 golang.org/x/term v0.38.0
) )
require ( require (
@ -16,12 +16,12 @@ require (
github.com/ebitengine/purego v0.9.1 // indirect github.com/ebitengine/purego v0.9.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg/v2 v2.0.2 // indirect github.com/go-git/gcfg/v2 v2.0.2 // indirect
github.com/go-git/go-billy/v6 v6.0.0-20251209065551-8afc3eb64e4d // indirect github.com/go-git/go-billy/v6 v6.0.0-20251217170237-e9738f50a3cd // indirect
github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect github.com/kevinburke/ssh_config v1.4.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/sergi/go-diff v1.4.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect
@ -30,4 +30,5 @@ require (
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/crypto v0.46.0 // indirect golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
) )

14
go.sum
View File

@ -23,17 +23,17 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
github.com/go-git/go-billy/v6 v6.0.0-20251209065551-8afc3eb64e4d h1:nfZPVEha54DwXl8twSNxi9J8edIiqfpSvnq/mGPfgc4= github.com/go-git/go-billy/v6 v6.0.0-20251217170237-e9738f50a3cd h1:Gd/f9cGi/3h1JOPaa6er+CkKUGyGX2DBJdFbDKVO+R0=
github.com/go-git/go-billy/v6 v6.0.0-20251209065551-8afc3eb64e4d/go.mod h1:d3XQcsHu1idnquxt48kAv+h+1MUiYKLH/e7LAzjP+pI= github.com/go-git/go-billy/v6 v6.0.0-20251217170237-e9738f50a3cd/go.mod h1:d3XQcsHu1idnquxt48kAv+h+1MUiYKLH/e7LAzjP+pI=
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20251205091929-ed656e84d025 h1:24Uc4y1yxMe8V30NhshaDdCaTOw97BWVhVGH/m1+udM= github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20251205091929-ed656e84d025 h1:24Uc4y1yxMe8V30NhshaDdCaTOw97BWVhVGH/m1+udM=
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20251205091929-ed656e84d025/go.mod h1:T6lRF5ejdxaYZLVaCTuTG1+ZSvwI/c2oeiTgBWORJ8Q= github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20251205091929-ed656e84d025/go.mod h1:T6lRF5ejdxaYZLVaCTuTG1+ZSvwI/c2oeiTgBWORJ8Q=
github.com/go-git/go-git/v6 v6.0.0-20251216093047-22c365fcee9c h1:pR4UmnVFMjNw956fgu+JlSAvmx37qW4ttVF0cu7DL/Q= github.com/go-git/go-git/v6 v6.0.0-20251216093047-22c365fcee9c h1:pR4UmnVFMjNw956fgu+JlSAvmx37qW4ttVF0cu7DL/Q=
github.com/go-git/go-git/v6 v6.0.0-20251216093047-22c365fcee9c/go.mod h1:EPzgAjDnw+TaCt1w/JUmj+SXwWHUae3c078ixiZQ10Y= github.com/go-git/go-git/v6 v6.0.0-20251216093047-22c365fcee9c/go.mod h1:EPzgAjDnw+TaCt1w/JUmj+SXwWHUae3c078ixiZQ10Y=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
@ -43,8 +43,8 @@ github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@ -71,13 +71,13 @@ golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

94
main.go
View File

@ -11,12 +11,19 @@ import (
"github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6"
"github.com/shirou/gopsutil/v4/process" "github.com/shirou/gopsutil/v4/process"
"golang.org/x/sys/unix" "golang.org/x/term"
) )
const statuslineWidthOffset = 7 const statuslineWidthOffset = 7
// Input JSON structure from Claude Code // TokenUsage tracks context window token consumption.
type TokenUsage struct {
InputTokens int `json:"input_tokens"`
CacheCreationTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
}
// StatusInput represents the JSON input from Claude Code.
type StatusInput struct { type StatusInput struct {
Model struct { Model struct {
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
@ -25,12 +32,8 @@ type StatusInput struct {
CurrentDir string `json:"current_dir"` CurrentDir string `json:"current_dir"`
} `json:"workspace"` } `json:"workspace"`
ContextWindow struct { ContextWindow struct {
ContextWindowSize int `json:"context_window_size"` ContextWindowSize int `json:"context_window_size"`
CurrentUsage *struct { CurrentUsage *TokenUsage `json:"current_usage"`
InputTokens int `json:"input_tokens"`
CacheCreationTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
} `json:"current_usage"`
} `json:"context_window"` } `json:"context_window"`
} }
@ -46,46 +49,66 @@ const (
boldGreen = "\033[1;32m" boldGreen = "\033[1;32m"
) )
func main() { // readInputFromStdin reads JSON input from stdin.
// Read JSON from stdin func readInputFromStdin(r *bufio.Reader) string {
reader := bufio.NewReader(os.Stdin)
var input strings.Builder var input strings.Builder
for { for {
line, err := reader.ReadString('\n') line, err := r.ReadString('\n')
input.WriteString(line) input.WriteString(line)
if err != nil { if err != nil {
break break
} }
} }
return input.String()
}
// parseStatusInput unmarshals JSON string into StatusInput.
func parseStatusInput(jsonStr string) (*StatusInput, error) {
var data StatusInput var data StatusInput
if err := json.Unmarshal([]byte(input.String()), &data); err != nil { if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing JSON: %v\n", err) return nil, err
os.Exit(1)
} }
return &data, nil
}
// Calculate context info // buildStatusLine constructs the left and right parts of the status line.
func buildStatusLine(data *StatusInput) (left, right string) {
contextInfo := formatContextInfo(data.ContextWindow.ContextWindowSize, data.ContextWindow.CurrentUsage) contextInfo := formatContextInfo(data.ContextWindow.ContextWindowSize, data.ContextWindow.CurrentUsage)
// Get directory name
dirName := filepath.Base(data.Workspace.CurrentDir) dirName := filepath.Base(data.Workspace.CurrentDir)
// Check gitea status
giteaStatus := getGiteaStatus() giteaStatus := getGiteaStatus()
// Get git info
gitInfo := getGitInfo(data.Workspace.CurrentDir) gitInfo := getGitInfo(data.Workspace.CurrentDir)
// Build left part left = fmt.Sprintf("%s %s%s%s %s➜%s %s%s%s%s",
left := fmt.Sprintf("%s %s%s%s %s➜%s %s%s%s%s",
giteaStatus, giteaStatus,
magenta, data.Model.DisplayName, reset, magenta, data.Model.DisplayName, reset,
boldGreen, reset, boldGreen, reset,
cyan, dirName, reset, cyan, dirName, reset,
gitInfo) gitInfo)
// Build right part right = fmt.Sprintf("%s%s%s", yellow, contextInfo, reset)
right := fmt.Sprintf("%s%s%s", yellow, contextInfo, reset) return left, right
}
// calculatePadding returns the number of spaces needed for padding.
func calculatePadding(leftVisible, rightVisible string, termWidth int) int {
return max(termWidth-len(leftVisible)-len(rightVisible), 1)
}
// formatOutput combines left, right, and padding into final output.
func formatOutput(left, right string, padding int) string {
return fmt.Sprintf("%s%s%s", left, strings.Repeat(" ", padding), right)
}
func main() {
jsonStr := readInputFromStdin(bufio.NewReader(os.Stdin))
data, err := parseStatusInput(jsonStr)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing JSON: %v\n", err)
os.Exit(1)
}
left, right := buildStatusLine(data)
// Calculate visible lengths (strip ANSI) // Calculate visible lengths (strip ANSI)
leftVisible := stripANSI(left) leftVisible := stripANSI(left)
@ -94,19 +117,14 @@ func main() {
// Get terminal width // Get terminal width
termWidth := getTerminalWidth() - statuslineWidthOffset termWidth := getTerminalWidth() - statuslineWidthOffset
// Calculate padding // Calculate and apply padding
padding := max(termWidth-len(leftVisible)-len(rightVisible), 1) padding := calculatePadding(leftVisible, rightVisible, termWidth)
output := formatOutput(left, right, padding)
// Output with padding fmt.Print(output)
fmt.Printf("%s%s%s", left, strings.Repeat(" ", padding), right)
} }
func formatContextInfo(contextSize int, usage *struct { func formatContextInfo(contextSize int, usage *TokenUsage) string {
InputTokens int `json:"input_tokens"`
CacheCreationTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
},
) string {
totalK := contextSize / 1000 totalK := contextSize / 1000
if usage == nil { if usage == nil {
@ -179,11 +197,11 @@ func getGitInfo(cwd string) string {
} }
func getTerminalWidth() int { func getTerminalWidth() int {
ws, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ) width, _, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil { if err != nil {
return 80 return 80
} }
return int(ws.Col) return width
} }
var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`) var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)

View File

@ -1,8 +1,10 @@
package main package main
import ( import (
"bufio"
"encoding/json" "encoding/json"
"os" "os"
"strings"
"testing" "testing"
) )
@ -15,11 +17,7 @@ func TestFormatContextInfo_NilUsage(t *testing.T) {
} }
func TestFormatContextInfo_WithUsage(t *testing.T) { func TestFormatContextInfo_WithUsage(t *testing.T) {
usage := &struct { usage := &TokenUsage{
InputTokens int `json:"input_tokens"`
CacheCreationTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
}{
InputTokens: 8500, InputTokens: 8500,
CacheCreationTokens: 5000, CacheCreationTokens: 5000,
CacheReadInputTokens: 2000, CacheReadInputTokens: 2000,
@ -32,11 +30,7 @@ func TestFormatContextInfo_WithUsage(t *testing.T) {
} }
func TestFormatContextInfo_SmallValues(t *testing.T) { func TestFormatContextInfo_SmallValues(t *testing.T) {
usage := &struct { usage := &TokenUsage{
InputTokens int `json:"input_tokens"`
CacheCreationTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
}{
InputTokens: 500, InputTokens: 500,
CacheCreationTokens: 0, CacheCreationTokens: 0,
CacheReadInputTokens: 0, CacheReadInputTokens: 0,
@ -203,3 +197,625 @@ func containsHelper(s, substr string) bool {
} }
return false return false
} }
func TestGetTerminalWidth(t *testing.T) {
// getTerminalWidth should return a positive integer
// In test environment (no TTY), it should fall back to 80
width := getTerminalWidth()
if width <= 0 {
t.Errorf("getTerminalWidth() = %d, expected positive value", width)
}
}
func TestGetTerminalWidth_DefaultFallback(t *testing.T) {
// When not connected to a terminal, should return 80
width := getTerminalWidth()
// In CI/test environments, this typically returns 80
if width != 80 && width < 40 {
t.Errorf("getTerminalWidth() = %d, expected 80 or reasonable terminal width", width)
}
}
func TestGetGitInfo_InvalidPath(t *testing.T) {
result := getGitInfo("/nonexistent/path/that/does/not/exist")
if result != "" {
t.Errorf("getGitInfo(invalid) = %q, expected empty string", result)
}
}
func TestGetGitInfo_RootDir(t *testing.T) {
// Root directory is unlikely to be a git repo
result := getGitInfo("/")
if result != "" && !contains(result, "git:(") {
t.Errorf("getGitInfo(/) = %q, expected empty or valid git info", result)
}
}
func TestFormatContextInfo_ZeroContextSize(t *testing.T) {
result := formatContextInfo(0, nil)
expected := "0/0k"
if result != expected {
t.Errorf("formatContextInfo(0, nil) = %q, want %q", result, expected)
}
}
func TestFormatContextInfo_LargeValues(t *testing.T) {
usage := &TokenUsage{
InputTokens: 150000,
CacheCreationTokens: 25000,
CacheReadInputTokens: 10000,
}
result := formatContextInfo(200000, usage)
expected := "185k/200k"
if result != expected {
t.Errorf("formatContextInfo(200000, large usage) = %q, want %q", result, expected)
}
}
func TestFormatContextInfo_ExactThousand(t *testing.T) {
usage := &TokenUsage{
InputTokens: 1000,
CacheCreationTokens: 0,
CacheReadInputTokens: 0,
}
result := formatContextInfo(100000, usage)
expected := "1k/100k"
if result != expected {
t.Errorf("formatContextInfo(100000, 1000 tokens) = %q, want %q", result, expected)
}
}
func TestStripANSI_Empty(t *testing.T) {
result := stripANSI("")
if result != "" {
t.Errorf("stripANSI(\"\") = %q, want empty", result)
}
}
func TestStripANSI_OnlyANSI(t *testing.T) {
result := stripANSI("\033[31m\033[0m")
if result != "" {
t.Errorf("stripANSI(only codes) = %q, want empty", result)
}
}
func TestStripANSI_NestedCodes(t *testing.T) {
input := "\033[1m\033[31mbold red\033[0m\033[0m"
result := stripANSI(input)
expected := "bold red"
if result != expected {
t.Errorf("stripANSI(%q) = %q, want %q", input, result, expected)
}
}
func TestStatusInputParsing_EmptyJSON(t *testing.T) {
jsonData := `{}`
var data StatusInput
err := json.Unmarshal([]byte(jsonData), &data)
if err != nil {
t.Fatalf("Failed to parse empty JSON: %v", err)
}
if data.Model.DisplayName != "" {
t.Errorf("Expected empty DisplayName, got %q", data.Model.DisplayName)
}
}
func TestStatusInputParsing_PartialJSON(t *testing.T) {
jsonData := `{"model": {"display_name": "Test"}}`
var data StatusInput
err := json.Unmarshal([]byte(jsonData), &data)
if err != nil {
t.Fatalf("Failed to parse partial JSON: %v", err)
}
if data.Model.DisplayName != "Test" {
t.Errorf("DisplayName = %q, want %q", data.Model.DisplayName, "Test")
}
if data.Workspace.CurrentDir != "" {
t.Errorf("Expected empty CurrentDir, got %q", data.Workspace.CurrentDir)
}
}
func TestStatusInputParsing_InvalidJSON(t *testing.T) {
jsonData := `{invalid json}`
var data StatusInput
err := json.Unmarshal([]byte(jsonData), &data)
if err == nil {
t.Error("Expected error for invalid JSON, got nil")
}
}
func TestANSIConstants(t *testing.T) {
// Verify ANSI constants are properly defined
tests := []struct {
name string
constant string
prefix string
}{
{"reset", reset, "\033["},
{"red", red, "\033["},
{"green", green, "\033["},
{"yellow", yellow, "\033["},
{"magenta", magenta, "\033["},
{"cyan", cyan, "\033["},
{"boldGreen", boldGreen, "\033["},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if !contains(tt.constant, tt.prefix) {
t.Errorf("%s constant doesn't start with ANSI escape", tt.name)
}
})
}
}
func TestGetGiteaStatus_ReturnsValidColor(t *testing.T) {
result := getGiteaStatus()
// Must contain the dot character
if !contains(result, "●") {
t.Errorf("getGiteaStatus() = %q, expected to contain dot", result)
}
// Must contain ANSI codes
stripped := stripANSI(result)
if stripped != "●" {
t.Errorf("stripped getGiteaStatus() = %q, expected just dot", stripped)
}
}
func TestReadInputFromStdin(t *testing.T) {
input := "line1\nline2\nline3"
reader := bufio.NewReader(strings.NewReader(input))
result := readInputFromStdin(reader)
expected := "line1\nline2\nline3"
if result != expected {
t.Errorf("readInputFromStdin = %q, want %q", result, expected)
}
}
func TestReadInputFromStdin_Empty(t *testing.T) {
reader := bufio.NewReader(strings.NewReader(""))
result := readInputFromStdin(reader)
if result != "" {
t.Errorf("readInputFromStdin (empty) = %q, want empty", result)
}
}
func TestParseStatusInput_Valid(t *testing.T) {
jsonStr := `{"model": {"display_name": "Test"}, "workspace": {"current_dir": "/test"}}`
data, err := parseStatusInput(jsonStr)
if err != nil {
t.Fatalf("parseStatusInput failed: %v", err)
}
if data.Model.DisplayName != "Test" {
t.Errorf("DisplayName = %q, want Test", data.Model.DisplayName)
}
if data.Workspace.CurrentDir != "/test" {
t.Errorf("CurrentDir = %q, want /test", data.Workspace.CurrentDir)
}
}
func TestParseStatusInput_Invalid(t *testing.T) {
_, err := parseStatusInput("invalid json")
if err == nil {
t.Error("parseStatusInput should fail on invalid JSON")
}
}
func TestBuildStatusLine_ContainsComponents(t *testing.T) {
data := &StatusInput{}
data.Model.DisplayName = "TestModel"
data.Workspace.CurrentDir = "/home/user/project"
data.ContextWindow.ContextWindowSize = 100000
data.ContextWindow.CurrentUsage = &TokenUsage{
InputTokens: 5000,
CacheCreationTokens: 1000,
CacheReadInputTokens: 500,
}
left, right := buildStatusLine(data)
// Check left contains model name and directory
if !contains(left, "TestModel") {
t.Errorf("left statusline missing model: %q", left)
}
if !contains(left, "project") {
t.Errorf("left statusline missing directory: %q", left)
}
// Check right contains context info
if !contains(right, "6k/100k") {
t.Errorf("right statusline missing context info: %q", right)
}
}
func TestBuildStatusLine_HasGiteaStatus(t *testing.T) {
data := &StatusInput{}
data.Model.DisplayName = "Model"
data.Workspace.CurrentDir = "/tmp"
data.ContextWindow.ContextWindowSize = 100000
left, _ := buildStatusLine(data)
// Check for gitea status (dot)
if !contains(left, "●") {
t.Errorf("left statusline missing gitea status: %q", left)
}
}
func TestCalculatePadding_ZeroWidth(t *testing.T) {
result := calculatePadding("left", "right", 0)
expected := 1
if result != expected {
t.Errorf("calculatePadding(\"left\", \"right\", 0) = %d, want %d", result, expected)
}
}
func TestCalculatePadding_NegativeResult(t *testing.T) {
result := calculatePadding("left", "right", 5)
expected := 1
if result != expected {
t.Errorf("calculatePadding with overflow = %d, want minimum of %d", result, expected)
}
}
func TestCalculatePadding_Normal(t *testing.T) {
result := calculatePadding("left", "right", 50)
expected := 50 - len("left") - len("right")
if result != expected {
t.Errorf("calculatePadding(\"left\", \"right\", 50) = %d, want %d", result, expected)
}
}
func TestFormatOutput_Composition(t *testing.T) {
result := formatOutput("LEFT", "RIGHT", 5)
expected := "LEFT RIGHT"
if result != expected {
t.Errorf("formatOutput = %q, want %q", result, expected)
}
}
func TestFormatOutput_Empty(t *testing.T) {
result := formatOutput("", "", 0)
expected := ""
if result != expected {
t.Errorf("formatOutput (empty) = %q, want %q", result, expected)
}
}
func TestFormatOutput_WithANSI(t *testing.T) {
left := red + "text" + reset
right := green + "info" + reset
result := formatOutput(left, right, 3)
stripped := stripANSI(result)
if !contains(stripped, "text") || !contains(stripped, "info") {
t.Errorf("formatOutput with ANSI = %q, expected both parts visible", stripped)
}
}
// Additional edge case tests
func TestFormatContextInfo_MaxTokens(t *testing.T) {
// Test when usage equals context size
usage := &TokenUsage{
InputTokens: 200000,
CacheCreationTokens: 0,
CacheReadInputTokens: 0,
}
result := formatContextInfo(200000, usage)
expected := "200k/200k"
if result != expected {
t.Errorf("formatContextInfo(max) = %q, want %q", result, expected)
}
}
func TestFormatContextInfo_OverflowTokens(t *testing.T) {
// Test when usage exceeds context size
usage := &TokenUsage{
InputTokens: 250000,
CacheCreationTokens: 0,
CacheReadInputTokens: 0,
}
result := formatContextInfo(200000, usage)
expected := "250k/200k"
if result != expected {
t.Errorf("formatContextInfo(overflow) = %q, want %q", result, expected)
}
}
func TestFormatContextInfo_AllCacheTypes(t *testing.T) {
// Test all cache token types contributing
usage := &TokenUsage{
InputTokens: 10000,
CacheCreationTokens: 20000,
CacheReadInputTokens: 30000,
}
result := formatContextInfo(100000, usage)
expected := "60k/100k"
if result != expected {
t.Errorf("formatContextInfo(all cache) = %q, want %q", result, expected)
}
}
func TestFormatContextInfo_OnlyCacheCreation(t *testing.T) {
usage := &TokenUsage{
InputTokens: 0,
CacheCreationTokens: 5000,
CacheReadInputTokens: 0,
}
result := formatContextInfo(100000, usage)
expected := "5k/100k"
if result != expected {
t.Errorf("formatContextInfo(cache creation) = %q, want %q", result, expected)
}
}
func TestFormatContextInfo_OnlyCacheRead(t *testing.T) {
usage := &TokenUsage{
InputTokens: 0,
CacheCreationTokens: 0,
CacheReadInputTokens: 8000,
}
result := formatContextInfo(100000, usage)
expected := "8k/100k"
if result != expected {
t.Errorf("formatContextInfo(cache read) = %q, want %q", result, expected)
}
}
func TestBuildStatusLine_EmptyDir(t *testing.T) {
data := &StatusInput{}
data.Model.DisplayName = "Model"
data.Workspace.CurrentDir = ""
data.ContextWindow.ContextWindowSize = 100000
left, right := buildStatusLine(data)
// Should not panic, should produce valid output
if left == "" {
t.Error("buildStatusLine with empty dir should produce left output")
}
if right == "" {
t.Error("buildStatusLine with empty dir should produce right output")
}
}
func TestBuildStatusLine_LongModelName(t *testing.T) {
data := &StatusInput{}
data.Model.DisplayName = "Claude 3.5 Sonnet with Extended Context"
data.Workspace.CurrentDir = "/home/user/my-very-long-project-name-here"
data.ContextWindow.ContextWindowSize = 200000
data.ContextWindow.CurrentUsage = &TokenUsage{
InputTokens: 50000,
}
left, right := buildStatusLine(data)
if !contains(left, "Claude 3.5 Sonnet with Extended Context") {
t.Errorf("long model name not in left: %q", left)
}
if !contains(left, "my-very-long-project-name-here") {
t.Errorf("long dir name not in left: %q", left)
}
if !contains(right, "50k/200k") {
t.Errorf("context info not in right: %q", right)
}
}
func TestBuildStatusLine_NilUsage(t *testing.T) {
data := &StatusInput{}
data.Model.DisplayName = "Model"
data.Workspace.CurrentDir = "/test"
data.ContextWindow.ContextWindowSize = 100000
data.ContextWindow.CurrentUsage = nil
left, right := buildStatusLine(data)
if !contains(right, "0/100k") {
t.Errorf("nil usage should show 0: %q", right)
}
if left == "" {
t.Error("left should not be empty")
}
}
func TestBuildStatusLine_RootDir(t *testing.T) {
data := &StatusInput{}
data.Model.DisplayName = "Model"
data.Workspace.CurrentDir = "/"
data.ContextWindow.ContextWindowSize = 100000
left, _ := buildStatusLine(data)
// filepath.Base("/") returns "/"
if !contains(left, "/") || !contains(left, "Model") {
t.Errorf("root dir handling failed: %q", left)
}
}
func TestCalculatePadding_ExactFit(t *testing.T) {
// When content exactly fills the width
result := calculatePadding("12345", "67890", 10)
expected := 1 // max(10-5-5, 1) = max(0, 1) = 1
if result != expected {
t.Errorf("calculatePadding(exact fit) = %d, want %d", result, expected)
}
}
func TestCalculatePadding_LargeWidth(t *testing.T) {
result := calculatePadding("left", "right", 200)
expected := 200 - 4 - 5 // 191
if result != expected {
t.Errorf("calculatePadding(large) = %d, want %d", result, expected)
}
}
func TestStripANSI_256Color(t *testing.T) {
// 256-color escape sequences
input := "\033[38;5;196mred\033[0m"
result := stripANSI(input)
expected := "red"
if result != expected {
t.Errorf("stripANSI(256color) = %q, want %q", result, expected)
}
}
func TestStripANSI_TrueColor(t *testing.T) {
// 24-bit true color escape sequences
input := "\033[38;2;255;0;0mred\033[0m"
result := stripANSI(input)
expected := "red"
if result != expected {
t.Errorf("stripANSI(truecolor) = %q, want %q", result, expected)
}
}
func TestStripANSI_Multiline(t *testing.T) {
input := "\033[31mline1\033[0m\n\033[32mline2\033[0m"
result := stripANSI(input)
expected := "line1\nline2"
if result != expected {
t.Errorf("stripANSI(multiline) = %q, want %q", result, expected)
}
}
func TestReadInputFromStdin_SingleLine(t *testing.T) {
reader := bufio.NewReader(strings.NewReader("single line"))
result := readInputFromStdin(reader)
if result != "single line" {
t.Errorf("readInputFromStdin(single) = %q, want 'single line'", result)
}
}
func TestReadInputFromStdin_JSONLike(t *testing.T) {
jsonStr := `{"key": "value", "nested": {"a": 1}}`
reader := bufio.NewReader(strings.NewReader(jsonStr))
result := readInputFromStdin(reader)
if result != jsonStr {
t.Errorf("readInputFromStdin(json) = %q, want %q", result, jsonStr)
}
}
func TestParseStatusInput_AllFields(t *testing.T) {
jsonStr := `{
"model": {"display_name": "FullTest"},
"workspace": {"current_dir": "/full/path"},
"context_window": {
"context_window_size": 150000,
"current_usage": {
"input_tokens": 1000,
"cache_creation_input_tokens": 2000,
"cache_read_input_tokens": 3000
}
}
}`
data, err := parseStatusInput(jsonStr)
if err != nil {
t.Fatalf("parseStatusInput failed: %v", err)
}
if data.Model.DisplayName != "FullTest" {
t.Errorf("DisplayName = %q, want FullTest", data.Model.DisplayName)
}
if data.ContextWindow.CurrentUsage.CacheCreationTokens != 2000 {
t.Errorf("CacheCreationTokens = %d, want 2000", data.ContextWindow.CurrentUsage.CacheCreationTokens)
}
if data.ContextWindow.CurrentUsage.CacheReadInputTokens != 3000 {
t.Errorf("CacheReadInputTokens = %d, want 3000", data.ContextWindow.CurrentUsage.CacheReadInputTokens)
}
}
func TestParseStatusInput_ExtraFields(t *testing.T) {
// JSON with extra unknown fields should still parse
jsonStr := `{"model": {"display_name": "Test", "unknown": "field"}, "extra": "data"}`
data, err := parseStatusInput(jsonStr)
if err != nil {
t.Fatalf("parseStatusInput with extra fields failed: %v", err)
}
if data.Model.DisplayName != "Test" {
t.Errorf("DisplayName = %q, want Test", data.Model.DisplayName)
}
}
func TestTokenUsage_ZeroValues(t *testing.T) {
usage := &TokenUsage{
InputTokens: 0,
CacheCreationTokens: 0,
CacheReadInputTokens: 0,
}
result := formatContextInfo(100000, usage)
expected := "0k/100k"
if result != expected {
t.Errorf("formatContextInfo(zero usage) = %q, want %q", result, expected)
}
}
func TestStatuslineWidthOffset_Constant(t *testing.T) {
// Verify the constant is defined and reasonable
if statuslineWidthOffset <= 0 || statuslineWidthOffset > 20 {
t.Errorf("statuslineWidthOffset = %d, expected between 1 and 20", statuslineWidthOffset)
}
}
// Benchmark tests
func BenchmarkFormatContextInfo(b *testing.B) {
usage := &TokenUsage{
InputTokens: 50000,
CacheCreationTokens: 10000,
CacheReadInputTokens: 5000,
}
for i := 0; i < b.N; i++ {
formatContextInfo(200000, usage)
}
}
func BenchmarkStripANSI(b *testing.B) {
input := "\033[32m●\033[0m \033[35mOpus\033[0m \033[1;32m➜\033[0m \033[36mproject\033[0m"
for i := 0; i < b.N; i++ {
stripANSI(input)
}
}
func BenchmarkParseStatusInput(b *testing.B) {
jsonStr := `{"model": {"display_name": "Test"}, "workspace": {"current_dir": "/test"}, "context_window": {"context_window_size": 200000, "current_usage": {"input_tokens": 50000}}}`
for i := 0; i < b.N; i++ {
parseStatusInput(jsonStr)
}
}
func BenchmarkBuildStatusLine(b *testing.B) {
data := &StatusInput{}
data.Model.DisplayName = "Claude Opus"
data.Workspace.CurrentDir = "/home/user/project"
data.ContextWindow.ContextWindowSize = 200000
data.ContextWindow.CurrentUsage = &TokenUsage{
InputTokens: 50000,
}
for i := 0; i < b.N; i++ {
buildStatusLine(data)
}
}
func BenchmarkCalculatePadding(b *testing.B) {
for i := 0; i < b.N; i++ {
calculatePadding("left side content", "right side", 120)
}
}
func BenchmarkFormatOutput(b *testing.B) {
left := red + "left" + reset
right := green + "right" + reset
for i := 0; i < b.N; i++ {
formatOutput(left, right, 50)
}
}
func BenchmarkReadInputFromStdin(b *testing.B) {
jsonStr := `{"model": {"display_name": "Test"}, "workspace": {"current_dir": "/test"}}`
for i := 0; i < b.N; i++ {
reader := bufio.NewReader(strings.NewReader(jsonStr))
readInputFromStdin(reader)
}
}