398 lines
12 KiB
Go
398 lines
12 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"charm.land/lipgloss/v2"
|
|
|
|
"github.com/smallnest/pigo/internal/cli/ui"
|
|
)
|
|
|
|
// statusBar renders the persistent bottom line described in the SPEC (US-003,
|
|
// Section 5.1): model name, thinking level, cwd (with $HOME abbreviated to ~),
|
|
// git branch + dirty/ahead markers, context-usage %, and the current task text.
|
|
// It holds no styling of its own beyond the Theme's StatusBar style; all width
|
|
// fitting is done against ui.Width so CJK/emoji count as two columns.
|
|
//
|
|
// The component is a plain value: Update-side code copies it into the Model,
|
|
// mutates the exported-to-package snapshot fields via the setters, and calls
|
|
// Render(width) from View. It never performs I/O — the git probe lives in
|
|
// gitinfo.go and feeds it via SetGit.
|
|
type statusBar struct {
|
|
theme Theme
|
|
|
|
// Static-ish config sourced from Options.
|
|
model string
|
|
thinking string
|
|
|
|
// cwd is the launch directory with $HOME already abbreviated to "~".
|
|
cwd string
|
|
|
|
// git is the latest probe result; rendered only when git.ok is true.
|
|
git gitInfoMsg
|
|
|
|
// contextPct is the latest context-window utilization in percent [0,100],
|
|
// derived from telemetryMsg (ContextUtilization * 100). -1 means unknown, so
|
|
// the segment is hidden until the first telemetry arrives.
|
|
contextPct int
|
|
|
|
// tokens is the most recently observed context-token count (ContextTokens),
|
|
// shown alongside the percentage. 0 means unknown/not yet reported.
|
|
tokens int
|
|
|
|
// task is the current activity text (e.g. the running tool or turn state).
|
|
task string
|
|
}
|
|
|
|
// newStatusBar builds a status bar from the theme, resolved Options, and the
|
|
// launch directory. contextPct starts at -1 (unknown) so the token segment stays
|
|
// hidden until telemetry arrives.
|
|
func newStatusBar(theme Theme, opts Options, cwd string) statusBar {
|
|
return statusBar{
|
|
theme: theme,
|
|
model: opts.Model,
|
|
thinking: string(opts.ThinkingLevel),
|
|
cwd: abbreviateHome(cwd),
|
|
contextPct: -1,
|
|
}
|
|
}
|
|
|
|
// SetGit stores the latest git probe result.
|
|
func (s *statusBar) SetGit(g gitInfoMsg) { s.git = g }
|
|
|
|
// SetModel updates the displayed model name after a /model switch.
|
|
func (s *statusBar) SetModel(model string) { s.model = model }
|
|
|
|
// SetThinking updates the displayed reasoning-effort level after a /think switch.
|
|
func (s *statusBar) SetThinking(level string) { s.thinking = level }
|
|
|
|
// SetTelemetry updates the context-usage percentage from a telemetry event.
|
|
// A zero/unknown window (ContextWindow == 0) leaves the segment hidden.
|
|
func (s *statusBar) SetTelemetry(ev telemetryEventView) {
|
|
if ev.window <= 0 {
|
|
s.contextPct = -1
|
|
s.tokens = 0
|
|
return
|
|
}
|
|
pct := int(ev.util*100 + 0.5)
|
|
if pct < 0 {
|
|
pct = 0
|
|
}
|
|
if pct > 100 {
|
|
pct = 100
|
|
}
|
|
s.contextPct = pct
|
|
if ev.tokens > 0 {
|
|
s.tokens = ev.tokens
|
|
}
|
|
}
|
|
|
|
// SetTask records the current activity text shown at the far right / high
|
|
// priority slot of the bar.
|
|
func (s *statusBar) SetTask(task string) { s.task = task }
|
|
|
|
// telemetryEventView is the minimal projection of agentcore.TelemetryEvent the
|
|
// status bar needs, so the caller (model.go) adapts the event rather than this
|
|
// file depending on agentcore directly for a two-field read.
|
|
type telemetryEventView struct {
|
|
util float64
|
|
window int
|
|
tokens int
|
|
}
|
|
|
|
// appName is the badge shown at the far left of the bar.
|
|
const appName = "pigo"
|
|
|
|
// Glyphs prefixing each segment plus the powerline separator, matching the
|
|
// decorated Claude-Code-plugin look. The segment icons are common Unicode; the
|
|
// separator (sepArrow) is a powerline glyph in the private-use area that Nerd
|
|
// Fonts and most modern terminal fonts render. Each measures one display column
|
|
// and ui.Width accounts for it during truncation.
|
|
const (
|
|
glyphGit = "⎇" // git branch
|
|
glyphDirty = "●" // uncommitted changes
|
|
glyphAhead = "⇡" // commits ahead of upstream
|
|
glyphModel = "✱" // model name
|
|
glyphThink = "✽" // thinking level
|
|
glyphCwd = "▸" // working directory
|
|
glyphCtx = "◔" // context-window usage
|
|
glyphTask = "⏵" // current activity
|
|
|
|
sepArrow = "" // filled right arrow — used at a background transition
|
|
)
|
|
|
|
// Powerline palette (ANSI 256-color cube, so it renders without true-color).
|
|
// Every segment is its own colored block. The arrow between two segments is
|
|
// drawn in the LEFT block's background color so it reads as that item's color
|
|
// spilling into the next; a closing arrow caps the final block back to the bar.
|
|
const (
|
|
sbBarBg = "236" // bar background behind the trailing pad
|
|
|
|
sbAppFg = "233" // app badge text (dark, on light gray)
|
|
sbAppBg = "252" // app badge block (light gray)
|
|
|
|
sbGitFg = "231" // git text
|
|
sbGitBg = "65" // git block (muted green)
|
|
|
|
sbModelFg = "231" // model text
|
|
sbModelBg = "97" // model block (muted purple)
|
|
|
|
sbThinkFg = "231" // thinking text
|
|
sbThinkBg = "60" // thinking block (slate)
|
|
|
|
sbCwdFg = "231" // cwd text
|
|
sbCwdBg = "67" // cwd block (steel blue)
|
|
|
|
sbCtxFg = "236" // context text (dark, on amber)
|
|
sbCtxBg = "179" // context block (amber/gold)
|
|
|
|
sbTaskFg = "231" // task text
|
|
sbTaskBg = "131" // task block (muted terracotta)
|
|
)
|
|
|
|
// segment is one labelled field of the bar together with its colors and
|
|
// truncation priority. bg == "" means the segment sits on the bar background;
|
|
// a non-empty bg gives it a filled powerline block. Higher priority survives
|
|
// longer when the terminal is too narrow.
|
|
type segment struct {
|
|
text string
|
|
fg string
|
|
bg string // "" => bar background
|
|
priority int // larger = kept longer under truncation
|
|
}
|
|
|
|
// Priority order (SPEC: task > model/app > token > git > cwd). Higher is more
|
|
// important and dropped/truncated last.
|
|
const (
|
|
prioCwd = 0
|
|
prioGit = 1
|
|
prioToken = 2
|
|
prioModel = 3
|
|
prioApp = 3 // the app badge rides at the model tier
|
|
prioTask = 4
|
|
)
|
|
|
|
// Render lays the bar out to exactly the configured width as a colored powerline
|
|
// ribbon. Each segment is a filled block joined to the next by an arrow drawn in
|
|
// the left block's background color, and the tail is padded with the bar
|
|
// background so the whole row is filled. When the ribbon would exceed the width
|
|
// it drops whole segments from lowest to highest priority; if even the single
|
|
// highest-priority segment still overflows it hard-truncates that segment's
|
|
// text. The rendered row's display width (ui.Width, which ignores ANSI) is
|
|
// always exactly width for width > 0; a non-positive width yields the empty
|
|
// string.
|
|
func (s statusBar) Render(width int) string {
|
|
if width <= 0 {
|
|
return ""
|
|
}
|
|
|
|
segs := s.segments()
|
|
for len(segs) > 0 {
|
|
ribbon, w := renderRibbon(segs)
|
|
if w <= width {
|
|
return ribbon + barPad(width-w)
|
|
}
|
|
if len(segs) == 1 {
|
|
break
|
|
}
|
|
segs = dropLowest(segs)
|
|
}
|
|
|
|
// Even a single highest-priority segment overflows: hard-truncate its text
|
|
// onto the bar background (no separators, so width stays bounded).
|
|
txt := TruncateToWidth(s.highestText(), width)
|
|
base := lipgloss.NewStyle().Foreground(lipgloss.Color(sbAppFg)).Background(lipgloss.Color(sbBarBg))
|
|
return base.Render(txt) + barPad(width-ui.Width(txt))
|
|
}
|
|
|
|
// renderRibbon builds the styled powerline string for segs and returns it with
|
|
// its visible width (excluding ANSI). The left edge starts at the first
|
|
// segment's background; a closing arrow caps any trailing block back to the bar.
|
|
func renderRibbon(segs []segment) (string, int) {
|
|
resolve := func(bg string) string {
|
|
if bg == "" {
|
|
return sbBarBg
|
|
}
|
|
return bg
|
|
}
|
|
|
|
var b strings.Builder
|
|
vis := 0
|
|
for i, seg := range segs {
|
|
curBg := resolve(seg.bg)
|
|
if i > 0 {
|
|
// The separator arrow is filled with the LEFT block's background, so
|
|
// it matches the item it flows out of, sitting on the next block's bg.
|
|
leftBg := resolve(segs[i-1].bg)
|
|
b.WriteString(lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color(leftBg)).
|
|
Background(lipgloss.Color(curBg)).
|
|
Render(sepArrow))
|
|
vis += ui.Width(sepArrow)
|
|
}
|
|
content := " " + seg.text + " "
|
|
b.WriteString(lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color(seg.fg)).
|
|
Background(lipgloss.Color(curBg)).
|
|
Render(content))
|
|
vis += ui.Width(content)
|
|
}
|
|
|
|
// Cap a trailing colored block with an arrow back to the bar background.
|
|
if lastBg := resolve(segs[len(segs)-1].bg); lastBg != sbBarBg {
|
|
b.WriteString(lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color(lastBg)).
|
|
Background(lipgloss.Color(sbBarBg)).
|
|
Render(sepArrow))
|
|
vis += ui.Width(sepArrow)
|
|
}
|
|
return b.String(), vis
|
|
}
|
|
|
|
// barPad returns n spaces painted with the bar background so the row fills the
|
|
// full terminal width. n <= 0 yields the empty string.
|
|
func barPad(n int) string {
|
|
if n <= 0 {
|
|
return ""
|
|
}
|
|
return lipgloss.NewStyle().
|
|
Background(lipgloss.Color(sbBarBg)).
|
|
Render(strings.Repeat(" ", n))
|
|
}
|
|
|
|
// segments builds the ordered list of visible segments. Order in the slice is
|
|
// the left-to-right display order; priority governs truncation, not position.
|
|
func (s statusBar) segments() []segment {
|
|
var segs []segment
|
|
|
|
// App badge leads the bar as a filled block.
|
|
segs = append(segs, segment{text: appName, fg: sbAppFg, bg: sbAppBg, priority: prioApp})
|
|
|
|
if s.git.ok {
|
|
segs = append(segs, segment{text: s.gitText(), fg: sbGitFg, bg: sbGitBg, priority: prioGit})
|
|
}
|
|
if s.model != "" {
|
|
segs = append(segs, segment{text: glyphModel + " " + s.model, fg: sbModelFg, bg: sbModelBg, priority: prioModel})
|
|
}
|
|
if s.thinking != "" {
|
|
// Thinking rides with the model priority — it is cheap and contextual.
|
|
segs = append(segs, segment{text: glyphThink + " " + s.thinking, fg: sbThinkFg, bg: sbThinkBg, priority: prioModel})
|
|
}
|
|
if s.cwd != "" {
|
|
segs = append(segs, segment{text: glyphCwd + " " + s.cwd, fg: sbCwdFg, bg: sbCwdBg, priority: prioCwd})
|
|
}
|
|
if s.contextPct >= 0 {
|
|
// The context readout is the highlighted amber block on the right.
|
|
segs = append(segs, segment{text: s.ctxText(), fg: sbCtxFg, bg: sbCtxBg, priority: prioToken})
|
|
}
|
|
if s.task != "" {
|
|
segs = append(segs, segment{text: glyphTask + " " + s.task, fg: sbTaskFg, bg: sbTaskBg, priority: prioTask})
|
|
}
|
|
return segs
|
|
}
|
|
|
|
// gitText formats the git segment, e.g. "⎇ master ●3 ⇡4": branch, then "●N" for
|
|
// N dirty entries and "⇡N" for N commits ahead, each shown only when non-zero.
|
|
func (s statusBar) gitText() string {
|
|
var b strings.Builder
|
|
b.WriteString(glyphGit + " " + s.git.branch)
|
|
if s.git.dirty > 0 {
|
|
fmt.Fprintf(&b, " %s%d", glyphDirty, s.git.dirty)
|
|
}
|
|
if s.git.ahead > 0 {
|
|
fmt.Fprintf(&b, " %s%d", glyphAhead, s.git.ahead)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// ctxText formats the context segment, e.g. "◔ 90,866 (46%)" when the token
|
|
// count is known, or "◔ 46%" before the first token count arrives.
|
|
func (s statusBar) ctxText() string {
|
|
if s.tokens > 0 {
|
|
return fmt.Sprintf("%s %s (%d%%)", glyphCtx, humanizeInt(s.tokens), s.contextPct)
|
|
}
|
|
return fmt.Sprintf("%s %d%%", glyphCtx, s.contextPct)
|
|
}
|
|
|
|
// humanizeInt renders n with thousands separators, e.g. 90866 → "90,866".
|
|
func humanizeInt(n int) string {
|
|
s := fmt.Sprintf("%d", n)
|
|
neg := strings.HasPrefix(s, "-")
|
|
if neg {
|
|
s = s[1:]
|
|
}
|
|
var b strings.Builder
|
|
for i, r := range s {
|
|
if i > 0 && (len(s)-i)%3 == 0 {
|
|
b.WriteByte(',')
|
|
}
|
|
b.WriteRune(r)
|
|
}
|
|
if neg {
|
|
return "-" + b.String()
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// highestText returns the text of the highest-priority segment, used as the last
|
|
// thing standing when the terminal cannot even fit one full segment.
|
|
func (s statusBar) highestText() string {
|
|
segs := s.segments()
|
|
if len(segs) == 0 {
|
|
return ""
|
|
}
|
|
best := segs[0]
|
|
for _, seg := range segs[1:] {
|
|
if seg.priority > best.priority {
|
|
best = seg
|
|
}
|
|
}
|
|
return best.text
|
|
}
|
|
|
|
// dropLowest removes one occurrence of the lowest-priority segment, preserving
|
|
// display order among the rest. It returns the shortened slice.
|
|
func dropLowest(segs []segment) []segment {
|
|
if len(segs) == 0 {
|
|
return segs
|
|
}
|
|
lowIdx := 0
|
|
for i, seg := range segs {
|
|
if seg.priority < segs[lowIdx].priority {
|
|
lowIdx = i
|
|
}
|
|
}
|
|
out := make([]segment, 0, len(segs)-1)
|
|
out = append(out, segs[:lowIdx]...)
|
|
out = append(out, segs[lowIdx+1:]...)
|
|
return out
|
|
}
|
|
|
|
// abbreviateHome replaces a leading $HOME in path with "~" so the status bar
|
|
// stays compact. It leaves paths outside $HOME untouched and never fails.
|
|
func abbreviateHome(path string) string {
|
|
home := homeDir()
|
|
if home == "" || path == "" {
|
|
return path
|
|
}
|
|
if path == home {
|
|
return "~"
|
|
}
|
|
if strings.HasPrefix(path, home+"/") {
|
|
return "~" + strings.TrimPrefix(path, home)
|
|
}
|
|
return path
|
|
}
|
|
|
|
// homeDir returns the user's home directory, or "" when it cannot be
|
|
// determined. Kept as a tiny wrapper so abbreviateHome stays testable.
|
|
func homeDir() string {
|
|
h, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return h
|
|
}
|