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.
This commit is contained in:
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
@ -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
|
||||
2
go.mod
2
go.mod
@ -1,3 +1,5 @@
|
||||
module github.com/kjanat/claude-statusline
|
||||
|
||||
go 1.24.11
|
||||
|
||||
require golang.org/x/sys v0.39.0
|
||||
|
||||
2
go.sum
Normal file
2
go.sum
Normal file
@ -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=
|
||||
202
main.go
Normal file
202
main.go
Normal file
@ -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, "")
|
||||
}
|
||||
Reference in New Issue
Block a user