From 135bbf68f1cb02ac609519cdcc0982246bc2f4a4 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Thu, 18 Dec 2025 04:59:15 +0100 Subject: [PATCH] Add Go statusline implementation Replace shell-based statusline with native Go for ~4x speedup. Parses JSON natively, detects git status, checks gitea process, and formats colored output with proper terminal width handling. --- .gitignore | 45 ++++++++++++ go.mod | 2 + go.sum | 2 + main.go | 202 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 251 insertions(+) create mode 100644 .gitignore create mode 100644 go.sum create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7a534f --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +/statusline +/bin + +# Created by https://gitignore.kjanat.com/api/go,linux +# Edit at https://gitignore.kjanat.com?templates=go,linux + +### Go ### +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +# End of https://gitignore.kjanat.com/api/go,linux diff --git a/go.mod b/go.mod index a8ca89a..9ba8a4e 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/kjanat/claude-statusline go 1.24.11 + +require golang.org/x/sys v0.39.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..42ca247 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/main.go b/main.go new file mode 100644 index 0000000..d6eba27 --- /dev/null +++ b/main.go @@ -0,0 +1,202 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "golang.org/x/sys/unix" +) + +const statuslineWidthOffset = 7 + +// Input JSON structure from Claude Code +type StatusInput struct { + Model struct { + DisplayName string `json:"display_name"` + } `json:"model"` + Workspace struct { + CurrentDir string `json:"current_dir"` + } `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"` + } `json:"context_window"` +} + +// ANSI color codes +const ( + reset = "\033[0m" + bold = "\033[1m" + red = "\033[31m" + green = "\033[32m" + yellow = "\033[33m" + magenta = "\033[35m" + cyan = "\033[36m" + boldGreen = "\033[1;32m" +) + +func main() { + // Read JSON from stdin + reader := bufio.NewReader(os.Stdin) + var input strings.Builder + for { + line, err := reader.ReadString('\n') + input.WriteString(line) + if err != nil { + break + } + } + + var data StatusInput + if err := json.Unmarshal([]byte(input.String()), &data); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing JSON: %v\n", err) + os.Exit(1) + } + + // Calculate context info + 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", + giteaStatus, + magenta, data.Model.DisplayName, reset, + boldGreen, reset, + cyan, dirName, reset, + gitInfo) + + // Build right part + right := fmt.Sprintf("%s%s%s", yellow, contextInfo, reset) + + // Calculate visible lengths (strip ANSI) + leftVisible := stripANSI(left) + rightVisible := stripANSI(right) + + // Get terminal width + termWidth := getTerminalWidth() - statuslineWidthOffset + + // Calculate padding + padding := termWidth - len(leftVisible) - len(rightVisible) + if padding < 1 { + padding = 1 + } + + // Output with padding + fmt.Printf("%s%s%s", left, strings.Repeat(" ", padding), right) +} + +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 { + totalK := contextSize / 1000 + + if usage == nil { + return fmt.Sprintf("0/%dk", totalK) + } + + currentTokens := usage.InputTokens + usage.CacheCreationTokens + usage.CacheReadInputTokens + currentK := currentTokens / 1000 + return fmt.Sprintf("%dk/%dk", currentK, totalK) +} + +func getGiteaStatus() string { + // Check if gitea process is running using pgrep + cmd := exec.Command("pgrep", "-x", "gitea") + if err := cmd.Run(); err == nil { + return green + "ā—" + reset + } + return red + "ā—" + reset +} + +func getGitInfo(cwd string) string { + // Change to the directory + oldDir, _ := os.Getwd() + if err := os.Chdir(cwd); err != nil { + return "" + } + defer os.Chdir(oldDir) + + // Check if we're in a git repo + cmd := exec.Command("git", "rev-parse", "--git-dir") + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return "" + } + + // Get branch name + branch := "" + cmd = exec.Command("git", "symbolic-ref", "--short", "HEAD") + cmd.Stderr = nil + if out, err := cmd.Output(); err == nil { + branch = strings.TrimSpace(string(out)) + } else { + // Fallback to short SHA + cmd = exec.Command("git", "rev-parse", "--short", "HEAD") + cmd.Stderr = nil + if out, err := cmd.Output(); err == nil { + branch = strings.TrimSpace(string(out)) + } + } + + if branch == "" { + return "" + } + + // Check if working tree is dirty + isDirty := false + + // Check unstaged changes + cmd = exec.Command("git", "diff", "--no-ext-diff", "--quiet", "--exit-code", "--no-optional-locks") + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + isDirty = true + } + + // Check staged changes + if !isDirty { + cmd = exec.Command("git", "diff-index", "--cached", "--quiet", "HEAD", "--no-optional-locks") + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + isDirty = true + } + } + + if isDirty { + 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`) + +func stripANSI(s string) string { + return ansiRegex.ReplaceAllString(s, "") +}