Compare commits
4 Commits
b6148b9fd8
...
58aaad4c9c
| Author | SHA1 | Date | |
|---|---|---|---|
|
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"
|
||||
}
|
||||
21
AGENTS.md
Normal file
21
AGENTS.md
Normal file
@ -0,0 +1,21 @@
|
||||
# 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.
|
||||
8
go.mod
8
go.mod
@ -1,6 +1,6 @@
|
||||
module gitea.kajkowalski.nl/kjanat/claude-statusline
|
||||
|
||||
go 1.24.11
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
github.com/go-git/go-git/v6 v6.0.0-20251216093047-22c365fcee9c
|
||||
@ -16,12 +16,12 @@ require (
|
||||
github.com/ebitengine/purego v0.9.1 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // 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-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20251217170237-e9738f50a3cd // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/kevinburke/ssh_config v1.4.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/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
|
||||
7
go.sum
7
go.sum
@ -25,12 +25,16 @@ 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/go-billy/v6 v6.0.0-20251209065551-8afc3eb64e4d h1:nfZPVEha54DwXl8twSNxi9J8edIiqfpSvnq/mGPfgc4=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20251209065551-8afc3eb64e4d/go.mod h1:d3XQcsHu1idnquxt48kAv+h+1MUiYKLH/e7LAzjP+pI=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20251217170237-e9738f50a3cd h1:Gd/f9cGi/3h1JOPaa6er+CkKUGyGX2DBJdFbDKVO+R0=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20251217170237-e9738f50a3cd/go.mod h1:d3XQcsHu1idnquxt48kAv+h+1MUiYKLH/e7LAzjP+pI=
|
||||
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20251205091929-ed656e84d025 h1:24Uc4y1yxMe8V30NhshaDdCaTOw97BWVhVGH/m1+udM=
|
||||
github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20251205091929-ed656e84d025/go.mod h1:T6lRF5ejdxaYZLVaCTuTG1+ZSvwI/c2oeiTgBWORJ8Q=
|
||||
github.com/go-git/go-git/v6 v6.0.0-20251216093047-22c365fcee9c h1:pR4UmnVFMjNw956fgu+JlSAvmx37qW4ttVF0cu7DL/Q=
|
||||
github.com/go-git/go-git/v6 v6.0.0-20251216093047-22c365fcee9c/go.mod h1:EPzgAjDnw+TaCt1w/JUmj+SXwWHUae3c078ixiZQ10Y=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.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/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
@ -45,6 +49,8 @@ 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/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
|
||||
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/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@ -71,6 +77,7 @@ golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
|
||||
86
main.go
86
main.go
@ -16,7 +16,14 @@ import (
|
||||
|
||||
const statuslineWidthOffset = 7
|
||||
|
||||
// Input JSON structure from Claude Code
|
||||
// TokenUsage tracks context window token consumption.
|
||||
type TokenUsage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
CacheCreationTokens int `json:"cache_creation_input_tokens"`
|
||||
CacheReadInputTokens int `json:"cache_read_input_tokens"`
|
||||
}
|
||||
|
||||
// StatusInput represents the JSON input from Claude Code.
|
||||
type StatusInput struct {
|
||||
Model struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
@ -26,11 +33,7 @@ type StatusInput struct {
|
||||
} `json:"workspace"`
|
||||
ContextWindow struct {
|
||||
ContextWindowSize int `json:"context_window_size"`
|
||||
CurrentUsage *struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
CacheCreationTokens int `json:"cache_creation_input_tokens"`
|
||||
CacheReadInputTokens int `json:"cache_read_input_tokens"`
|
||||
} `json:"current_usage"`
|
||||
CurrentUsage *TokenUsage `json:"current_usage"`
|
||||
} `json:"context_window"`
|
||||
}
|
||||
|
||||
@ -46,46 +49,66 @@ const (
|
||||
boldGreen = "\033[1;32m"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Read JSON from stdin
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
// readInputFromStdin reads JSON input from stdin.
|
||||
func readInputFromStdin(r *bufio.Reader) string {
|
||||
var input strings.Builder
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
line, err := r.ReadString('\n')
|
||||
input.WriteString(line)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return input.String()
|
||||
}
|
||||
|
||||
// parseStatusInput unmarshals JSON string into StatusInput.
|
||||
func parseStatusInput(jsonStr string) (*StatusInput, error) {
|
||||
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)
|
||||
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// Calculate context info
|
||||
// buildStatusLine constructs the left and right parts of the status line.
|
||||
func buildStatusLine(data *StatusInput) (left, right string) {
|
||||
contextInfo := formatContextInfo(data.ContextWindow.ContextWindowSize, data.ContextWindow.CurrentUsage)
|
||||
|
||||
// Get directory name
|
||||
dirName := filepath.Base(data.Workspace.CurrentDir)
|
||||
|
||||
// Check gitea status
|
||||
giteaStatus := getGiteaStatus()
|
||||
|
||||
// Get git info
|
||||
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,
|
||||
magenta, data.Model.DisplayName, reset,
|
||||
boldGreen, reset,
|
||||
cyan, dirName, reset,
|
||||
gitInfo)
|
||||
|
||||
// Build right part
|
||||
right := fmt.Sprintf("%s%s%s", yellow, contextInfo, reset)
|
||||
right = fmt.Sprintf("%s%s%s", yellow, contextInfo, reset)
|
||||
return left, right
|
||||
}
|
||||
|
||||
// calculatePadding returns the number of spaces needed for padding.
|
||||
func calculatePadding(leftVisible, rightVisible string, termWidth int) int {
|
||||
return max(termWidth-len(leftVisible)-len(rightVisible), 1)
|
||||
}
|
||||
|
||||
// formatOutput combines left, right, and padding into final output.
|
||||
func formatOutput(left, right string, padding int) string {
|
||||
return fmt.Sprintf("%s%s%s", left, strings.Repeat(" ", padding), right)
|
||||
}
|
||||
|
||||
func main() {
|
||||
jsonStr := readInputFromStdin(bufio.NewReader(os.Stdin))
|
||||
|
||||
data, err := parseStatusInput(jsonStr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing JSON: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
left, right := buildStatusLine(data)
|
||||
|
||||
// Calculate visible lengths (strip ANSI)
|
||||
leftVisible := stripANSI(left)
|
||||
@ -94,19 +117,14 @@ func main() {
|
||||
// Get terminal width
|
||||
termWidth := getTerminalWidth() - statuslineWidthOffset
|
||||
|
||||
// Calculate padding
|
||||
padding := max(termWidth-len(leftVisible)-len(rightVisible), 1)
|
||||
// Calculate and apply padding
|
||||
padding := calculatePadding(leftVisible, rightVisible, termWidth)
|
||||
output := formatOutput(left, right, padding)
|
||||
|
||||
// Output with padding
|
||||
fmt.Printf("%s%s%s", left, strings.Repeat(" ", padding), right)
|
||||
fmt.Print(output)
|
||||
}
|
||||
|
||||
func formatContextInfo(contextSize int, usage *struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
CacheCreationTokens int `json:"cache_creation_input_tokens"`
|
||||
CacheReadInputTokens int `json:"cache_read_input_tokens"`
|
||||
},
|
||||
) string {
|
||||
func formatContextInfo(contextSize int, usage *TokenUsage) string {
|
||||
totalK := contextSize / 1000
|
||||
|
||||
if usage == nil {
|
||||
|
||||
636
main_test.go
636
main_test.go
@ -1,8 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@ -15,11 +17,7 @@ func TestFormatContextInfo_NilUsage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFormatContextInfo_WithUsage(t *testing.T) {
|
||||
usage := &struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
CacheCreationTokens int `json:"cache_creation_input_tokens"`
|
||||
CacheReadInputTokens int `json:"cache_read_input_tokens"`
|
||||
}{
|
||||
usage := &TokenUsage{
|
||||
InputTokens: 8500,
|
||||
CacheCreationTokens: 5000,
|
||||
CacheReadInputTokens: 2000,
|
||||
@ -32,11 +30,7 @@ func TestFormatContextInfo_WithUsage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFormatContextInfo_SmallValues(t *testing.T) {
|
||||
usage := &struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
CacheCreationTokens int `json:"cache_creation_input_tokens"`
|
||||
CacheReadInputTokens int `json:"cache_read_input_tokens"`
|
||||
}{
|
||||
usage := &TokenUsage{
|
||||
InputTokens: 500,
|
||||
CacheCreationTokens: 0,
|
||||
CacheReadInputTokens: 0,
|
||||
@ -203,3 +197,625 @@ func containsHelper(s, substr string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestGetTerminalWidth(t *testing.T) {
|
||||
// getTerminalWidth should return a positive integer
|
||||
// In test environment (no TTY), it should fall back to 80
|
||||
width := getTerminalWidth()
|
||||
if width <= 0 {
|
||||
t.Errorf("getTerminalWidth() = %d, expected positive value", width)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTerminalWidth_DefaultFallback(t *testing.T) {
|
||||
// When not connected to a terminal, should return 80
|
||||
width := getTerminalWidth()
|
||||
// In CI/test environments, this typically returns 80
|
||||
if width != 80 && width < 40 {
|
||||
t.Errorf("getTerminalWidth() = %d, expected 80 or reasonable terminal width", width)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGitInfo_InvalidPath(t *testing.T) {
|
||||
result := getGitInfo("/nonexistent/path/that/does/not/exist")
|
||||
if result != "" {
|
||||
t.Errorf("getGitInfo(invalid) = %q, expected empty string", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGitInfo_RootDir(t *testing.T) {
|
||||
// Root directory is unlikely to be a git repo
|
||||
result := getGitInfo("/")
|
||||
if result != "" && !contains(result, "git:(") {
|
||||
t.Errorf("getGitInfo(/) = %q, expected empty or valid git info", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatContextInfo_ZeroContextSize(t *testing.T) {
|
||||
result := formatContextInfo(0, nil)
|
||||
expected := "0/0k"
|
||||
if result != expected {
|
||||
t.Errorf("formatContextInfo(0, nil) = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatContextInfo_LargeValues(t *testing.T) {
|
||||
usage := &TokenUsage{
|
||||
InputTokens: 150000,
|
||||
CacheCreationTokens: 25000,
|
||||
CacheReadInputTokens: 10000,
|
||||
}
|
||||
result := formatContextInfo(200000, usage)
|
||||
expected := "185k/200k"
|
||||
if result != expected {
|
||||
t.Errorf("formatContextInfo(200000, large usage) = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatContextInfo_ExactThousand(t *testing.T) {
|
||||
usage := &TokenUsage{
|
||||
InputTokens: 1000,
|
||||
CacheCreationTokens: 0,
|
||||
CacheReadInputTokens: 0,
|
||||
}
|
||||
result := formatContextInfo(100000, usage)
|
||||
expected := "1k/100k"
|
||||
if result != expected {
|
||||
t.Errorf("formatContextInfo(100000, 1000 tokens) = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripANSI_Empty(t *testing.T) {
|
||||
result := stripANSI("")
|
||||
if result != "" {
|
||||
t.Errorf("stripANSI(\"\") = %q, want empty", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripANSI_OnlyANSI(t *testing.T) {
|
||||
result := stripANSI("\033[31m\033[0m")
|
||||
if result != "" {
|
||||
t.Errorf("stripANSI(only codes) = %q, want empty", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripANSI_NestedCodes(t *testing.T) {
|
||||
input := "\033[1m\033[31mbold red\033[0m\033[0m"
|
||||
result := stripANSI(input)
|
||||
expected := "bold red"
|
||||
if result != expected {
|
||||
t.Errorf("stripANSI(%q) = %q, want %q", input, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusInputParsing_EmptyJSON(t *testing.T) {
|
||||
jsonData := `{}`
|
||||
var data StatusInput
|
||||
err := json.Unmarshal([]byte(jsonData), &data)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse empty JSON: %v", err)
|
||||
}
|
||||
if data.Model.DisplayName != "" {
|
||||
t.Errorf("Expected empty DisplayName, got %q", data.Model.DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusInputParsing_PartialJSON(t *testing.T) {
|
||||
jsonData := `{"model": {"display_name": "Test"}}`
|
||||
var data StatusInput
|
||||
err := json.Unmarshal([]byte(jsonData), &data)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse partial JSON: %v", err)
|
||||
}
|
||||
if data.Model.DisplayName != "Test" {
|
||||
t.Errorf("DisplayName = %q, want %q", data.Model.DisplayName, "Test")
|
||||
}
|
||||
if data.Workspace.CurrentDir != "" {
|
||||
t.Errorf("Expected empty CurrentDir, got %q", data.Workspace.CurrentDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusInputParsing_InvalidJSON(t *testing.T) {
|
||||
jsonData := `{invalid json}`
|
||||
var data StatusInput
|
||||
err := json.Unmarshal([]byte(jsonData), &data)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestANSIConstants(t *testing.T) {
|
||||
// Verify ANSI constants are properly defined
|
||||
tests := []struct {
|
||||
name string
|
||||
constant string
|
||||
prefix string
|
||||
}{
|
||||
{"reset", reset, "\033["},
|
||||
{"red", red, "\033["},
|
||||
{"green", green, "\033["},
|
||||
{"yellow", yellow, "\033["},
|
||||
{"magenta", magenta, "\033["},
|
||||
{"cyan", cyan, "\033["},
|
||||
{"boldGreen", boldGreen, "\033["},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if !contains(tt.constant, tt.prefix) {
|
||||
t.Errorf("%s constant doesn't start with ANSI escape", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGiteaStatus_ReturnsValidColor(t *testing.T) {
|
||||
result := getGiteaStatus()
|
||||
|
||||
// Must contain the dot character
|
||||
if !contains(result, "●") {
|
||||
t.Errorf("getGiteaStatus() = %q, expected to contain dot", result)
|
||||
}
|
||||
|
||||
// Must contain ANSI codes
|
||||
stripped := stripANSI(result)
|
||||
if stripped != "●" {
|
||||
t.Errorf("stripped getGiteaStatus() = %q, expected just dot", stripped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadInputFromStdin(t *testing.T) {
|
||||
input := "line1\nline2\nline3"
|
||||
reader := bufio.NewReader(strings.NewReader(input))
|
||||
result := readInputFromStdin(reader)
|
||||
expected := "line1\nline2\nline3"
|
||||
if result != expected {
|
||||
t.Errorf("readInputFromStdin = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadInputFromStdin_Empty(t *testing.T) {
|
||||
reader := bufio.NewReader(strings.NewReader(""))
|
||||
result := readInputFromStdin(reader)
|
||||
if result != "" {
|
||||
t.Errorf("readInputFromStdin (empty) = %q, want empty", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStatusInput_Valid(t *testing.T) {
|
||||
jsonStr := `{"model": {"display_name": "Test"}, "workspace": {"current_dir": "/test"}}`
|
||||
data, err := parseStatusInput(jsonStr)
|
||||
if err != nil {
|
||||
t.Fatalf("parseStatusInput failed: %v", err)
|
||||
}
|
||||
if data.Model.DisplayName != "Test" {
|
||||
t.Errorf("DisplayName = %q, want Test", data.Model.DisplayName)
|
||||
}
|
||||
if data.Workspace.CurrentDir != "/test" {
|
||||
t.Errorf("CurrentDir = %q, want /test", data.Workspace.CurrentDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStatusInput_Invalid(t *testing.T) {
|
||||
_, err := parseStatusInput("invalid json")
|
||||
if err == nil {
|
||||
t.Error("parseStatusInput should fail on invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStatusLine_ContainsComponents(t *testing.T) {
|
||||
data := &StatusInput{}
|
||||
data.Model.DisplayName = "TestModel"
|
||||
data.Workspace.CurrentDir = "/home/user/project"
|
||||
data.ContextWindow.ContextWindowSize = 100000
|
||||
data.ContextWindow.CurrentUsage = &TokenUsage{
|
||||
InputTokens: 5000,
|
||||
CacheCreationTokens: 1000,
|
||||
CacheReadInputTokens: 500,
|
||||
}
|
||||
|
||||
left, right := buildStatusLine(data)
|
||||
|
||||
// Check left contains model name and directory
|
||||
if !contains(left, "TestModel") {
|
||||
t.Errorf("left statusline missing model: %q", left)
|
||||
}
|
||||
if !contains(left, "project") {
|
||||
t.Errorf("left statusline missing directory: %q", left)
|
||||
}
|
||||
|
||||
// Check right contains context info
|
||||
if !contains(right, "6k/100k") {
|
||||
t.Errorf("right statusline missing context info: %q", right)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStatusLine_HasGiteaStatus(t *testing.T) {
|
||||
data := &StatusInput{}
|
||||
data.Model.DisplayName = "Model"
|
||||
data.Workspace.CurrentDir = "/tmp"
|
||||
data.ContextWindow.ContextWindowSize = 100000
|
||||
|
||||
left, _ := buildStatusLine(data)
|
||||
|
||||
// Check for gitea status (dot)
|
||||
if !contains(left, "●") {
|
||||
t.Errorf("left statusline missing gitea status: %q", left)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculatePadding_ZeroWidth(t *testing.T) {
|
||||
result := calculatePadding("left", "right", 0)
|
||||
expected := 1
|
||||
if result != expected {
|
||||
t.Errorf("calculatePadding(\"left\", \"right\", 0) = %d, want %d", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculatePadding_NegativeResult(t *testing.T) {
|
||||
result := calculatePadding("left", "right", 5)
|
||||
expected := 1
|
||||
if result != expected {
|
||||
t.Errorf("calculatePadding with overflow = %d, want minimum of %d", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculatePadding_Normal(t *testing.T) {
|
||||
result := calculatePadding("left", "right", 50)
|
||||
expected := 50 - len("left") - len("right")
|
||||
if result != expected {
|
||||
t.Errorf("calculatePadding(\"left\", \"right\", 50) = %d, want %d", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOutput_Composition(t *testing.T) {
|
||||
result := formatOutput("LEFT", "RIGHT", 5)
|
||||
expected := "LEFT RIGHT"
|
||||
if result != expected {
|
||||
t.Errorf("formatOutput = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOutput_Empty(t *testing.T) {
|
||||
result := formatOutput("", "", 0)
|
||||
expected := ""
|
||||
if result != expected {
|
||||
t.Errorf("formatOutput (empty) = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOutput_WithANSI(t *testing.T) {
|
||||
left := red + "text" + reset
|
||||
right := green + "info" + reset
|
||||
result := formatOutput(left, right, 3)
|
||||
|
||||
stripped := stripANSI(result)
|
||||
if !contains(stripped, "text") || !contains(stripped, "info") {
|
||||
t.Errorf("formatOutput with ANSI = %q, expected both parts visible", stripped)
|
||||
}
|
||||
}
|
||||
|
||||
// Additional edge case tests
|
||||
|
||||
func TestFormatContextInfo_MaxTokens(t *testing.T) {
|
||||
// Test when usage equals context size
|
||||
usage := &TokenUsage{
|
||||
InputTokens: 200000,
|
||||
CacheCreationTokens: 0,
|
||||
CacheReadInputTokens: 0,
|
||||
}
|
||||
result := formatContextInfo(200000, usage)
|
||||
expected := "200k/200k"
|
||||
if result != expected {
|
||||
t.Errorf("formatContextInfo(max) = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatContextInfo_OverflowTokens(t *testing.T) {
|
||||
// Test when usage exceeds context size
|
||||
usage := &TokenUsage{
|
||||
InputTokens: 250000,
|
||||
CacheCreationTokens: 0,
|
||||
CacheReadInputTokens: 0,
|
||||
}
|
||||
result := formatContextInfo(200000, usage)
|
||||
expected := "250k/200k"
|
||||
if result != expected {
|
||||
t.Errorf("formatContextInfo(overflow) = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatContextInfo_AllCacheTypes(t *testing.T) {
|
||||
// Test all cache token types contributing
|
||||
usage := &TokenUsage{
|
||||
InputTokens: 10000,
|
||||
CacheCreationTokens: 20000,
|
||||
CacheReadInputTokens: 30000,
|
||||
}
|
||||
result := formatContextInfo(100000, usage)
|
||||
expected := "60k/100k"
|
||||
if result != expected {
|
||||
t.Errorf("formatContextInfo(all cache) = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatContextInfo_OnlyCacheCreation(t *testing.T) {
|
||||
usage := &TokenUsage{
|
||||
InputTokens: 0,
|
||||
CacheCreationTokens: 5000,
|
||||
CacheReadInputTokens: 0,
|
||||
}
|
||||
result := formatContextInfo(100000, usage)
|
||||
expected := "5k/100k"
|
||||
if result != expected {
|
||||
t.Errorf("formatContextInfo(cache creation) = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatContextInfo_OnlyCacheRead(t *testing.T) {
|
||||
usage := &TokenUsage{
|
||||
InputTokens: 0,
|
||||
CacheCreationTokens: 0,
|
||||
CacheReadInputTokens: 8000,
|
||||
}
|
||||
result := formatContextInfo(100000, usage)
|
||||
expected := "8k/100k"
|
||||
if result != expected {
|
||||
t.Errorf("formatContextInfo(cache read) = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStatusLine_EmptyDir(t *testing.T) {
|
||||
data := &StatusInput{}
|
||||
data.Model.DisplayName = "Model"
|
||||
data.Workspace.CurrentDir = ""
|
||||
data.ContextWindow.ContextWindowSize = 100000
|
||||
|
||||
left, right := buildStatusLine(data)
|
||||
|
||||
// Should not panic, should produce valid output
|
||||
if left == "" {
|
||||
t.Error("buildStatusLine with empty dir should produce left output")
|
||||
}
|
||||
if right == "" {
|
||||
t.Error("buildStatusLine with empty dir should produce right output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStatusLine_LongModelName(t *testing.T) {
|
||||
data := &StatusInput{}
|
||||
data.Model.DisplayName = "Claude 3.5 Sonnet with Extended Context"
|
||||
data.Workspace.CurrentDir = "/home/user/my-very-long-project-name-here"
|
||||
data.ContextWindow.ContextWindowSize = 200000
|
||||
data.ContextWindow.CurrentUsage = &TokenUsage{
|
||||
InputTokens: 50000,
|
||||
}
|
||||
|
||||
left, right := buildStatusLine(data)
|
||||
|
||||
if !contains(left, "Claude 3.5 Sonnet with Extended Context") {
|
||||
t.Errorf("long model name not in left: %q", left)
|
||||
}
|
||||
if !contains(left, "my-very-long-project-name-here") {
|
||||
t.Errorf("long dir name not in left: %q", left)
|
||||
}
|
||||
if !contains(right, "50k/200k") {
|
||||
t.Errorf("context info not in right: %q", right)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStatusLine_NilUsage(t *testing.T) {
|
||||
data := &StatusInput{}
|
||||
data.Model.DisplayName = "Model"
|
||||
data.Workspace.CurrentDir = "/test"
|
||||
data.ContextWindow.ContextWindowSize = 100000
|
||||
data.ContextWindow.CurrentUsage = nil
|
||||
|
||||
left, right := buildStatusLine(data)
|
||||
|
||||
if !contains(right, "0/100k") {
|
||||
t.Errorf("nil usage should show 0: %q", right)
|
||||
}
|
||||
if left == "" {
|
||||
t.Error("left should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStatusLine_RootDir(t *testing.T) {
|
||||
data := &StatusInput{}
|
||||
data.Model.DisplayName = "Model"
|
||||
data.Workspace.CurrentDir = "/"
|
||||
data.ContextWindow.ContextWindowSize = 100000
|
||||
|
||||
left, _ := buildStatusLine(data)
|
||||
|
||||
// filepath.Base("/") returns "/"
|
||||
if !contains(left, "/") || !contains(left, "Model") {
|
||||
t.Errorf("root dir handling failed: %q", left)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculatePadding_ExactFit(t *testing.T) {
|
||||
// When content exactly fills the width
|
||||
result := calculatePadding("12345", "67890", 10)
|
||||
expected := 1 // max(10-5-5, 1) = max(0, 1) = 1
|
||||
if result != expected {
|
||||
t.Errorf("calculatePadding(exact fit) = %d, want %d", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculatePadding_LargeWidth(t *testing.T) {
|
||||
result := calculatePadding("left", "right", 200)
|
||||
expected := 200 - 4 - 5 // 191
|
||||
if result != expected {
|
||||
t.Errorf("calculatePadding(large) = %d, want %d", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripANSI_256Color(t *testing.T) {
|
||||
// 256-color escape sequences
|
||||
input := "\033[38;5;196mred\033[0m"
|
||||
result := stripANSI(input)
|
||||
expected := "red"
|
||||
if result != expected {
|
||||
t.Errorf("stripANSI(256color) = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripANSI_TrueColor(t *testing.T) {
|
||||
// 24-bit true color escape sequences
|
||||
input := "\033[38;2;255;0;0mred\033[0m"
|
||||
result := stripANSI(input)
|
||||
expected := "red"
|
||||
if result != expected {
|
||||
t.Errorf("stripANSI(truecolor) = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripANSI_Multiline(t *testing.T) {
|
||||
input := "\033[31mline1\033[0m\n\033[32mline2\033[0m"
|
||||
result := stripANSI(input)
|
||||
expected := "line1\nline2"
|
||||
if result != expected {
|
||||
t.Errorf("stripANSI(multiline) = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadInputFromStdin_SingleLine(t *testing.T) {
|
||||
reader := bufio.NewReader(strings.NewReader("single line"))
|
||||
result := readInputFromStdin(reader)
|
||||
if result != "single line" {
|
||||
t.Errorf("readInputFromStdin(single) = %q, want 'single line'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadInputFromStdin_JSONLike(t *testing.T) {
|
||||
jsonStr := `{"key": "value", "nested": {"a": 1}}`
|
||||
reader := bufio.NewReader(strings.NewReader(jsonStr))
|
||||
result := readInputFromStdin(reader)
|
||||
if result != jsonStr {
|
||||
t.Errorf("readInputFromStdin(json) = %q, want %q", result, jsonStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStatusInput_AllFields(t *testing.T) {
|
||||
jsonStr := `{
|
||||
"model": {"display_name": "FullTest"},
|
||||
"workspace": {"current_dir": "/full/path"},
|
||||
"context_window": {
|
||||
"context_window_size": 150000,
|
||||
"current_usage": {
|
||||
"input_tokens": 1000,
|
||||
"cache_creation_input_tokens": 2000,
|
||||
"cache_read_input_tokens": 3000
|
||||
}
|
||||
}
|
||||
}`
|
||||
data, err := parseStatusInput(jsonStr)
|
||||
if err != nil {
|
||||
t.Fatalf("parseStatusInput failed: %v", err)
|
||||
}
|
||||
if data.Model.DisplayName != "FullTest" {
|
||||
t.Errorf("DisplayName = %q, want FullTest", data.Model.DisplayName)
|
||||
}
|
||||
if data.ContextWindow.CurrentUsage.CacheCreationTokens != 2000 {
|
||||
t.Errorf("CacheCreationTokens = %d, want 2000", data.ContextWindow.CurrentUsage.CacheCreationTokens)
|
||||
}
|
||||
if data.ContextWindow.CurrentUsage.CacheReadInputTokens != 3000 {
|
||||
t.Errorf("CacheReadInputTokens = %d, want 3000", data.ContextWindow.CurrentUsage.CacheReadInputTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStatusInput_ExtraFields(t *testing.T) {
|
||||
// JSON with extra unknown fields should still parse
|
||||
jsonStr := `{"model": {"display_name": "Test", "unknown": "field"}, "extra": "data"}`
|
||||
data, err := parseStatusInput(jsonStr)
|
||||
if err != nil {
|
||||
t.Fatalf("parseStatusInput with extra fields failed: %v", err)
|
||||
}
|
||||
if data.Model.DisplayName != "Test" {
|
||||
t.Errorf("DisplayName = %q, want Test", data.Model.DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenUsage_ZeroValues(t *testing.T) {
|
||||
usage := &TokenUsage{
|
||||
InputTokens: 0,
|
||||
CacheCreationTokens: 0,
|
||||
CacheReadInputTokens: 0,
|
||||
}
|
||||
result := formatContextInfo(100000, usage)
|
||||
expected := "0k/100k"
|
||||
if result != expected {
|
||||
t.Errorf("formatContextInfo(zero usage) = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatuslineWidthOffset_Constant(t *testing.T) {
|
||||
// Verify the constant is defined and reasonable
|
||||
if statuslineWidthOffset <= 0 || statuslineWidthOffset > 20 {
|
||||
t.Errorf("statuslineWidthOffset = %d, expected between 1 and 20", statuslineWidthOffset)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
|
||||
func BenchmarkFormatContextInfo(b *testing.B) {
|
||||
usage := &TokenUsage{
|
||||
InputTokens: 50000,
|
||||
CacheCreationTokens: 10000,
|
||||
CacheReadInputTokens: 5000,
|
||||
}
|
||||
for i := 0; i < b.N; i++ {
|
||||
formatContextInfo(200000, usage)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkStripANSI(b *testing.B) {
|
||||
input := "\033[32m●\033[0m \033[35mOpus\033[0m \033[1;32m➜\033[0m \033[36mproject\033[0m"
|
||||
for i := 0; i < b.N; i++ {
|
||||
stripANSI(input)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkParseStatusInput(b *testing.B) {
|
||||
jsonStr := `{"model": {"display_name": "Test"}, "workspace": {"current_dir": "/test"}, "context_window": {"context_window_size": 200000, "current_usage": {"input_tokens": 50000}}}`
|
||||
for i := 0; i < b.N; i++ {
|
||||
parseStatusInput(jsonStr)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkBuildStatusLine(b *testing.B) {
|
||||
data := &StatusInput{}
|
||||
data.Model.DisplayName = "Claude Opus"
|
||||
data.Workspace.CurrentDir = "/home/user/project"
|
||||
data.ContextWindow.ContextWindowSize = 200000
|
||||
data.ContextWindow.CurrentUsage = &TokenUsage{
|
||||
InputTokens: 50000,
|
||||
}
|
||||
for i := 0; i < b.N; i++ {
|
||||
buildStatusLine(data)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCalculatePadding(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
calculatePadding("left side content", "right side", 120)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFormatOutput(b *testing.B) {
|
||||
left := red + "left" + reset
|
||||
right := green + "right" + reset
|
||||
for i := 0; i < b.N; i++ {
|
||||
formatOutput(left, right, 50)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkReadInputFromStdin(b *testing.B) {
|
||||
jsonStr := `{"model": {"display_name": "Test"}, "workspace": {"current_dir": "/test"}}`
|
||||
for i := 0; i < b.N; i++ {
|
||||
reader := bufio.NewReader(strings.NewReader(jsonStr))
|
||||
readInputFromStdin(reader)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user