From f1ca1ccaa01c5b52847cad501c5df5dcdabdb655 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Thu, 18 Dec 2025 05:11:34 +0100 Subject: [PATCH] Use go-git for native git operations Replace exec-based git calls with go-git/v6 library for ~5.6x speedup over shell version. Only pgrep remains as external call. --- go.mod | 22 ++++- go.sum | 62 ++++++++++++ main.go | 77 +++++++-------- statusline.md | 265 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 383 insertions(+), 43 deletions(-) create mode 100644 statusline.md diff --git a/go.mod b/go.mod index 9ba8a4e..87ba1fd 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,24 @@ module github.com/kjanat/claude-statusline go 1.24.11 -require golang.org/x/sys v0.39.0 +require ( + github.com/go-git/go-git/v6 v6.0.0-20251216093047-22c365fcee9c + golang.org/x/sys v0.39.0 +) + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/cyphar/filepath-securejoin v0.6.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/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/pjbgf/sha1cd v0.5.0 // indirect + github.com/sergi/go-diff v1.4.0 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect +) diff --git a/go.sum b/go.sum index 42ca247..d0da2d7 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,64 @@ +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +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/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/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/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +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/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-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/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/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= +github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +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/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +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= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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/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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +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.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= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +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/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index d6eba27..37d0225 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "regexp" "strings" + "github.com/go-git/go-git/v6" "golang.org/x/sys/unix" ) @@ -94,10 +95,7 @@ func main() { termWidth := getTerminalWidth() - statuslineWidthOffset // Calculate padding - padding := termWidth - len(leftVisible) - len(rightVisible) - if padding < 1 { - padding = 1 - } + padding := max(termWidth-len(leftVisible)-len(rightVisible), 1) // Output with padding fmt.Printf("%s%s%s", left, strings.Repeat(" ", padding), right) @@ -129,64 +127,59 @@ func getGiteaStatus() string { } func getGitInfo(cwd string) string { - // Change to the directory - oldDir, _ := os.Getwd() - if err := os.Chdir(cwd); err != nil { + // Open the repository (searches up from cwd) + repo, err := git.PlainOpenWithOptions(cwd, &git.PlainOpenOptions{ + DetectDotGit: true, + }) + if 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 { + // Get HEAD reference + head, err := repo.Head() + if 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)) + var branch string + if head.Name().IsBranch() { + branch = head.Name().Short() } 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 "" + // Detached HEAD - use short hash + branch = head.Hash().String()[:7] } // 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 + worktree, err := repo.Worktree() + if err != nil { + return fmt.Sprintf(" git:(%s)", branch) } - // 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 - } + status, err := worktree.Status() + if err != nil { + return fmt.Sprintf(" git:(%s)", branch) } - if isDirty { + if !status.IsClean() { return fmt.Sprintf(" git:(%s) ✗", branch) } return fmt.Sprintf(" git:(%s)", branch) } +func findGitRoot(path string) string { + for { + if _, err := os.Stat(filepath.Join(path, ".git")); err == nil { + return path + } + parent := filepath.Dir(path) + if parent == path { + return "" + } + path = parent + } +} + func getTerminalWidth() int { ws, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ) if err != nil { diff --git a/statusline.md b/statusline.md new file mode 100644 index 0000000..9630744 --- /dev/null +++ b/statusline.md @@ -0,0 +1,265 @@ +# Status line configuration + +> Create a custom status line for Claude Code to display contextual information + +Make Claude Code your own with a custom status line that displays at the bottom +of the Claude Code interface, similar to how terminal prompts (PS1) work in +shells like Oh-my-zsh. + +## Create a custom status line + +You can either: + +- Run `/statusline` to ask Claude Code to help you set up a custom status line. + By default, it will try to reproduce your terminal's prompt, but you can + provide additional instructions about the behavior you want to Claude Code, + such as `/statusline show the model name in orange` + +- Directly add a `statusLine` command to your `.claude/settings.json`: + +```json theme={null} +{ + "statusLine": { + "type": "command", + "command": "~/.claude/statusline.sh", + "padding": 0 // Optional: set to 0 to let status line go to edge + } +} +``` + +## How it Works + +- The status line is updated when the conversation messages update +- Updates run at most every 300 ms +- The first line of stdout from your command becomes the status line text +- ANSI color codes are supported for styling your status line +- Claude Code passes contextual information about the current session (model, + directories, etc.) as JSON to your script via stdin + +## JSON Input Structure + +Your status line command receives structured data via stdin in JSON format: + +```json theme={null} +{ + "hook_event_name": "Status", + "session_id": "abc123...", + "transcript_path": "/path/to/transcript.json", + "cwd": "/current/working/directory", + "model": { + "id": "claude-opus-4-1", + "display_name": "Opus" + }, + "workspace": { + "current_dir": "/current/working/directory", + "project_dir": "/original/project/directory" + }, + "version": "1.0.80", + "output_style": { + "name": "default" + }, + "cost": { + "total_cost_usd": 0.01234, + "total_duration_ms": 45000, + "total_api_duration_ms": 2300, + "total_lines_added": 156, + "total_lines_removed": 23 + }, + "context_window": { + "total_input_tokens": 15234, + "total_output_tokens": 4521, + "context_window_size": 200000, + "current_usage": { + "input_tokens": 8500, + "output_tokens": 1200, + "cache_creation_input_tokens": 5000, + "cache_read_input_tokens": 2000 + } + } +} +``` + +## Example Scripts + +### Simple Status Line + +```bash theme={null} +#!/bin/bash +# Read JSON input from stdin +input=$(cat) + +# Extract values using jq +MODEL_DISPLAY=$(echo "$input" | jq -r '.model.display_name') +CURRENT_DIR=$(echo "$input" | jq -r '.workspace.current_dir') + +echo "[$MODEL_DISPLAY] 📁 ${CURRENT_DIR##*/}" +``` + +### Git-Aware Status Line + +```bash theme={null} +#!/bin/bash +# Read JSON input from stdin +input=$(cat) + +# Extract values using jq +MODEL_DISPLAY=$(echo "$input" | jq -r '.model.display_name') +CURRENT_DIR=$(echo "$input" | jq -r '.workspace.current_dir') + +# Show git branch if in a git repo +GIT_BRANCH="" +if git rev-parse --git-dir > /dev/null 2>&1; then + BRANCH=$(git branch --show-current 2>/dev/null) + if [ -n "$BRANCH" ]; then + GIT_BRANCH=" | 🌿 $BRANCH" + fi +fi + +echo "[$MODEL_DISPLAY] 📁 ${CURRENT_DIR##*/}$GIT_BRANCH" +``` + +### Python Example + +```python theme={null} +#!/usr/bin/env python3 +import json +import sys +import os + +# Read JSON from stdin +data = json.load(sys.stdin) + +# Extract values +model = data['model']['display_name'] +current_dir = os.path.basename(data['workspace']['current_dir']) + +# Check for git branch +git_branch = "" +if os.path.exists('.git'): + try: + with open('.git/HEAD', 'r') as f: + ref = f.read().strip() + if ref.startswith('ref: refs/heads/'): + git_branch = f" | 🌿 {ref.replace('ref: refs/heads/', '')}" + except: + pass + +print(f"[{model}] 📁 {current_dir}{git_branch}") +``` + +### Node.js Example + +```javascript theme={null} +#!/usr/bin/env node + +const fs = require("fs"); +const path = require("path"); + +// Read JSON from stdin +let input = ""; +process.stdin.on("data", (chunk) => input += chunk); +process.stdin.on("end", () => { + const data = JSON.parse(input); + + // Extract values + const model = data.model.display_name; + const currentDir = path.basename(data.workspace.current_dir); + + // Check for git branch + let gitBranch = ""; + try { + const headContent = fs.readFileSync(".git/HEAD", "utf8").trim(); + if (headContent.startsWith("ref: refs/heads/")) { + gitBranch = ` | 🌿 ${headContent.replace("ref: refs/heads/", "")}`; + } + } catch (e) { + // Not a git repo or can't read HEAD + } + + console.log(`[${model}] 📁 ${currentDir}${gitBranch}`); +}); +``` + +### Helper Function Approach + +For more complex bash scripts, you can create helper functions: + +```bash theme={null} +#!/bin/bash +# Read JSON input once +input=$(cat) + +# Helper functions for common extractions +get_model_name() { echo "$input" | jq -r '.model.display_name'; } +get_current_dir() { echo "$input" | jq -r '.workspace.current_dir'; } +get_project_dir() { echo "$input" | jq -r '.workspace.project_dir'; } +get_version() { echo "$input" | jq -r '.version'; } +get_cost() { echo "$input" | jq -r '.cost.total_cost_usd'; } +get_duration() { echo "$input" | jq -r '.cost.total_duration_ms'; } +get_lines_added() { echo "$input" | jq -r '.cost.total_lines_added'; } +get_lines_removed() { echo "$input" | jq -r '.cost.total_lines_removed'; } +get_input_tokens() { echo "$input" | jq -r '.context_window.total_input_tokens'; } +get_output_tokens() { echo "$input" | jq -r '.context_window.total_output_tokens'; } +get_context_window_size() { echo "$input" | jq -r '.context_window.context_window_size'; } + +# Use the helpers +MODEL=$(get_model_name) +DIR=$(get_current_dir) +echo "[$MODEL] 📁 ${DIR##*/}" +``` + +### Context Window Usage + +Display the percentage of context window consumed. The `context_window` object +contains: + +- `total_input_tokens` / `total_output_tokens`: Cumulative totals across the + entire session +- `current_usage`: Current context window usage from the last API call (may be + `null` if no messages yet) + - `input_tokens`: Input tokens in current context + - `output_tokens`: Output tokens generated + - `cache_creation_input_tokens`: Tokens written to cache + - `cache_read_input_tokens`: Tokens read from cache + +For accurate context percentage, use `current_usage` which reflects the actual +context window state: + +```bash theme={null} +#!/bin/bash +input=$(cat) + +MODEL=$(echo "$input" | jq -r '.model.display_name') +CONTEXT_SIZE=$(echo "$input" | jq -r '.context_window.context_window_size') +USAGE=$(echo "$input" | jq '.context_window.current_usage') + +if [ "$USAGE" != "null" ]; then + # Calculate current context from current_usage fields + CURRENT_TOKENS=$(echo "$USAGE" | jq '.input_tokens + .cache_creation_input_tokens + .cache_read_input_tokens') + PERCENT_USED=$((CURRENT_TOKENS * 100 / CONTEXT_SIZE)) + echo "[$MODEL] Context: ${PERCENT_USED}%" +else + echo "[$MODEL] Context: 0%" +fi +``` + +## Tips + +- Keep your status line concise - it should fit on one line +- Use emojis (if your terminal supports them) and colors to make information + scannable +- Use `jq` for JSON parsing in Bash (see examples above) +- Test your script by running it manually with mock JSON input: + `echo '{"model":{"display_name":"Test"},"workspace":{"current_dir":"/test"}}' | ./statusline.sh` +- Consider caching expensive operations (like git status) if needed + +## Troubleshooting + +- If your status line doesn't appear, check that your script is executable + (`chmod +x`) +- Ensure your script outputs to stdout (not stderr) + +--- + +> To find navigation and other pages in this documentation, fetch the llms.txt +> file at: https://code.claude.com/docs/llms.txt