294 lines
8.1 KiB
Go
294 lines
8.1 KiB
Go
package mujica
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/egoist/mygo/ui"
|
|
|
|
"mujicaui/icons"
|
|
"mujicaui/internal"
|
|
"mujicaui/theme"
|
|
)
|
|
|
|
// AgentState is an agent or step state; zero is pending.
|
|
type AgentState int
|
|
|
|
const (
|
|
AgentPending AgentState = iota
|
|
AgentPlanning
|
|
AgentRunning
|
|
AgentWaiting
|
|
AgentDone
|
|
AgentFailed
|
|
AgentSkipped
|
|
)
|
|
|
|
func (s AgentState) String() string {
|
|
switch s {
|
|
case AgentPending:
|
|
return "pending"
|
|
case AgentPlanning:
|
|
return "planning"
|
|
case AgentRunning:
|
|
return "running"
|
|
case AgentWaiting:
|
|
return "waiting"
|
|
case AgentDone:
|
|
return "done"
|
|
case AgentFailed:
|
|
return "failed"
|
|
case AgentSkipped:
|
|
return "skipped"
|
|
}
|
|
panic(fmt.Sprintf("mujica: unknown AgentState %d", int(s)))
|
|
}
|
|
|
|
// agentTone returns the color and icon marking s.
|
|
func agentTone(k theme.Tokens, s AgentState) (ui.Color, *ui.SVG) {
|
|
switch s {
|
|
case AgentPlanning:
|
|
return k.Info, icons.Must("list")
|
|
case AgentRunning:
|
|
return k.AccentText, icons.Must("loader-circle")
|
|
case AgentWaiting:
|
|
return k.Warning, icons.Must("clock")
|
|
case AgentDone:
|
|
return k.Success, icons.Must("circle-check")
|
|
case AgentFailed:
|
|
return k.Danger, icons.Must("circle-x")
|
|
case AgentSkipped:
|
|
return k.TextMuted, icons.Must("ban")
|
|
}
|
|
return k.TextMuted, icons.Must("circle")
|
|
}
|
|
|
|
// agentMark draws s; running spins unless still.
|
|
func agentMark(c *ui.Context, s AgentState, size float32, still bool) *ui.Element {
|
|
tone, icon := agentTone(Tokens(c), s)
|
|
if s == AgentRunning && !still {
|
|
return Spinner(c, SpinnerOptions{Size: size, Color: tone, Label: msg(c, "agent_status.running")})
|
|
}
|
|
return ui.Icon(c, icon).Size(size, size).TextColor(tone).Shrink(0)
|
|
}
|
|
|
|
// agentWord is the built-in word for s.
|
|
func agentWord(c *ui.Context, s AgentState) string { return msg(c, "agent_status."+s.String()) }
|
|
|
|
// agentDuration formats d like "320 ms" or "4.2 s".
|
|
func agentDuration(d time.Duration) string {
|
|
if d < 0 {
|
|
panic(fmt.Sprintf("mujica: negative duration %v", d))
|
|
}
|
|
switch {
|
|
case d < time.Second:
|
|
return fmt.Sprintf("%d ms", d.Milliseconds())
|
|
case d < time.Minute:
|
|
return fmt.Sprintf("%.1f s", d.Seconds())
|
|
case d < time.Hour:
|
|
return fmt.Sprintf("%d m %02d s", int(d.Minutes()), int(d.Seconds())%60)
|
|
}
|
|
return fmt.Sprintf("%d h %02d m", int(d.Hours()), int(d.Minutes())%60)
|
|
}
|
|
|
|
// agentCard is the bordered surface every agent card sits on.
|
|
func agentCard(c *ui.Context) *ui.Element {
|
|
k := Tokens(c)
|
|
u := Density(c).Unit()
|
|
return ui.Column(c).Shrink(0).Padding(u*3).Gap(u*2).Radius(theme.CardRadius).
|
|
Background(k.Surface).Border(theme.BorderWidth, k.Border)
|
|
}
|
|
|
|
// agentCaption is small muted text.
|
|
func agentCaption(c *ui.Context, s string) *ui.Element {
|
|
return ui.Text(c, s).FontSize(FontSize(c, theme.CaptionSize)).TextColor(Tokens(c).TextMuted)
|
|
}
|
|
|
|
// agentWell styles a sunken monospace well.
|
|
func agentWell(c *ui.Context, l *ui.Element, maxHeight float32) *ui.Element {
|
|
k := Tokens(c)
|
|
u := Density(c).Unit()
|
|
return l.Shrink(1).MaxHeight(maxHeight).Padding(u*2, u*3).Radius(theme.ControlRadius).
|
|
Background(k.Background).Border(theme.BorderWidth, k.Border).Font(theme.MonoFont).
|
|
FontSize(FontSize(c, theme.CaptionSize+1))
|
|
}
|
|
|
|
// agentWellRows builds n rows in a well.
|
|
func agentWellRows(c *ui.Context, n int, row func(i int), maxHeight float32) *ui.Element {
|
|
return agentWell(c, agentRows(c, n, row), maxHeight)
|
|
}
|
|
|
|
// agentRows builds n scrolling rows, virtualized when many.
|
|
func agentRows(c *ui.Context, n int, row func(i int)) *ui.Element {
|
|
if n > agentVirtualRows {
|
|
return ui.List(c, nil, n, row)
|
|
}
|
|
return ui.Scroll(c).Children(func() {
|
|
for i := range n {
|
|
row(i)
|
|
}
|
|
})
|
|
}
|
|
|
|
// agentVirtualRows is the row count above which wells virtualize.
|
|
const agentVirtualRows = 200
|
|
|
|
// agentCodeCache keeps lines and comment states per text.
|
|
type agentCodeCache struct {
|
|
language, text string
|
|
lines []string
|
|
open []bool
|
|
}
|
|
|
|
func (cc *agentCodeCache) update(language, text string) {
|
|
if cc.lines != nil && cc.language == language && cc.text == text {
|
|
return
|
|
}
|
|
cc.language, cc.text = language, text
|
|
cc.lines = internal.SplitLines(text)
|
|
cc.open = make([]bool, len(cc.lines))
|
|
block := false
|
|
for i, line := range cc.lines {
|
|
cc.open[i] = block
|
|
_, block = internal.Highlight(language, line, block)
|
|
}
|
|
}
|
|
|
|
// agentPrettyJSON indents valid JSON; anything else comes back unchanged.
|
|
func agentPrettyJSON(s string) string {
|
|
var b bytes.Buffer
|
|
if json.Indent(&b, []byte(s), "", " ") != nil {
|
|
return s
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// agentTokenColor colors a highlight token from the theme.
|
|
func agentTokenColor(k theme.Tokens, kind internal.TokenKind) ui.Color {
|
|
switch kind {
|
|
case internal.TokKeyword:
|
|
return k.AccentText
|
|
case internal.TokString:
|
|
return k.Success
|
|
case internal.TokNumber, internal.TokType:
|
|
return k.Info
|
|
case internal.TokComment, internal.TokPunct:
|
|
return k.TextMuted
|
|
}
|
|
return k.Text
|
|
}
|
|
|
|
// agentCodeLine builds one highlighted line of language.
|
|
func agentCodeLine(c *ui.Context, language, line string, inBlock bool) {
|
|
k := Tokens(c)
|
|
toks, _ := internal.Highlight(language, line, inBlock)
|
|
spans := make([]ui.Span, 0, len(toks))
|
|
for _, t := range toks {
|
|
spans = append(spans, ui.Span{Text: t.Text, Color: agentTokenColor(k, t.Kind)})
|
|
}
|
|
if len(spans) == 0 {
|
|
spans = append(spans, ui.Span{Text: " "})
|
|
}
|
|
ui.RichText(c, spans...)
|
|
}
|
|
|
|
// agentCode shows highlighted text in a capped well.
|
|
func agentCode(c *ui.Context, language, text string, maxHeight float32) *ui.Element {
|
|
holder := ui.Column(c).Shrink(1)
|
|
cc := ui.Local(holder, "mujica.agent.code", func() *agentCodeCache { return &agentCodeCache{} })
|
|
(*cc).update(language, text)
|
|
lines, open := (*cc).lines, (*cc).open
|
|
row := func(i int) { agentCodeLine(c, language, lines[i], open[i]) }
|
|
holder.Children(func() {
|
|
agentWellRows(c, len(lines), row, maxHeight)
|
|
})
|
|
return holder
|
|
}
|
|
|
|
// agentAnsiColor maps a terminal color onto the theme.
|
|
func agentAnsiColor(k theme.Tokens, a internal.AnsiColor, fallback ui.Color) ui.Color {
|
|
switch a.Kind {
|
|
case internal.AnsiRGB:
|
|
return ui.RGB(a.R, a.G, a.B)
|
|
case internal.AnsiIndexed:
|
|
switch a.Index % 8 {
|
|
case 0:
|
|
return k.TextMuted
|
|
case 1:
|
|
return k.Danger
|
|
case 2:
|
|
return k.Success
|
|
case 3:
|
|
return k.Warning
|
|
case 4, 6:
|
|
return k.Info
|
|
case 5:
|
|
return k.AccentText
|
|
case 7:
|
|
return k.Text
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// agentAnsiSpans turns parsed terminal spans into rich text spans.
|
|
func agentAnsiSpans(k theme.Tokens, in []internal.AnsiSpan) []ui.Span {
|
|
out := make([]ui.Span, 0, len(in)+1)
|
|
for _, s := range in {
|
|
fg := agentAnsiColor(k, s.Style.FG, k.Text)
|
|
bg := agentAnsiColor(k, s.Style.BG, ui.Transparent)
|
|
if s.Style.Inverse {
|
|
fg, bg = agentAnsiColor(k, s.Style.BG, k.Background), fg
|
|
}
|
|
if s.Style.Dim {
|
|
fg = fg.Alpha(0.7)
|
|
}
|
|
sp := ui.Span{Text: s.Text, Color: fg, Background: bg, Italic: s.Style.Italic,
|
|
Underline: s.Style.Underline, Strikethrough: s.Style.Strike}
|
|
if s.Style.Bold {
|
|
sp.Weight = 700
|
|
}
|
|
out = append(out, sp)
|
|
}
|
|
if len(out) == 0 {
|
|
out = append(out, ui.Span{Text: " "})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// agentBar is a thin progress bar named label.
|
|
func agentBar(c *ui.Context, frac float64, label string) *ui.Element {
|
|
if frac < 0 || frac > 1 || frac != frac {
|
|
panic(fmt.Sprintf("mujica: progress %v outside 0..1", frac))
|
|
}
|
|
k := Tokens(c)
|
|
return ui.Box(c).FillWidth().Height(4).Shrink(0).Role(ui.RoleProgress).Label(label).
|
|
Draw(func(p *ui.Painter, r ui.Rect) {
|
|
p.Fill(r, k.Border, 2)
|
|
if frac > 0 {
|
|
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * float32(frac), H: r.H}, k.AccentText, 2)
|
|
}
|
|
})
|
|
}
|
|
|
|
// agentToggle is a small text button reporting a click.
|
|
func agentToggle(c *ui.Context, label string, icon string, disabled bool) bool {
|
|
k := Tokens(c)
|
|
u := Density(c).Unit()
|
|
b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Padding(0, u*2).Gap(u).
|
|
Radius(theme.ControlRadius).Disabled(disabled).TextColor(k.AccentText)
|
|
internal.Ring(b, k.Focus, theme.ControlRadius)
|
|
if !disabled && b.Hovered() {
|
|
b.Background(k.SurfaceHover)
|
|
}
|
|
b.Children(func() {
|
|
if icon != "" {
|
|
ui.Icon(c, icons.Must(icon)).Size(14, 14)
|
|
}
|
|
ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine()
|
|
})
|
|
return !b.IsDisabled() && b.Clicked()
|
|
}
|