first commit
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user