Split into category packages; module github.com/ZacharyZhang-NY/MujicaUI

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 16:18:48 -04:00
1 parent 5a7e921c7e
commit 22b53e9e9e
1822 files changed
+24568 -21314

No files matched your search

+128
View File
@@ -0,0 +1,128 @@
package agent
import (
"fmt"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// FileChange is a proposed edit; empty Old adds a file.
type FileChange struct {
Path string
Old, New string
}
// FileDecision is a review state; zero is FilePending.
type FileDecision int
const (
FilePending FileDecision = iota
FileAccepted
FileRejected
)
// agentDiffCache holds a change's line diff until its text changes.
type agentDiffCache struct {
old, new string
lines []internal.DiffLine
add, del int
firstEdit int
done bool
}
func (dc *agentDiffCache) update(ch FileChange) {
if dc.done && dc.old == ch.Old && dc.new == ch.New {
return
}
dc.old, dc.new, dc.done = ch.Old, ch.New, true
dc.lines = internal.LineDiff(internal.SplitLines(ch.Old), internal.SplitLines(ch.New))
dc.add, dc.del = internal.DiffStats(dc.lines)
dc.firstEdit = 0
for i, l := range dc.lines {
if l.Op != internal.DiffEqual {
dc.firstEdit = i
break
}
}
}
// agentDiffOf returns the cached diff of ch kept on holder.
func agentDiffOf(holder *ui.Element, ch FileChange) *agentDiffCache {
dc := ui.Local(holder, "mujica.agent.diff", func() *agentDiffCache { return &agentDiffCache{} })
(*dc).update(ch)
return *dc
}
// agentDiffStats shows signed added and removed counts.
func agentDiffStats(c *ui.Context, add, del int) {
k := core.Tokens(c)
size := core.FontSize(c, theme.CaptionSize+1)
ui.Row(c).Gap(core.Density(c).Unit() * 1.5).Shrink(0).Label(fmt.Sprintf(core.Msg(c, "file_change_card.stats"), add, del)).Children(func() {
ui.Text(c, fmt.Sprintf("+%d", add)).TextColor(k.Success).FontSize(size).Font(theme.MonoFont)
ui.Text(c, fmt.Sprintf("−%d", del)).TextColor(k.Danger).FontSize(size).Font(theme.MonoFont)
})
}
// agentDiffRow draws one numbered, signed, tinted diff line.
func agentDiffRow(c *ui.Context, l internal.DiffLine) {
k := core.Tokens(c)
u := core.Density(c).Unit()
sign, bg, fg := " ", ui.Transparent, k.TextMuted
switch l.Op {
case internal.DiffInsert:
sign, bg, fg = "+", k.Success.Alpha(0.14), k.Success
case internal.DiffDelete:
sign, bg, fg = "−", k.Danger.Alpha(0.14), k.Danger
}
num := func(n int) string {
if n == 0 {
return ""
}
return fmt.Sprint(n)
}
ui.Row(c).Gap(u*2).Background(bg).Padding(0, u).Children(func() {
ui.Text(c, num(l.Old)).Width(36).TextAlign(ui.End).TextColor(k.TextMuted).Shrink(0)
ui.Text(c, num(l.New)).Width(36).TextAlign(ui.End).TextColor(k.TextMuted).Shrink(0)
ui.Text(c, sign).TextColor(fg).Width(10).Shrink(0)
text := l.Text
if text == "" {
text = " "
}
ui.Text(c, text).Grow(1).Shrink(1)
})
}
// agentDiffView shows lines[from:to] in a capped well, virtualized when long.
func agentDiffView(c *ui.Context, lines []internal.DiffLine, maxHeight float32) *ui.Element {
w := AgentWellRows(c, len(lines), func(i int) { agentDiffRow(c, lines[i]) }, maxHeight)
return w.Padding(core.Density(c).Unit(), 0)
}
// agentDecisionMark shows a decision by icon and word.
func agentDecisionMark(c *ui.Context, d FileDecision) {
ui.Row(c).Gap(core.Density(c).Unit()).AlignItems(ui.Center).Shrink(0).Children(func() {
AgentCaption(c, agentDecisionIcon(c, d))
})
}
// agentDecisionIcon draws a named decision icon, returning its word.
func agentDecisionIcon(c *ui.Context, d FileDecision) string {
k := core.Tokens(c)
tone, icon, word := k.TextMuted, "circle", core.Msg(c, "file_change_card.pending")
switch d {
case FileAccepted:
tone, icon, word = k.Success, "circle-check", core.Msg(c, "file_change_card.accepted")
case FileRejected:
tone, icon, word = k.Danger, "circle-x", core.Msg(c, "file_change_card.rejected")
case FilePending:
default:
panic(fmt.Sprintf("mujica: unknown FileDecision %d", int(d)))
}
ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(tone).Shrink(0).Label(word)
return word
}
+294
View File
@@ -0,0 +1,294 @@
package agent
import (
"bytes"
"encoding/json"
"fmt"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/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(core.Tokens(c), s)
if s == AgentRunning && !still {
return core.Spinner(c, core.SpinnerOptions{Size: size, Color: tone, Label: core.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 core.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 := core.Tokens(c)
u := core.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(core.FontSize(c, theme.CaptionSize)).TextColor(core.Tokens(c).TextMuted)
}
// agentWell styles a sunken monospace well.
func agentWell(c *ui.Context, l *ui.Element, maxHeight float32) *ui.Element {
k := core.Tokens(c)
u := core.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(core.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 := core.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 := core.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 := core.Tokens(c)
u := core.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(core.FontSize(c, theme.CaptionSize+1)).SingleLine()
})
return !b.IsDisabled() && b.Clicked()
}
+148
View File
@@ -0,0 +1,148 @@
package agent
import (
"fmt"
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// PlanItem is a plan task: pending, running, done or skipped.
type PlanItem struct {
ID string
Text string
State AgentState
}
// AgentPlanOptions configure an AgentPlan.
type AgentPlanOptions struct {
// Title heads the checklist; empty uses "Plan".
Title string
Disabled bool
Still bool
}
type agentPlanState struct{ draft string }
// AgentPlan is a live checklist with skip, restore and add.
func AgentPlan(c *ui.Context, items *[]PlanItem, opts AgentPlanOptions) core.ChoiceResult {
if items == nil {
panic("mujica: AgentPlan needs items")
}
seen := map[string]bool{}
done, counted := 0, 0
for i, it := range *items {
if it.ID == "" || seen[it.ID] {
panic(fmt.Sprintf("mujica: AgentPlan item %d needs a unique ID", i))
}
switch it.State {
case AgentPending, AgentRunning, AgentDone, AgentSkipped:
default:
panic(fmt.Sprintf("mujica: AgentPlan item %q has state %v", it.ID, it.State))
}
seen[it.ID] = true
if it.State != AgentSkipped {
counted++
}
done += agentB2I(it.State == AgentDone)
}
u := core.Density(c).Unit()
title := opts.Title
if title == "" {
title = core.Msg(c, "agent_plan.title")
}
var r core.ChoiceResult
r.Element = AgentCard(c).Disabled(opts.Disabled).Label(title)
st := ui.Local(r.Element, "mujica.plan", func() agentPlanState { return agentPlanState{} })
locked := r.Element.IsDisabled()
r.Element.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, title).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).FontWeight(600).Grow(1)
AgentCaption(c, fmt.Sprintf(core.Msg(c, "agent_plan.progress"), done, counted))
})
frac := 0.0
if counted > 0 {
frac = float64(done) / float64(counted)
}
agentBar(c, frac, fmt.Sprintf(core.Msg(c, "agent_plan.progress"), done, counted))
list := *items
for i := range list {
ui.Row(c).Key(list[i].ID).Gap(u * 2).AlignItems(ui.Center).MinHeight(core.Density(c).ControlHeight()).Children(func() {
if agentPlanRow(c, &list[i], locked, opts.Still) {
r = core.NewChoiceResult(r.Element, true)
}
})
}
if !locked && agentPlanAdd(c, &st.draft) {
*items = append(*items, PlanItem{ID: agentPlanID(seen), Text: strings.TrimSpace(st.draft)})
st.draft = ""
r = core.NewChoiceResult(r.Element, true)
}
})
if r.Changed() {
slog.Debug("mujica: agent_plan change", "items", len(*items))
}
return r
}
// agentPlanRow builds one item, reporting a user change.
func agentPlanRow(c *ui.Context, it *PlanItem, locked, still bool) bool {
k := core.Tokens(c)
checked := it.State == AgentDone
box := input.Checkbox(c, &checked, "", input.CheckboxOptions{Disabled: locked || it.State == AgentSkipped}).Label(it.Text)
changed := !locked && box.Changed()
if changed {
it.State = AgentPending
if checked {
it.State = AgentDone
}
}
t := ui.Text(c, it.Text).Grow(1).Shrink(1)
switch it.State {
case AgentSkipped:
t.Strikethrough().TextColor(k.TextMuted)
case AgentRunning:
agentMark(c, AgentRunning, 14, still)
AgentCaption(c, core.Msg(c, "agent_plan.doing"))
}
if it.State == AgentDone || locked {
return changed
}
label, icon, next := core.Msg(c, "agent_plan.skip"), "ban", AgentSkipped
if it.State == AgentSkipped {
label, icon, next = core.Msg(c, "agent_plan.restore"), "refresh-cw", AgentPending
}
if AgentToggle(c, label, icon, false) {
it.State = next
changed = true
}
return changed
}
// agentPlanAdd builds the new-item field and reports a non-empty submit.
func agentPlanAdd(c *ui.Context, draft *string) bool {
u := core.Density(c).Unit()
add := false
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
in := input.TextInput(c, draft, input.TextInputOptions{Placeholder: core.Msg(c, "agent_plan.placeholder"), Label: core.Msg(c, "agent_plan.placeholder")})
in.Element.Grow(1)
blank := strings.TrimSpace(*draft) == ""
add = input.Button(c, core.Msg(c, "agent_plan.add"), input.ButtonOptions{Variant: input.Secondary, Disabled: blank}).Clicked()
add = (add || in.Submitted()) && !blank
})
return add
}
// agentPlanID returns an ID no item uses yet.
func agentPlanID(seen map[string]bool) string {
for n := len(seen) + 1; ; n++ {
if id := fmt.Sprintf("item-%d", n); !seen[id] {
return id
}
}
}
+96
View File
@@ -0,0 +1,96 @@
package agent
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func agentPlanSample() []PlanItem {
return []PlanItem{
{ID: "a", Text: "Read the guest list", State: AgentDone},
{ID: "b", Text: "Assign the seats", State: AgentRunning},
{ID: "c", Text: "Order the flowers"},
}
}
func planTester(items *[]PlanItem, opts AgentPlanOptions, changes *int) *ui.Tester {
opts.Still = true
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if AgentPlan(c, items, opts).Changed() {
*changes++
}
}, 560, 500)
}
func TestAgentPlanProgressCheckSkipAdd(t *testing.T) {
items := agentPlanSample()
changes := 0
tt := planTester(&items, AgentPlanOptions{}, &changes)
if !tt.HasText("1 / 3 done") || !tt.HasText("In progress") {
t.Fatalf("texts %q", tt.Texts())
}
_ = tt.Click("Order the flowers") // the box is named by the item
if items[2].State != AgentDone || changes != 1 || !tt.HasText("2 / 3 done") {
t.Fatalf("check: %+v changes %d", items[2], changes)
}
skips, _ := tt.Find("Skip")
tt.ClickAt(skips.X+4, skips.Y+4) // the running item's Skip
if items[1].State != AgentSkipped || !tt.HasText("2 / 2 done") {
t.Fatalf("skip: %+v texts %q", items[1], tt.Texts())
}
_ = tt.Click("Restore")
if items[1].State != AgentPending {
t.Fatalf("restore: %+v", items[1])
}
_ = tt.Click("Add a step")
tt.Type(" ")
tt.Key(0, ui.KeyEnter)
if len(items) != 3 {
t.Fatal("blank item added")
}
tt.Type("Light the candles")
tt.Key(0, ui.KeyEnter)
if len(items) != 4 || items[3].Text != "Light the candles" || items[3].ID == "" || !tt.HasText("Light the candles") {
t.Fatalf("add: %+v", items)
}
}
func TestAgentPlanLiveUpdateAndDisabled(t *testing.T) {
items := agentPlanSample()
changes := 0
tt := planTester(&items, AgentPlanOptions{Disabled: true, Title: "Ball"}, &changes)
_ = tt.Click("Order the flowers")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if changes != 0 || items[2].State != AgentPending || tt.HasText("Add a step") || tt.HasText("Skip") {
t.Fatalf("disabled plan changed: %+v", items)
}
items[2].State = AgentDone // the agent checks it off
tt.Frame()
if !tt.HasText("2 / 3 done") || !tt.HasText("Ball") {
t.Fatalf("live update: %q", tt.Texts())
}
}
func TestAgentPlanRejectsBadState(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
items := []PlanItem{{ID: "a", Text: "x", State: AgentFailed}}
n := 0
planTester(&items, AgentPlanOptions{}, &n)
}
func TestAgentPlanSnapshot(t *testing.T) {
snapshot(t, "agent_plan", 520, 330, func(c *ui.Context) {
items := agentPlanSample()
items = append(items, PlanItem{ID: "d", Text: "Hire the quartet", State: AgentSkipped})
ui.Column(c).Padding(16).Children(func() { AgentPlan(c, &items, AgentPlanOptions{Still: true}) })
})
}
+39
View File
@@ -0,0 +1,39 @@
package agent
import (
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/feedback"
)
// AgentProgressOptions configure an AgentProgress.
type AgentProgressOptions struct {
// Label names the work; empty uses "Progress".
Label string
// Action is what the agent does now.
Action string
// Indeterminate shows work of unknown length; the value is ignored.
Indeterminate bool
Still bool
}
// AgentProgress shows progress 0 to 1 and the current action.
func AgentProgress(c *ui.Context, value float64, opts AgentProgressOptions) *ui.Element {
label := opts.Label
if label == "" {
label = core.Msg(c, "agent_progress.label")
}
u := core.Density(c).Unit()
col := ui.Column(c).FillWidth().Shrink(0).Gap(u * 1.5)
col.Children(func() {
feedback.Progress(c, value, feedback.ProgressOptions{Label: label, ShowValue: !opts.Indeterminate, Indeterminate: opts.Indeterminate})
if opts.Action != "" {
ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).Role(ui.RoleStatus).Label(opts.Action).Children(func() {
agentMark(c, AgentRunning, 14, opts.Still)
AgentCaption(c, opts.Action)
})
}
})
return col
}
+56
View File
@@ -0,0 +1,56 @@
package agent
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestAgentProgressValueAndAction(t *testing.T) {
value, action := 0.25, "Reading ledger.md"
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
AgentProgress(c, value, AgentProgressOptions{Action: action, Still: true})
}, 400, 200)
if !tt.HasText("25%") || !tt.HasText("Reading ledger.md") || !tt.HasText("Progress") {
t.Fatalf("texts %q", tt.Texts())
}
value, action = 0.8, "Writing summary.md"
tt.Frame()
if !tt.HasText("80%") || !tt.HasText("Writing summary.md") || tt.HasText("Reading ledger.md") {
t.Fatalf("update: %q", tt.Texts())
}
}
func TestAgentProgressIndeterminate(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
AgentProgress(c, 0, AgentProgressOptions{Label: "Research", Indeterminate: true, Still: true})
}, 400, 200)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
if tt.HasText("0%") || !tt.HasText("Research") {
t.Fatalf("texts %q", tt.Texts())
}
}
func TestAgentProgressRejectsOutOfRange(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
AgentProgress(c, 1.5, AgentProgressOptions{})
}, 200, 100)
}
func TestAgentProgressSnapshot(t *testing.T) {
snapshot(t, "agent_progress", 420, 110, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
AgentProgress(c, 0.62, AgentProgressOptions{Label: "Preparing the ball", Action: "Writing the seating chart"})
})
})
}
+35
View File
@@ -0,0 +1,35 @@
package agent
import (
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// AgentStatusOptions configure an AgentStatus.
type AgentStatusOptions struct {
// Label replaces the built-in word, as "Writing the report".
Label string
// Still stops the running animation.
Still bool
}
// AgentStatus shows a state by icon shape and word.
func AgentStatus(c *ui.Context, state AgentState, opts AgentStatusOptions) *ui.Element {
label := opts.Label
if label == "" {
label = agentWord(c, state) // panics on an unknown state
}
k := core.Tokens(c)
u := core.Density(c).Unit()
tone, _ := agentTone(k, state)
pill := ui.Row(c).Shrink(0).AlignSelf(ui.Start).AlignItems(ui.Center).Gap(u*1.5).Padding(u, u*2.5).
Radius(theme.CardRadius).Border(theme.BorderWidth, tone).Background(k.Surface).
Role(ui.RoleStatus).Label(label)
pill.Children(func() {
agentMark(c, state, 14, opts.Still)
ui.Text(c, label).FontSize(core.FontSize(c, theme.CaptionSize+1)).TextColor(k.Text).SingleLine()
})
return pill
}
+71
View File
@@ -0,0 +1,71 @@
package agent
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func TestAgentStatusWords(t *testing.T) {
states := []AgentState{AgentPlanning, AgentRunning, AgentWaiting, AgentDone, AgentFailed}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
for _, s := range states {
AgentStatus(c, s, AgentStatusOptions{Still: true})
}
AgentStatus(c, AgentRunning, AgentStatusOptions{Label: "Writing the report", Still: true})
}, 400, 400)
for _, w := range []string{"Planning", "Running", "Waiting", "Done", "Failed", "Writing the report"} {
if !tt.HasText(w) {
t.Fatalf("missing %q in %q", w, tt.Texts())
}
}
}
func TestAgentStatusAnimationStopsWhenStill(t *testing.T) {
still := false
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
AgentStatus(c, AgentRunning, AgentStatusOptions{Still: still})
}, 300, 100)
tt.Frame()
moving := string(tt.Image().Pix)
time.Sleep(80 * time.Millisecond)
tt.Frame()
if moving == string(tt.Image().Pix) {
t.Fatal("running status did not animate")
}
still = true
tt.Frame()
a := string(tt.Image().Pix)
time.Sleep(80 * time.Millisecond)
tt.Frame()
if a != string(tt.Image().Pix) {
t.Fatal("still status kept moving")
}
}
func TestAgentStatusRejectsUnknownState(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
AgentStatus(c, AgentState(42), AgentStatusOptions{})
}, 200, 100)
}
func TestAgentStatusSnapshot(t *testing.T) {
snapshot(t, "agent_status", 480, 120, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(8).Wrap().Children(func() {
for _, s := range []AgentState{AgentPlanning, AgentRunning, AgentWaiting, AgentDone, AgentFailed} {
AgentStatus(c, s, AgentStatusOptions{})
}
})
})
}
+141
View File
@@ -0,0 +1,141 @@
package agent
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// AgentStep is one step of an agent run.
type AgentStep struct {
ID string
Title string
Detail string
State AgentState
Duration time.Duration
}
// AgentStepListOptions configure an AgentStepList.
type AgentStepListOptions struct {
Disabled bool
Still bool
}
// AgentStepListResult reports this frame's events.
type AgentStepListResult struct {
Element *ui.Element
// Retry is the failed step to retry, else "".
Retry string
// Toggled is the step whose detail toggled, else "".
Toggled string
}
// AgentStepList is a step timeline with details and retries.
func AgentStepList(c *ui.Context, steps []AgentStep, opts AgentStepListOptions) AgentStepListResult {
current := -1
seen := make(map[string]bool, len(steps))
for i, s := range steps {
if s.ID == "" || s.Title == "" || seen[s.ID] {
panic(fmt.Sprintf("mujica: AgentStepList step %d needs a unique ID and a Title", i))
}
seen[s.ID] = true
if current < 0 && (s.State == AgentRunning || s.State == AgentWaiting || s.State == AgentPlanning) {
current = i
}
}
var r AgentStepListResult
r.Element = ui.Column(c).Shrink(0).FocusGroup(ui.Vertical).Disabled(opts.Disabled)
open := ui.Local(r.Element, "mujica.steps.open", func() map[string]bool { return map[string]bool{} })
r.Element.Children(func() {
for i, s := range steps {
ui.Column(c).Key(s.ID).Children(func() {
if agentStepRow(c, s, i, len(steps), i == current, (*open)[s.ID], opts.Still, &r) {
(*open)[s.ID] = !(*open)[s.ID]
r.Toggled = s.ID
}
})
}
})
if r.Element.IsDisabled() {
r.Retry, r.Toggled = "", ""
}
for id := range *open {
if !seen[id] {
delete(*open, id)
}
}
if r.Retry != "" || r.Toggled != "" {
slog.Debug("mujica: agent_step_list event", "retry", r.Retry, "toggled", r.Toggled)
}
return r
}
// agentStepRow builds one step, reporting a header click.
func agentStepRow(c *ui.Context, s AgentStep, i, n int, current, open, still bool, r *AgentStepListResult) bool {
k := core.Tokens(c)
d := core.Density(c)
u := d.Unit()
clicked := false
top := (d.ControlHeight() - 18) / 2 // centers the mark on the step header
ui.Row(c).Gap(u * 3).AlignItems(ui.Stretch).Children(func() {
// The rail joins the marks; it is drawn, not focusable.
ui.Column(c).Width(20).Shrink(0).AlignItems(ui.Center).Draw(func(p *ui.Painter, rc ui.Rect) {
x := internal.Snap(p, rc.X+rc.W/2)
if i > 0 {
p.Line(x, rc.Y, x, rc.Y+top-3, 1, k.ControlBorder)
}
if i < n-1 {
p.Line(x, rc.Y+top+21, x, rc.Y+rc.H, 1, k.ControlBorder)
}
}).Children(func() {
ui.Box(c).Height(top)
agentMark(c, s.State, 18, still)
})
ui.Column(c).Grow(1).Shrink(1).Gap(u).Padding(0, 0, u*2, 0).Children(func() {
head := ui.ButtonBase(c).Justify(ui.Start).Gap(u*2).MinHeight(d.ControlHeight()).Padding(0, u*2).
Radius(theme.ControlRadius).Label(s.Title + ", " + agentWord(c, s.State))
internal.Ring(head, k.Focus, theme.ControlRadius)
if current {
head.Background(k.Selection).Border(theme.BorderWidth, k.AccentText)
} else if head.Hovered() {
head.Background(k.SurfaceHover)
}
head.Children(func() {
ui.Text(c, s.Title).FontWeight(600).Grow(1).Shrink(1)
if current {
AgentCaption(c, core.Msg(c, "agent_step_list.current"))
}
AgentCaption(c, agentWord(c, s.State))
if s.State == AgentDone || s.State == AgentFailed {
AgentCaption(c, AgentDuration(s.Duration))
}
if s.Detail != "" {
chev := "chevron-right"
if open {
chev = "chevron-down"
}
ui.Icon(c, icons.Must(chev)).Size(14, 14).TextColor(k.TextMuted)
}
})
clicked = s.Detail != "" && !head.IsDisabled() && head.Clicked()
if open && s.Detail != "" {
ui.Text(c, s.Detail).TextColor(k.TextMuted).Selectable().Padding(0, u*2)
}
if s.State == AgentFailed {
ui.Row(c).Children(func() {
if AgentToggle(c, core.Msg(c, "agent_step_list.retry"), "refresh-cw", head.IsDisabled()) {
r.Retry = s.ID
}
})
}
})
})
return clicked
}
+89
View File
@@ -0,0 +1,89 @@
package agent
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var agentStepsSample = []AgentStep{
{ID: "plan", Title: "Read the guest list", State: AgentDone, Duration: 800 * time.Millisecond, Detail: "42 guests, 3 late."},
{ID: "seat", Title: "Assign the seats", State: AgentFailed, Duration: 2 * time.Second, Detail: "Table 4 is over capacity."},
{ID: "send", Title: "Send the invitations", State: AgentRunning},
{ID: "log", Title: "Record the ledger", State: AgentPending},
}
func stepTester(steps []AgentStep, opts AgentStepListOptions, got *[]AgentStepListResult) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
r := AgentStepList(c, steps, opts)
if r.Retry != "" || r.Toggled != "" {
*got = append(*got, r)
}
}, 600, 500)
}
func TestAgentStepListCurrentDetailRetry(t *testing.T) {
var got []AgentStepListResult
tt := stepTester(agentStepsSample, AgentStepListOptions{Still: true}, &got)
if !tt.HasText("Current") || !tt.HasText("Running") || !tt.HasText("800 ms") {
t.Fatalf("texts %q", tt.Texts())
}
if tt.HasText("Table 4 is over capacity.") {
t.Fatal("detail shown while closed")
}
_ = tt.Click("Assign the seats")
if len(got) != 1 || got[0].Toggled != "seat" || !tt.HasText("Table 4 is over capacity.") {
t.Fatalf("toggle: %+v", got)
}
_ = tt.Click("Retry")
if len(got) != 2 || got[1].Retry != "seat" {
t.Fatalf("retry: %+v", got)
}
}
func TestAgentStepListKeyboard(t *testing.T) {
var got []AgentStepListResult
tt := stepTester(agentStepsSample, AgentStepListOptions{Still: true}, &got)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if len(got) != 1 || got[0].Toggled != "plan" || !tt.HasText("42 guests, 3 late.") {
t.Fatalf("Enter on the first step: %+v", got)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown) // past Assign the seats onto its Retry
tt.Key(0, ui.KeyEnter)
if len(got) != 2 || got[1].Retry != "seat" {
t.Fatalf("arrows to Retry: %+v", got)
}
}
func TestAgentStepListDisabled(t *testing.T) {
var got []AgentStepListResult
tt := stepTester(agentStepsSample, AgentStepListOptions{Disabled: true, Still: true}, &got)
_ = tt.Click("Assign the seats")
_ = tt.Click("Retry")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if len(got) != 0 {
t.Fatalf("disabled list reported %+v", got)
}
}
func TestAgentStepListRejectsDuplicateIDs(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
stepTester([]AgentStep{{ID: "a", Title: "x"}, {ID: "a", Title: "y"}}, AgentStepListOptions{}, nil)
}
func TestAgentStepListSnapshot(t *testing.T) {
snapshot(t, "agent_step_list", 520, 340, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { AgentStepList(c, agentStepsSample, AgentStepListOptions{Still: true}) })
})
}
+149
View File
@@ -0,0 +1,149 @@
package agent
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ArtifactKind is what an artifact holds; zero is code.
type ArtifactKind int
const (
ArtifactCode ArtifactKind = iota
ArtifactDocument
ArtifactWeb
)
// Artifact is something an agent made; the caller owns it.
type Artifact struct {
Title string
Kind ArtifactKind
// Language highlights code, as "go" or "json".
Language string
Content string
}
// ArtifactPanelOptions configure an ArtifactPanel.
type ArtifactPanelOptions struct {
// Preview renders documents and web pages; the caller supplies it.
Preview func()
// Height of the body; 320 DIPs when zero.
Height float32
Disabled bool
}
// ArtifactPanelResult reports this frame's events.
type ArtifactPanelResult struct {
Element *ui.Element
// Copied reports the content went to the clipboard.
Copied bool
// Exported asks the caller to export the artifact.
Exported bool
}
type artifactPanelState struct {
source bool
copiedAt time.Time
}
// ArtifactPanel shows an artifact, with copy and export.
func ArtifactPanel(c *ui.Context, art Artifact, opts ArtifactPanelOptions) ArtifactPanelResult {
if art.Title == "" {
panic("mujica: ArtifactPanel needs a Title")
}
if art.Kind < ArtifactCode || art.Kind > ArtifactWeb {
panic(fmt.Sprintf("mujica: unknown ArtifactKind %d", int(art.Kind)))
}
if art.Kind != ArtifactCode && opts.Preview == nil {
panic("mujica: ArtifactPanel needs a Preview for documents and web pages")
}
if opts.Height < 0 {
panic("mujica: ArtifactPanel Height is negative")
}
h := opts.Height
if h == 0 {
h = 320
}
k := core.Tokens(c)
u := core.Density(c).Unit()
var r ArtifactPanelResult
r.Element = AgentCard(c).Disabled(opts.Disabled).Label(art.Title)
st := ui.Local(r.Element, "mujica.artifact", func() artifactPanelState { return artifactPanelState{source: art.Kind == ArtifactCode} })
locked := r.Element.IsDisabled()
r.Element.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, art.Title).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).FontWeight(600).SingleLine().Grow(1).Shrink(1)
if opts.Preview != nil {
artifactViewSwitch(c, &st.source, locked)
}
copied := !st.copiedAt.IsZero() && c.Now().Sub(st.copiedAt) < 2*time.Second
label, icon := core.Msg(c, "artifact_panel.copy"), "copy"
if copied {
label, icon = core.Msg(c, "artifact_panel.copied"), "check"
c.After(2*time.Second - c.Now().Sub(st.copiedAt))
}
if AgentToggle(c, label, icon, locked) {
c.WriteClipboard(art.Content)
st.copiedAt, r.Copied = c.Now(), true
}
if AgentToggle(c, core.Msg(c, "artifact_panel.export"), "upload", locked) {
r.Exported = true
}
})
ui.Column(c).Height(h).Shrink(0).Children(func() {
if st.source || opts.Preview == nil {
AgentCode(c, art.Language, art.Content, h).Grow(1)
return
}
ui.Scroll(c).Grow(1).Padding(u*3).Radius(theme.ControlRadius).Background(k.Background).
Border(theme.BorderWidth, k.Border).Children(opts.Preview)
})
})
if r.Copied || r.Exported {
slog.Debug("mujica: artifact_panel event", "title", art.Title, "copied", r.Copied, "exported", r.Exported)
}
return r
}
// artifactViewSwitch is the Preview / Source segmented switch.
func artifactViewSwitch(c *ui.Context, source *bool, locked bool) {
k := core.Tokens(c)
u := core.Density(c).Unit()
ui.Row(c).Shrink(0).FocusGroup(ui.Horizontal).Border(theme.BorderWidth, k.ControlBorder).Radius(theme.ControlRadius).
Role(ui.RoleRadioGroup).Children(func() {
for _, src := range []bool{false, true} {
label, icon := core.Msg(c, "artifact_panel.preview"), "eye"
if src {
label, icon = core.Msg(c, "artifact_panel.source"), "file-text"
}
b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, u*2).Gap(u).Role(ui.RoleRadio).Label(label).
Disabled(locked)
internal.Ring(b, k.Focus, theme.ControlRadius)
on := *source == src
if on {
b.Background(k.Selection)
} else if b.Hovered() {
b.Background(k.SurfaceHover)
}
b.Children(func() {
if on {
ui.Icon(c, icons.Must("check")).Size(12, 12).TextColor(k.AccentText)
}
ui.Icon(c, icons.Must(icon)).Size(14, 14)
ui.Text(c, label).FontSize(core.FontSize(c, theme.CaptionSize+1))
})
if !b.IsDisabled() && b.Clicked() && !on {
*source = src
slog.Debug("mujica: artifact_panel view", "source", src)
}
}
})
}
+81
View File
@@ -0,0 +1,81 @@
package agent
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var artifactCodeSample = Artifact{Title: "seating.go", Language: "go", Content: "package court\n\n// Seats maps guests to tables.\nfunc Seats() map[string]int {\n\treturn map[string]int{\"Sakiko\": 1}\n}\n"}
func artifactTester(art Artifact, opts ArtifactPanelOptions, got *[]ArtifactPanelResult) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
r := ArtifactPanel(c, art, opts)
if r.Copied || r.Exported {
*got = append(*got, r)
}
}, 600, 460)
}
func TestArtifactPanelCodeCopyExport(t *testing.T) {
var got []ArtifactPanelResult
tt := artifactTester(artifactCodeSample, ArtifactPanelOptions{}, &got)
if !tt.HasText("// Seats maps guests to tables.") || tt.HasText("Preview") {
t.Fatalf("texts %q", tt.Texts())
}
_ = tt.Click("Copy")
if len(got) != 1 || !got[0].Copied || tt.Clipboard() != artifactCodeSample.Content || !tt.HasText("Copied") {
t.Fatalf("copy: %+v clipboard %q", got, tt.Clipboard())
}
_ = tt.Click("Export")
if len(got) != 2 || !got[1].Exported {
t.Fatalf("export: %+v", got)
}
}
func TestArtifactPanelPreviewSourceSwitch(t *testing.T) {
doc := Artifact{Title: "Invitation", Kind: ArtifactDocument, Content: "# You are invited\n"}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ArtifactPanel(c, doc, ArtifactPanelOptions{Preview: func() { ui.Text(c, "Rendered invitation") }})
}, 600, 460)
if !tt.HasText("Rendered invitation") || tt.HasText("# You are invited") {
t.Fatalf("preview first: %q", tt.Texts())
}
_ = tt.Click("Source")
if !tt.HasText("# You are invited") || tt.HasText("Rendered invitation") {
t.Fatalf("source: %q", tt.Texts())
}
tt.Key(0, ui.KeyLeft) // the switch is a focus group
tt.Key(0, ui.KeyEnter)
if !tt.HasText("Rendered invitation") {
t.Fatal("keyboard back to preview failed")
}
}
func TestArtifactPanelDisabledAndRules(t *testing.T) {
var got []ArtifactPanelResult
tt := artifactTester(artifactCodeSample, ArtifactPanelOptions{Disabled: true}, &got)
_ = tt.Click("Copy")
_ = tt.Click("Export")
if len(got) != 0 || tt.Clipboard() != "" {
t.Fatalf("disabled panel acted: %+v", got)
}
defer func() {
if recover() == nil {
t.Fatal("web artifact without Preview did not panic")
}
}()
artifactTester(Artifact{Title: "x", Kind: ArtifactWeb}, ArtifactPanelOptions{}, &got)
}
func TestArtifactPanelSnapshot(t *testing.T) {
snapshot(t, "artifact_panel", 560, 300, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
ArtifactPanel(c, artifactCodeSample, ArtifactPanelOptions{Height: 170, Preview: func() {}})
})
})
}
+90
View File
@@ -0,0 +1,90 @@
package agent
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
)
// ArtifactVersion is one saved version of an artifact.
type ArtifactVersion struct {
Label string
Note string
}
// ArtifactVersionSwitcherOptions configure an ArtifactVersionSwitcher.
type ArtifactVersionSwitcherOptions struct {
Disabled bool
}
// ArtifactVersionResult reports this frame's events.
type ArtifactVersionResult struct {
Element *ui.Element
// Changed reports *current moved.
Changed bool
// Compare asks to diff current against the previous version.
Compare bool
}
// ArtifactVersionSwitcher steps between artifact versions.
func ArtifactVersionSwitcher(c *ui.Context, current *int, versions []ArtifactVersion, opts ArtifactVersionSwitcherOptions) ArtifactVersionResult {
if current == nil || len(versions) == 0 || *current < 0 || *current >= len(versions) {
panic("mujica: ArtifactVersionSwitcher needs versions and a current index inside them")
}
for i, v := range versions {
if v.Label == "" {
panic(fmt.Sprintf("mujica: ArtifactVersionSwitcher version %d needs a Label", i))
}
}
u := core.Density(c).Unit()
n := len(versions)
var r ArtifactVersionResult
r.Element = ui.Row(c).Shrink(0).Gap(u * 2).AlignItems(ui.Center).Disabled(opts.Disabled).
Label(core.Msg(c, "artifact_version_switcher.label"))
locked := r.Element.IsDisabled()
move := func(d int) {
if to := *current + d; !locked && to >= 0 && to < n {
*current, r.Changed = to, true
}
}
r.Element.Children(func() {
if core.IconAction(c, icons.Must("chevron-left"), core.Msg(c, "artifact_version_switcher.older")).Disabled(locked || *current == 0).Clicked() {
move(-1)
}
v := versions[*current]
ui.Column(c).Shrink(1).Role(ui.RoleStatus).Label(v.Label).Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, v.Label).FontWeight(600).SingleLine()
AgentCaption(c, fmt.Sprintf(core.Msg(c, "artifact_version_switcher.count"), *current+1, n))
})
if v.Note != "" {
AgentCaption(c, v.Note).SingleLine()
}
})
if core.IconAction(c, icons.Must("chevron-right"), core.Msg(c, "artifact_version_switcher.newer")).Disabled(locked || *current == n-1).Clicked() {
move(1)
}
if *current > 0 {
label := fmt.Sprintf(core.Msg(c, "artifact_version_switcher.compare"), versions[*current-1].Label)
if AgentToggle(c, label, "arrow-up-down", locked) {
r.Compare = true
}
}
})
if !locked {
if r.Element.Shortcut(0, ui.KeyLeft) {
move(-1)
}
if r.Element.Shortcut(0, ui.KeyRight) {
move(1)
}
}
if r.Changed || r.Compare {
slog.Debug("mujica: artifact_version_switcher event", "current", *current, "compare", r.Compare)
}
return r
}
+79
View File
@@ -0,0 +1,79 @@
package agent
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var artifactVersionsSample = []ArtifactVersion{
{Label: "v1", Note: "First draft"},
{Label: "v2", Note: "Added the quartet"},
{Label: "v3", Note: "Moved the wine table"},
}
func versionTester(cur *int, opts ArtifactVersionSwitcherOptions, got *[]ArtifactVersionResult) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
r := ArtifactVersionSwitcher(c, cur, artifactVersionsSample, opts)
if r.Changed || r.Compare {
*got = append(*got, r)
}
}, 600, 120)
}
func TestArtifactVersionSwitcherStepsAndCompare(t *testing.T) {
cur := 2
var got []ArtifactVersionResult
tt := versionTester(&cur, ArtifactVersionSwitcherOptions{}, &got)
if !tt.HasText("3 of 3") || !tt.HasText("Moved the wine table") || !tt.HasText("Compare with v2") {
t.Fatalf("texts %q", tt.Texts())
}
_ = tt.Click("Compare with v2")
if len(got) != 1 || !got[0].Compare || cur != 2 {
t.Fatalf("compare: %+v", got)
}
_ = tt.Click("Older version")
_ = tt.Click("Older version")
if cur != 0 || tt.HasText("Compare with v1") || !tt.HasText("First draft") {
t.Fatalf("older: cur %d texts %q", cur, tt.Texts())
}
_ = tt.Click("Older version") // disabled at the first version
if cur != 0 || len(got) != 3 {
t.Fatalf("past the first: cur %d events %d", cur, len(got))
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if cur != 1 {
t.Fatalf("Right: cur %d", cur)
}
}
func TestArtifactVersionSwitcherDisabledAndRange(t *testing.T) {
cur := 1
var got []ArtifactVersionResult
tt := versionTester(&cur, ArtifactVersionSwitcherOptions{Disabled: true}, &got)
_ = tt.Click("Older version")
_ = tt.Click("Compare with v1")
if cur != 1 || len(got) != 0 {
t.Fatalf("disabled moved: cur %d %+v", cur, got)
}
defer func() {
if recover() == nil {
t.Fatal("out-of-range current did not panic")
}
}()
cur = 5
versionTester(&cur, ArtifactVersionSwitcherOptions{}, &got)
}
func TestArtifactVersionSwitcherSnapshot(t *testing.T) {
snapshot(t, "artifact_version_switcher", 560, 90, func(c *ui.Context) {
cur := 1
ui.Column(c).Padding(16).Children(func() {
ArtifactVersionSwitcher(c, &cur, artifactVersionsSample, ArtifactVersionSwitcherOptions{})
})
})
}
+121
View File
@@ -0,0 +1,121 @@
package agent
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// Checkpoint is a saved point to roll back to.
type Checkpoint struct {
ID string
Label string
Note string
}
// CheckpointListOptions configure a CheckpointList.
type CheckpointListOptions struct {
// MaxHeight caps the scrolling list; zero means 360.
MaxHeight float32
Disabled bool
}
type checkpointListState struct {
asking bool
target string
}
// CheckpointList marks the current point and confirms rollbacks.
func CheckpointList(c *ui.Context, current *string, points []Checkpoint, opts CheckpointListOptions) core.ChoiceResult {
if current == nil {
panic("mujica: CheckpointList needs current")
}
if opts.MaxHeight < 0 {
panic("mujica: CheckpointList MaxHeight is negative")
}
seen := map[string]bool{}
for i, p := range points {
if p.ID == "" || p.Label == "" || seen[p.ID] {
panic(fmt.Sprintf("mujica: CheckpointList point %d needs a unique ID and a Label", i))
}
seen[p.ID] = true
}
maxH := opts.MaxHeight
if maxH == 0 {
maxH = 360
}
k := core.Tokens(c)
var r core.ChoiceResult
r.Element = ui.Column(c).Shrink(0).Disabled(opts.Disabled).Label(core.Msg(c, "checkpoint_list.label"))
st := ui.Local(r.Element, "mujica.checkpoints", func() checkpointListState { return checkpointListState{} })
locked := r.Element.IsDisabled()
if locked {
st.asking = false // a disabled list closes its confirmation
}
r.Element.Children(func() {
if len(points) == 0 {
AgentCaption(c, core.Msg(c, "checkpoint_list.empty"))
return
}
AgentRows(c, len(points), func(i int) {
if checkpointRow(c, points[i], points[i].ID == *current, locked) {
st.asking, st.target = true, points[i].ID
}
}).Shrink(1).MaxHeight(maxH).Background(k.Surface).Border(theme.BorderWidth, k.Border).
Radius(theme.CardRadius).Dividers(theme.BorderWidth, k.Border)
label := st.target
for _, p := range points {
if p.ID == st.target {
label = p.Label
}
}
res := overlay.AlertDialog(c, &st.asking, overlay.AlertDialogOptions{Title: fmt.Sprintf(core.Msg(c, "checkpoint_list.confirm"), label),
Description: core.Msg(c, "checkpoint_list.warning"), ConfirmLabel: core.Msg(c, "checkpoint_list.rollback")})
if res.Confirmed && !locked && seen[st.target] {
*current, r = st.target, core.NewChoiceResult(r.Element, true)
slog.Debug("mujica: checkpoint_list rollback", "to", st.target)
}
})
return r
}
// checkpointRow shows one checkpoint and reports a rollback request.
func checkpointRow(c *ui.Context, p Checkpoint, current, locked bool) bool {
k := core.Tokens(c)
d := core.Density(c)
u := d.Unit()
asked := false
row := ui.Row(c).Key(p.ID).Gap(u*3).AlignItems(ui.Center).MinHeight(d.ControlHeight()+u*2).Padding(u*2, u*3)
if current {
row.Background(k.Selection)
}
row.Children(func() {
ui.Box(c).Size(14, 14).Shrink(0).Draw(func(pt *ui.Painter, r ui.Rect) {
cx, cy := internal.Snap(pt, r.X+r.W/2), internal.Snap(pt, r.Y+r.H/2)
if current {
pt.FillPath(internal.Diamond(cx, cy, 12), k.AccentText)
return
}
pt.StrokePath(internal.Diamond(cx, cy, 10), 1.5, k.ControlBorder)
})
ui.Column(c).Grow(1).Shrink(1).Gap(u / 2).Children(func() {
ui.Text(c, p.Label).FontWeight(600).SingleLine()
if p.Note != "" {
AgentCaption(c, p.Note)
}
})
if current {
AgentCaption(c, core.Msg(c, "checkpoint_list.current"))
return
}
asked = AgentToggle(c, core.Msg(c, "checkpoint_list.rollback"), "refresh-cw", locked)
})
row.Label(p.Label)
return asked
}
+79
View File
@@ -0,0 +1,79 @@
package agent
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var checkpointsSample = []Checkpoint{
{ID: "c3", Label: "Seats assigned", Note: "12 files changed"},
{ID: "c2", Label: "Menu drafted", Note: "3 files changed"},
{ID: "c1", Label: "Start"},
}
func checkpointTester(cur *string, points []Checkpoint, opts CheckpointListOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if CheckpointList(c, cur, points, opts).Changed() {
*changes++
}
}, 560, 420)
}
func TestCheckpointListRollbackConfirm(t *testing.T) {
cur, changes := "c3", 0
tt := checkpointTester(&cur, checkpointsSample, CheckpointListOptions{}, &changes)
if !tt.HasText("Current") || !tt.HasText("12 files changed") {
t.Fatalf("texts %q", tt.Texts())
}
rolls, _ := tt.Find("Roll back")
tt.ClickAt(rolls.X+4, rolls.Y+4) // Menu drafted
if !tt.HasText("Roll back to “Menu drafted”?") || !tt.Focused("Cancel") {
t.Fatalf("confirm: %q", tt.Texts())
}
tt.Key(0, ui.KeyEnter) // Enter on Cancel cancels
if cur != "c3" || changes != 0 {
t.Fatalf("Enter rolled back: %s", cur)
}
tt.ClickAt(rolls.X+4, rolls.Y+4)
_ = tt.Click("Roll back to “Menu drafted”?")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if cur != "c2" || changes != 1 {
t.Fatalf("rollback: cur %s changes %d", cur, changes)
}
}
func TestCheckpointListDisabledEmptyAndMany(t *testing.T) {
cur, n := "c3", 0
tt := checkpointTester(&cur, checkpointsSample, CheckpointListOptions{Disabled: true}, &n)
_ = tt.Click("Roll back")
if tt.HasText("Roll back to “Menu drafted”?") || cur != "c3" {
t.Fatal("disabled list asked")
}
tt = checkpointTester(&cur, nil, CheckpointListOptions{}, &n)
if !tt.HasText("No checkpoints yet") {
t.Fatalf("empty: %q", tt.Texts())
}
many := make([]Checkpoint, 100000)
for i := range many {
many[i] = Checkpoint{ID: fmt.Sprint(i), Label: fmt.Sprintf("point %d", i)}
}
tt = checkpointTester(&cur, many, CheckpointListOptions{}, &n)
if got := len(tt.Texts()); got > 300 {
t.Fatalf("%d texts built for 100000 checkpoints", got)
}
}
func TestCheckpointListSnapshot(t *testing.T) {
snapshot(t, "checkpoint_list", 520, 240, func(c *ui.Context) {
cur := "c2"
ui.Column(c).Padding(16).Children(func() {
CheckpointList(c, &cur, checkpointsSample, CheckpointListOptions{})
})
})
}
+130
View File
@@ -0,0 +1,130 @@
package agent
import (
"fmt"
"log/slog"
"strings"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// CommandRun is a command run; the caller appends Output.
type CommandRun struct {
Command string
Dir string
// Output is the raw terminal output; ANSI colors are kept.
Output string
Running bool
ExitCode int
Duration time.Duration
}
// CommandExecutionCardOptions configure a CommandExecutionCard.
type CommandExecutionCardOptions struct {
// Height of the output well; 240 DIPs when zero.
Height float32
Disabled bool
Still bool
}
// commandOutput keeps parsed lines; appends parse only new lines.
type commandOutput struct {
src string
lines [][]internal.AnsiSpan
style internal.AnsiStyle
list ui.ListState
}
func (o *commandOutput) update(out string) {
if out == o.src {
return
}
if !strings.HasPrefix(out, o.src) || o.src != "" && !strings.HasSuffix(o.src, "\n") {
o.lines, o.style = nil, internal.AnsiStyle{}
o.src = ""
}
for _, line := range internal.SplitLines(out[len(o.src):]) {
var spans []internal.AnsiSpan
spans, o.style = internal.ParseAnsi(line, o.style)
o.lines = append(o.lines, spans)
}
o.src = out
}
// CommandExecutionCard shows a command run; Changed requests an abort.
func CommandExecutionCard(c *ui.Context, run CommandRun, opts CommandExecutionCardOptions) core.ChoiceResult {
if run.Command == "" {
panic("mujica: CommandExecutionCard needs a Command")
}
if opts.Height < 0 {
panic("mujica: CommandExecutionCard Height is negative")
}
h := opts.Height
if h == 0 {
h = 240
}
k := core.Tokens(c)
u := core.Density(c).Unit()
var r core.ChoiceResult
r.Element = AgentCard(c).Disabled(opts.Disabled).Label(run.Command)
out := ui.Local(r.Element, "mujica.cmd.output", func() *commandOutput { return &commandOutput{} })
o := *out
o.list.FollowEnd = true
o.update(run.Output)
r.Element.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).MinHeight(theme.MinHitSize).Children(func() {
ui.Text(c, "$").Font(theme.MonoFont).TextColor(k.TextMuted).Shrink(0)
ui.Text(c, run.Command).Font(theme.MonoFont).FontWeight(600).Grow(1).Shrink(1).Selectable()
if run.Running {
agentMark(c, AgentRunning, 16, opts.Still)
AgentCaption(c, core.Msg(c, "agent_status.running"))
locked := r.Element.IsDisabled()
if input.Button(c, core.Msg(c, "command_execution_card.abort"), input.ButtonOptions{Variant: input.Outline, Disabled: locked}).Clicked() && !locked {
r = core.NewChoiceResult(r.Element, true)
}
return
}
commandExit(c, run.ExitCode)
AgentCaption(c, AgentDuration(run.Duration))
})
if run.Dir != "" {
ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).Children(func() {
ui.Icon(c, icons.Must("folder")).Size(14, 14).TextColor(k.TextMuted)
AgentCaption(c, run.Dir).Font(theme.MonoFont)
})
}
well := ui.List(c, &o.list, len(o.lines), func(i int) {
ui.RichText(c, agentAnsiSpans(k, o.lines[i])...)
})
agentWell(c, well, h).Height(h).Label(core.Msg(c, "command_execution_card.output")).Children(func() {
if len(o.lines) == 0 {
AgentCaption(c, core.Msg(c, "command_execution_card.no_output"))
}
})
})
if r.Changed() {
slog.Debug("mujica: command_execution_card abort", "command", run.Command)
}
return r
}
// commandExit shows the exit code by icon and word.
func commandExit(c *ui.Context, code int) {
k := core.Tokens(c)
tone, icon := k.Success, "circle-check"
if code != 0 {
tone, icon = k.Danger, "circle-x"
}
ui.Row(c).Gap(core.Density(c).Unit()).AlignItems(ui.Center).Shrink(0).Children(func() {
ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(tone)
ui.Text(c, fmt.Sprintf(core.Msg(c, "command_execution_card.exit"), code)).TextColor(tone).
FontSize(core.FontSize(c, theme.CaptionSize+1)).SingleLine()
})
}
+100
View File
@@ -0,0 +1,100 @@
package agent
import (
"fmt"
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func cmdTester(run *CommandRun, opts CommandExecutionCardOptions, aborts *int) *ui.Tester {
opts.Still = true
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if CommandExecutionCard(c, *run, opts).Changed() {
*aborts++
}
}, 600, 420)
}
func TestCommandExecutionCardRunningAbortAndFinish(t *testing.T) {
run := CommandRun{Command: "go test ./...", Dir: "~/court", Running: true, Output: "\x1b[32mok\x1b[0m court\n"}
aborts := 0
tt := cmdTester(&run, CommandExecutionCardOptions{}, &aborts)
for _, s := range []string{"go test ./...", "~/court", "ok", " court", "Running"} {
if !tt.HasText(s) {
t.Fatalf("missing %q in %q", s, tt.Texts())
}
}
if tt.HasText("\x1b[32mok") {
t.Fatal("escape codes shown as text")
}
_ = tt.Click("Abort")
if aborts != 1 {
t.Fatalf("aborts %d", aborts)
}
run.Output += "FAIL ledger\n"
run.Running, run.ExitCode, run.Duration = false, 1, 3200*time.Millisecond
tt.Frame()
if !tt.HasText("FAIL ledger") || !tt.HasText("Exit 1") || !tt.HasText("3.2 s") || tt.HasText("Abort") {
t.Fatalf("finished: %q", tt.Texts())
}
}
func TestCommandExecutionCardAnsiAcrossLinesAndReset(t *testing.T) {
var o commandOutput
o.update("\x1b[31mred\nstill red\x1b[0m plain\n")
if len(o.lines) != 2 || o.lines[1][0].Style.FG.Index != 1 || o.lines[1][1].Style.FG.Index != 0 {
t.Fatalf("lines %+v", o.lines)
}
o.update("\x1b[31mred\nstill red\x1b[0m plain\nnext\n")
if len(o.lines) != 3 || o.lines[2][0].Text != "next" {
t.Fatalf("append: %+v", o.lines)
}
o.update("fresh\n")
if len(o.lines) != 1 || o.lines[0][0].Text != "fresh" {
t.Fatalf("replace: %+v", o.lines)
}
}
func TestCommandExecutionCardDisabledAndEmpty(t *testing.T) {
run := CommandRun{Command: "sleep 10", Running: true}
aborts := 0
tt := cmdTester(&run, CommandExecutionCardOptions{Disabled: true}, &aborts)
_ = tt.Click("Abort")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if aborts != 0 || !tt.HasText("No output") {
t.Fatalf("aborts %d texts %q", aborts, tt.Texts())
}
}
func TestCommandExecutionCardLargeOutputVirtualized(t *testing.T) {
var b strings.Builder
for i := range 100000 {
fmt.Fprintf(&b, "\x1b[2mline %d\x1b[0m\n", i)
}
run := CommandRun{Command: "yes", Output: b.String()}
n := 0
tt := cmdTester(&run, CommandExecutionCardOptions{}, &n)
if got := len(tt.Texts()); got > 200 {
t.Fatalf("%d texts built for 100000 lines", got)
}
if !tt.HasText("line 99999") {
t.Fatal("output does not follow its end")
}
}
func TestCommandExecutionCardSnapshot(t *testing.T) {
snapshot(t, "command_execution_card", 560, 300, func(c *ui.Context) {
run := CommandRun{Command: "go test ./court/...", Dir: "~/projects/court", ExitCode: 1, Duration: 4200 * time.Millisecond,
Output: "\x1b[32mok\x1b[0m court/guests 0.21s\n\x1b[1;31mFAIL\x1b[0m court/ledger 0.08s\n ledger_test.go:14: want 42 guests, got 40\n\x1b[33mwarning:\x1b[0m 1 test skipped\n"}
ui.Column(c).Padding(16).Children(func() {
CommandExecutionCard(c, run, CommandExecutionCardOptions{Height: 140})
})
})
}
+2
View File
@@ -0,0 +1,2 @@
// Package agent holds MujicaUI agent plans, tool calls and approvals.
package agent
+244
View File
@@ -0,0 +1,244 @@
package agent_test
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/agent"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func ExampleToolCallCard() {
call := agent.ToolCall{ID: "1", Name: "read_file", Args: "{\"path\":\"ledger.md\"}",
Result: "{\"lines\":40}", State: agent.AgentDone, Duration: 420 * time.Millisecond}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
agent.ToolCallCard(c, call, agent.ToolCallCardOptions{OpenArgs: true})
}
ui.Render(view, 400, 300, 1)
}
func ExampleToolCallGroup() {
open := false
calls := []agent.ToolCall{
{ID: "a", Name: "list_dir", State: agent.AgentDone, Duration: 300 * time.Millisecond},
{ID: "b", Name: "read_file", State: agent.AgentFailed, Error: "not found"},
}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
agent.ToolCallGroup(c, &open, calls, agent.ToolCallGroupOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleToolApprovalDialog() {
open := true
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := agent.ToolApprovalDialog(c, &open, agent.ToolApprovalOptions{
Tool: "delete_files", Args: "{\"glob\":\"*.tmp\"}", Reason: "Clean the build folder.",
})
if r.Allowed {
fmt.Println("run the tool")
}
}
ui.Render(view, 400, 300, 1)
}
func ExamplePermissionPrompt() {
open := true
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := agent.PermissionPrompt(c, &open, agent.PermissionPromptOptions{
Title: "Write to the ledger?", Scope: "Write access to ~/court/ledger",
})
if r.Choice == agent.PermissionAllowAlways {
fmt.Println("remember the grant")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleAgentStepList() {
steps := []agent.AgentStep{
{ID: "read", Title: "Read the guest list", State: agent.AgentDone, Duration: time.Second},
{ID: "seat", Title: "Assign the seats", State: agent.AgentFailed, Detail: "Table 4 is full."},
{ID: "send", Title: "Send invitations", State: agent.AgentPending},
}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if r := agent.AgentStepList(c, steps, agent.AgentStepListOptions{}); r.Retry != "" {
fmt.Println("retry", r.Retry)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleAgentPlan() {
items := []agent.PlanItem{
{ID: "a", Text: "Read the guest list", State: agent.AgentDone},
{ID: "b", Text: "Assign the seats", State: agent.AgentRunning},
{ID: "c", Text: "Order the flowers"},
}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
agent.AgentPlan(c, &items, agent.AgentPlanOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleAgentStatus() {
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
agent.AgentStatus(c, agent.AgentWaiting, agent.AgentStatusOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleAgentProgress() {
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
agent.AgentProgress(c, 0.4, agent.AgentProgressOptions{Action: "Reading ledger.md"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleSubAgentTree() {
agents := []agent.SubAgent{{ID: "lead", Name: "Steward", State: agent.AgentRunning, Children: []agent.SubAgent{
{ID: "cook", Name: "Cook", Task: "Plan the menu", State: agent.AgentWaiting},
}}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
agent.SubAgentTree(c, agents, agent.SubAgentTreeOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleFileChangeCard() {
decision := agent.FilePending
change := agent.FileChange{Path: "ledger.md", Old: "Guests: 40\n", New: "Guests: 42\n"}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if agent.FileChangeCard(c, &decision, change, agent.FileChangeCardOptions{}).OpenDiff {
fmt.Println("open the full diff")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleMultiFileDiffReview() {
files := []agent.FileChange{
{Path: "ledger.md", Old: "Guests: 40\n", New: "Guests: 42\n"},
{Path: "menu.md", Old: "soup\n", New: "soup\ntart\n"},
}
decisions := make([]agent.FileDecision, len(files))
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
agent.MultiFileDiffReview(c, &decisions, files, agent.MultiFileDiffReviewOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleCommandExecutionCard() {
run := agent.CommandRun{Command: "go test ./...", Dir: "~/court", Running: true, Output: "\x1b[32mok\x1b[0m court\n"}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if agent.CommandExecutionCard(c, run, agent.CommandExecutionCardOptions{}).Changed() {
fmt.Println("abort the process")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleScreenshotStream() {
var st agent.ScreenshotStreamState
var frames []agent.ScreenshotFrame // appended as the agent captures them
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
agent.ScreenshotStream(c, &st, frames, agent.ScreenshotStreamOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleArtifactPanel() {
art := agent.Artifact{Title: "seating.go", Language: "go", Content: "package court\n"}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if agent.ArtifactPanel(c, art, agent.ArtifactPanelOptions{}).Exported {
fmt.Println("save the file")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleArtifactVersionSwitcher() {
current := 1
versions := []agent.ArtifactVersion{{Label: "v1", Note: "First draft"}, {Label: "v2", Note: "Added music"}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if agent.ArtifactVersionSwitcher(c, &current, versions, agent.ArtifactVersionSwitcherOptions{}).Compare {
fmt.Println("diff v1 and v2")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleSandboxStatus() {
info := agent.SandboxInfo{Name: "sandbox-7", State: agent.SandboxRunning,
Resources: []agent.SandboxResource{{Name: "CPU", Used: 1.5, Limit: 2, Unit: "cores"}}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
agent.SandboxStatus(c, info, agent.SandboxStatusOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleMCPServerList() {
servers := []agent.MCPServer{{ID: "fs", Name: "filesystem", Transport: "stdio", State: agent.MCPConnected, Tools: 12}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if r := agent.MCPServerList(c, servers, agent.MCPServerListOptions{}); r.Action == agent.MCPStop {
fmt.Println("stop", r.Server)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleMemoryPanel() {
entries := []agent.MemoryEntry{{ID: "1", Text: "Lady Sakiko prefers the east wing.", Source: "Chat"}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if r := agent.MemoryPanel(c, &entries, agent.MemoryPanelOptions{}); r.Deleted != "" {
fmt.Println("forget", r.Deleted)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleCheckpointList() {
current := "c2"
points := []agent.Checkpoint{{ID: "c2", Label: "Menu drafted"}, {ID: "c1", Label: "Start"}}
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
if agent.CheckpointList(c, &current, points, agent.CheckpointListOptions{}).Changed() {
fmt.Println("restore", current)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleHumanInputRequest() {
answer := ""
view := func(c *ui.Context) {
core.Use(c, core.Settings{})
r := agent.HumanInputRequest(c, &answer, agent.HumanInputRequestOptions{
Question: "Which hall?", Options: []string{"East wing", "Great hall"}, AllowFree: true,
})
if r.Submitted {
fmt.Println("reply", answer)
}
}
ui.Render(view, 400, 300, 1)
}
+87
View File
@@ -0,0 +1,87 @@
package agent
import (
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// FileChangeCardOptions configure a FileChangeCard.
type FileChangeCardOptions struct {
// Preview is the diff lines shown; zero means 8.
Preview int
Disabled bool
}
// FileChangeResult reports this frame's events.
type FileChangeResult struct {
Element *ui.Element
// Decided reports that *decision changed.
Decided bool
// OpenDiff asks the caller to show the full diff.
OpenDiff bool
}
// FileChangeCard previews a file change to accept or reject.
func FileChangeCard(c *ui.Context, decision *FileDecision, change FileChange, opts FileChangeCardOptions) FileChangeResult {
if decision == nil || change.Path == "" {
panic("mujica: FileChangeCard needs a decision and a Path")
}
if opts.Preview < 0 {
panic("mujica: FileChangeCard Preview is negative")
}
preview := opts.Preview
if preview == 0 {
preview = 8
}
u := core.Density(c).Unit()
var r FileChangeResult
r.Element = AgentCard(c).Disabled(opts.Disabled).Label(change.Path)
dc := agentDiffOf(r.Element, change)
locked := r.Element.IsDisabled()
r.Element.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).MinHeight(theme.MinHitSize).Children(func() {
ui.Icon(c, icons.Must("file-text")).Size(16, 16).TextColor(core.Tokens(c).TextMuted).Shrink(0)
ui.Text(c, change.Path).Font(theme.MonoFont).FontWeight(600).SingleLine().Grow(1).Shrink(1)
switch {
case change.Old == "" && change.New != "":
AgentCaption(c, core.Msg(c, "file_change_card.new"))
case change.New == "" && change.Old != "":
AgentCaption(c, core.Msg(c, "file_change_card.deleted"))
}
agentDiffStats(c, dc.add, dc.del)
agentDecisionMark(c, *decision)
})
from := max(0, dc.firstEdit-2)
shown := dc.lines[from:min(len(dc.lines), from+preview)]
if len(shown) > 0 {
agentDiffView(c, shown, 320).Font(theme.MonoFont)
}
ui.Row(c).Gap(u * 2).Justify(ui.End).Wrap().Children(func() {
if input.Button(c, core.Msg(c, "file_change_card.open"), input.ButtonOptions{Variant: input.Ghost, Disabled: locked}).Clicked() && !locked {
r.OpenDiff = true
}
if *decision != FilePending {
if input.Button(c, core.Msg(c, "file_change_card.undo"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked}).Clicked() && !locked {
*decision, r.Decided = FilePending, true
}
return
}
if input.Button(c, core.Msg(c, "file_change_card.reject"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked}).Clicked() && !locked {
*decision, r.Decided = FileRejected, true
}
if input.Button(c, core.Msg(c, "file_change_card.accept"), input.ButtonOptions{Disabled: locked}).Clicked() && !locked {
*decision, r.Decided = FileAccepted, true
}
})
})
if r.Decided || r.OpenDiff {
slog.Debug("mujica: file_change_card event", "path", change.Path, "decision", int(*decision), "open", r.OpenDiff)
}
return r
}
+86
View File
@@ -0,0 +1,86 @@
package agent
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var fileChangeSample = FileChange{Path: "court/ledger.md",
Old: "# Ledger\nGuests: 40\nWine: claret\nMusic: none\n",
New: "# Ledger\nGuests: 42\nWine: claret\nMusic: quartet\nFlowers: lilies\n"}
func fileChangeTester(d *FileDecision, ch FileChange, opts FileChangeCardOptions, got *[]FileChangeResult) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
r := FileChangeCard(c, d, ch, opts)
if r.Decided || r.OpenDiff {
*got = append(*got, r)
}
}, 560, 400)
}
func TestFileChangeCardStatsPreviewAndDecide(t *testing.T) {
d := FilePending
var got []FileChangeResult
tt := fileChangeTester(&d, fileChangeSample, FileChangeCardOptions{}, &got)
for _, s := range []string{"court/ledger.md", "+3", "−2", "Pending", "Guests: 40", "Guests: 42", "Flowers: lilies"} {
if !tt.HasText(s) {
t.Fatalf("missing %q in %q", s, tt.Texts())
}
}
_ = tt.Click("Open diff")
if len(got) != 1 || !got[0].OpenDiff || d != FilePending {
t.Fatalf("open diff: %+v", got)
}
_ = tt.Click("Accept")
if d != FileAccepted || !got[1].Decided || !tt.HasText("Accepted") {
t.Fatalf("accept: %v", d)
}
_ = tt.Click("Undo")
if d != FilePending {
t.Fatalf("undo: %v", d)
}
tt = fileChangeTester(&d, fileChangeSample, FileChangeCardOptions{}, &got)
for range 3 {
tt.Key(0, ui.KeyTab)
if tt.Focused("Reject") {
break
}
}
tt.Key(0, ui.KeyEnter)
if d != FileRejected || !tt.HasText("Rejected") {
t.Fatalf("keyboard reject: %v", d)
}
}
func TestFileChangeCardPreviewLimitAndNewFile(t *testing.T) {
d := FilePending
var got []FileChangeResult
tt := fileChangeTester(&d, FileChange{Path: "a.txt", New: "1\n2\n3\n4\n5\n"}, FileChangeCardOptions{Preview: 2}, &got)
if !tt.HasText("New file") || !tt.HasText("+5") || !tt.HasText("2") || tt.HasText("3") {
t.Fatalf("texts %q", tt.Texts())
}
}
func TestFileChangeCardDisabled(t *testing.T) {
d := FilePending
var got []FileChangeResult
tt := fileChangeTester(&d, fileChangeSample, FileChangeCardOptions{Disabled: true}, &got)
_ = tt.Click("Accept")
_ = tt.Click("Open diff")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if len(got) != 0 || d != FilePending {
t.Fatalf("disabled card acted: %+v %v", got, d)
}
}
func TestFileChangeCardSnapshot(t *testing.T) {
snapshot(t, "file_change_card", 520, 330, func(c *ui.Context) {
d := FilePending
ui.Column(c).Padding(16).Children(func() { FileChangeCard(c, &d, fileChangeSample, FileChangeCardOptions{}) })
})
}
+30
View File
@@ -0,0 +1,30 @@
package agent
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/internal/snaptest"
)
// snapshot compares view in both themes with testdata/snapshots/<os>/<name>-<mode>.png.
func snapshot(t *testing.T, name string, w, h int, view func(c *ui.Context)) {
t.Helper()
snaptest.Run(t, name, w, h, snapshotTheme, view, nil)
}
// snapshotAfter is snapshot with drive sending input before the capture.
func snapshotAfter(t *testing.T, name string, w, h int, view func(c *ui.Context), drive func(tt *ui.Tester)) {
t.Helper()
snaptest.Run(t, name, w, h, snapshotTheme, view, drive)
}
func snapshotTheme(c *ui.Context, mode string) {
m := core.Light
if mode == "dark" {
m = core.Dark
}
core.Use(c, core.Settings{Mode: m})
}
+128
View File
@@ -0,0 +1,128 @@
package agent
import (
"fmt"
"log/slog"
"slices"
"strings"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// HumanInputRequestOptions configure a HumanInputRequest. Question is required.
type HumanInputRequestOptions struct {
Question string
// Options are suggested answers; AllowFree adds a free one.
Options []string
AllowFree bool
// Deadline is when the agent stops waiting; zero waits forever.
Deadline time.Time
Disabled bool
}
// HumanInputResult reports this frame's events.
type HumanInputResult struct {
Element *ui.Element
Submitted bool
// TimedOut reports the frame the deadline passed.
TimedOut bool
}
type humanInputState struct {
draft string
expired bool
}
// HumanInputRequest asks the user a question before a deadline.
func HumanInputRequest(c *ui.Context, answer *string, opts HumanInputRequestOptions) HumanInputResult {
if answer == nil || opts.Question == "" {
panic("mujica: HumanInputRequest needs an answer and a Question")
}
if len(opts.Options) == 0 && !opts.AllowFree {
panic("mujica: HumanInputRequest needs Options or AllowFree")
}
k := core.Tokens(c)
u := core.Density(c).Unit()
var r HumanInputResult
r.Element = AgentCard(c).Border(theme.BorderWidth, k.Info).Role(ui.RoleGroup).Label(opts.Question)
st := ui.Local(r.Element, "mujica.humaninput", func() humanInputState { return humanInputState{} })
left := time.Duration(0)
if !opts.Deadline.IsZero() {
left = opts.Deadline.Sub(c.Now())
if left <= 0 && !st.expired {
st.expired, r.TimedOut = true, true
}
}
r.Element.Disabled(opts.Disabled || st.expired)
locked := r.Element.IsDisabled()
r.Element.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Children(func() {
ui.Icon(c, icons.Must("user")).Size(18, 18).TextColor(k.Info).Shrink(0).Margin(2, 0, 0, 0)
ui.Text(c, opts.Question).FontWeight(600).Grow(1).Shrink(1)
})
if len(opts.Options) > 0 {
items := make([]input.RadioItem[string], len(opts.Options))
for i, o := range opts.Options {
items[i] = input.RadioItem[string]{Value: o, Label: o}
}
chosen := *answer
if input.RadioGroup(c, &chosen, items, input.RadioGroupOptions{Label: opts.Question, Disabled: locked}).Changed() && !locked {
*answer, st.draft = chosen, ""
}
}
submit := false
if opts.AllowFree {
in := input.TextInput(c, &st.draft, input.TextInputOptions{Placeholder: core.Msg(c, "human_input_request.free"),
Label: core.Msg(c, "human_input_request.free"), Disabled: locked})
if in.Changed() && !locked {
*answer = st.draft
}
submit = in.Submitted()
}
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
humanInputClock(c, opts.Deadline, left, st.expired)
blank := strings.TrimSpace(*answer) == ""
if input.Button(c, core.Msg(c, "human_input_request.send"), input.ButtonOptions{Disabled: locked || blank}).Clicked() {
submit = true
}
r.Submitted = submit && !locked && !blank
})
})
if len(opts.Options) > 0 && !opts.AllowFree && *answer != "" && !slices.Contains(opts.Options, *answer) {
panic(fmt.Sprintf("mujica: HumanInputRequest answer %q is not an option", *answer))
}
if r.Submitted || r.TimedOut {
slog.Debug("mujica: human_input_request event", "submitted", r.Submitted, "timed_out", r.TimedOut)
}
return r
}
// humanInputClock shows the time left, or that it ran out.
func humanInputClock(c *ui.Context, deadline time.Time, left time.Duration, expired bool) {
k := core.Tokens(c)
text, tone := "", k.TextMuted
switch {
case expired:
text, tone = core.Msg(c, "human_input_request.timeout"), k.Warning
case !deadline.IsZero():
text = fmt.Sprintf(core.Msg(c, "human_input_request.left"), AgentDuration(left.Truncate(time.Second)))
wait := left % time.Second
if wait == 0 {
wait = time.Second
}
c.After(wait)
}
ui.Row(c).Grow(1).Gap(core.Density(c).Unit()).AlignItems(ui.Center).Role(ui.RoleStatus).Label(text).Children(func() {
if text == "" {
return
}
ui.Icon(c, icons.Must("clock")).Size(14, 14).TextColor(tone)
ui.Text(c, text).TextColor(tone).FontSize(core.FontSize(c, theme.CaptionSize+1))
})
}
+96
View File
@@ -0,0 +1,96 @@
package agent
import (
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func humanTester(answer *string, opts *HumanInputRequestOptions, got *[]HumanInputResult) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
r := HumanInputRequest(c, answer, *opts)
if r.Submitted || r.TimedOut {
*got = append(*got, r)
}
}, 560, 400)
}
func TestHumanInputRequestOptionAndFree(t *testing.T) {
answer := ""
opts := HumanInputRequestOptions{Question: "Which hall for the ball?", Options: []string{"East wing", "Great hall"}, AllowFree: true}
var got []HumanInputResult
tt := humanTester(&answer, &opts, &got)
_ = tt.Click("Send answer") // disabled while blank
if len(got) != 0 {
t.Fatal("blank answer sent")
}
_ = tt.Click("Great hall")
if answer != "Great hall" {
t.Fatalf("option: %q", answer)
}
_ = tt.Click("Or write your own answer")
tt.Type("The garden")
if answer != "The garden" {
t.Fatalf("free: %q", answer)
}
tt.Key(0, ui.KeyEnter)
if len(got) != 1 || !got[0].Submitted {
t.Fatalf("Enter: %+v", got)
}
}
func TestHumanInputRequestDeadline(t *testing.T) {
answer := "East wing"
opts := HumanInputRequestOptions{Question: "Which hall?", Options: []string{"East wing"}, Deadline: time.Now().Add(75 * time.Second)}
var got []HumanInputResult
tt := humanTester(&answer, &opts, &got)
found := false
for _, s := range tt.Texts() {
found = found || strings.HasPrefix(s, "Answer within 1 m 1")
}
if !found {
t.Fatalf("countdown missing: %q", tt.Texts())
}
opts.Deadline = time.Now().Add(-time.Second)
tt.Frame()
tt.Frame()
if len(got) != 1 || !got[0].TimedOut || !tt.HasText("The agent stopped waiting") {
t.Fatalf("timeout: %+v", got)
}
_ = tt.Click("Send answer")
if len(got) != 1 {
t.Fatal("answer sent after the deadline")
}
}
func TestHumanInputRequestDisabledAndRules(t *testing.T) {
answer := ""
opts := HumanInputRequestOptions{Question: "Which hall?", Options: []string{"East wing"}, Disabled: true}
var got []HumanInputResult
tt := humanTester(&answer, &opts, &got)
_ = tt.Click("East wing")
if answer != "" {
t.Fatal("disabled request changed the answer")
}
defer func() {
if recover() == nil {
t.Fatal("no options and no free answer did not panic")
}
}()
humanTester(&answer, &HumanInputRequestOptions{Question: "x"}, &got)
}
func TestHumanInputRequestSnapshot(t *testing.T) {
snapshot(t, "human_input_request", 520, 300, func(c *ui.Context) {
answer := "Great hall"
ui.Column(c).Padding(16).Children(func() {
HumanInputRequest(c, &answer, HumanInputRequestOptions{Question: "Which hall should host the masquerade?",
Options: []string{"East wing", "Great hall"}, AllowFree: true})
})
})
}
+146
View File
@@ -0,0 +1,146 @@
package agent
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// MCPState is a server connection; zero is stopped.
type MCPState int
const (
MCPStopped MCPState = iota
MCPConnecting
MCPConnected
MCPError
)
// MCPServer is one configured MCP server; the caller owns it.
type MCPServer struct {
ID string
Name string
Transport string
State MCPState
Tools int
// Error explains MCPError.
Error string
}
// MCPAction is a list request; zero is none.
type MCPAction int
const (
MCPNone MCPAction = iota
MCPStart
MCPStop
MCPReconnect
)
// MCPServerListOptions configure an MCPServerList.
type MCPServerListOptions struct {
// MaxHeight caps the scrolling list; zero means 360.
MaxHeight float32
Disabled bool
Still bool
}
// MCPServerListResult reports this frame's request.
type MCPServerListResult struct {
Element *ui.Element
Action MCPAction
// Server is the ID the action is for.
Server string
}
// MCPServerList lists MCP servers with start, stop and reconnect.
func MCPServerList(c *ui.Context, servers []MCPServer, opts MCPServerListOptions) MCPServerListResult {
seen := map[string]bool{}
for i, s := range servers {
if s.ID == "" || s.Name == "" || seen[s.ID] || s.Tools < 0 || s.State < MCPStopped || s.State > MCPError {
panic(fmt.Sprintf("mujica: MCPServerList server %d needs a unique ID, a Name, a known State and Tools >= 0", i))
}
seen[s.ID] = true
}
if opts.MaxHeight < 0 {
panic("mujica: MCPServerList MaxHeight is negative")
}
maxH := opts.MaxHeight
if maxH == 0 {
maxH = 360
}
var r MCPServerListResult
r.Element = ui.Column(c).Shrink(0).Disabled(opts.Disabled).Label(core.Msg(c, "mcp_server_list.label"))
locked := r.Element.IsDisabled()
r.Element.Children(func() {
if len(servers) == 0 {
AgentCaption(c, core.Msg(c, "mcp_server_list.empty"))
return
}
k := core.Tokens(c)
AgentRows(c, len(servers), func(i int) {
if a := mcpServerRow(c, servers[i], locked, opts.Still); a != MCPNone && !locked {
r.Action, r.Server = a, servers[i].ID
}
}).Shrink(1).MaxHeight(maxH).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius).
Dividers(theme.BorderWidth, k.Border)
})
if r.Action != MCPNone {
slog.Debug("mujica: mcp_server_list action", "server", r.Server, "action", int(r.Action))
}
return r
}
// mcpServerRow builds one server, returning its pressed action.
func mcpServerRow(c *ui.Context, s MCPServer, locked, still bool) MCPAction {
k := core.Tokens(c)
d := core.Density(c)
u := d.Unit()
words := [...]string{"stopped", "connecting", "connected", "error"}
word := core.Msg(c, "mcp_server_list."+words[s.State])
act := MCPNone
ui.Row(c).Key(s.ID).Gap(u*3).AlignItems(ui.Center).MinHeight(d.ControlHeight()+u*2).Padding(u*2, u*3).
Label(s.Name + ", " + word).Children(func() {
switch s.State {
case MCPConnecting:
agentMark(c, AgentRunning, 16, still)
case MCPConnected:
agentMark(c, AgentDone, 16, still)
case MCPError:
agentMark(c, AgentFailed, 16, still)
default:
ui.Icon(c, icons.Must("wifi-off")).Size(16, 16).TextColor(k.TextMuted)
}
ui.Column(c).Grow(1).Shrink(1).Gap(u / 2).Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, s.Name).FontWeight(600).SingleLine()
AgentCaption(c, s.Transport).Font(theme.MonoFont)
})
line := word
if s.State == MCPConnected {
line += " · " + fmt.Sprintf(core.Msg(c, "mcp_server_list.tools"), s.Tools)
}
AgentCaption(c, line)
if s.State == MCPError && s.Error != "" {
ui.Text(c, s.Error).TextColor(k.Danger).FontSize(core.FontSize(c, theme.CaptionSize)).Selectable()
}
})
if s.State == MCPError && input.Button(c, core.Msg(c, "mcp_server_list.reconnect"), input.ButtonOptions{Variant: input.Outline, Disabled: locked}).Clicked() {
act = MCPReconnect
}
if s.State == MCPStopped {
if input.Button(c, core.Msg(c, "mcp_server_list.start"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked}).Clicked() {
act = MCPStart
}
} else if input.Button(c, core.Msg(c, "mcp_server_list.stop"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked}).Clicked() {
act = MCPStop
}
})
return act
}
+93
View File
@@ -0,0 +1,93 @@
package agent
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var mcpServersSample = []MCPServer{
{ID: "fs", Name: "filesystem", Transport: "stdio", State: MCPConnected, Tools: 12},
{ID: "gh", Name: "github", Transport: "http", State: MCPError, Error: "401 Unauthorized"},
{ID: "db", Name: "postgres", Transport: "stdio", State: MCPStopped},
{ID: "web", Name: "browser", Transport: "sse", State: MCPConnecting},
}
func mcpTester(servers []MCPServer, opts MCPServerListOptions, got *[]MCPServerListResult) *ui.Tester {
opts.Still = true
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
r := MCPServerList(c, servers, opts)
if r.Action != MCPNone {
*got = append(*got, r)
}
}, 600, 460)
}
func TestMCPServerListStatesAndActions(t *testing.T) {
var got []MCPServerListResult
tt := mcpTester(mcpServersSample, MCPServerListOptions{}, &got)
for _, s := range []string{"Connected · 12 tools", "Connection failed", "401 Unauthorized", "Stopped", "Connecting"} {
if !tt.HasText(s) {
t.Fatalf("missing %q in %q", s, tt.Texts())
}
}
_ = tt.Click("Reconnect")
_ = tt.Click("Start")
if len(got) != 2 || got[0].Action != MCPReconnect || got[0].Server != "gh" || got[1].Action != MCPStart || got[1].Server != "db" {
t.Fatalf("actions %+v", got)
}
tt = mcpTester(mcpServersSample, MCPServerListOptions{}, &got)
tt.Key(0, ui.KeyTab) // filesystem's Stop
tt.Key(0, ui.KeyEnter)
if len(got) != 3 || got[2].Action != MCPStop || got[2].Server != "fs" {
t.Fatalf("keyboard stop: %+v", got)
}
}
func TestMCPServerListDisabledAndEmpty(t *testing.T) {
var got []MCPServerListResult
tt := mcpTester(mcpServersSample, MCPServerListOptions{Disabled: true}, &got)
_ = tt.Click("Reconnect")
_ = tt.Click("Start")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if len(got) != 0 {
t.Fatalf("disabled list acted: %+v", got)
}
tt = mcpTester(nil, MCPServerListOptions{}, &got)
if !tt.HasText("No MCP servers") {
t.Fatalf("empty: %q", tt.Texts())
}
}
func TestMCPServerListManyServersVirtualized(t *testing.T) {
servers := make([]MCPServer, 100000)
for i := range servers {
servers[i] = MCPServer{ID: fmt.Sprint(i), Name: fmt.Sprintf("server-%d", i), State: MCPConnected, Tools: i % 30}
}
var got []MCPServerListResult
tt := mcpTester(servers, MCPServerListOptions{}, &got)
if n := len(tt.Texts()); n > 300 {
t.Fatalf("%d texts built for 100000 servers", n)
}
}
func TestMCPServerListRejectsDuplicateIDs(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
var got []MCPServerListResult
mcpTester([]MCPServer{{ID: "a", Name: "x"}, {ID: "a", Name: "y"}}, MCPServerListOptions{}, &got)
}
func TestMCPServerListSnapshot(t *testing.T) {
snapshot(t, "mcp_server_list", 560, 330, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { MCPServerList(c, mcpServersSample, MCPServerListOptions{Still: true}) })
})
}
+162
View File
@@ -0,0 +1,162 @@
package agent
import (
"fmt"
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// MemoryEntry is one memory; the caller persists it.
type MemoryEntry struct {
ID string
Text string
// Source says where it came from, as "Chat, 3 Oct".
Source string
}
// MemoryPanelOptions configure a MemoryPanel.
type MemoryPanelOptions struct {
// MaxHeight caps the scrolling list; zero means 360.
MaxHeight float32
Disabled bool
}
// MemoryPanelResult reports this frame's in-place edits.
type MemoryPanelResult struct {
Element *ui.Element
// Edited and Deleted are changed IDs, else "".
Edited string
Deleted string
}
type memoryPanelState struct {
query, editing, draft string
confirm string
// The filter reruns only when query or entries change.
key string
shown []int
}
// MemoryPanel lists memories with search, inline edit and confirmed delete.
func MemoryPanel(c *ui.Context, entries *[]MemoryEntry, opts MemoryPanelOptions) MemoryPanelResult {
if entries == nil {
panic("mujica: MemoryPanel needs entries")
}
if opts.MaxHeight < 0 {
panic("mujica: MemoryPanel MaxHeight is negative")
}
maxH := opts.MaxHeight
if maxH == 0 {
maxH = 360
}
u := core.Density(c).Unit()
var r MemoryPanelResult
r.Element = AgentCard(c).Disabled(opts.Disabled).Label(core.Msg(c, "memory_panel.title"))
st := ui.Local(r.Element, "mujica.memory", func() memoryPanelState { return memoryPanelState{} })
locked := r.Element.IsDisabled()
r.Element.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, core.Msg(c, "memory_panel.title")).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).FontWeight(600).Grow(1)
AgentCaption(c, fmt.Sprintf(core.Msg(c, "memory_panel.count"), len(*entries)))
})
input.SearchInput(c, &st.query, input.SearchInputOptions{Placeholder: core.Msg(c, "memory_panel.search"), Disabled: locked})
memoryFilter(st, *entries)
if len(st.shown) == 0 {
AgentCaption(c, core.Msg(c, "memory_panel.empty"))
return
}
AgentRows(c, len(st.shown), func(i int) {
idx := st.shown[i]
ui.Column(c).Key((*entries)[idx].ID).Children(func() { memoryRow(c, entries, idx, st, locked, &r) })
}).Shrink(1).MaxHeight(maxH).Dividers(theme.BorderWidth, core.Tokens(c).Border)
})
if r.Deleted != "" {
for i, e := range *entries {
if e.ID == r.Deleted {
*entries = append((*entries)[:i:i], (*entries)[i+1:]...)
break
}
}
st.key = "" // refilter
}
if r.Edited != "" || r.Deleted != "" {
slog.Debug("mujica: memory_panel change", "edited", r.Edited, "deleted", r.Deleted)
}
return r
}
// memoryFilter recomputes matches when inputs change.
func memoryFilter(st *memoryPanelState, entries []MemoryEntry) {
key := fmt.Sprintf("%s\x00%d", st.query, len(entries))
if len(entries) > 0 {
key += fmt.Sprintf("\x00%p", &entries[0])
}
if key == st.key && st.shown != nil {
return
}
st.key = key
q := strings.ToLower(strings.TrimSpace(st.query))
st.shown = st.shown[:0]
for i, e := range entries {
if q == "" || strings.Contains(strings.ToLower(e.Text), q) || strings.Contains(strings.ToLower(e.Source), q) {
st.shown = append(st.shown, i)
}
}
if st.shown == nil {
st.shown = []int{}
}
}
// memoryRow shows one entry or its editor.
func memoryRow(c *ui.Context, entries *[]MemoryEntry, idx int, st *memoryPanelState, locked bool, r *MemoryPanelResult) {
e := &(*entries)[idx]
if e.ID == "" {
panic(fmt.Sprintf("mujica: MemoryPanel entry %d needs an ID", idx))
}
u := core.Density(c).Unit()
ui.Column(c).Gap(u).Padding(u*2, 0).Children(func() {
if st.editing == e.ID && !locked {
input.TextArea(c, &st.draft, input.TextAreaOptions{Label: core.Msg(c, "memory_panel.edit"), MinHeight: 60}).Input.AutoFocus()
ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() {
if input.Button(c, core.Msg(c, "memory_panel.cancel"), input.ButtonOptions{Variant: input.Secondary}).Clicked() {
st.editing = ""
}
blank := strings.TrimSpace(st.draft) == ""
if input.Button(c, core.Msg(c, "memory_panel.save"), input.ButtonOptions{Disabled: blank}).Clicked() && !blank {
e.Text, st.editing, st.key = strings.TrimSpace(st.draft), "", ""
r.Edited = e.ID
}
})
return
}
ui.Text(c, e.Text).Selectable()
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
AgentCaption(c, e.Source).Grow(1)
if AgentToggle(c, core.Msg(c, "memory_panel.edit"), "", locked) {
st.editing, st.draft = e.ID, e.Text
}
del := ui.Row(c)
del.Children(func() {
if AgentToggle(c, core.Msg(c, "memory_panel.delete"), "trash-2", locked) {
st.confirm = e.ID
}
})
open := st.confirm == e.ID && !locked
res := overlay.Popconfirm(c, del, &open, overlay.PopconfirmOptions{Title: core.Msg(c, "memory_panel.confirm"), Description: e.Text,
ConfirmLabel: core.Msg(c, "memory_panel.delete"), Danger: true})
if st.confirm == e.ID && !open {
st.confirm = ""
}
if res.Confirmed {
r.Deleted = e.ID
}
})
})
}
+98
View File
@@ -0,0 +1,98 @@
package agent
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func memorySample() []MemoryEntry {
return []MemoryEntry{
{ID: "1", Text: "Lady Sakiko prefers the east wing.", Source: "Chat, 3 Oct"},
{ID: "2", Text: "The quartet needs two days' notice.", Source: "Plan, 1 Oct"},
{ID: "3", Text: "Claret is served after nine.", Source: "Ledger"},
}
}
func memoryTester(entries *[]MemoryEntry, opts MemoryPanelOptions, got *[]MemoryPanelResult) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
r := MemoryPanel(c, entries, opts)
if r.Edited != "" || r.Deleted != "" {
*got = append(*got, r)
}
}, 560, 560)
}
func TestMemoryPanelSearchEditDelete(t *testing.T) {
entries := memorySample()
var got []MemoryPanelResult
tt := memoryTester(&entries, MemoryPanelOptions{}, &got)
if !tt.HasText("3 entries") || !tt.HasText("Claret is served after nine.") {
t.Fatalf("texts %q", tt.Texts())
}
_ = tt.Click("Search memory")
tt.Type("quartet")
if tt.HasText("Claret is served after nine.") || !tt.HasText("The quartet needs two days' notice.") {
t.Fatalf("search: %q", tt.Texts())
}
_ = tt.Click("Edit")
tt.Key(ui.Cmd, ui.KeyA)
tt.Type("The quartet needs a week's notice.")
_ = tt.Click("Save")
if entries[1].Text != "The quartet needs a week's notice." || len(got) != 1 || got[0].Edited != "2" {
t.Fatalf("edit: %+v %+v", entries[1], got)
}
_ = tt.Click("Delete")
if !tt.HasText("Forget this memory?") || len(entries) != 3 {
t.Fatal("delete did not ask first")
}
_ = tt.Click("Cancel")
if len(entries) != 3 {
t.Fatal("cancel deleted")
}
_ = tt.Click("Delete")
tt.Key(0, ui.KeyTab) // Cancel has the focus; Tab to Delete
tt.Key(0, ui.KeyEnter)
if len(entries) != 2 || got[1].Deleted != "2" || !tt.HasText("No memories match") {
t.Fatalf("delete: %+v %+v %q", entries, got, tt.Texts())
}
}
func TestMemoryPanelDisabled(t *testing.T) {
entries := memorySample()
var got []MemoryPanelResult
tt := memoryTester(&entries, MemoryPanelOptions{Disabled: true}, &got)
_ = tt.Click("Edit")
_ = tt.Click("Delete")
if tt.HasText("Save") || tt.HasText("Forget this memory?") || len(got) != 0 {
t.Fatal("disabled panel acted")
}
}
func TestMemoryPanelManyEntriesVirtualized(t *testing.T) {
entries := make([]MemoryEntry, 100000)
for i := range entries {
entries[i] = MemoryEntry{ID: fmt.Sprint(i), Text: fmt.Sprintf("fact %d", i), Source: "import"}
}
var got []MemoryPanelResult
tt := memoryTester(&entries, MemoryPanelOptions{}, &got)
if n := len(tt.Texts()); n > 300 {
t.Fatalf("%d texts built for 100000 entries", n)
}
_ = tt.Click("Search memory")
tt.Type("fact 99999")
if !tt.HasText("fact 99999") || tt.HasText("fact 0") {
t.Fatal("search over many entries failed")
}
}
func TestMemoryPanelSnapshot(t *testing.T) {
snapshot(t, "memory_panel", 520, 360, func(c *ui.Context) {
entries := memorySample()
ui.Column(c).Padding(16).Children(func() { MemoryPanel(c, &entries, MemoryPanelOptions{}) })
})
}
+160
View File
@@ -0,0 +1,160 @@
package agent
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// MultiFileDiffReviewOptions configure a MultiFileDiffReview.
type MultiFileDiffReviewOptions struct {
// Height is 420 DIPs when zero.
Height float32
Disabled bool
}
type multiFileDiffState struct{ selected int }
// MultiFileDiffReview reviews many file changes, one diff at a time.
func MultiFileDiffReview(c *ui.Context, decisions *[]FileDecision, files []FileChange, opts MultiFileDiffReviewOptions) core.ChoiceResult {
if decisions == nil || len(*decisions) != len(files) {
panic("mujica: MultiFileDiffReview needs one decision per file")
}
if opts.Height < 0 {
panic("mujica: MultiFileDiffReview Height is negative")
}
seen := map[string]bool{}
reviewed := 0
for i, f := range files {
if f.Path == "" || seen[f.Path] {
panic(fmt.Sprintf("mujica: MultiFileDiffReview file %d needs a unique Path", i))
}
seen[f.Path] = true
reviewed += agentB2I((*decisions)[i] != FilePending)
}
h := opts.Height
if h == 0 {
h = 420
}
k := core.Tokens(c)
u := core.Density(c).Unit()
var r core.ChoiceResult
r.Element = AgentCard(c).Height(h).Disabled(opts.Disabled).Label(core.Msg(c, "multi_file_diff_review.title"))
st := ui.Local(r.Element, "mujica.mfdr", func() multiFileDiffState { return multiFileDiffState{} })
st.selected = max(0, min(st.selected, len(files)-1))
locked := r.Element.IsDisabled()
r.Element.Children(func() {
ui.Row(c).Gap(u * 3).AlignItems(ui.Center).Children(func() {
ui.Text(c, core.Msg(c, "multi_file_diff_review.title")).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).FontWeight(600)
AgentCaption(c, fmt.Sprintf(core.Msg(c, "multi_file_diff_review.progress"), reviewed, len(files))).Grow(1)
all := reviewed < len(files)
if input.Button(c, core.Msg(c, "multi_file_diff_review.accept_all"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked || !all}).Clicked() && !locked && all {
for i := range *decisions {
if (*decisions)[i] == FilePending {
(*decisions)[i] = FileAccepted
}
}
r = core.NewChoiceResult(r.Element, true)
}
})
frac := 0.0
if len(files) > 0 {
frac = float64(reviewed) / float64(len(files))
}
agentBar(c, frac, fmt.Sprintf(core.Msg(c, "multi_file_diff_review.progress"), reviewed, len(files)))
if len(files) == 0 {
AgentCaption(c, core.Msg(c, "multi_file_diff_review.empty"))
return
}
ui.Row(c).Grow(1).Shrink(1).Gap(u * 3).AlignItems(ui.Stretch).Children(func() {
row := func(i int) {
if multiFileRow(c, files[i], (*decisions)[i], i == st.selected) && !locked {
st.selected = i
slog.Debug("mujica: multi_file_diff_review select", "path", files[i].Path)
}
}
var list *ui.Element
if len(files) > agentVirtualRows {
list = ui.List(c, nil, len(files), row)
} else {
list = ui.Scroll(c).Children(func() {
for i := range files {
row(i)
}
})
}
list.Width(240).Shrink(0).FocusGroup(ui.Vertical).Border(theme.BorderWidth, k.Border).
Radius(theme.ControlRadius).Padding(u)
ui.Column(c).Grow(1).Shrink(1).Gap(u * 2).Children(func() {
i := st.selected
if fileChangeDecide(c, &(*decisions)[i], files[i], locked) {
r = core.NewChoiceResult(r.Element, true)
}
})
})
})
if r.Changed() {
slog.Debug("mujica: multi_file_diff_review decide", "reviewed", reviewed)
}
return r
}
// multiFileRow is one listed file; it reports a choice.
func multiFileRow(c *ui.Context, f FileChange, d FileDecision, selected bool) bool {
k := core.Tokens(c)
u := core.Density(c).Unit()
row := ui.ButtonBase(c).Key(f.Path).Justify(ui.Start).Gap(u*2).MinHeight(core.Density(c).ControlHeight()).
Padding(0, u*2).Radius(theme.ControlRadius).Label(f.Path)
internal.Ring(row, k.Focus, theme.ControlRadius)
dc := agentDiffOf(row, f)
switch {
case selected:
row.Background(k.Selection).DrawOver(func(p *ui.Painter, r ui.Rect) {
p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 2, H: r.H - 8}, k.AccentText, 1)
})
case row.Hovered():
row.Background(k.SurfaceHover)
}
row.Children(func() {
agentDecisionIcon(c, d)
ui.Text(c, f.Path).Font(theme.MonoFont).FontSize(core.FontSize(c, theme.CaptionSize+1)).SingleLine().Grow(1).Shrink(1)
agentDiffStats(c, dc.add, dc.del)
})
return row.Clicked()
}
// fileChangeDecide shows a full diff and its decision buttons.
func fileChangeDecide(c *ui.Context, d *FileDecision, f FileChange, locked bool) bool {
u := core.Density(c).Unit()
changed := false
holder := ui.Column(c).Key(f.Path).Grow(1).Shrink(1).Gap(u * 2)
dc := agentDiffOf(holder, f)
holder.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, f.Path).Font(theme.MonoFont).FontWeight(600).SingleLine().Grow(1).Shrink(1)
agentDecisionMark(c, *d)
})
agentDiffView(c, dc.lines, 1e5).Grow(1).Font(theme.MonoFont)
ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() {
if *d != FilePending {
if input.Button(c, core.Msg(c, "file_change_card.undo"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked}).Clicked() && !locked {
*d, changed = FilePending, true
}
return
}
if input.Button(c, core.Msg(c, "file_change_card.reject"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked}).Clicked() && !locked {
*d, changed = FileRejected, true
}
if input.Button(c, core.Msg(c, "file_change_card.accept"), input.ButtonOptions{Disabled: locked}).Clicked() && !locked {
*d, changed = FileAccepted, true
}
})
})
return changed
}
+120
View File
@@ -0,0 +1,120 @@
package agent
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func reviewFiles() []FileChange {
return []FileChange{
fileChangeSample,
{Path: "court/menu.md", Old: "soup\nroast\n", New: "soup\nroast\ntart\n"},
{Path: "court/old.md", Old: "gone\n"},
}
}
func reviewTester(d *[]FileDecision, files []FileChange, opts MultiFileDiffReviewOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ui.Column(c).Padding(8).Children(func() {
if MultiFileDiffReview(c, d, files, opts).Changed() {
*changes++
}
})
}, 760, 520)
}
func TestMultiFileDiffReviewPerFileAndAll(t *testing.T) {
files := reviewFiles()
d := make([]FileDecision, len(files))
changes := 0
tt := reviewTester(&d, files, MultiFileDiffReviewOptions{}, &changes)
if !tt.HasText("0 of 3 files reviewed") || !tt.HasText("Guests: 42") {
t.Fatalf("texts %q", tt.Texts())
}
_ = tt.Click("court/menu.md")
if !tt.HasText("tart") || tt.HasText("Guests: 42") {
t.Fatal("choosing a file did not show its diff")
}
_ = tt.Click("Reject")
if d[1] != FileRejected || changes != 1 || !tt.HasText("1 of 3 files reviewed") {
t.Fatalf("reject: %v", d)
}
_ = tt.Click("Accept all")
if d[0] != FileAccepted || d[1] != FileRejected || d[2] != FileAccepted || !tt.HasText("3 of 3 files reviewed") {
t.Fatalf("accept all: %v", d)
}
}
func TestMultiFileDiffReviewKeyboard(t *testing.T) {
files := reviewFiles()
d := make([]FileDecision, len(files))
n := 0
tt := reviewTester(&d, files, MultiFileDiffReviewOptions{}, &n)
_ = tt.Click("court/ledger.md")
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if !tt.HasText("gone") || tt.HasText("Guests: 42") {
t.Fatalf("keyboard choice: %q", tt.Texts())
}
}
func TestMultiFileDiffReviewDisabledAndEmpty(t *testing.T) {
files := reviewFiles()
d := make([]FileDecision, len(files))
n := 0
tt := reviewTester(&d, files, MultiFileDiffReviewOptions{Disabled: true}, &n)
_ = tt.Click("Accept all")
_ = tt.Click("Accept")
_ = tt.Click("court/menu.md")
if n != 0 || d[0] != FilePending || tt.HasText("tart") {
t.Fatalf("disabled review acted: %v", d)
}
var none []FileDecision
tt = reviewTester(&none, nil, MultiFileDiffReviewOptions{}, &n)
if !tt.HasText("No changes to review") {
t.Fatalf("empty: %q", tt.Texts())
}
}
func TestMultiFileDiffReviewManyFilesVirtualized(t *testing.T) {
files := make([]FileChange, 100000)
for i := range files {
files[i] = FileChange{Path: fmt.Sprintf("f%06d.txt", i), Old: "a\n", New: "b\n"}
}
d := make([]FileDecision, len(files))
n := 0
tt := reviewTester(&d, files, MultiFileDiffReviewOptions{}, &n)
if got := len(tt.Texts()); got > 400 {
t.Fatalf("%d texts built for 100000 files", got)
}
if !tt.HasText("f000000.txt") {
t.Fatal("first file missing")
}
}
func TestMultiFileDiffReviewNeedsMatchingDecisions(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
d := []FileDecision{}
n := 0
reviewTester(&d, reviewFiles(), MultiFileDiffReviewOptions{}, &n)
}
func TestMultiFileDiffReviewSnapshot(t *testing.T) {
snapshot(t, "multi_file_diff_review", 760, 420, func(c *ui.Context) {
files := reviewFiles()
d := []FileDecision{FilePending, FileAccepted, FileRejected}
ui.Column(c).Padding(12).Children(func() {
MultiFileDiffReview(c, &d, files, MultiFileDiffReviewOptions{Height: 390})
})
})
}
+79
View File
@@ -0,0 +1,79 @@
package agent
import (
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// PermissionChoice answers a PermissionPrompt; zero is no answer.
type PermissionChoice int
const (
PermissionNone PermissionChoice = iota
PermissionAllowOnce
PermissionAllowAlways
PermissionDeny
)
// PermissionPromptOptions configure a PermissionPrompt. Title and Scope are required.
type PermissionPromptOptions struct {
Title string
Description string
// Scope says what "always" covers, as "Write access to ~/court".
Scope string
}
// PermissionResult reports the choice made in this frame.
type PermissionResult struct {
Element *ui.Element
Choice PermissionChoice
}
// PermissionPrompt asks allow once, always, or deny; Deny has focus.
func PermissionPrompt(c *ui.Context, open *bool, opts PermissionPromptOptions) PermissionResult {
if opts.Title == "" || opts.Scope == "" {
panic("mujica: PermissionPrompt needs a Title and a Scope")
}
var r PermissionResult
was := *open
k := core.Tokens(c)
u := core.Density(c).Unit()
r.Element = overlay.Dialog(c, open, overlay.DialogOptions{
Title: opts.Title, Description: opts.Description, KeepOnBackdrop: true, HideClose: true,
Actions: func() {
if input.Button(c, core.Msg(c, "permission_prompt.deny"), input.ButtonOptions{Variant: input.Secondary}).AutoFocus().Clicked() {
r.Choice = PermissionDeny
}
if input.Button(c, core.Msg(c, "permission_prompt.always"), input.ButtonOptions{Variant: input.Outline}).Clicked() {
r.Choice = PermissionAllowAlways
}
if input.Button(c, core.Msg(c, "permission_prompt.once"), input.ButtonOptions{}).Clicked() {
r.Choice = PermissionAllowOnce
}
},
}, func() {
ui.Row(c).Gap(u*2).AlignItems(ui.Start).Padding(u*2, u*3).Background(k.Background).Radius(theme.ControlRadius).
Border(1, k.Border).Children(func() {
ui.Icon(c, icons.Must("lock")).Size(16, 16).TextColor(k.Info).Shrink(0).Margin(2, 0, 0, 0)
ui.Column(c).Gap(u / 2).Shrink(1).Children(func() {
AgentCaption(c, core.Msg(c, "permission_prompt.scope"))
ui.Text(c, opts.Scope)
})
})
})
if was && !*open && r.Choice == PermissionNone {
r.Choice = PermissionDeny // Escape
}
if r.Choice != PermissionNone {
slog.Debug("mujica: permission_prompt choose", "choice", int(r.Choice))
*open = false
}
return r
}
+81
View File
@@ -0,0 +1,81 @@
package agent
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
func permissionTester(open *bool, got *[]PermissionChoice) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if input.Button(c, "Ask", input.ButtonOptions{}).Clicked() {
*open = true
}
r := PermissionPrompt(c, open, PermissionPromptOptions{Title: "Write to the ledger?", Scope: "Write access to ~/court/ledger"})
if r.Choice != PermissionNone {
*got = append(*got, r.Choice)
}
}, 600, 400)
}
func TestPermissionPromptChoices(t *testing.T) {
for _, tc := range []struct {
tabs int
want PermissionChoice
}{{0, PermissionDeny}, {1, PermissionAllowAlways}, {2, PermissionAllowOnce}} {
open := false
var got []PermissionChoice
tt := permissionTester(&open, &got)
_ = tt.Click("Ask")
if !tt.Focused("Deny") || !tt.HasText("Write access to ~/court/ledger") {
t.Fatalf("Deny focused %v, texts %q", tt.Focused("Deny"), tt.Texts())
}
for range tc.tabs {
tt.Key(0, ui.KeyTab)
}
tt.Key(0, ui.KeyEnter)
if len(got) != 1 || got[0] != tc.want || open {
t.Fatalf("tabs %d: got %v open %v", tc.tabs, got, open)
}
}
}
func TestPermissionPromptEscapeAndBackdrop(t *testing.T) {
open := false
var got []PermissionChoice
tt := permissionTester(&open, &got)
_ = tt.Click("Ask")
tt.ClickAt(5, 395)
if !open || len(got) != 0 {
t.Fatal("backdrop acted")
}
tt.Key(0, ui.KeyEscape)
if len(got) != 1 || got[0] != PermissionDeny {
t.Fatalf("Escape gave %v", got)
}
}
func TestPermissionPromptNeedsScope(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
open := true
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
PermissionPrompt(c, &open, PermissionPromptOptions{Title: "x"})
}, 200, 200)
}
func TestPermissionPromptSnapshot(t *testing.T) {
snapshot(t, "permission_prompt", 600, 340, func(c *ui.Context) {
open := true
PermissionPrompt(c, &open, PermissionPromptOptions{Title: "Write to the ledger?",
Description: "The agent wants to record tonight's guests.", Scope: "Write access to ~/court/ledger and its subfolders"})
})
}
+108
View File
@@ -0,0 +1,108 @@
package agent
import (
"fmt"
"math"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// SandboxState is a sandbox's lifecycle. The zero value is SandboxStarting.
type SandboxState int
const (
SandboxStarting SandboxState = iota
SandboxRunning
SandboxStopped
SandboxFailed
)
// SandboxResource is one metered resource, as CPU or memory.
type SandboxResource struct {
Name string
Used, Limit float64
// Unit follows the numbers, as "GB" or "cores".
Unit string
}
// SandboxInfo is the sandbox the caller reports.
type SandboxInfo struct {
Name string
State SandboxState
Resources []SandboxResource
// Expires is the shutdown time; zero means none.
Expires time.Time
}
// SandboxStatusOptions configure a SandboxStatus.
type SandboxStatusOptions struct {
Still bool
}
// SandboxStatus shows sandbox state, resources and time left.
func SandboxStatus(c *ui.Context, info SandboxInfo, opts SandboxStatusOptions) *ui.Element {
agent := map[SandboxState]AgentState{SandboxStarting: AgentPlanning, SandboxRunning: AgentRunning, SandboxStopped: AgentSkipped, SandboxFailed: AgentFailed}
state, ok := agent[info.State]
if !ok {
panic(fmt.Sprintf("mujica: unknown SandboxState %d", int(info.State)))
}
for _, r := range info.Resources {
if r.Name == "" || !(r.Limit > 0) || r.Used < 0 || math.IsNaN(r.Used) || r.Used > r.Limit {
panic(fmt.Sprintf("mujica: SandboxStatus resource %q needs a name and 0 <= Used <= Limit", r.Name))
}
}
k := core.Tokens(c)
u := core.Density(c).Unit()
word := core.Msg(c, "sandbox_status."+[...]string{"starting", "running", "stopped", "failed"}[info.State])
card := AgentCard(c).Label(info.Name + ", " + word)
card.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, info.Name).FontWeight(600).SingleLine().Grow(1).Shrink(1)
AgentStatus(c, state, AgentStatusOptions{Label: word, Still: opts.Still})
})
for _, r := range info.Resources {
frac := r.Used / r.Limit
ui.Column(c).Gap(u).Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
AgentCaption(c, r.Name).Grow(1)
if frac >= 0.9 {
ui.Icon(c, icons.Must("triangle-alert")).Size(12, 12).TextColor(k.Warning).Label(core.Msg(c, "sandbox_status.high"))
}
ui.Text(c, fmt.Sprintf("%s / %s %s", SandboxNum(r.Used), SandboxNum(r.Limit), r.Unit)).
FontSize(core.FontSize(c, theme.CaptionSize)).FontFeatures("tnum").TextColor(k.Text)
})
agentBar(c, frac, fmt.Sprintf("%s %.0f%%", r.Name, frac*100))
})
}
if !info.Expires.IsZero() && info.State != SandboxStopped && info.State != SandboxFailed {
left := info.Expires.Sub(c.Now())
text := core.Msg(c, "sandbox_status.expired")
if left > 0 {
text = fmt.Sprintf(core.Msg(c, "sandbox_status.left"), AgentDuration(left.Truncate(time.Second)))
wait := left % time.Second
if wait == 0 {
wait = time.Second
}
c.After(wait)
}
ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).Role(ui.RoleStatus).Label(text).Children(func() {
ui.Icon(c, icons.Must("clock")).Size(14, 14).TextColor(k.TextMuted)
AgentCaption(c, text)
})
}
})
return card
}
// SandboxNum prints a resource amount with at most one decimal.
func SandboxNum(v float64) string {
if v == math.Trunc(v) {
return fmt.Sprintf("%.0f", v)
}
return fmt.Sprintf("%.1f", v)
}
+70
View File
@@ -0,0 +1,70 @@
package agent
import (
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
func sandboxSample(expires time.Time) SandboxInfo {
return SandboxInfo{Name: "sandbox-7", State: SandboxRunning, Expires: expires, Resources: []SandboxResource{
{Name: "CPU", Used: 1.5, Limit: 2, Unit: "cores"},
{Name: "Memory", Used: 3.8, Limit: 4, Unit: "GB"},
}}
}
func TestSandboxStatusResourcesAndCountdown(t *testing.T) {
info := sandboxSample(time.Now().Add(90 * time.Second))
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
SandboxStatus(c, info, SandboxStatusOptions{Still: true})
}, 480, 300)
for _, s := range []string{"sandbox-7", "Running", "1.5 / 2 cores", "3.8 / 4 GB", "Near the limit"} {
if !tt.HasText(s) {
t.Fatalf("missing %q in %q", s, tt.Texts())
}
}
left := ""
for _, s := range tt.Texts() {
if strings.HasSuffix(s, " left") {
left = s
}
}
if !strings.HasPrefix(left, "1 m ") {
t.Fatalf("countdown %q", left)
}
info.Expires = time.Now().Add(-time.Second)
tt.Frame()
if !tt.HasText("Time is up") {
t.Fatalf("expired: %q", tt.Texts())
}
info.State = SandboxStopped
tt.Frame()
if !tt.HasText("Stopped") || tt.HasText("Time is up") {
t.Fatalf("stopped: %q", tt.Texts())
}
}
func TestSandboxStatusRejectsBadResource(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
SandboxStatus(c, SandboxInfo{Name: "x", Resources: []SandboxResource{{Name: "CPU", Used: 3, Limit: 2}}}, SandboxStatusOptions{})
}, 200, 200)
}
func TestSandboxStatusSnapshot(t *testing.T) {
snapshot(t, "sandbox_status", 440, 230, func(c *ui.Context) {
info := sandboxSample(time.Time{})
info.Resources = append(info.Resources, SandboxResource{Name: "Disk", Used: 12, Limit: 40, Unit: "GB"})
ui.Column(c).Padding(16).Children(func() { SandboxStatus(c, info, SandboxStatusOptions{Still: true}) })
})
}
+127
View File
@@ -0,0 +1,127 @@
package agent
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ScreenshotFrame is one caller-supplied frame of the agent's screen.
type ScreenshotFrame struct {
Image ui.ImageSource
Caption string
// At is the time since the run started.
At time.Duration
}
// ScreenshotStreamState is the shown frame; unpaused, it follows the newest.
type ScreenshotStreamState struct {
Index int
Paused bool
}
// ScreenshotStreamOptions configure a ScreenshotStream.
type ScreenshotStreamOptions struct {
// Height of the viewport; 280 DIPs when zero.
Height float32
Disabled bool
}
// ScreenshotStream shows screen frames live, paused or stepped.
func ScreenshotStream(c *ui.Context, st *ScreenshotStreamState, frames []ScreenshotFrame, opts ScreenshotStreamOptions) core.ChoiceResult {
if st == nil {
panic("mujica: ScreenshotStream needs a state")
}
if opts.Height < 0 {
panic("mujica: ScreenshotStream Height is negative")
}
for i, f := range frames {
if f.Image == nil {
panic(fmt.Sprintf("mujica: ScreenshotStream frame %d has no Image", i))
}
}
h := opts.Height
if h == 0 {
h = 280
}
k := core.Tokens(c)
u := core.Density(c).Unit()
n := len(frames)
if st.Index < 0 {
panic(fmt.Sprintf("mujica: ScreenshotStream Index %d is negative", st.Index))
}
if !st.Paused || st.Index >= n { // frames may be dropped while paused
st.Index = max(0, n-1)
}
var r core.ChoiceResult
r.Element = AgentCard(c).Disabled(opts.Disabled).Label(core.Msg(c, "screenshot_stream.label"))
locked := r.Element.IsDisabled()
step := func(d int) {
if n == 0 || locked {
return
}
st.Index = max(0, min(n-1, st.Index+d))
st.Paused, r = true, core.NewChoiceResult(r.Element, true)
}
r.Element.Children(func() {
view := ui.Box(c).FillWidth().Height(h).Center().Clip().Radius(theme.ControlRadius).
Background(k.Background).Border(theme.BorderWidth, k.Border).Focusable().
Label(core.Msg(c, "screenshot_stream.label"))
internal.Ring(view, k.Focus, theme.ControlRadius)
view.Children(func() {
if n == 0 {
AgentCaption(c, core.Msg(c, "screenshot_stream.waiting"))
return
}
ui.Image(c, frames[st.Index].Image).Fill().Fit(ui.Contain).Label(frames[st.Index].Caption)
})
if !locked {
switch {
case view.Shortcut(0, ui.KeyLeft):
step(-1)
case view.Shortcut(0, ui.KeyRight):
step(1)
case view.Shortcut(0, ui.KeySpace):
st.Paused, r = !st.Paused, core.NewChoiceResult(r.Element, true)
}
}
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
if core.IconAction(c, icons.Must("chevron-left"), core.Msg(c, "screenshot_stream.prev")).Disabled(locked || st.Index == 0).Clicked() {
step(-1)
}
pause := core.Msg(c, "screenshot_stream.pause")
if st.Paused {
pause = core.Msg(c, "screenshot_stream.live")
}
if input.Button(c, pause, input.ButtonOptions{Variant: input.Secondary, Disabled: locked || n == 0}).Clicked() && !locked {
st.Paused, r = !st.Paused, core.NewChoiceResult(r.Element, true)
}
if core.IconAction(c, icons.Must("chevron-right"), core.Msg(c, "screenshot_stream.next")).Disabled(locked || st.Index >= n-1).Clicked() {
step(1)
}
counter := fmt.Sprintf(core.Msg(c, "screenshot_stream.counter"), min(st.Index+1, n), n)
AgentCaption(c, counter).Role(ui.RoleStatus)
if n > 0 {
f := frames[st.Index]
ui.Text(c, f.Caption).SingleLine().Grow(1).Shrink(1)
AgentCaption(c, AgentDuration(f.At))
}
if !st.Paused && n > 0 {
AgentStatus(c, AgentRunning, AgentStatusOptions{Label: core.Msg(c, "screenshot_stream.following"), Still: true})
}
})
})
if r.Changed() {
slog.Debug("mujica: screenshot_stream move", "index", st.Index, "paused", st.Paused)
}
return r
}
+112
View File
@@ -0,0 +1,112 @@
package agent
import (
"fmt"
"image"
"image/color"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
// screenshotFrames makes n solid frames of shifting colors.
func screenshotFrames(n int) []ScreenshotFrame {
out := make([]ScreenshotFrame, n)
for i := range out {
img := image.NewRGBA(image.Rect(0, 0, 160, 90))
for p := 0; p < len(img.Pix); p += 4 {
img.Pix[p], img.Pix[p+1], img.Pix[p+2], img.Pix[p+3] = uint8(60+i*40), 90, uint8(160-i*20), 255
}
img.Set(10, 10, color.White)
out[i] = ScreenshotFrame{Image: ui.NewBitmap(img), Caption: fmt.Sprintf("Step %d", i+1), At: time.Duration(i) * time.Second}
}
return out
}
func screenshotTester(st *ScreenshotStreamState, frames *[]ScreenshotFrame, opts ScreenshotStreamOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if ScreenshotStream(c, st, *frames, opts).Changed() {
*changes++
}
}, 600, 420)
}
func TestScreenshotStreamFollowsPausesAndSteps(t *testing.T) {
var st ScreenshotStreamState
frames := screenshotFrames(3)
changes := 0
tt := screenshotTester(&st, &frames, ScreenshotStreamOptions{}, &changes)
if st.Index != 2 || !tt.HasText("Frame 3 of 3") || !tt.HasText("Live") {
t.Fatalf("live: %+v %q", st, tt.Texts())
}
frames = append(frames, screenshotFrames(4)[3])
tt.Frame()
if st.Index != 3 || !tt.HasText("Step 4") {
t.Fatalf("new frame not followed: %+v", st)
}
_ = tt.Click("Previous frame")
if !st.Paused || st.Index != 2 || changes != 1 || !tt.HasText("Step 3") {
t.Fatalf("step back: %+v", st)
}
frames = append(frames, screenshotFrames(5)[4])
tt.Frame()
if st.Index != 2 {
t.Fatal("paused viewer moved")
}
_ = tt.Click("Go live")
if st.Paused || st.Index != 4 {
t.Fatalf("go live: %+v", st)
}
}
func TestScreenshotStreamKeyboard(t *testing.T) {
var st ScreenshotStreamState
frames := screenshotFrames(3)
n := 0
tt := screenshotTester(&st, &frames, ScreenshotStreamOptions{}, &n)
tt.Key(0, ui.KeyTab) // the viewport
tt.Key(0, ui.KeyLeft)
tt.Key(0, ui.KeyLeft)
tt.Key(0, ui.KeyLeft) // stops at the first
if st.Index != 0 || !st.Paused {
t.Fatalf("left: %+v", st)
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeySpace)
if st.Paused || st.Index != 2 {
t.Fatalf("space: %+v", st)
}
}
func TestScreenshotStreamEmptyAndDisabled(t *testing.T) {
var st ScreenshotStreamState
var frames []ScreenshotFrame
n := 0
tt := screenshotTester(&st, &frames, ScreenshotStreamOptions{}, &n)
if !tt.HasText("Waiting for frames") || !tt.HasText("Frame 0 of 0") {
t.Fatalf("empty: %q", tt.Texts())
}
frames = screenshotFrames(3)
st = ScreenshotStreamState{}
tt = screenshotTester(&st, &frames, ScreenshotStreamOptions{Disabled: true}, &n)
_ = tt.Click("Previous frame")
_ = tt.Click("Pause")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyLeft)
if n != 0 || st.Paused || st.Index != 2 {
t.Fatalf("disabled moved: %+v", st)
}
}
func TestScreenshotStreamSnapshot(t *testing.T) {
snapshot(t, "screenshot_stream", 520, 330, func(c *ui.Context) {
st := ScreenshotStreamState{Index: 1, Paused: true}
ui.Column(c).Padding(16).Children(func() {
ScreenshotStream(c, &st, screenshotFrames(3), ScreenshotStreamOptions{Height: 200})
})
})
}
+105
View File
@@ -0,0 +1,105 @@
package agent
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/icons"
"github.com/ZacharyZhang-NY/MujicaUI/internal"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// SubAgent is one agent in a hierarchy.
type SubAgent struct {
ID string
Name string
Task string
State AgentState
Children []SubAgent
}
// SubAgentTreeOptions configure a SubAgentTree.
type SubAgentTreeOptions struct {
Disabled bool
Still bool
}
// SubAgentTree shows nested agents and their states.
func SubAgentTree(c *ui.Context, roots []SubAgent, opts SubAgentTreeOptions) core.ChoiceResult {
seen := map[string]bool{}
var check func([]SubAgent)
check = func(list []SubAgent) {
for _, a := range list {
if a.ID == "" || a.Name == "" || seen[a.ID] {
panic(fmt.Sprintf("mujica: SubAgentTree agent %q needs a unique ID and a Name", a.ID))
}
seen[a.ID] = true
check(a.Children)
}
}
check(roots)
var r core.ChoiceResult
r.Element = ui.Column(c).Shrink(0).FocusGroup(ui.Vertical).Disabled(opts.Disabled)
open := ui.Local(r.Element, "mujica.subagents.open", func() map[string]bool { return map[string]bool{} })
r.Element.Children(func() {
var rows func([]SubAgent, int)
rows = func(list []SubAgent, depth int) {
for _, a := range list {
if subAgentRow(c, a, depth, (*open)[a.ID], opts.Still) {
(*open)[a.ID] = !(*open)[a.ID]
r = core.NewChoiceResult(r.Element, true)
slog.Debug("mujica: sub_agent_tree toggle", "id", a.ID, "open", (*open)[a.ID])
}
if (*open)[a.ID] {
rows(a.Children, depth+1)
}
}
}
rows(roots, 0)
})
for id := range *open {
if !seen[id] {
delete(*open, id)
}
}
return r
}
// subAgentRow builds one row, reporting an expand or collapse.
func subAgentRow(c *ui.Context, a SubAgent, depth int, open, still bool) bool {
k := core.Tokens(c)
d := core.Density(c)
u := d.Unit()
parent := len(a.Children) > 0
row := ui.ButtonBase(c).Key(a.ID).Justify(ui.Start).Gap(u*2).MinHeight(d.ControlHeight()).
Padding(0, u*2, 0, u*2+float32(depth)*u*5).Radius(theme.ControlRadius).
Label(a.Name + ", " + agentWord(c, a.State))
internal.Ring(row, k.Focus, theme.ControlRadius)
if row.Hovered() {
row.Background(k.SurfaceHover)
}
row.Children(func() {
chev := ui.Box(c).Size(14, 14).Shrink(0)
if parent {
name := "chevron-right"
if open {
name = "chevron-down"
}
chev.Children(func() { ui.Icon(c, icons.Must(name)).Size(14, 14).TextColor(k.TextMuted) })
}
agentMark(c, a.State, 16, still)
ui.Text(c, a.Name).FontWeight(600).SingleLine().Shrink(0)
ui.Text(c, a.Task).TextColor(k.TextMuted).FontSize(core.FontSize(c, theme.CaptionSize+1)).SingleLine().Grow(1).Shrink(1)
AgentCaption(c, agentWord(c, a.State))
if parent {
AgentCaption(c, fmt.Sprintf(core.Msg(c, "sub_agent_tree.children"), len(a.Children)))
}
})
if !parent || row.IsDisabled() {
return false
}
return row.Clicked() || !open && row.Shortcut(0, ui.KeyRight) || open && row.Shortcut(0, ui.KeyLeft)
}
+83
View File
@@ -0,0 +1,83 @@
package agent
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var subAgentSample = []SubAgent{
{ID: "lead", Name: "Steward", Task: "Prepare the ball", State: AgentRunning, Children: []SubAgent{
{ID: "music", Name: "Musician", Task: "Hire the quartet", State: AgentDone},
{ID: "kitchen", Name: "Cook", Task: "Plan the menu", State: AgentWaiting, Children: []SubAgent{
{ID: "wine", Name: "Sommelier", Task: "Choose the wine", State: AgentFailed},
}},
}},
}
func subAgentTester(opts SubAgentTreeOptions, changes *int) *ui.Tester {
opts.Still = true
return ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if SubAgentTree(c, subAgentSample, opts).Changed() {
*changes++
}
}, 560, 300)
}
func TestSubAgentTreeExpandByPointerAndKeys(t *testing.T) {
changes := 0
tt := subAgentTester(SubAgentTreeOptions{}, &changes)
if tt.HasText("Musician") || !tt.HasText("Running") {
t.Fatalf("texts %q", tt.Texts())
}
_ = tt.Click("Steward")
if changes != 1 || !tt.HasText("Musician") || !tt.HasText("Waiting") || tt.HasText("Sommelier") {
t.Fatalf("click: changes %d texts %q", changes, tt.Texts())
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown) // Cook
tt.Key(0, ui.KeyRight)
if !tt.HasText("Sommelier") || !tt.HasText("Failed") {
t.Fatal("Right did not expand Cook")
}
tt.Key(0, ui.KeyRight) // already open: nothing
tt.Key(0, ui.KeyLeft)
if tt.HasText("Sommelier") || changes != 3 {
t.Fatalf("Left: changes %d", changes)
}
}
func TestSubAgentTreeDisabled(t *testing.T) {
changes := 0
tt := subAgentTester(SubAgentTreeOptions{Disabled: true}, &changes)
_ = tt.Click("Steward")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if changes != 0 || tt.HasText("Musician") {
t.Fatal("disabled tree expanded")
}
}
func TestSubAgentTreeRejectsDuplicateIDs(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
SubAgentTree(c, []SubAgent{{ID: "a", Name: "x", Children: []SubAgent{{ID: "a", Name: "y"}}}}, SubAgentTreeOptions{})
}, 200, 200)
}
func TestSubAgentTreeSnapshot(t *testing.T) {
snapshotAfter(t, "sub_agent_tree", 540, 200, func(c *ui.Context) {
ui.Column(c).Padding(12).Children(func() { SubAgentTree(c, subAgentSample, SubAgentTreeOptions{Still: true}) })
}, func(tt *ui.Tester) {
_ = tt.Click("Steward")
_ = tt.Click("Cook")
})
}
+68
View File
@@ -0,0 +1,68 @@
package agent
import (
"log/slog"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ToolApprovalOptions configure a ToolApprovalDialog. Tool is required.
type ToolApprovalOptions struct {
Tool string
// Args is the call's arguments, JSON when valid.
Args string
// Reason says why the agent wants to run the tool.
Reason string
}
// ToolApprovalResult reports the choice made in this frame.
type ToolApprovalResult struct {
Element *ui.Element
Allowed bool
Denied bool
}
// ToolApprovalDialog asks to approve a tool call; Deny has focus.
func ToolApprovalDialog(c *ui.Context, open *bool, opts ToolApprovalOptions) ToolApprovalResult {
if opts.Tool == "" {
panic("mujica: ToolApprovalDialog needs a Tool")
}
var r ToolApprovalResult
was := *open
u := core.Density(c).Unit()
r.Element = overlay.Dialog(c, open, overlay.DialogOptions{
Title: core.Msg(c, "tool_approval_dialog.title"), Description: opts.Reason, KeepOnBackdrop: true, HideClose: true,
Actions: func() {
// Deny first: a reflexive Enter never runs the tool.
if input.Button(c, core.Msg(c, "tool_approval_dialog.deny"), input.ButtonOptions{Variant: input.Secondary}).AutoFocus().Clicked() {
r.Denied = true
}
if input.Button(c, core.Msg(c, "tool_approval_dialog.allow"), input.ButtonOptions{}).Clicked() {
r.Allowed = true
}
},
}, func() {
ui.Column(c).Gap(u * 2).Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
AgentCaption(c, core.Msg(c, "tool_approval_dialog.tool"))
ui.Text(c, opts.Tool).Font(theme.MonoFont).FontWeight(600)
})
if opts.Args != "" {
AgentCode(c, "json", agentPrettyJSON(opts.Args), 260)
}
})
})
if was && !*open && !r.Allowed {
r.Denied = true // Escape
}
if r.Allowed || r.Denied {
slog.Debug("mujica: tool_approval_dialog choose", "tool", opts.Tool, "allowed", r.Allowed)
*open = false
}
return r
}
+100
View File
@@ -0,0 +1,100 @@
package agent
import (
"testing"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/input"
)
type approvalApp struct {
open bool
allowed, denied int
}
func (a *approvalApp) view(c *ui.Context) {
core.Use(c, core.Settings{})
if input.Button(c, "Run", input.ButtonOptions{}).Clicked() {
a.open = true
}
r := ToolApprovalDialog(c, &a.open, ToolApprovalOptions{Tool: "delete_files", Args: `{"glob":"*.tmp"}`, Reason: "Clean the build folder."})
if r.Allowed {
a.allowed++
}
if r.Denied {
a.denied++
}
}
func newApprovalApp() (*approvalApp, *ui.Tester) {
a := &approvalApp{}
tt := ui.NewTester(a.view, 600, 500)
_ = tt.Click("Run")
return a, tt
}
func TestToolApprovalDialogDenyFirst(t *testing.T) {
a, tt := newApprovalApp()
for _, s := range []string{"delete_files", "Clean the build folder.", ` "glob": "*.tmp"`} {
if !tt.HasText(s) {
t.Fatalf("missing %q in %q", s, tt.Texts())
}
}
if !tt.Focused("Deny") {
t.Fatal("Deny does not have the focus")
}
tt.Key(0, ui.KeyEnter)
if a.denied != 1 || a.allowed != 0 || a.open {
t.Fatalf("Enter: denied %d allowed %d open %v", a.denied, a.allowed, a.open)
}
if !tt.Focused("Run") {
t.Fatal("focus not restored")
}
}
func TestToolApprovalDialogAllowByTabAndBackdrop(t *testing.T) {
a, tt := newApprovalApp()
tt.ClickAt(5, 495)
if !a.open || a.denied != 0 {
t.Fatal("backdrop click acted")
}
tt.Key(0, ui.KeyTab)
if !tt.Focused("Allow") {
t.Fatal("Tab did not reach Allow")
}
tt.Key(0, ui.KeyEnter)
if a.allowed != 1 || a.denied != 0 || a.open {
t.Fatalf("allowed %d denied %d", a.allowed, a.denied)
}
}
func TestToolApprovalDialogEscapeDenies(t *testing.T) {
a, tt := newApprovalApp()
tt.Key(0, ui.KeyEscape)
if a.denied != 1 || a.allowed != 0 || a.open {
t.Fatalf("Escape: denied %d allowed %d open %v", a.denied, a.allowed, a.open)
}
}
func TestToolApprovalDialogNeedsTool(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
open := true
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ToolApprovalDialog(c, &open, ToolApprovalOptions{})
}, 200, 200)
}
func TestToolApprovalDialogSnapshot(t *testing.T) {
snapshot(t, "tool_approval_dialog", 560, 380, func(c *ui.Context) {
open := true
ToolApprovalDialog(c, &open, ToolApprovalOptions{Tool: "delete_files", Args: `{"glob":"build/*.tmp","dry_run":false}`,
Reason: "The agent wants to clean temporary files from the build folder."})
})
}
+81
View File
@@ -0,0 +1,81 @@
package agent
import (
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ToolCall is one tool invocation; the caller owns it.
type ToolCall struct {
ID string
Name string
// Args and Result show as JSON when valid.
Args string
Result string
// Error replaces Result when the call failed.
Error string
State AgentState
Duration time.Duration
}
// ToolCallCardOptions configure a ToolCallCard.
type ToolCallCardOptions struct {
// OpenArgs and OpenResult start the sections expanded.
OpenArgs, OpenResult bool
// Still stops the running animation.
Still bool
}
type toolCallCardState struct{ args, result bool }
// ToolCallCard shows a tool call with foldable arguments and result.
func ToolCallCard(c *ui.Context, call ToolCall, opts ToolCallCardOptions) core.ChoiceResult {
if call.Name == "" {
panic("mujica: ToolCallCard needs a Name")
}
k := core.Tokens(c)
u := core.Density(c).Unit()
var r core.ChoiceResult
r.Element = AgentCard(c).Role(ui.RoleGroup).Label(call.Name + ", " + agentWord(c, call.State))
st := ui.Local(r.Element, "mujica.toolcall", func() toolCallCardState {
return toolCallCardState{opts.OpenArgs, opts.OpenResult}
})
r.Element.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).MinHeight(theme.MinHitSize).Children(func() {
agentMark(c, call.State, 16, opts.Still)
ui.Text(c, call.Name).Font(theme.MonoFont).FontWeight(600).SingleLine().Grow(1).Shrink(1)
AgentCaption(c, agentWord(c, call.State))
if call.State == AgentDone || call.State == AgentFailed {
AgentCaption(c, AgentDuration(call.Duration))
}
})
if call.Args != "" {
r = core.NewChoiceResult(r.Element, data.Collapsible(c, &st.args, data.CollapsibleOptions{Title: core.Msg(c, "tool_call_card.args")}, func() {
AgentCode(c, "json", agentPrettyJSON(call.Args), 240)
}).Changed() || r.Changed())
}
body, title := call.Result, core.Msg(c, "tool_call_card.result")
if call.Error != "" {
body, title = call.Error, core.Msg(c, "tool_call_card.error")
}
if body != "" {
r = core.NewChoiceResult(r.Element, data.Collapsible(c, &st.result, data.CollapsibleOptions{Title: title}, func() {
if call.Error != "" {
ui.Text(c, call.Error).TextColor(k.Danger).Selectable()
return
}
AgentCode(c, "json", agentPrettyJSON(body), 320)
}).Changed() || r.Changed())
}
})
if r.Changed() {
slog.Debug("mujica: tool_call_card toggle", "tool", call.Name, "args", st.args, "result", st.result)
}
return r
}
+105
View File
@@ -0,0 +1,105 @@
package agent
import (
"fmt"
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var toolCallSample = ToolCall{ID: "1", Name: "read_file", Args: `{"path":"court/ledger.md","limit":40}`,
Result: `{"lines":40,"truncated":false}`, State: AgentDone, Duration: 420 * time.Millisecond}
func TestToolCallCardShowsAndFolds(t *testing.T) {
call := toolCallSample
changes := 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if ToolCallCard(c, call, ToolCallCardOptions{}).Changed() {
changes++
}
}, 500, 500)
for _, s := range []string{"read_file", "Done", "420 ms", "Arguments", "Result"} {
if !tt.HasText(s) {
t.Fatalf("missing %q in %q", s, tt.Texts())
}
}
if tt.HasText(` "path": "court/ledger.md",`) {
t.Fatal("arguments shown while folded")
}
_ = tt.Click("Arguments")
if changes != 1 || !tt.HasText(` "path": "court/ledger.md",`) {
t.Fatalf("changes %d, pretty JSON %q", changes, tt.Texts())
}
tt.Key(0, ui.KeyTab) // keyboard reaches the Result fold
tt.Key(0, ui.KeyEnter)
if changes != 2 || !tt.HasText(` "lines": 40,`) {
t.Fatalf("keyboard fold: changes %d", changes)
}
}
func TestToolCallCardErrorAndPlainText(t *testing.T) {
call := ToolCall{Name: "shell", Args: "not json at all", Error: "permission denied", State: AgentFailed, Duration: 3 * time.Second}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ToolCallCard(c, call, ToolCallCardOptions{OpenArgs: true, OpenResult: true})
}, 500, 400)
for _, s := range []string{"Failed", "3.0 s", "Error", "permission denied", "not json at all"} {
if !tt.HasText(s) {
t.Fatalf("missing %q in %q", s, tt.Texts())
}
}
}
func TestToolCallCardRunningHidesDuration(t *testing.T) {
call := ToolCall{Name: "search", State: AgentRunning, Duration: time.Second}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ToolCallCard(c, call, ToolCallCardOptions{Still: true})
}, 400, 200)
if !tt.HasText("Running") || tt.HasText("1.0 s") {
t.Fatalf("texts %q", tt.Texts())
}
}
func TestToolCallCardLargeResultIsVirtualized(t *testing.T) {
var b strings.Builder
for i := range 100000 {
fmt.Fprintf(&b, "row %d\n", i)
}
call := ToolCall{Name: "dump", Result: b.String(), State: AgentDone}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ToolCallCard(c, call, ToolCallCardOptions{OpenResult: true})
}, 500, 500)
if n := len(tt.Texts()); n > 200 {
t.Fatalf("%d texts built for 100000 rows", n)
}
if !tt.HasText("row 0") {
t.Fatal("first row missing")
}
}
func TestToolCallCardNeedsName(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ToolCallCard(c, ToolCall{}, ToolCallCardOptions{})
}, 200, 200)
}
func TestToolCallCardSnapshot(t *testing.T) {
snapshot(t, "tool_call_card", 460, 300, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
ToolCallCard(c, toolCallSample, ToolCallCardOptions{OpenArgs: true})
})
})
}
+77
View File
@@ -0,0 +1,77 @@
package agent
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
"github.com/ZacharyZhang-NY/MujicaUI/data"
"github.com/ZacharyZhang-NY/MujicaUI/theme"
)
// ToolCallGroupOptions configure a ToolCallGroup.
type ToolCallGroupOptions struct {
// Title replaces the built-in "N tool calls".
Title string
Still bool
}
// ToolCallGroup folds tool calls under a count, time, failures summary.
func ToolCallGroup(c *ui.Context, open *bool, calls []ToolCall, opts ToolCallGroupOptions) core.ChoiceResult {
if open == nil {
panic("mujica: ToolCallGroup needs open")
}
seen := make(map[string]bool, len(calls))
var total time.Duration
failed, running := 0, false
for _, call := range calls {
if call.ID == "" || seen[call.ID] {
panic(fmt.Sprintf("mujica: ToolCallGroup call IDs must be unique and set, got %q", call.ID))
}
seen[call.ID] = true
total += call.Duration
failed += agentB2I(call.State == AgentFailed)
running = running || call.State == AgentRunning
}
k := core.Tokens(c)
u := core.Density(c).Unit()
title := opts.Title
if title == "" {
title = fmt.Sprintf(core.Msg(c, "tool_call_group.count"), len(calls))
}
state := AgentDone
switch {
case running:
state = AgentRunning
case failed > 0:
state = AgentFailed
}
summary := title + " · " + AgentDuration(total)
if failed > 0 {
summary += " · " + fmt.Sprintf(core.Msg(c, "tool_call_group.failed"), failed)
}
return data.Collapsible(c, open, data.CollapsibleOptions{Title: summary, Trigger: func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
agentMark(c, state, 16, opts.Still)
ui.Text(c, title).FontWeight(600).SingleLine()
AgentCaption(c, AgentDuration(total))
if failed > 0 {
ui.Text(c, fmt.Sprintf(core.Msg(c, "tool_call_group.failed"), failed)).TextColor(k.Danger).
FontSize(core.FontSize(c, theme.CaptionSize+1)).SingleLine()
}
})
}}, func() {
for _, call := range calls {
ui.Column(c).Key(call.ID).Children(func() { ToolCallCard(c, call, ToolCallCardOptions{Still: opts.Still}) })
}
})
}
func agentB2I(b bool) int {
if b {
return 1
}
return 0
}
+74
View File
@@ -0,0 +1,74 @@
package agent
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"github.com/ZacharyZhang-NY/MujicaUI/core"
)
var toolCallGroupSample = []ToolCall{
{ID: "a", Name: "list_dir", State: AgentDone, Duration: 300 * time.Millisecond},
{ID: "b", Name: "read_file", State: AgentFailed, Error: "not found", Duration: 1200 * time.Millisecond},
{ID: "c", Name: "grep", State: AgentDone, Duration: 500 * time.Millisecond},
}
func TestToolCallGroupSummaryAndFold(t *testing.T) {
open, changes := false, 0
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
if ToolCallGroup(c, &open, toolCallGroupSample, ToolCallGroupOptions{}).Changed() {
changes++
}
}, 500, 600)
for _, s := range []string{"3 tool calls", "2.0 s", "1 failed"} {
if !tt.HasText(s) {
t.Fatalf("missing %q in %q", s, tt.Texts())
}
}
if tt.HasText("read_file") {
t.Fatal("calls shown while folded")
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if !open || changes != 1 || !tt.HasText("read_file") || !tt.HasText("grep") {
t.Fatalf("open %v changes %d texts %q", open, changes, tt.Texts())
}
}
func TestToolCallGroupRunningAndEmpty(t *testing.T) {
open := false
calls := []ToolCall{{ID: "x", Name: "fetch", State: AgentRunning}}
tt := ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ToolCallGroup(c, &open, calls, ToolCallGroupOptions{Title: "Research", Still: true})
ToolCallGroup(c, &open, nil, ToolCallGroupOptions{})
}, 500, 300)
if !tt.HasText("Research") || !tt.HasText("0 tool calls") || tt.HasText("1 failed") {
t.Fatalf("texts %q", tt.Texts())
}
}
func TestToolCallGroupRejectsDuplicateIDs(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
open := false
ui.NewTester(func(c *ui.Context) {
core.Use(c, core.Settings{})
ToolCallGroup(c, &open, []ToolCall{{ID: "a", Name: "x"}, {ID: "a", Name: "y"}}, ToolCallGroupOptions{})
}, 200, 200)
}
func TestToolCallGroupSnapshot(t *testing.T) {
snapshot(t, "tool_call_group", 460, 330, func(c *ui.Context) {
open := true
ui.Column(c).Padding(16).Children(func() {
ToolCallGroup(c, &open, toolCallGroupSample, ToolCallGroupOptions{})
})
})
}