Compare commits
14 Commits
b6148b9fd8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
32b48c7e5c
|
|||
|
bca7b59248
|
|||
|
27128e2e3b
|
|||
|
a06aa4c549
|
|||
|
29350032ba
|
|||
|
0ff559c7ea
|
|||
|
35af56ea9d
|
|||
|
ed312a3ed0
|
|||
|
99ad5b9d7f
|
|||
|
0638707349
|
|||
|
58aaad4c9c
|
|||
|
52d6bbaf84
|
|||
|
40452e100e
|
|||
|
47ea4eb509
|
23
.opencode/opencode.jsonc
Normal file
23
.opencode/opencode.jsonc
Normal 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
28
AGENTS.md
Normal 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.
|
||||||
16
CLAUDE.md
16
CLAUDE.md
@@ -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
22
LICENSE
Normal 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
70
README.md
Normal 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
|
||||||
20
Taskfile.yml
20
Taskfile.yml
@@ -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
|
||||||
@@ -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
20
go.mod
@@ -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
52
go.sum
@@ -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=
|
||||||
|
|||||||
103
main.go
103
main.go
@@ -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"`
|
||||||
@@ -25,12 +29,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,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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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 {
|
||||||
|
|||||||
658
main_test.go
658
main_test.go
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user