Compare commits

..

14 Commits

Author SHA1 Message Date
32b48c7e5c Add extra spacing between git info and token usage 2026-02-09 16:46:47 +01:00
bca7b59248 Drop padding and terminal width detection
Output left + right with a single space separator instead of trying
to right-align to a detected terminal width. Terminal detection is
unreliable when all fds are piped by Claude Code, and hardcoding a
width defeats the purpose. Also removes the x/term dependency.
2026-02-08 23:37:48 +01:00
27128e2e3b Use STATUSLINE_WIDTH env var for reliable width detection
term.GetSize returns unreliable values when running inside Claude Code
over SSH. Replace the hardcoded statuslineWidthOffset with STATUSLINE_WIDTH
env var (set in Claude Code settings), falling back to terminal detection,
then 80 columns.
2026-02-08 23:27:26 +01:00
a06aa4c549 Fix right-alignment by detecting terminal width from stderr/stdin
When Claude Code captures stdout, term.GetSize on stdout fails and
falls back to 80 columns. Now tries stderr and stdin first (which
remain connected to the TTY), then COLUMNS env var, then 80.
2026-02-08 23:14:13 +01:00
29350032ba Add MIT license and fix Taskfile install path 2026-02-08 23:10:27 +01:00
0ff559c7ea Use GOBIN for one-step install in README 2026-02-08 23:08:36 +01:00
35af56ea9d Add README with installation and configuration instructions 2026-02-08 23:03:58 +01:00
ed312a3ed0 Update deps and boost test coverage from 70.8% to 90%
Update go-git v6, gopsutil v4.26.1, x/term v0.39.0, and transitive deps.
Refactor main() into testable run() function, add injectable processLister
and termWidthFunc for test isolation, and add tests covering detached HEAD,
dirty/clean worktree, empty repo, process listing errors, and terminal
width fallback.
2026-02-08 22:58:08 +01:00
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
10 changed files with 847 additions and 165 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

22
LICENSE Normal file
View File

@@ -0,0 +1,22 @@
MIT License
===========
Copyright (c) 2026 kjanat
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

70
README.md Normal file
View File

@@ -0,0 +1,70 @@
# claude-statusline
A custom status line binary for
[Claude Code](https://docs.anthropic.com/en/docs/claude-code), written in Go.
Reads JSON from stdin (provided by Claude Code's status hook) and outputs a
formatted, ANSI-colored status line.
```
● Opus 4 ➜ statusline git:(master) 6k/200k
```
**What it shows:**
- Gitea service status (green/red dot)
- Model name
- Current directory
- Git branch and dirty indicator
- Context window usage
## Install
```bash
GOBIN=~/.claude go install gitea.kajkowalski.nl/kjanat/claude-statusline@latest
```
Or build from source:
```bash
git clone https://gitea.kajkowalski.nl/kjanat/claude-statusline.git
go build -C claude-statusline -ldflags="-s -w" -o ~/.claude/claude-statusline .
```
## Configure Claude Code
Add to your `~/.claude/settings.json`:
```json
{
"statusLine": {
"type": "command",
"command": "~/.claude/claude-statusline",
"padding": 0
}
}
```
## Development
Requires Go 1.25+.
```bash
# Build
task build
# Run tests
task test
# Run tests with coverage
task test:cover
# Lint
task lint
# Run with test fixture
task run
```
## License
MIT

View File

@@ -1,6 +1,6 @@
# https://taskfile.dev # https://taskfile.dev
version: '3' version: "3"
vars: vars:
BINARY: statusline BINARY: statusline
@@ -115,5 +115,5 @@ tasks:
deps: [build] deps: [build]
cmds: cmds:
- mkdir -p ~/.claude - mkdir -p ~/.claude
- cp bin/{{.BINARY}} ~/.claude/{{.BINARY}} - cp bin/{{.BINARY}} ~/.claude/claude-{{.BINARY}}
- echo "Installed to ~/.claude/{{.BINARY}}" - echo "Installed to ~/.claude/claude-{{.BINARY}}"

20
go.mod
View File

@@ -1,33 +1,33 @@
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-20260206150416-f623c7555599
github.com/shirou/gopsutil/v4 v4.25.11 github.com/shirou/gopsutil/v4 v4.26.1
golang.org/x/sys v0.39.0
) )
require ( require (
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect github.com/cloudflare/circl v1.6.3 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect
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-20260207062542-7cf3dc9049c3 // 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
github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect github.com/tklauser/numcpus v0.11.0 // indirect
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.47.0 // indirect
golang.org/x/net v0.48.0 // indirect golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.41.0 // indirect
) )

52
go.sum
View File

@@ -6,8 +6,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -15,25 +15,23 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 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-20260207062542-7cf3dc9049c3 h1:SYirmki6iTQJnXONzJmHYTD0IRZIPkHSOLt7unzllQA=
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-20260207062542-7cf3dc9049c3/go.mod h1:X1oe0Z2qMsa9hkar3AAPuL9hu4Mi3ztXEjdqRhr6fcc=
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.20260122163445-0622d7459a67 h1:3hutPZF+/FBjR/9MdsLJ7e1mlt9pwHgwxMW7CrbmWII=
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.20260122163445-0622d7459a67/go.mod h1:xKt0pNHST9tYHvbiLxSY27CQWFwgIxBJuDrOE0JvbZw=
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-20260206150416-f623c7555599 h1:M7Z/G+T9nx6cM1DYsPwV1IWk7zqfGFym14DwUwOB/z8=
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-20260206150416-f623c7555599/go.mod h1:EWlxLBkiFCzXNCadvt05fT9PCAE2sUedgDsvUUIo18s=
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 +41,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=
@@ -53,8 +51,8 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shirou/gopsutil/v4 v4.25.11 h1:X53gB7muL9Gnwwo2evPSE+SfOrltMoR6V3xJAXZILTY= github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo=
github.com/shirou/gopsutil/v4 v4.25.11/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU= github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
@@ -65,19 +63,19 @@ github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9R
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
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=

105
main.go
View File

@@ -11,12 +11,16 @@ 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"
) )
const statuslineWidthOffset = 7 // 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"`
}
// Input JSON structure from Claude Code // 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"`
@@ -26,11 +30,7 @@ type StatusInput struct {
} `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,37 +46,35 @@ 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()
var data StatusInput
if err := json.Unmarshal([]byte(input.String()), &data); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing JSON: %v\n", err)
os.Exit(1)
} }
// Calculate context info // parseStatusInput unmarshals JSON string into StatusInput.
func parseStatusInput(jsonStr string) (*StatusInput, error) {
var data StatusInput
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
return nil, err
}
return &data, nil
}
// buildStatusLine constructs the formatted status line.
func buildStatusLine(data *StatusInput) 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,
@@ -84,29 +82,34 @@ func main() {
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)
// Calculate visible lengths (strip ANSI) return left + " " + right
leftVisible := stripANSI(left)
rightVisible := stripANSI(right)
// Get terminal width
termWidth := getTerminalWidth() - statuslineWidthOffset
// Calculate padding
padding := max(termWidth-len(leftVisible)-len(rightVisible), 1)
// Output with padding
fmt.Printf("%s%s%s", left, strings.Repeat(" ", padding), right)
} }
func formatContextInfo(contextSize int, usage *struct { // run reads JSON from r, builds the statusline, and writes it to w.
InputTokens int `json:"input_tokens"` func run(r *bufio.Reader, w *strings.Builder) error {
CacheCreationTokens int `json:"cache_creation_input_tokens"` jsonStr := readInputFromStdin(r)
CacheReadInputTokens int `json:"cache_read_input_tokens"`
}, data, err := parseStatusInput(jsonStr)
) string { if err != nil {
return fmt.Errorf("error parsing JSON: %w", err)
}
w.WriteString(buildStatusLine(data))
return nil
}
func main() {
var out strings.Builder
if err := run(bufio.NewReader(os.Stdin), &out); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
fmt.Print(out.String())
}
func formatContextInfo(contextSize int, usage *TokenUsage) string {
totalK := contextSize / 1000 totalK := contextSize / 1000
if usage == nil { if usage == nil {
@@ -118,9 +121,13 @@ func formatContextInfo(contextSize int, usage *struct {
return fmt.Sprintf("%dk/%dk", currentK, totalK) return fmt.Sprintf("%dk/%dk", currentK, totalK)
} }
// processLister returns the list of running processes.
// Replaced in tests to avoid depending on real process state.
var processLister = process.Processes
func getGiteaStatus() string { func getGiteaStatus() string {
// Check if gitea process is running using gopsutil (cross-platform) // Check if gitea process is running using gopsutil (cross-platform)
procs, err := process.Processes() procs, err := processLister()
if err != nil { if err != nil {
return red + "●" + reset return red + "●" + reset
} }
@@ -178,14 +185,6 @@ func getGitInfo(cwd string) string {
return fmt.Sprintf(" git:(%s)", branch) return fmt.Sprintf(" git:(%s)", branch)
} }
func getTerminalWidth() int {
ws, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ)
if err != nil {
return 80
}
return int(ws.Col)
}
var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`) var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
func stripANSI(s string) string { func stripANSI(s string) string {

View File

@@ -1,9 +1,16 @@
package main package main
import ( import (
"bufio"
"encoding/json" "encoding/json"
"errors"
"os" "os"
"strings"
"testing" "testing"
"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing/object"
"github.com/shirou/gopsutil/v4/process"
) )
func TestFormatContextInfo_NilUsage(t *testing.T) { func TestFormatContextInfo_NilUsage(t *testing.T) {
@@ -15,11 +22,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 +35,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,
@@ -48,37 +47,135 @@ func TestFormatContextInfo_SmallValues(t *testing.T) {
} }
} }
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 TestFormatContextInfo_MaxTokens(t *testing.T) {
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) {
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) {
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 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 TestStripANSI(t *testing.T) { func TestStripANSI(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
input string input string
expected string expected string
}{ }{
{ {"no ansi", "hello world", "hello world"},
name: "no ansi", {"single color", "\033[32mgreen\033[0m", "green"},
input: "hello world", {"multiple colors", "\033[31mred\033[0m \033[32mgreen\033[0m", "red green"},
expected: "hello world", {"bold", "\033[1mbold\033[0m", "bold"},
}, {"complex", "\033[32m●\033[0m \033[35mOpus\033[0m \033[1;32m➜\033[0m", "● Opus ➜"},
{ {"empty", "", ""},
name: "single color", {"only ansi", "\033[31m\033[0m", ""},
input: "\033[32mgreen\033[0m", {"nested", "\033[1m\033[31mbold red\033[0m\033[0m", "bold red"},
expected: "green", {"256 color", "\033[38;5;196mred\033[0m", "red"},
}, {"true color", "\033[38;2;255;0;0mred\033[0m", "red"},
{ {"multiline", "\033[31mline1\033[0m\n\033[32mline2\033[0m", "line1\nline2"},
name: "multiple colors",
input: "\033[31mred\033[0m \033[32mgreen\033[0m",
expected: "red green",
},
{
name: "bold",
input: "\033[1mbold\033[0m",
expected: "bold",
},
{
name: "complex",
input: "\033[32m●\033[0m \033[35mOpus\033[0m \033[1;32m➜\033[0m",
expected: "● Opus ➜",
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -114,19 +211,15 @@ func TestStatusInputParsing(t *testing.T) {
if data.Model.DisplayName != "Opus 4.5" { if data.Model.DisplayName != "Opus 4.5" {
t.Errorf("Model.DisplayName = %q, want %q", data.Model.DisplayName, "Opus 4.5") t.Errorf("Model.DisplayName = %q, want %q", data.Model.DisplayName, "Opus 4.5")
} }
if data.Workspace.CurrentDir != "/root/projects/statusline" { if data.Workspace.CurrentDir != "/root/projects/statusline" {
t.Errorf("Workspace.CurrentDir = %q, want %q", data.Workspace.CurrentDir, "/root/projects/statusline") t.Errorf("Workspace.CurrentDir = %q, want %q", data.Workspace.CurrentDir, "/root/projects/statusline")
} }
if data.ContextWindow.ContextWindowSize != 200000 { if data.ContextWindow.ContextWindowSize != 200000 {
t.Errorf("ContextWindow.ContextWindowSize = %d, want %d", data.ContextWindow.ContextWindowSize, 200000) t.Errorf("ContextWindow.ContextWindowSize = %d, want %d", data.ContextWindow.ContextWindowSize, 200000)
} }
if data.ContextWindow.CurrentUsage == nil { if data.ContextWindow.CurrentUsage == nil {
t.Fatal("ContextWindow.CurrentUsage is nil") t.Fatal("ContextWindow.CurrentUsage is nil")
} }
if data.ContextWindow.CurrentUsage.InputTokens != 5000 { if data.ContextWindow.CurrentUsage.InputTokens != 5000 {
t.Errorf("CurrentUsage.InputTokens = %d, want %d", data.ContextWindow.CurrentUsage.InputTokens, 5000) t.Errorf("CurrentUsage.InputTokens = %d, want %d", data.ContextWindow.CurrentUsage.InputTokens, 5000)
} }
@@ -136,9 +229,7 @@ func TestStatusInputParsing_NilUsage(t *testing.T) {
jsonData := `{ jsonData := `{
"model": {"display_name": "Sonnet"}, "model": {"display_name": "Sonnet"},
"workspace": {"current_dir": "/tmp"}, "workspace": {"current_dir": "/tmp"},
"context_window": { "context_window": {"context_window_size": 100000}
"context_window_size": 100000
}
}` }`
var data StatusInput var data StatusInput
@@ -146,60 +237,503 @@ func TestStatusInputParsing_NilUsage(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Failed to parse JSON: %v", err) t.Fatalf("Failed to parse JSON: %v", err)
} }
if data.ContextWindow.CurrentUsage != nil { if data.ContextWindow.CurrentUsage != nil {
t.Errorf("ContextWindow.CurrentUsage should be nil, got %+v", data.ContextWindow.CurrentUsage) t.Errorf("ContextWindow.CurrentUsage should be nil, got %+v", data.ContextWindow.CurrentUsage)
} }
} }
func TestStatusInputParsing_EmptyJSON(t *testing.T) {
var data StatusInput
err := json.Unmarshal([]byte(`{}`), &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) {
var data StatusInput
err := json.Unmarshal([]byte(`{"model": {"display_name": "Test"}}`), &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) {
var data StatusInput
if err := json.Unmarshal([]byte(`{invalid json}`), &data); err == nil {
t.Error("Expected error for invalid JSON, got nil")
}
}
func TestANSIConstants(t *testing.T) {
tests := []struct {
name string
constant string
}{
{"reset", reset},
{"red", red},
{"green", green},
{"yellow", yellow},
{"magenta", magenta},
{"cyan", cyan},
{"boldGreen", boldGreen},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if !strings.HasPrefix(tt.constant, "\033[") {
t.Errorf("%s constant doesn't start with ANSI escape", tt.name)
}
})
}
}
func TestReadInputFromStdin(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"multiline", "line1\nline2\nline3", "line1\nline2\nline3"},
{"empty", "", ""},
{"single line", "single line", "single line"},
{"json", `{"key": "value"}`, `{"key": "value"}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reader := bufio.NewReader(strings.NewReader(tt.input))
result := readInputFromStdin(reader)
if result != tt.expected {
t.Errorf("readInputFromStdin = %q, want %q", result, tt.expected)
}
})
}
}
func TestParseStatusInput_Valid(t *testing.T) {
data, err := parseStatusInput(`{"model": {"display_name": "Test"}, "workspace": {"current_dir": "/test"}}`)
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) {
if _, err := parseStatusInput("invalid json"); err == nil {
t.Error("parseStatusInput should fail on invalid JSON")
}
}
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)
}
}
func TestParseStatusInput_ExtraFields(t *testing.T) {
data, err := parseStatusInput(`{"model": {"display_name": "Test", "unknown": "field"}, "extra": "data"}`)
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 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,
}
result := buildStatusLine(data)
if !strings.Contains(result, "TestModel") {
t.Errorf("statusline missing model: %q", result)
}
if !strings.Contains(result, "project") {
t.Errorf("statusline missing directory: %q", result)
}
if !strings.Contains(result, "6k/100k") {
t.Errorf("statusline missing context info: %q", result)
}
if !strings.Contains(result, "●") {
t.Errorf("statusline missing gitea status: %q", result)
}
}
func TestBuildStatusLine_NilUsage(t *testing.T) {
data := &StatusInput{}
data.Model.DisplayName = "Model"
data.Workspace.CurrentDir = "/test"
data.ContextWindow.ContextWindowSize = 100000
result := buildStatusLine(data)
if !strings.Contains(result, "0/100k") {
t.Errorf("nil usage should show 0: %q", result)
}
}
func TestBuildStatusLine_EmptyDir(t *testing.T) {
data := &StatusInput{}
data.Model.DisplayName = "Model"
data.Workspace.CurrentDir = ""
data.ContextWindow.ContextWindowSize = 100000
result := buildStatusLine(data)
if result == "" {
t.Error("buildStatusLine with empty dir should produce 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}
result := buildStatusLine(data)
if !strings.Contains(result, "Claude 3.5 Sonnet with Extended Context") {
t.Errorf("long model name not in output: %q", result)
}
if !strings.Contains(result, "50k/200k") {
t.Errorf("context info not in output: %q", result)
}
}
func TestBuildStatusLine_RootDir(t *testing.T) {
data := &StatusInput{}
data.Model.DisplayName = "Model"
data.Workspace.CurrentDir = "/"
data.ContextWindow.ContextWindowSize = 100000
result := buildStatusLine(data)
if !strings.Contains(result, "Model") {
t.Errorf("root dir handling failed: %q", result)
}
}
func TestGetGitInfo_CurrentRepo(t *testing.T) { func TestGetGitInfo_CurrentRepo(t *testing.T) {
cwd, err := os.Getwd() cwd, err := os.Getwd()
if err != nil { if err != nil {
t.Skipf("Could not get working directory: %v", err) t.Skipf("Could not get working directory: %v", err)
} }
result := getGitInfo(cwd) result := getGitInfo(cwd)
// Should return something like " git:(master)" or " git:(master) ✗"
if result == "" { if result == "" {
t.Skip("Not in a git repository") t.Skip("Not in a git repository")
} }
if !strings.Contains(result, "git:(") {
if !contains(result, "git:(") {
t.Errorf("getGitInfo(%q) = %q, expected to contain 'git:('", cwd, result) t.Errorf("getGitInfo(%q) = %q, expected to contain 'git:('", cwd, result)
} }
} }
func TestGetGitInfo_NonRepo(t *testing.T) { func TestGetGitInfo_NonRepo(t *testing.T) {
result := getGitInfo("/tmp") result := getGitInfo("/tmp")
if result != "" && !strings.Contains(result, "git:(") {
// /tmp is unlikely to be a git repo
if result != "" && !contains(result, "git:(") {
t.Errorf("getGitInfo(/tmp) = %q, expected empty or valid git info", result) t.Errorf("getGitInfo(/tmp) = %q, expected empty or valid git info", result)
} }
} }
func TestGetGitInfo_InvalidPath(t *testing.T) {
if result := getGitInfo("/nonexistent/path"); result != "" {
t.Errorf("getGitInfo(invalid) = %q, expected empty string", result)
}
}
func TestGetGitInfo_RootDir(t *testing.T) {
result := getGitInfo("/")
if result != "" && !strings.Contains(result, "git:(") {
t.Errorf("getGitInfo(/) = %q, expected empty or valid git info", result)
}
}
func TestGetGitInfo_DetachedHEAD(t *testing.T) {
dir := t.TempDir()
repo, err := git.PlainInit(dir, false)
if err != nil {
t.Fatalf("git init: %v", err)
}
wt, err := repo.Worktree()
if err != nil {
t.Fatalf("worktree: %v", err)
}
if err := os.WriteFile(dir+"/file.txt", []byte("hello"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
wt.Add("file.txt")
hash, err := wt.Commit("initial", &git.CommitOptions{
Author: &object.Signature{Name: "test", Email: "test@test.com"},
})
if err != nil {
t.Fatalf("commit: %v", err)
}
if err := wt.Checkout(&git.CheckoutOptions{Hash: hash}); err != nil {
t.Fatalf("checkout: %v", err)
}
result := getGitInfo(dir)
expected := " git:(" + hash.String()[:7] + ")"
if result != expected {
t.Errorf("getGitInfo(detached) = %q, want %q", result, expected)
}
}
func TestGetGitInfo_DirtyWorktree(t *testing.T) {
dir := t.TempDir()
repo, err := git.PlainInit(dir, false)
if err != nil {
t.Fatalf("git init: %v", err)
}
wt, err := repo.Worktree()
if err != nil {
t.Fatalf("worktree: %v", err)
}
if err := os.WriteFile(dir+"/file.txt", []byte("hello"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
wt.Add("file.txt")
if _, err = wt.Commit("initial", &git.CommitOptions{
Author: &object.Signature{Name: "test", Email: "test@test.com"},
}); err != nil {
t.Fatalf("commit: %v", err)
}
if err := os.WriteFile(dir+"/file.txt", []byte("modified"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
result := getGitInfo(dir)
if !strings.Contains(result, "✗") {
t.Errorf("getGitInfo(dirty) = %q, expected dirty marker ✗", result)
}
if !strings.Contains(result, "git:(master)") {
t.Errorf("getGitInfo(dirty) = %q, expected branch name", result)
}
}
func TestGetGitInfo_CleanWorktree(t *testing.T) {
dir := t.TempDir()
repo, err := git.PlainInit(dir, false)
if err != nil {
t.Fatalf("git init: %v", err)
}
wt, err := repo.Worktree()
if err != nil {
t.Fatalf("worktree: %v", err)
}
if err := os.WriteFile(dir+"/file.txt", []byte("hello"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
wt.Add("file.txt")
if _, err = wt.Commit("initial", &git.CommitOptions{
Author: &object.Signature{Name: "test", Email: "test@test.com"},
}); err != nil {
t.Fatalf("commit: %v", err)
}
result := getGitInfo(dir)
if result != " git:(master)" {
t.Errorf("getGitInfo(clean) = %q, want %q", result, " git:(master)")
}
}
func TestGetGitInfo_EmptyRepo(t *testing.T) {
dir := t.TempDir()
if _, err := git.PlainInit(dir, false); err != nil {
t.Fatalf("git init: %v", err)
}
if result := getGitInfo(dir); result != "" {
t.Errorf("getGitInfo(empty repo) = %q, want empty", result)
}
}
func TestGetGitInfo_UntrackedFile(t *testing.T) {
dir := t.TempDir()
repo, err := git.PlainInit(dir, false)
if err != nil {
t.Fatalf("git init: %v", err)
}
wt, err := repo.Worktree()
if err != nil {
t.Fatalf("worktree: %v", err)
}
if err := os.WriteFile(dir+"/tracked.txt", []byte("tracked"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
wt.Add("tracked.txt")
if _, err = wt.Commit("initial", &git.CommitOptions{
Author: &object.Signature{Name: "test", Email: "test@test.com"},
}); err != nil {
t.Fatalf("commit: %v", err)
}
if err := os.WriteFile(dir+"/untracked.txt", []byte("new"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
result := getGitInfo(dir)
if !strings.Contains(result, "✗") {
t.Errorf("getGitInfo(untracked) = %q, expected dirty marker", result)
}
}
func TestGetGiteaStatus(t *testing.T) { func TestGetGiteaStatus(t *testing.T) {
result := getGiteaStatus() result := getGiteaStatus()
// Should return either green or red dot
greenDot := green + "●" + reset greenDot := green + "●" + reset
redDot := red + "●" + reset redDot := red + "●" + reset
if result != greenDot && result != redDot { if result != greenDot && result != redDot {
t.Errorf("getGiteaStatus() = %q, expected green or red dot", result) t.Errorf("getGiteaStatus() = %q, expected green or red dot", result)
} }
} }
func contains(s, substr string) bool { func TestGetGiteaStatus_ReturnsValidColor(t *testing.T) {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr)) result := getGiteaStatus()
if !strings.Contains(result, "●") {
t.Errorf("getGiteaStatus() = %q, expected to contain dot", result)
}
if stripped := stripANSI(result); stripped != "●" {
t.Errorf("stripped getGiteaStatus() = %q, expected just dot", stripped)
}
} }
func containsHelper(s, substr string) bool { func TestGetGiteaStatus_ProcessListError(t *testing.T) {
for i := 0; i <= len(s)-len(substr); i++ { orig := processLister
if s[i:i+len(substr)] == substr { t.Cleanup(func() { processLister = orig })
return true
processLister = func() ([]*process.Process, error) {
return nil, errors.New("mock error")
}
result := getGiteaStatus()
if expected := red + "●" + reset; result != expected {
t.Errorf("getGiteaStatus(error) = %q, want red dot", result)
} }
} }
return false
func TestGetGiteaStatus_NoGiteaProcess(t *testing.T) {
orig := processLister
t.Cleanup(func() { processLister = orig })
processLister = func() ([]*process.Process, error) {
return []*process.Process{}, nil
}
result := getGiteaStatus()
if expected := red + "●" + reset; result != expected {
t.Errorf("getGiteaStatus(no gitea) = %q, want red dot", result)
}
}
func TestRun_ValidInput(t *testing.T) {
jsonStr := `{"model":{"display_name":"Test"},"workspace":{"current_dir":"/tmp"},"context_window":{"context_window_size":100000}}`
r := bufio.NewReader(strings.NewReader(jsonStr))
var out strings.Builder
if err := run(r, &out); err != nil {
t.Fatalf("run() returned error: %v", err)
}
result := out.String()
if result == "" {
t.Error("run() produced empty output")
}
if !strings.Contains(result, "Test") {
t.Errorf("run() output missing model name: %q", result)
}
}
func TestRun_InvalidJSON(t *testing.T) {
r := bufio.NewReader(strings.NewReader("not json"))
var out strings.Builder
if err := run(r, &out); err == nil {
t.Error("run() should return error on invalid JSON")
}
}
func TestRun_EmptyInput(t *testing.T) {
r := bufio.NewReader(strings.NewReader(""))
var out strings.Builder
if err := run(r, &out); err == nil {
t.Error("run() should return error on empty input")
}
}
// Benchmark tests
func BenchmarkFormatContextInfo(b *testing.B) {
usage := &TokenUsage{
InputTokens: 50000,
CacheCreationTokens: 10000,
CacheReadInputTokens: 5000,
}
for b.Loop() {
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 b.Loop() {
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 b.Loop() {
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 b.Loop() {
buildStatusLine(data)
}
}
func BenchmarkReadInputFromStdin(b *testing.B) {
jsonStr := `{"model": {"display_name": "Test"}, "workspace": {"current_dir": "/test"}}`
for b.Loop() {
reader := bufio.NewReader(strings.NewReader(jsonStr))
readInputFromStdin(reader)
}
} }