196 lines
6.3 KiB
Go
196 lines
6.3 KiB
Go
package tui
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"charm.land/lipgloss/v2"
|
|
|
|
"github.com/smallnest/pigo/internal/cli/ui"
|
|
)
|
|
|
|
// Theme bundles the lipgloss styles for every visual element the TUI paints so
|
|
// the transcript, tool cards and status bar share one palette instead of each
|
|
// call site hand-rolling colors (see tasks/spec-tui-agent.md Sections 2.2, 5.1).
|
|
// The reference palette is: success green, error/warn red & yellow, file/accent
|
|
// blue, and gray for secondary chrome. Styles are plain value types, so a Theme
|
|
// is cheap to copy and safe to pass by value.
|
|
type Theme struct {
|
|
// User styles the human's turns in the transcript.
|
|
User lipgloss.Style
|
|
// Assistant styles the model's turns in the transcript.
|
|
Assistant lipgloss.Style
|
|
// System styles system / meta notices (secondary gray).
|
|
System lipgloss.Style
|
|
// ToolHeader styles the title line of a tool invocation card.
|
|
ToolHeader lipgloss.Style
|
|
// ToolBody styles the body/output region of a tool card.
|
|
ToolBody lipgloss.Style
|
|
// StatusBar styles the persistent bottom status bar.
|
|
StatusBar lipgloss.Style
|
|
// Accent styles file names and other highlighted tokens (blue).
|
|
Accent lipgloss.Style
|
|
// Error styles failure messages (red).
|
|
Error lipgloss.Style
|
|
// Warn styles warnings (yellow).
|
|
Warn lipgloss.Style
|
|
// Success styles successful outcomes (green).
|
|
Success lipgloss.Style
|
|
// ScrollThumb styles the transcript scrollbar thumb (medium gray block).
|
|
ScrollThumb lipgloss.Style
|
|
// ScrollTrack styles the transcript scrollbar track (dim shaded column).
|
|
ScrollTrack lipgloss.Style
|
|
// Spinner styles the animated "working" indicator glyph + verb (warm coral).
|
|
Spinner lipgloss.Style
|
|
}
|
|
|
|
// Palette color numbers use the ANSI 256-color cube so the theme renders
|
|
// consistently across terminals without depending on true-color support.
|
|
const (
|
|
colorSuccess = "42" // green
|
|
colorError = "196" // red
|
|
colorWarn = "214" // yellow/amber
|
|
colorAccent = "39" // blue (file names, highlights)
|
|
colorGray = "245" // secondary / muted text
|
|
colorScroll = "250" // scrollbar thumb (bright gray pill, clearly visible)
|
|
colorTrack = "240" // scrollbar groove (dim gray, visible but recessive)
|
|
colorUser = "15" // bright white
|
|
colorAssist = "252" // near-white
|
|
colorStatus = "62" // status bar background (violet)
|
|
colorSpinner = "173" // spinner glyph/verb (warm coral, matches Claude Code)
|
|
)
|
|
|
|
// DefaultTheme returns the built-in palette described in the SPEC: success
|
|
// green, error/warn red & yellow, file/accent blue, and gray for secondary
|
|
// chrome. It performs no I/O and never panics, so callers can construct it
|
|
// eagerly at startup.
|
|
func DefaultTheme() Theme {
|
|
return Theme{
|
|
User: lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color(colorUser)).
|
|
Bold(true),
|
|
Assistant: lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color(colorAssist)),
|
|
System: lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color(colorGray)).
|
|
Italic(true),
|
|
ToolHeader: lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color(colorAccent)).
|
|
Bold(true),
|
|
ToolBody: lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color(colorGray)),
|
|
StatusBar: lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color(colorUser)).
|
|
Background(lipgloss.Color(colorStatus)),
|
|
Accent: lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color(colorAccent)),
|
|
Error: lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color(colorError)).
|
|
Bold(true),
|
|
Warn: lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color(colorWarn)),
|
|
Success: lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color(colorSuccess)),
|
|
ScrollThumb: lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color(colorScroll)),
|
|
ScrollTrack: lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color(colorTrack)),
|
|
Spinner: lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color(colorSpinner)).
|
|
Bold(true),
|
|
}
|
|
}
|
|
|
|
// ellipsis is the single rune appended to truncated strings. It is itself a
|
|
// single display column, so it is cheap to reserve room for.
|
|
const ellipsis = "…"
|
|
|
|
// WrapToWidth wraps s to at most width display columns per line, measuring width
|
|
// by terminal cells (CJK and emoji count as two) via ui.Width rather than byte
|
|
// length. It never splits inside a multi-byte rune or a double-width character:
|
|
// a rune that would overflow the current line starts a new line instead. Any
|
|
// existing newlines in s are preserved as hard breaks. A non-positive width is
|
|
// treated as "no wrapping" and s is returned unchanged.
|
|
func WrapToWidth(s string, width int) string {
|
|
if width <= 0 {
|
|
return s
|
|
}
|
|
|
|
var out strings.Builder
|
|
lines := strings.Split(s, "\n")
|
|
for li, line := range lines {
|
|
if li > 0 {
|
|
out.WriteByte('\n')
|
|
}
|
|
wrapLine(&out, line, width)
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
// wrapLine wraps a single newline-free line into out, breaking on rune
|
|
// boundaries so no double-width rune is ever cut in half.
|
|
func wrapLine(out *strings.Builder, line string, width int) {
|
|
cur := 0 // display width accumulated on the current output line
|
|
first := true
|
|
for _, r := range line {
|
|
rw := ui.Width(string(r))
|
|
if !first && cur+rw > width {
|
|
out.WriteByte('\n')
|
|
cur = 0
|
|
}
|
|
out.WriteRune(r)
|
|
cur += rw
|
|
first = false
|
|
}
|
|
}
|
|
|
|
// TruncateToWidth returns s clipped to at most width display columns, appending
|
|
// an ellipsis "…" when it removes content. Width is measured in terminal cells
|
|
// (CJK and emoji count as two) via ui.Width, and truncation happens on rune
|
|
// boundaries so a double-width character is never sliced. The returned string's
|
|
// display width is guaranteed to be <= width. A non-positive width yields the
|
|
// empty string.
|
|
func TruncateToWidth(s string, width int) string {
|
|
if width <= 0 {
|
|
return ""
|
|
}
|
|
if ui.Width(s) <= width {
|
|
return s
|
|
}
|
|
|
|
// Reserve room for the ellipsis. If width is too small to even hold the
|
|
// ellipsis plus one column, fall back to fitting bare runes into width.
|
|
budget := width - ui.Width(ellipsis)
|
|
if budget <= 0 {
|
|
return fitRunes(s, width)
|
|
}
|
|
|
|
var b strings.Builder
|
|
used := 0
|
|
for _, r := range s {
|
|
rw := ui.Width(string(r))
|
|
if used+rw > budget {
|
|
break
|
|
}
|
|
b.WriteRune(r)
|
|
used += rw
|
|
}
|
|
b.WriteString(ellipsis)
|
|
return b.String()
|
|
}
|
|
|
|
// fitRunes packs as many leading runes of s as fit within width columns without
|
|
// any ellipsis, breaking on rune boundaries.
|
|
func fitRunes(s string, width int) string {
|
|
var b strings.Builder
|
|
used := 0
|
|
for _, r := range s {
|
|
rw := ui.Width(string(r))
|
|
if used+rw > width {
|
|
break
|
|
}
|
|
b.WriteRune(r)
|
|
used += rw
|
|
}
|
|
return b.String()
|
|
}
|