Replace direct unix.IoctlGetWinsize() call with term.GetSize() for cleaner API. No binary size change as x/sys remains an indirect dependency.
212 lines
4.9 KiB
Go
212 lines
4.9 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/go-git/go-git/v6"
|
|
"github.com/shirou/gopsutil/v4/process"
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
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"`
|
|
}
|
|
|
|
// StatusInput represents the JSON input 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 *TokenUsage `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"
|
|
)
|
|
|
|
// readInputFromStdin reads JSON input from stdin.
|
|
func readInputFromStdin(r *bufio.Reader) string {
|
|
var input strings.Builder
|
|
for {
|
|
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(jsonStr), &data); err != nil {
|
|
return nil, err
|
|
}
|
|
return &data, nil
|
|
}
|
|
|
|
// 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)
|
|
dirName := filepath.Base(data.Workspace.CurrentDir)
|
|
giteaStatus := getGiteaStatus()
|
|
gitInfo := getGitInfo(data.Workspace.CurrentDir)
|
|
|
|
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)
|
|
|
|
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)
|
|
rightVisible := stripANSI(right)
|
|
|
|
// Get terminal width
|
|
termWidth := getTerminalWidth() - statuslineWidthOffset
|
|
|
|
// Calculate and apply padding
|
|
padding := calculatePadding(leftVisible, rightVisible, termWidth)
|
|
output := formatOutput(left, right, padding)
|
|
|
|
fmt.Print(output)
|
|
}
|
|
|
|
func formatContextInfo(contextSize int, usage *TokenUsage) 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 gopsutil (cross-platform)
|
|
procs, err := process.Processes()
|
|
if err != nil {
|
|
return red + "●" + reset
|
|
}
|
|
|
|
for _, p := range procs {
|
|
name, err := p.Name()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if name == "gitea" {
|
|
return green + "●" + reset
|
|
}
|
|
}
|
|
return red + "●" + reset
|
|
}
|
|
|
|
func getGitInfo(cwd string) string {
|
|
// Open the repository (searches up from cwd)
|
|
repo, err := git.PlainOpenWithOptions(cwd, &git.PlainOpenOptions{
|
|
DetectDotGit: true,
|
|
})
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
// Get HEAD reference
|
|
head, err := repo.Head()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
// Get branch name
|
|
var branch string
|
|
if head.Name().IsBranch() {
|
|
branch = head.Name().Short()
|
|
} else {
|
|
// Detached HEAD - use short hash
|
|
branch = head.Hash().String()[:7]
|
|
}
|
|
|
|
// Check if working tree is dirty
|
|
worktree, err := repo.Worktree()
|
|
if err != nil {
|
|
return fmt.Sprintf(" git:(%s)", branch)
|
|
}
|
|
|
|
status, err := worktree.Status()
|
|
if err != nil {
|
|
return fmt.Sprintf(" git:(%s)", branch)
|
|
}
|
|
|
|
if !status.IsClean() {
|
|
return fmt.Sprintf(" git:(%s) ✗", branch)
|
|
}
|
|
return fmt.Sprintf(" git:(%s)", branch)
|
|
}
|
|
|
|
func getTerminalWidth() int {
|
|
width, _, err := term.GetSize(int(os.Stdout.Fd()))
|
|
if err != nil {
|
|
return 80
|
|
}
|
|
return width
|
|
}
|
|
|
|
var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
|
|
|
func stripANSI(s string) string {
|
|
return ansiRegex.ReplaceAllString(s, "")
|
|
}
|