Agent: components 261-280

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 14:29:40 -04:00
1 parent 6f0c02d339
commit 7c94e9e190
108 files changed
+6363 -7

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
package mujica
import (
"fmt"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"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 := Tokens(c)
size := FontSize(c, theme.CaptionSize+1)
ui.Row(c).Gap(Density(c).Unit() * 1.5).Shrink(0).Label(fmt.Sprintf(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 := Tokens(c)
u := 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(Density(c).Unit(), 0)
}
// agentDecisionMark shows a decision by icon and word.
func agentDecisionMark(c *ui.Context, d FileDecision) {
ui.Row(c).Gap(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 := Tokens(c)
tone, icon, word := k.TextMuted, "circle", msg(c, "file_change_card.pending")
switch d {
case FileAccepted:
tone, icon, word = k.Success, "circle-check", msg(c, "file_change_card.accepted")
case FileRejected:
tone, icon, word = k.Danger, "circle-x", 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
}
+293
View File
@@ -0,0 +1,293 @@
package mujica
import (
"bytes"
"encoding/json"
"fmt"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// AgentState is an agent or step state; zero is pending.
type AgentState int
const (
AgentPending AgentState = iota
AgentPlanning
AgentRunning
AgentWaiting
AgentDone
AgentFailed
AgentSkipped
)
func (s AgentState) String() string {
switch s {
case AgentPending:
return "pending"
case AgentPlanning:
return "planning"
case AgentRunning:
return "running"
case AgentWaiting:
return "waiting"
case AgentDone:
return "done"
case AgentFailed:
return "failed"
case AgentSkipped:
return "skipped"
}
panic(fmt.Sprintf("mujica: unknown AgentState %d", int(s)))
}
// agentTone returns the color and icon marking s.
func agentTone(k theme.Tokens, s AgentState) (ui.Color, *ui.SVG) {
switch s {
case AgentPlanning:
return k.Info, icons.Must("list")
case AgentRunning:
return k.AccentText, icons.Must("loader-circle")
case AgentWaiting:
return k.Warning, icons.Must("clock")
case AgentDone:
return k.Success, icons.Must("circle-check")
case AgentFailed:
return k.Danger, icons.Must("circle-x")
case AgentSkipped:
return k.TextMuted, icons.Must("ban")
}
return k.TextMuted, icons.Must("circle")
}
// agentMark draws s; running spins unless still.
func agentMark(c *ui.Context, s AgentState, size float32, still bool) *ui.Element {
tone, icon := agentTone(Tokens(c), s)
if s == AgentRunning && !still {
return Spinner(c, SpinnerOptions{Size: size, Color: tone, Label: msg(c, "agent_status.running")})
}
return ui.Icon(c, icon).Size(size, size).TextColor(tone).Shrink(0)
}
// agentWord is the built-in word for s.
func agentWord(c *ui.Context, s AgentState) string { return msg(c, "agent_status."+s.String()) }
// agentDuration formats d like "320 ms" or "4.2 s".
func agentDuration(d time.Duration) string {
if d < 0 {
panic(fmt.Sprintf("mujica: negative duration %v", d))
}
switch {
case d < time.Second:
return fmt.Sprintf("%d ms", d.Milliseconds())
case d < time.Minute:
return fmt.Sprintf("%.1f s", d.Seconds())
case d < time.Hour:
return fmt.Sprintf("%d m %02d s", int(d.Minutes()), int(d.Seconds())%60)
}
return fmt.Sprintf("%d h %02d m", int(d.Hours()), int(d.Minutes())%60)
}
// agentCard is the bordered surface every agent card sits on.
func agentCard(c *ui.Context) *ui.Element {
k := Tokens(c)
u := Density(c).Unit()
return ui.Column(c).Shrink(0).Padding(u*3).Gap(u*2).Radius(theme.CardRadius).
Background(k.Surface).Border(theme.BorderWidth, k.Border)
}
// agentCaption is small muted text.
func agentCaption(c *ui.Context, s string) *ui.Element {
return ui.Text(c, s).FontSize(FontSize(c, theme.CaptionSize)).TextColor(Tokens(c).TextMuted)
}
// agentWell styles a sunken monospace well.
func agentWell(c *ui.Context, l *ui.Element, maxHeight float32) *ui.Element {
k := Tokens(c)
u := Density(c).Unit()
return l.Shrink(1).MaxHeight(maxHeight).Padding(u*2, u*3).Radius(theme.ControlRadius).
Background(k.Background).Border(theme.BorderWidth, k.Border).Font(theme.MonoFont).
FontSize(FontSize(c, theme.CaptionSize+1))
}
// agentWellRows builds n rows in a well.
func agentWellRows(c *ui.Context, n int, row func(i int), maxHeight float32) *ui.Element {
return agentWell(c, agentRows(c, n, row), maxHeight)
}
// agentRows builds n scrolling rows, virtualized when many.
func agentRows(c *ui.Context, n int, row func(i int)) *ui.Element {
if n > agentVirtualRows {
return ui.List(c, nil, n, row)
}
return ui.Scroll(c).Children(func() {
for i := range n {
row(i)
}
})
}
// agentVirtualRows is the row count above which wells virtualize.
const agentVirtualRows = 200
// agentCodeCache keeps lines and comment states per text.
type agentCodeCache struct {
language, text string
lines []string
open []bool
}
func (cc *agentCodeCache) update(language, text string) {
if cc.lines != nil && cc.language == language && cc.text == text {
return
}
cc.language, cc.text = language, text
cc.lines = internal.SplitLines(text)
cc.open = make([]bool, len(cc.lines))
block := false
for i, line := range cc.lines {
cc.open[i] = block
_, block = internal.Highlight(language, line, block)
}
}
// agentPrettyJSON indents valid JSON; anything else comes back unchanged.
func agentPrettyJSON(s string) string {
var b bytes.Buffer
if json.Indent(&b, []byte(s), "", " ") != nil {
return s
}
return b.String()
}
// agentTokenColor colors a highlight token from the theme.
func agentTokenColor(k theme.Tokens, kind internal.TokenKind) ui.Color {
switch kind {
case internal.TokKeyword:
return k.AccentText
case internal.TokString:
return k.Success
case internal.TokNumber, internal.TokType:
return k.Info
case internal.TokComment, internal.TokPunct:
return k.TextMuted
}
return k.Text
}
// agentCodeLine builds one highlighted line of language.
func agentCodeLine(c *ui.Context, language, line string, inBlock bool) {
k := Tokens(c)
toks, _ := internal.Highlight(language, line, inBlock)
spans := make([]ui.Span, 0, len(toks))
for _, t := range toks {
spans = append(spans, ui.Span{Text: t.Text, Color: agentTokenColor(k, t.Kind)})
}
if len(spans) == 0 {
spans = append(spans, ui.Span{Text: " "})
}
ui.RichText(c, spans...)
}
// agentCode shows highlighted text in a capped well.
func agentCode(c *ui.Context, language, text string, maxHeight float32) *ui.Element {
holder := ui.Column(c).Shrink(1)
cc := ui.Local(holder, "mujica.agent.code", func() *agentCodeCache { return &agentCodeCache{} })
(*cc).update(language, text)
lines, open := (*cc).lines, (*cc).open
row := func(i int) { agentCodeLine(c, language, lines[i], open[i]) }
holder.Children(func() {
agentWellRows(c, len(lines), row, maxHeight)
})
return holder
}
// agentAnsiColor maps a terminal color onto the theme.
func agentAnsiColor(k theme.Tokens, a internal.AnsiColor, fallback ui.Color) ui.Color {
switch a.Kind {
case internal.AnsiRGB:
return ui.RGB(a.R, a.G, a.B)
case internal.AnsiIndexed:
switch a.Index % 8 {
case 0:
return k.TextMuted
case 1:
return k.Danger
case 2:
return k.Success
case 3:
return k.Warning
case 4, 6:
return k.Info
case 5:
return k.AccentText
case 7:
return k.Text
}
}
return fallback
}
// agentAnsiSpans turns parsed terminal spans into rich text spans.
func agentAnsiSpans(k theme.Tokens, in []internal.AnsiSpan) []ui.Span {
out := make([]ui.Span, 0, len(in)+1)
for _, s := range in {
fg := agentAnsiColor(k, s.Style.FG, k.Text)
bg := agentAnsiColor(k, s.Style.BG, ui.Transparent)
if s.Style.Inverse {
fg, bg = agentAnsiColor(k, s.Style.BG, k.Background), fg
}
if s.Style.Dim {
fg = fg.Alpha(0.7)
}
sp := ui.Span{Text: s.Text, Color: fg, Background: bg, Italic: s.Style.Italic,
Underline: s.Style.Underline, Strikethrough: s.Style.Strike}
if s.Style.Bold {
sp.Weight = 700
}
out = append(out, sp)
}
if len(out) == 0 {
out = append(out, ui.Span{Text: " "})
}
return out
}
// agentBar is a thin progress bar named label.
func agentBar(c *ui.Context, frac float64, label string) *ui.Element {
if frac < 0 || frac > 1 || frac != frac {
panic(fmt.Sprintf("mujica: progress %v outside 0..1", frac))
}
k := Tokens(c)
return ui.Box(c).FillWidth().Height(4).Shrink(0).Role(ui.RoleProgress).Label(label).
Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(r, k.Border, 2)
if frac > 0 {
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * float32(frac), H: r.H}, k.AccentText, 2)
}
})
}
// agentToggle is a small text button reporting a click.
func agentToggle(c *ui.Context, label string, icon string, disabled bool) bool {
k := Tokens(c)
u := Density(c).Unit()
b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Padding(0, u*2).Gap(u).
Radius(theme.ControlRadius).Disabled(disabled).TextColor(k.AccentText)
internal.Ring(b, k.Focus, theme.ControlRadius)
if !disabled && b.Hovered() {
b.Background(k.SurfaceHover)
}
b.Children(func() {
if icon != "" {
ui.Icon(c, icons.Must(icon)).Size(14, 14)
}
ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine()
})
return !b.IsDisabled() && b.Clicked()
}
+146
View File
@@ -0,0 +1,146 @@
package mujica
import (
"fmt"
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"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) 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 := Density(c).Unit()
title := opts.Title
if title == "" {
title = msg(c, "agent_plan.title")
}
var r 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(FontSize(c, theme.H3Size)).FontWeight(600).Grow(1)
agentCaption(c, fmt.Sprintf(msg(c, "agent_plan.progress"), done, counted))
})
frac := 0.0
if counted > 0 {
frac = float64(done) / float64(counted)
}
agentBar(c, frac, fmt.Sprintf(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(Density(c).ControlHeight()).Children(func() {
if agentPlanRow(c, &list[i], locked, opts.Still) {
r.changed = true
}
})
}
if !locked && agentPlanAdd(c, &st.draft) {
*items = append(*items, PlanItem{ID: agentPlanID(seen), Text: strings.TrimSpace(st.draft)})
st.draft = ""
r.changed = 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 := Tokens(c)
checked := it.State == AgentDone
box := Checkbox(c, &checked, "", 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, msg(c, "agent_plan.doing"))
}
if it.State == AgentDone || locked {
return changed
}
label, icon, next := msg(c, "agent_plan.skip"), "ban", AgentSkipped
if it.State == AgentSkipped {
label, icon, next = 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 := Density(c).Unit()
add := false
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
in := TextInput(c, draft, TextInputOptions{Placeholder: msg(c, "agent_plan.placeholder"), Label: msg(c, "agent_plan.placeholder")})
in.Element.Grow(1)
blank := strings.TrimSpace(*draft) == ""
add = Button(c, msg(c, "agent_plan.add"), ButtonOptions{Variant: 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
}
}
}
+94
View File
@@ -0,0 +1,94 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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}) })
})
}
+36
View File
@@ -0,0 +1,36 @@
package mujica
import (
"github.com/egoist/mygo/ui"
)
// 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 = msg(c, "agent_progress.label")
}
u := Density(c).Unit()
col := ui.Column(c).FillWidth().Shrink(0).Gap(u * 1.5)
col.Children(func() {
Progress(c, value, 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
}
+54
View File
@@ -0,0 +1,54 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestAgentProgressValueAndAction(t *testing.T) {
value, action := 0.25, "Reading ledger.md"
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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) {
Use(c, 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) {
Use(c, 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"})
})
})
}
+34
View File
@@ -0,0 +1,34 @@
package mujica
import (
"github.com/egoist/mygo/ui"
"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 := Tokens(c)
u := 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(FontSize(c, theme.CaptionSize+1)).TextColor(k.Text).SingleLine()
})
return pill
}
+69
View File
@@ -0,0 +1,69 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func TestAgentStatusWords(t *testing.T) {
states := []AgentState{AgentPlanning, AgentRunning, AgentWaiting, AgentDone, AgentFailed}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, 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) {
Use(c, 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) {
Use(c, 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{})
}
})
})
}
+140
View File
@@ -0,0 +1,140 @@
package mujica
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"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 := Tokens(c)
d := 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, 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, msg(c, "agent_step_list.retry"), "refresh-cw", head.IsDisabled()) {
r.Retry = s.ID
}
})
}
})
})
return clicked
}
+87
View File
@@ -0,0 +1,87 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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}) })
})
}
+148
View File
@@ -0,0 +1,148 @@
package mujica
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"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 := Tokens(c)
u := 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(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 := msg(c, "artifact_panel.copy"), "copy"
if copied {
label, icon = 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, 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 := Tokens(c)
u := 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 := msg(c, "artifact_panel.preview"), "eye"
if src {
label, icon = 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(FontSize(c, theme.CaptionSize+1))
})
if !b.IsDisabled() && b.Clicked() && !on {
*source = src
slog.Debug("mujica: artifact_panel view", "source", src)
}
}
})
}
+79
View File
@@ -0,0 +1,79 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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) {
Use(c, 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() {}})
})
})
}
+89
View File
@@ -0,0 +1,89 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"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 := 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(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 iconAction(c, icons.Must("chevron-left"), 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(msg(c, "artifact_version_switcher.count"), *current+1, n))
})
if v.Note != "" {
agentCaption(c, v.Note).SingleLine()
}
})
if iconAction(c, icons.Must("chevron-right"), msg(c, "artifact_version_switcher.newer")).Disabled(locked || *current == n-1).Clicked() {
move(1)
}
if *current > 0 {
label := fmt.Sprintf(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
}
+77
View File
@@ -0,0 +1,77 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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{})
})
})
}
+119
View File
@@ -0,0 +1,119 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"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) 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 := Tokens(c)
var r ChoiceResult
r.Element = ui.Column(c).Shrink(0).Disabled(opts.Disabled).Label(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, 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 := AlertDialog(c, &st.asking, AlertDialogOptions{Title: fmt.Sprintf(msg(c, "checkpoint_list.confirm"), label),
Description: msg(c, "checkpoint_list.warning"), ConfirmLabel: msg(c, "checkpoint_list.rollback")})
if res.Confirmed && !locked && seen[st.target] {
*current, r.changed = st.target, 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 := Tokens(c)
d := 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, msg(c, "checkpoint_list.current"))
return
}
asked = agentToggle(c, msg(c, "checkpoint_list.rollback"), "refresh-cw", locked)
})
row.Label(p.Label)
return asked
}
+77
View File
@@ -0,0 +1,77 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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{})
})
})
}
+128
View File
@@ -0,0 +1,128 @@
package mujica
import (
"fmt"
"log/slog"
"strings"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"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) 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 := Tokens(c)
u := Density(c).Unit()
var r 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, msg(c, "agent_status.running"))
locked := r.Element.IsDisabled()
if Button(c, msg(c, "command_execution_card.abort"), ButtonOptions{Variant: Outline, Disabled: locked}).Clicked() && !locked {
r.changed = 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(msg(c, "command_execution_card.output")).Children(func() {
if len(o.lines) == 0 {
agentCaption(c, 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 := Tokens(c)
tone, icon := k.Success, "circle-check"
if code != 0 {
tone, icon = k.Danger, "circle-x"
}
ui.Row(c).Gap(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(msg(c, "command_execution_card.exit"), code)).TextColor(tone).
FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine()
})
}
+98
View File
@@ -0,0 +1,98 @@
package mujica
import (
"fmt"
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func cmdTester(run *CommandRun, opts CommandExecutionCardOptions, aborts *int) *ui.Tester {
opts.Still = true
return ui.NewTester(func(c *ui.Context) {
Use(c, 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})
})
})
}
+47
View File
@@ -0,0 +1,47 @@
# 261 ToolCallCard 工具调用卡片
## 用途
显示一次工具调用:工具名、状态、耗时,参数与结果可折叠,JSON 自动格式化并高亮。
## 最小示例
```go
call := mujica.ToolCall{ID: "1", Name: "read_file", Args: "{\"path\":\"ledger.md\"}",
Result: "{\"lines\":40}", State: mujica.AgentDone, Duration: 420 * time.Millisecond}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ToolCallCard(c, call, mujica.ToolCallCardOptions{OpenArgs: true})
}
ui.Render(view, 400, 300, 1)
```
签名:`func ToolCallCard(c *ui.Context, call ToolCall, opts ToolCallCardOptions) ChoiceResult`。`call.Name` 为空时 panic。
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `OpenArgs` | `bool` | `false` | 参数区初始展开。 |
| `OpenResult` | `bool` | `false` | 结果区初始展开。 |
| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 |
## 状态
- `ToolCall` 字段:`ID`、`Name`、`Args`(JSON 或纯文本)、`Result`、`Error`(非空时替代结果并以错误色显示)、`State`、`Duration`。
- `AgentState`:`AgentPending`、`AgentPlanning`、`AgentRunning`、`AgentWaiting`、`AgentDone`、`AgentFailed`、`AgentSkipped`,越界 panic。状态同时由图标形状和文字表达,不只靠颜色。
- 仅完成或失败时显示耗时;合法 JSON 以两格缩进格式化,非 JSON 原样显示。
- 结果超过 200 行时虚拟化,只构建可见行。
## 事件
- `Changed()`:用户展开或折叠参数、结果。
## 键盘操作
- Tab 到“参数”“结果”折叠按钮,Enter / Space 展开或折叠。
## 限制
- 展开状态保存在卡片元素上;在列表中请用稳定 `Key` 包裹每张卡片。
- 高亮只区分关键字、字符串、数字和标点,不做 JSON 结构校验提示。
+47
View File
@@ -0,0 +1,47 @@
# 262 ToolCallGroup 工具调用组
## 用途
把连续的工具调用折叠成一组,标题行显示调用次数、总耗时与失败数。
## 最小示例
```go
open := false
calls := []mujica.ToolCall{
{ID: "a", Name: "list_dir", State: mujica.AgentDone, Duration: 300 * time.Millisecond},
{ID: "b", Name: "read_file", State: mujica.AgentFailed, Error: "not found"},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ToolCallGroup(c, &open, calls, mujica.ToolCallGroupOptions{})
}
ui.Render(view, 400, 300, 1)
```
签名:`func ToolCallGroup(c *ui.Context, open *bool, calls []ToolCall, opts ToolCallGroupOptions) ChoiceResult`。`open` 为 nil、调用 `ID` 为空或重复时 panic。
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Title` | `string` | “N 次工具调用” | 替换内置标题。 |
| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 |
## 状态
- 组的状态:有调用执行中为执行中,否则有失败为失败,否则为完成。
- 失败数以文字“N 次失败”显示。
- 展开后每次调用用 ToolCallCard 显示。
## 事件
- `Changed()`:用户展开或折叠该组。
## 键盘操作
- Tab 到组标题,Enter / Space 展开或折叠;组内卡片各自可折叠。
## 限制
- 总耗时是各调用耗时之和,不扣除并行重叠。
@@ -0,0 +1,51 @@
# 263 ToolApprovalDialog 工具审批对话框
## 用途
工具执行前请求批准:显示工具名、执行理由和参数预览,允许或拒绝。遵循 Dialog 与 AlertDialog 规则。
## 最小示例
```go
open := true
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.ToolApprovalDialog(c, &open, mujica.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 ToolApprovalDialog(c *ui.Context, open *bool, opts ToolApprovalOptions) ToolApprovalResult`。`Tool` 为空时 panic。
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Tool` | `string` | 必填 | 工具名。 |
| `Args` | `string` | `""` | 参数,合法 JSON 会格式化高亮。 |
| `Reason` | `string` | `""` | Agent 给出的理由,作为对话框说明。 |
## 状态
- 打开时焦点在“拒绝”;点击遮罩不做任何事。
- 参数过长时在预览区内滚动,按钮始终可见。
## 事件
- `Allowed`:点击允许,或焦点在允许上按 Enter / Space。
- `Denied`:点击拒绝、焦点在拒绝上按 Enter、或按 Escape。
- `Element`:对话框面板,关闭时为 nil。
## 键盘操作
- 打开时焦点在拒绝,Enter 只触发聚焦按钮,必须 Tab 到“允许”才能批准。
- Escape 等同拒绝;Tab 在对话框内循环;关闭后焦点回到打开者。
## 限制
- 输入法组合期间按 Escape 仍会拒绝(MyGo 不公开组合状态,`ui/editor.go:73`)。
+48
View File
@@ -0,0 +1,48 @@
# 264 PermissionPrompt 权限请求
## 用途
请求权限:允许一次、始终允许或拒绝,并说明“始终允许”覆盖的范围。
## 最小示例
```go
open := true
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.PermissionPrompt(c, &open, mujica.PermissionPromptOptions{
Title: "Write to the ledger?", Scope: "Write access to ~/court/ledger",
})
if r.Choice == mujica.PermissionAllowAlways {
fmt.Println("remember the grant")
}
}
ui.Render(view, 400, 300, 1)
```
签名:`func PermissionPrompt(c *ui.Context, open *bool, opts PermissionPromptOptions) PermissionResult`。`Title` 或 `Scope` 为空时 panic。
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Title` | `string` | 必填 | 问题。 |
| `Description` | `string` | `""` | 补充说明。 |
| `Scope` | `string` | 必填 | “始终允许”覆盖的范围。 |
## 状态
- 范围说明放在带锁形图标的框内。
- 打开时焦点在“拒绝”,点击遮罩无效。
## 事件
- `Choice`:本帧的选择,`PermissionAllowOnce`、`PermissionAllowAlways`、`PermissionDeny`;无选择为 `PermissionNone`。Escape 记为拒绝。
## 键盘操作
- 打开时焦点在拒绝;Tab 依次到“始终允许”“允许一次”;Enter 触发聚焦按钮;Escape 拒绝。
## 限制
- 组件不保存授权,记住“始终允许”由调用方负责。
+51
View File
@@ -0,0 +1,51 @@
# 265 AgentStepList 执行步骤列表
## 用途
以时间线显示 Agent 的执行步骤:当前步加框标出,步骤可展开详情,失败步提供重试。
## 最小示例
```go
steps := []mujica.AgentStep{
{ID: "read", Title: "Read the guest list", State: mujica.AgentDone, Duration: time.Second},
{ID: "seat", Title: "Assign the seats", State: mujica.AgentFailed, Detail: "Table 4 is full."},
{ID: "send", Title: "Send invitations", State: mujica.AgentPending},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.AgentStepList(c, steps, mujica.AgentStepListOptions{}); r.Retry != "" {
fmt.Println("retry", r.Retry)
}
}
ui.Render(view, 400, 300, 1)
```
签名:`func AgentStepList(c *ui.Context, steps []AgentStep, opts AgentStepListOptions) AgentStepListResult`。步骤 `ID` 为空或重复、`Title` 为空时 panic。
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Disabled` | `bool` | `false` | 禁止展开与重试。 |
| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 |
## 状态
- `AgentStep` 字段:`ID`、`Title`、`Detail`、`State`、`Duration`。
- `AgentState`:`AgentPending`、`AgentPlanning`、`AgentRunning`、`AgentWaiting`、`AgentDone`、`AgentFailed`、`AgentSkipped`,越界 panic。状态同时由图标形状和文字表达,不只靠颜色。
- 当前步是第一个执行中、等待或规划中的步骤,用选中底色、边框和“当前”文字标出。
- 失败步下方显示“重试”。
## 事件
- `Toggled`:本帧展开或折叠的步骤 ID。
- `Retry`:本帧请求重试的步骤 ID。
## 键盘操作
- Tab 进入列表;↑ ↓ 在步骤与重试按钮间移动;Enter / Space 展开详情或重试。
## 限制
- 步骤数量较少时使用;列表不虚拟化。
+49
View File
@@ -0,0 +1,49 @@
# 266 AgentPlan 执行计划
## 用途
显示 Agent 的计划清单与进度:随 Agent 实时勾选,用户可勾选、跳过、恢复和新增项。
## 最小示例
```go
items := []mujica.PlanItem{
{ID: "a", Text: "Read the guest list", State: mujica.AgentDone},
{ID: "b", Text: "Assign the seats", State: mujica.AgentRunning},
{ID: "c", Text: "Order the flowers"},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AgentPlan(c, &items, mujica.AgentPlanOptions{})
}
ui.Render(view, 400, 300, 1)
```
签名:`func AgentPlan(c *ui.Context, items *[]PlanItem, opts AgentPlanOptions) ChoiceResult`。`items` 为 nil、项 `ID` 重复或状态不是待办、进行中、完成、跳过之一时 panic。
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Title` | `string` | “计划” | 标题。 |
| `Disabled` | `bool` | `false` | 只读显示,隐藏跳过与新增。 |
| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 |
## 状态
- `PlanItem` 字段:`ID`、`Text`、`State`(`AgentPending`、`AgentRunning`、`AgentDone`、`AgentSkipped`)。
- 进度为“已完成 / 未跳过总数”,以文字和细进度条显示。
- 跳过的项加删除线并禁用勾选;进行中的项显示“进行中”。
- 新增项的 ID 自动生成为未使用的 `item-N`。
## 事件
- `Changed()`:用户勾选、跳过、恢复或新增了项,`*items` 已更新。
## 键盘操作
- Tab 到每项的勾选框(Space 切换)、跳过或恢复按钮;在新增输入框中按 Enter 添加。
## 限制
- 空白内容不会被添加。
+41
View File
@@ -0,0 +1,41 @@
# 267 AgentStatus Agent 状态
## 用途
显示 Agent 当前状态:规划中、执行中、等待、完成、失败,用图标形状和文字表达,不只靠颜色。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AgentStatus(c, mujica.AgentWaiting, mujica.AgentStatusOptions{})
}
ui.Render(view, 400, 300, 1)
```
签名:`func AgentStatus(c *ui.Context, state AgentState, opts AgentStatusOptions) *ui.Element`。未知状态 panic。
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Label` | `string` | 内置状态词 | 替换显示的文字。 |
| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 |
## 状态
- `AgentState`:`AgentPending`、`AgentPlanning`、`AgentRunning`、`AgentWaiting`、`AgentDone`、`AgentFailed`、`AgentSkipped`,越界 panic。状态同时由图标形状和文字表达,不只靠颜色。
- 执行中显示旋转的菱形;`Still` 或系统减少动画时改为静态图标。
## 事件
- 无事件;屏幕阅读器以状态角色读出文字。
## 键盘操作
- 不获取焦点。
## 限制
- 状态动画只在显示时重绘,隐藏后停止。
+42
View File
@@ -0,0 +1,42 @@
# 268 AgentProgress Agent 进度
## 用途
显示整体进度和当前动作文字;时长未知时用不确定模式。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AgentProgress(c, 0.4, mujica.AgentProgressOptions{Action: "Reading ledger.md"})
}
ui.Render(view, 400, 300, 1)
```
签名:`func AgentProgress(c *ui.Context, value float64, opts AgentProgressOptions) *ui.Element`。确定模式下 `value` 不在 0 到 1 时 panic。
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Label` | `string` | “进度” | 进度名称。 |
| `Action` | `string` | `""` | 当前动作,如“正在读取 ledger.md”。 |
| `Indeterminate` | `bool` | `false` | 不确定模式,忽略 value。 |
| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 |
## 状态
- 确定模式显示百分比;不确定模式显示流动条,减少动画时为静态条加“进行中”。
## 事件
- 无事件;当前动作以状态角色读出。
## 键盘操作
- 不获取焦点。
## 限制
- 进度值由调用方计算。
+45
View File
@@ -0,0 +1,45 @@
# 269 SubAgentTree 子 Agent 树
## 用途
以树显示子 Agent 层级、各自任务与状态,可展开查看下级。
## 最小示例
```go
agents := []mujica.SubAgent{{ID: "lead", Name: "Steward", State: mujica.AgentRunning, Children: []mujica.SubAgent{
{ID: "cook", Name: "Cook", Task: "Plan the menu", State: mujica.AgentWaiting},
}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.SubAgentTree(c, agents, mujica.SubAgentTreeOptions{})
}
ui.Render(view, 400, 300, 1)
```
签名:`func SubAgentTree(c *ui.Context, roots []SubAgent, opts SubAgentTreeOptions) ChoiceResult`。`ID` 为空或重复、`Name` 为空时 panic。
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Disabled` | `bool` | `false` | 禁止展开与折叠。 |
| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 |
## 状态
- `SubAgent` 字段:`ID`、`Name`、`Task`、`State`、`Children`。
- `AgentState`:`AgentPending`、`AgentPlanning`、`AgentRunning`、`AgentWaiting`、`AgentDone`、`AgentFailed`、`AgentSkipped`,越界 panic。状态同时由图标形状和文字表达,不只靠颜色。
- 有下级的行显示箭头和“N 个子智能体”。
## 事件
- `Changed()`:用户展开或折叠了某个 Agent。
## 键盘操作
- Tab 进入树;↑ ↓ 移动;→ 展开,← 折叠;Enter / Space 切换。
## 限制
- 树不虚拟化,适合几十到几百个 Agent。
+48
View File
@@ -0,0 +1,48 @@
# 270 FileChangeCard 文件变更卡片
## 用途
预览一个文件的变更:增删行数、首处修改附近的差异行,提供接受、拒绝和打开完整差异的入口。
## 最小示例
```go
decision := mujica.FilePending
change := mujica.FileChange{Path: "ledger.md", Old: "Guests: 40\n", New: "Guests: 42\n"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.FileChangeCard(c, &decision, change, mujica.FileChangeCardOptions{}).OpenDiff {
fmt.Println("open the full diff")
}
}
ui.Render(view, 400, 300, 1)
```
签名:`func FileChangeCard(c *ui.Context, decision *FileDecision, change FileChange, opts FileChangeCardOptions) FileChangeResult`。`decision` 为 nil、`Path` 为空或 `Preview` 为负时 panic。
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Preview` | `int` | 8 | 从首处修改前两行起显示的差异行数。 |
| `Disabled` | `bool` | `false` | 禁止决定与打开差异。 |
## 状态
- `FileChange` 字段:`Path`、`Old`、`New`;`Old` 为空表示新文件,`New` 为空表示删除。
- 差异由 `internal.LineDiff`(Myers 行差异)计算,文本不变时只算一次。
- 新增行带“+”、删除行带“−”和行号,颜色只是辅助。
- 决定状态 `FilePending`、`FileAccepted`、`FileRejected` 以图标和文字显示;已决定后显示“撤销”。
## 事件
- `Decided`:`*decision` 被用户改变。
- `OpenDiff`:请求调用方打开完整差异。
## 键盘操作
- Tab 依次到“打开差异”“拒绝”“接受”(或“撤销”),Enter / Space 触发。
## 限制
- 只做行级差异;词级高亮由 Git 组扩展。
@@ -0,0 +1,48 @@
# 271 MultiFileDiffReview 多文件差异审阅
## 用途
审阅多个文件的变更:左侧文件列表,右侧所选文件的完整差异,逐文件接受或拒绝,全部接受,并显示审阅进度。
## 最小示例
```go
files := []mujica.FileChange{
{Path: "ledger.md", Old: "Guests: 40\n", New: "Guests: 42\n"},
{Path: "menu.md", Old: "soup\n", New: "soup\ntart\n"},
}
decisions := make([]mujica.FileDecision, len(files))
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.MultiFileDiffReview(c, &decisions, files, mujica.MultiFileDiffReviewOptions{})
}
ui.Render(view, 400, 300, 1)
```
签名:`func MultiFileDiffReview(c *ui.Context, decisions *[]FileDecision, files []FileChange, opts MultiFileDiffReviewOptions) ChoiceResult`。`decisions` 与 `files` 数量不同、`Path` 为空或重复、`Height` 为负时 panic。
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Height` | `float32` | 420 | 组件高度。 |
| `Disabled` | `bool` | `false` | 禁止选择与决定。 |
## 状态
- 进度为“已审阅 N / 总数”,以文字和细进度条显示。
- 所选文件用选中底色和左侧竖条标出。
- 文件超过 200 个或差异超过 200 行时虚拟化。
- “全部接受”只接受尚未决定的文件,全部决定后禁用。
## 事件
- `Changed()`:用户改变了任一文件的决定。
## 键盘操作
- Tab 进入文件列表;↑ ↓ 移动,Enter 选择;Tab 到右侧按钮后 Enter 接受或拒绝。
## 限制
- 文件很多时方向键只在已构建的可见行间移动,滚动后再继续。
@@ -0,0 +1,48 @@
# 272 CommandExecutionCard 命令执行卡片
## 用途
显示一条命令的执行:命令、工作目录、带 ANSI 颜色的输出、退出码与耗时,执行中可请求中止。
## 最小示例
```go
run := mujica.CommandRun{Command: "go test ./...", Dir: "~/court", Running: true, Output: "\x1b[32mok\x1b[0m court\n"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.CommandExecutionCard(c, run, mujica.CommandExecutionCardOptions{}).Changed() {
fmt.Println("abort the process")
}
}
ui.Render(view, 400, 300, 1)
```
签名:`func CommandExecutionCard(c *ui.Context, run CommandRun, opts CommandExecutionCardOptions) ChoiceResult`。`Command` 为空或 `Height` 为负时 panic。
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Height` | `float32` | 240 | 输出区高度。 |
| `Disabled` | `bool` | `false` | 禁止中止。 |
| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 |
## 状态
- `CommandRun` 字段:`Command`、`Dir`、`Output`、`Running`、`ExitCode`、`Duration`。
- 输出由 `internal.ParseAnsi` 解析,颜色映射到主题;样式可跨行延续;追加输出只解析新增行。
- 输出虚拟化并跟随末尾,用户向上滚动后停止跟随。
- 退出码以图标和“退出码 N”文字显示。
## 事件
- `Changed()`:用户点击了“中止”;终止进程由调用方完成。
## 键盘操作
- Tab 到“中止”,Enter / Space 触发;输出区可滚动。
## 限制
- 只支持 SGR 颜色与样式,其它转义序列原样显示。
- 不运行命令,也不提供 PTY。
+45
View File
@@ -0,0 +1,45 @@
# 273 ScreenshotStream Agent 画面流
## 用途
查看 Agent 的画面帧序列:默认跟随最新帧,可暂停并逐帧前后查看。帧由调用方提供。
## 最小示例
```go
var st mujica.ScreenshotStreamState
var frames []mujica.ScreenshotFrame // appended as the agent captures them
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ScreenshotStream(c, &st, frames, mujica.ScreenshotStreamOptions{})
}
ui.Render(view, 400, 300, 1)
```
签名:`func ScreenshotStream(c *ui.Context, st *ScreenshotStreamState, frames []ScreenshotFrame, opts ScreenshotStreamOptions) ChoiceResult`。`st` 为 nil、帧没有图像或 `Height` 为负时 panic。
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Height` | `float32` | 280 | 画面区高度。 |
| `Disabled` | `bool` | `false` | 禁止暂停与逐帧。 |
## 状态
- `ScreenshotFrame` 字段:`Image`(`ui.ImageSource`)、`Caption`、`At`。
- `ScreenshotStreamState` 字段:`Index`、`Paused`;未暂停时 `Index` 总是最后一帧。
- 逐帧操作会自动暂停;“回到实时”恢复跟随。
- 没有帧时显示“等待画面”。
## 事件
- `Changed()`:用户暂停、恢复或移动了帧。
## 键盘操作
- Tab 到画面区:← → 逐帧,Space 暂停或恢复;上一帧、下一帧按钮同样可用。
## 限制
- 不解码视频,帧图像由调用方提供。
+47
View File
@@ -0,0 +1,47 @@
# 274 ArtifactPanel 产物面板
## 用途
展示 Agent 生成的产物:代码高亮显示,文档与网页由调用方的预览插槽渲染,可在预览与源码间切换,提供复制与导出。
## 最小示例
```go
art := mujica.Artifact{Title: "seating.go", Language: "go", Content: "package court\n"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ArtifactPanel(c, art, mujica.ArtifactPanelOptions{}).Exported {
fmt.Println("save the file")
}
}
ui.Render(view, 400, 300, 1)
```
签名:`func ArtifactPanel(c *ui.Context, art Artifact, opts ArtifactPanelOptions) ArtifactPanelResult`。`Title` 为空、`Kind` 越界、文档或网页缺少 `Preview`、`Height` 为负时 panic。
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Preview` | `func()` | `nil` | 预览插槽;文档与网页必填。 |
| `Height` | `float32` | 320 | 内容区高度。 |
| `Disabled` | `bool` | `false` | 禁止复制、导出与切换。 |
## 状态
- `Artifact` 字段:`Title`、`Kind`(`ArtifactCode`、`ArtifactDocument`、`ArtifactWeb`)、`Language`、`Content`。
- 预览与源码切换用选中底色加勾号标出当前项。
- 复制后两秒内按钮显示“已复制”。
## 事件
- `Copied`:内容已写入剪贴板。
- `Exported`:请求调用方导出。
## 键盘操作
- Tab 到切换组,← → 移动,Enter / Space 选择;Tab 到复制、导出。
## 限制
- 不内置网页渲染,网页预览须由调用方提供(无 WebView)。
@@ -0,0 +1,45 @@
# 275 ArtifactVersionSwitcher 产物版本切换
## 用途
在产物的多个版本之间切换,并提供与上一版本比较差异的入口。
## 最小示例
```go
current := 1
versions := []mujica.ArtifactVersion{{Label: "v1", Note: "First draft"}, {Label: "v2", Note: "Added music"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ArtifactVersionSwitcher(c, &current, versions, mujica.ArtifactVersionSwitcherOptions{}).Compare {
fmt.Println("diff v1 and v2")
}
}
ui.Render(view, 400, 300, 1)
```
签名:`func ArtifactVersionSwitcher(c *ui.Context, current *int, versions []ArtifactVersion, opts ArtifactVersionSwitcherOptions) ArtifactVersionResult`。没有版本、`*current` 越界或版本 `Label` 为空时 panic。
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Disabled` | `bool` | `false` | 禁止切换与比较。 |
## 状态
- 显示版本名、“N / 总数”和说明;第一个版本时“较早版本”禁用,最后一个时“较新版本”禁用。
- 非第一个版本显示“与上一版本比较”。
## 事件
- `Changed`:`*current` 改变。
- `Compare`:请求比较当前版本与上一版本。
## 键盘操作
- 焦点在组件内时 ← → 切换版本;Tab 到各按钮,Enter / Space 触发。
## 限制
- 差异视图由调用方打开,可用 FileChangeCard 或 MultiFileDiffReview 呈现。
+43
View File
@@ -0,0 +1,43 @@
# 276 SandboxStatus 沙箱状态
## 用途
显示沙箱的运行状态、资源占用和剩余时间。
## 最小示例
```go
info := mujica.SandboxInfo{Name: "sandbox-7", State: mujica.SandboxRunning,
Resources: []mujica.SandboxResource{{Name: "CPU", Used: 1.5, Limit: 2, Unit: "cores"}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.SandboxStatus(c, info, mujica.SandboxStatusOptions{})
}
ui.Render(view, 400, 300, 1)
```
签名:`func SandboxStatus(c *ui.Context, info SandboxInfo, opts SandboxStatusOptions) *ui.Element`。状态未知、资源名为空、`Limit` 不大于 0 或 `Used` 不在 0 到 `Limit` 之间时 panic。
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 |
## 状态
- `SandboxInfo` 字段:`Name`、`State`(`SandboxStarting`、`SandboxRunning`、`SandboxStopped`、`SandboxFailed`)、`Resources`、`Expires`。
- 每项资源显示“已用 / 上限 单位”与细条;达到 90% 时加警示图标和“接近上限”说明。
- 设置 `Expires` 且运行中时显示剩余时间,每秒刷新;到时显示“已到时”。
## 事件
- 无事件。
## 键盘操作
- 不获取焦点。
## 限制
- 倒计时依赖界面帧,窗口隐藏时 MyGo 暂停帧,重新显示后立即校正。
+46
View File
@@ -0,0 +1,46 @@
# 277 MCPServerList MCP 服务器列表
## 用途
列出 MCP 服务器:连接状态、传输方式、工具数,提供启动、停止与重新连接回调。
## 最小示例
```go
servers := []mujica.MCPServer{{ID: "fs", Name: "filesystem", Transport: "stdio", State: mujica.MCPConnected, Tools: 12}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.MCPServerList(c, servers, mujica.MCPServerListOptions{}); r.Action == mujica.MCPStop {
fmt.Println("stop", r.Server)
}
}
ui.Render(view, 400, 300, 1)
```
签名:`func MCPServerList(c *ui.Context, servers []MCPServer, opts MCPServerListOptions) MCPServerListResult`。`ID` 为空或重复、`Name` 为空、状态未知、`Tools` 为负、`MaxHeight` 为负时 panic。
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `MaxHeight` | `float32` | 360 | 列表最大高度,超出后滚动。 |
| `Disabled` | `bool` | `false` | 禁止所有操作。 |
| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 |
## 状态
- `MCPServer` 字段:`ID`、`Name`、`Transport`、`State`(`MCPStopped`、`MCPConnecting`、`MCPConnected`、`MCPError`)、`Tools`、`Error`。
- 已连接时显示“N 个工具”;连接失败时显示错误文字与“重新连接”。
- 超过 200 台服务器时虚拟化。
## 事件
- `Action`:本帧请求 `MCPStart`、`MCPStop` 或 `MCPReconnect`,`Server` 为对应 ID。
## 键盘操作
- Tab 依次到各行按钮,Enter / Space 触发。
## 限制
- 组件只发出请求,连接管理由调用方完成。
+47
View File
@@ -0,0 +1,47 @@
# 278 MemoryPanel 记忆面板
## 用途
列出 Agent 的记忆条目,支持搜索、行内编辑和确认后删除。
## 最小示例
```go
entries := []mujica.MemoryEntry{{ID: "1", Text: "Lady Sakiko prefers the east wing.", Source: "Chat"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.MemoryPanel(c, &entries, mujica.MemoryPanelOptions{}); r.Deleted != "" {
fmt.Println("forget", r.Deleted)
}
}
ui.Render(view, 400, 300, 1)
```
签名:`func MemoryPanel(c *ui.Context, entries *[]MemoryEntry, opts MemoryPanelOptions) MemoryPanelResult`。`entries` 为 nil、条目 `ID` 为空或 `MaxHeight` 为负时 panic。
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `MaxHeight` | `float32` | 360 | 列表最大高度。 |
| `Disabled` | `bool` | `false` | 只读,禁止搜索、编辑与删除。 |
## 状态
- `MemoryEntry` 字段:`ID`、`Text`、`Source`。
- 搜索不区分大小写,匹配内容与来源;只在搜索词或条目变化时重新筛选。
- 编辑时出现文本框和保存、取消;删除前用 Popconfirm 确认,确认按钮为危险样式且默认聚焦取消。
- 条目超过 200 条时虚拟化。
## 事件
- `Edited`:本帧保存的条目 ID,`*entries` 已更新。
- `Deleted`:本帧删除的条目 ID,已从 `*entries` 移除。
## 键盘操作
- Tab 到搜索框、各条的编辑与删除;删除确认中 Escape 取消。
## 限制
- 调用方原地修改条目文本而不改变切片长度时,筛选结果到下次搜索变化才更新。
+46
View File
@@ -0,0 +1,46 @@
# 279 CheckpointList 检查点列表
## 用途
列出 Agent 运行的检查点,标出当前点,回滚到其它检查点前要求确认。
## 最小示例
```go
current := "c2"
points := []mujica.Checkpoint{{ID: "c2", Label: "Menu drafted"}, {ID: "c1", Label: "Start"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.CheckpointList(c, &current, points, mujica.CheckpointListOptions{}).Changed() {
fmt.Println("restore", current)
}
}
ui.Render(view, 400, 300, 1)
```
签名:`func CheckpointList(c *ui.Context, current *string, points []Checkpoint, opts CheckpointListOptions) ChoiceResult`。`current` 为 nil、`ID` 为空或重复、`Label` 为空、`MaxHeight` 为负时 panic。
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `MaxHeight` | `float32` | 360 | 列表最大高度。 |
| `Disabled` | `bool` | `false` | 禁止回滚。 |
## 状态
- 当前点用实心菱形、选中底色和“当前”文字标出;其它点为空心菱形。
- 回滚用 AlertDialog 确认:默认聚焦取消,Enter 不会直接回滚。
- 超过 200 个检查点时虚拟化。
## 事件
- `Changed()`:确认回滚后 `*current` 已改为目标 ID。
## 键盘操作
- Tab 到各“回滚”,Enter 打开确认;确认框内 Tab 到“回滚”再按 Enter。
## 限制
- 恢复文件与状态由调用方完成。
@@ -0,0 +1,52 @@
# 280 HumanInputRequest 人工输入请求
## 用途
Agent 向用户提问:显示问题、可选答案与自由回答,并提示剩余时间与超时。
## 最小示例
```go
answer := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.HumanInputRequest(c, &answer, mujica.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)
```
签名:`func HumanInputRequest(c *ui.Context, answer *string, opts HumanInputRequestOptions) HumanInputResult`。`answer` 为 nil、`Question` 为空、既无选项又不允许自由回答时 panic。
## 参数
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Question` | `string` | 必填 | 问题。 |
| `Options` | `[]string` | `nil` | 可选答案。 |
| `AllowFree` | `bool` | `false` | 允许自由回答。 |
| `Deadline` | `time.Time` | 零值 | 截止时间;零值不限时。 |
| `Disabled` | `bool` | `false` | 禁止回答。 |
## 状态
- 选择选项会清空自由回答;输入自由回答会取代所选选项。
- 剩余时间每秒刷新;到时显示“智能体已停止等待”并禁用。
- 回答为空白时“发送回答”禁用。
## 事件
- `Submitted`:用户发送了 `*answer`。
- `TimedOut`:截止时间在本帧到达(只报告一次)。
## 键盘操作
- Tab 进入选项组,方向键选择;Tab 到输入框,Enter 发送;或 Tab 到“发送回答”。
## 限制
- 超时只在界面帧中检测,窗口隐藏期间推迟到重新显示。
+243
View File
@@ -0,0 +1,243 @@
package mujica_test
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func ExampleToolCallCard() {
call := mujica.ToolCall{ID: "1", Name: "read_file", Args: "{\"path\":\"ledger.md\"}",
Result: "{\"lines\":40}", State: mujica.AgentDone, Duration: 420 * time.Millisecond}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ToolCallCard(c, call, mujica.ToolCallCardOptions{OpenArgs: true})
}
ui.Render(view, 400, 300, 1)
}
func ExampleToolCallGroup() {
open := false
calls := []mujica.ToolCall{
{ID: "a", Name: "list_dir", State: mujica.AgentDone, Duration: 300 * time.Millisecond},
{ID: "b", Name: "read_file", State: mujica.AgentFailed, Error: "not found"},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ToolCallGroup(c, &open, calls, mujica.ToolCallGroupOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleToolApprovalDialog() {
open := true
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.ToolApprovalDialog(c, &open, mujica.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) {
mujica.Use(c, mujica.Settings{})
r := mujica.PermissionPrompt(c, &open, mujica.PermissionPromptOptions{
Title: "Write to the ledger?", Scope: "Write access to ~/court/ledger",
})
if r.Choice == mujica.PermissionAllowAlways {
fmt.Println("remember the grant")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleAgentStepList() {
steps := []mujica.AgentStep{
{ID: "read", Title: "Read the guest list", State: mujica.AgentDone, Duration: time.Second},
{ID: "seat", Title: "Assign the seats", State: mujica.AgentFailed, Detail: "Table 4 is full."},
{ID: "send", Title: "Send invitations", State: mujica.AgentPending},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.AgentStepList(c, steps, mujica.AgentStepListOptions{}); r.Retry != "" {
fmt.Println("retry", r.Retry)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleAgentPlan() {
items := []mujica.PlanItem{
{ID: "a", Text: "Read the guest list", State: mujica.AgentDone},
{ID: "b", Text: "Assign the seats", State: mujica.AgentRunning},
{ID: "c", Text: "Order the flowers"},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AgentPlan(c, &items, mujica.AgentPlanOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleAgentStatus() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AgentStatus(c, mujica.AgentWaiting, mujica.AgentStatusOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleAgentProgress() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AgentProgress(c, 0.4, mujica.AgentProgressOptions{Action: "Reading ledger.md"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleSubAgentTree() {
agents := []mujica.SubAgent{{ID: "lead", Name: "Steward", State: mujica.AgentRunning, Children: []mujica.SubAgent{
{ID: "cook", Name: "Cook", Task: "Plan the menu", State: mujica.AgentWaiting},
}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.SubAgentTree(c, agents, mujica.SubAgentTreeOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleFileChangeCard() {
decision := mujica.FilePending
change := mujica.FileChange{Path: "ledger.md", Old: "Guests: 40\n", New: "Guests: 42\n"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.FileChangeCard(c, &decision, change, mujica.FileChangeCardOptions{}).OpenDiff {
fmt.Println("open the full diff")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleMultiFileDiffReview() {
files := []mujica.FileChange{
{Path: "ledger.md", Old: "Guests: 40\n", New: "Guests: 42\n"},
{Path: "menu.md", Old: "soup\n", New: "soup\ntart\n"},
}
decisions := make([]mujica.FileDecision, len(files))
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.MultiFileDiffReview(c, &decisions, files, mujica.MultiFileDiffReviewOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleCommandExecutionCard() {
run := mujica.CommandRun{Command: "go test ./...", Dir: "~/court", Running: true, Output: "\x1b[32mok\x1b[0m court\n"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.CommandExecutionCard(c, run, mujica.CommandExecutionCardOptions{}).Changed() {
fmt.Println("abort the process")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleScreenshotStream() {
var st mujica.ScreenshotStreamState
var frames []mujica.ScreenshotFrame // appended as the agent captures them
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ScreenshotStream(c, &st, frames, mujica.ScreenshotStreamOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleArtifactPanel() {
art := mujica.Artifact{Title: "seating.go", Language: "go", Content: "package court\n"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ArtifactPanel(c, art, mujica.ArtifactPanelOptions{}).Exported {
fmt.Println("save the file")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleArtifactVersionSwitcher() {
current := 1
versions := []mujica.ArtifactVersion{{Label: "v1", Note: "First draft"}, {Label: "v2", Note: "Added music"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ArtifactVersionSwitcher(c, &current, versions, mujica.ArtifactVersionSwitcherOptions{}).Compare {
fmt.Println("diff v1 and v2")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleSandboxStatus() {
info := mujica.SandboxInfo{Name: "sandbox-7", State: mujica.SandboxRunning,
Resources: []mujica.SandboxResource{{Name: "CPU", Used: 1.5, Limit: 2, Unit: "cores"}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.SandboxStatus(c, info, mujica.SandboxStatusOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleMCPServerList() {
servers := []mujica.MCPServer{{ID: "fs", Name: "filesystem", Transport: "stdio", State: mujica.MCPConnected, Tools: 12}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.MCPServerList(c, servers, mujica.MCPServerListOptions{}); r.Action == mujica.MCPStop {
fmt.Println("stop", r.Server)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleMemoryPanel() {
entries := []mujica.MemoryEntry{{ID: "1", Text: "Lady Sakiko prefers the east wing.", Source: "Chat"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.MemoryPanel(c, &entries, mujica.MemoryPanelOptions{}); r.Deleted != "" {
fmt.Println("forget", r.Deleted)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleCheckpointList() {
current := "c2"
points := []mujica.Checkpoint{{ID: "c2", Label: "Menu drafted"}, {ID: "c1", Label: "Start"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.CheckpointList(c, &current, points, mujica.CheckpointListOptions{}).Changed() {
fmt.Println("restore", current)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleHumanInputRequest() {
answer := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.HumanInputRequest(c, &answer, mujica.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)
}
+280 -1
View File
@@ -1,3 +1,282 @@
package main
var agentPages = []page{}
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
const agentCategory = "Agent"
var agentPages = []page{
{ID: "261", Name: "ToolCallCard", ZhName: "工具调用卡片", Category: agentCategory, Demo: toolCallCardDemo, Code: toolCallCardCode,
Keys: "Tab 到参数、结果折叠按钮,Enter / Space 展开或折叠。"},
{ID: "262", Name: "ToolCallGroup", ZhName: "工具调用组", Category: agentCategory, Demo: toolCallGroupDemo, Code: toolCallGroupCode,
Keys: "Tab 到组标题,Enter / Space 展开或折叠。"},
{ID: "263", Name: "ToolApprovalDialog", ZhName: "工具审批对话框", Category: agentCategory, Demo: toolApprovalDemo, Code: toolApprovalDialogCode,
Keys: "打开时焦点在拒绝;Enter 只触发聚焦按钮,Tab 到允许才能批准;Escape 拒绝。"},
{ID: "264", Name: "PermissionPrompt", ZhName: "权限请求", Category: agentCategory, Demo: permissionDemo, Code: permissionPromptCode,
Keys: "打开时焦点在拒绝;Tab 到始终允许、允许一次;Escape 拒绝。"},
{ID: "265", Name: "AgentStepList", ZhName: "执行步骤列表", Category: agentCategory, Demo: agentStepListDemo, Code: agentStepListCode,
Keys: "↑ ↓ 在步骤与重试间移动;Enter 展开详情或重试。"},
{ID: "266", Name: "AgentPlan", ZhName: "执行计划", Category: agentCategory, Demo: agentPlanDemo, Code: agentPlanCode,
Keys: "Space 勾选;Tab 到跳过或恢复;在输入框中按 Enter 新增。"},
{ID: "267", Name: "AgentStatus", ZhName: "Agent 状态", Category: agentCategory, Demo: agentStatusDemo, Code: agentStatusCode,
Keys: "不获取焦点;屏幕阅读器读出状态文字。"},
{ID: "268", Name: "AgentProgress", ZhName: "Agent 进度", Category: agentCategory, Demo: agentProgressDemo, Code: agentProgressCode,
Keys: "不获取焦点;当前动作以状态读出。"},
{ID: "269", Name: "SubAgentTree", ZhName: "子 Agent 树", Category: agentCategory, Demo: subAgentTreeDemo, Code: subAgentTreeCode,
Keys: "↑ ↓ 移动;→ 展开,← 折叠;Enter / Space 切换。"},
{ID: "270", Name: "FileChangeCard", ZhName: "文件变更卡片", Category: agentCategory, Demo: fileChangeCardDemo, Code: fileChangeCardCode,
Keys: "Tab 到打开差异、拒绝、接受或撤销,Enter / Space 触发。"},
{ID: "271", Name: "MultiFileDiffReview", ZhName: "多文件差异审阅", Category: agentCategory, Demo: multiFileDiffDemo, Code: multiFileDiffReviewCode,
Keys: "文件列表中 ↑ ↓ 移动,Enter 选择;Tab 到接受、拒绝、全部接受。"},
{ID: "272", Name: "CommandExecutionCard", ZhName: "命令执行卡片", Category: agentCategory, Demo: commandExecutionDemo, Code: commandExecutionCardCode,
Keys: "Tab 到中止,Enter / Space 请求中止;输出区可滚动。"},
{ID: "273", Name: "ScreenshotStream", ZhName: "Agent 画面流", Category: agentCategory, Demo: screenshotStreamDemo, Code: screenshotStreamCode,
Keys: "画面区中 ← → 逐帧,Space 暂停或恢复。"},
{ID: "274", Name: "ArtifactPanel", ZhName: "产物面板", Category: agentCategory, Demo: artifactPanelDemo, Code: artifactPanelCode,
Keys: "切换组中 ← → 移动、Enter 选择;Tab 到复制、导出。"},
{ID: "275", Name: "ArtifactVersionSwitcher", ZhName: "产物版本切换", Category: agentCategory, Demo: artifactVersionDemo, Code: artifactVersionSwitcherCode,
Keys: "焦点在组件内时 ← → 切换版本;Tab 到比较按钮。"},
{ID: "276", Name: "SandboxStatus", ZhName: "沙箱状态", Category: agentCategory, Demo: sandboxStatusDemo, Code: sandboxStatusCode,
Keys: "不获取焦点;剩余时间每秒刷新。"},
{ID: "277", Name: "MCPServerList", ZhName: "MCP 服务器列表", Category: agentCategory, Demo: mcpServerListDemo, Code: mcpServerListCode,
Keys: "Tab 到各行的启动、停止、重新连接,Enter / Space 触发。"},
{ID: "278", Name: "MemoryPanel", ZhName: "记忆面板", Category: agentCategory, Demo: memoryPanelDemo, Code: memoryPanelCode,
Keys: "Tab 到搜索、编辑、删除;删除确认默认聚焦取消,Escape 取消。"},
{ID: "279", Name: "CheckpointList", ZhName: "检查点列表", Category: agentCategory, Demo: checkpointListDemo, Code: checkpointListCode,
Keys: "Tab 到回滚,Enter 打开确认;确认框默认聚焦取消。"},
{ID: "280", Name: "HumanInputRequest", ZhName: "人工输入请求", Category: agentCategory, Demo: humanInputDemo, Code: humanInputRequestCode,
Keys: "方向键选择选项;输入框中 Enter 发送;Tab 到发送回答。"},
}
// agentStates are the states a demo can pick from.
var agentStates = []string{"Pending", "Planning", "Running", "Waiting", "Done", "Failed", "Skipped"}
// agentStatePick is a select over agentStates, logging changes.
func agentStatePick(c *ui.Context, log func(string), v *string) mujica.AgentState {
if ui.Select(c, v, agentStates).Changed() {
log("state = " + *v)
}
for i, s := range agentStates {
if s == *v {
return mujica.AgentState(i)
}
}
panic("gallery: unknown state " + *v)
}
type toolCallDemoState struct {
state string
failed bool
still bool
}
func toolCallCardDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "261", func() toolCallDemoState { return toolCallDemoState{state: "Done"} })
var st mujica.AgentState
overlayControls(c, func() {
st = agentStatePick(c, log, &s.state)
overlayCheck(c, log, &s.failed, "Error")
overlayCheck(c, log, &s.still, "Still")
})
call := mujica.ToolCall{ID: "1", Name: "read_file", Args: `{"path":"court/ledger.md","limit":40}`,
Result: `{"lines":40,"truncated":false}`, State: st, Duration: 420 * time.Millisecond}
if s.failed {
call.Error = "permission denied: court/ledger.md"
}
if mujica.ToolCallCard(c, call, mujica.ToolCallCardOptions{Still: s.still}).Changed() {
log("folded or unfolded a section")
}
}
func toolCallGroupDemo(c *ui.Context, log func(string)) {
open := ui.Local(c.Root(), "262", func() bool { return true })
calls := []mujica.ToolCall{
{ID: "a", Name: "list_dir", Args: `{"path":"court"}`, Result: `["ledger.md","menu.md"]`, State: mujica.AgentDone, Duration: 300 * time.Millisecond},
{ID: "b", Name: "read_file", Args: `{"path":"court/wine.md"}`, Error: "not found", State: mujica.AgentFailed, Duration: 1200 * time.Millisecond},
{ID: "c", Name: "grep", Args: `{"pattern":"Sakiko"}`, Result: `{"matches":3}`, State: mujica.AgentDone, Duration: 500 * time.Millisecond},
}
if mujica.ToolCallGroup(c, open, calls, mujica.ToolCallGroupOptions{}).Changed() {
log(fmt.Sprintf("group open = %v", *open))
}
}
func toolApprovalDemo(c *ui.Context, log func(string)) {
open := ui.Local(c.Root(), "263", func() bool { return false })
if mujica.Button(c, "Run delete_files", mujica.ButtonOptions{}).Clicked() {
*open = true
log("asked for approval")
}
r := mujica.ToolApprovalDialog(c, open, mujica.ToolApprovalOptions{Tool: "delete_files",
Args: `{"glob":"build/*.tmp","dry_run":false}`, Reason: "The agent wants to clean temporary files from the build folder."})
if r.Allowed {
log("allowed")
}
if r.Denied {
log("denied")
}
}
type permissionDemoState struct {
open bool
grant string
}
func permissionDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "264", func() permissionDemoState { return permissionDemoState{grant: "none"} })
ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() {
if mujica.Button(c, "Ask to write", mujica.ButtonOptions{}).Clicked() {
s.open = true
log("asked for permission")
}
ui.Text(c, "Grant: "+s.grant)
})
r := mujica.PermissionPrompt(c, &s.open, mujica.PermissionPromptOptions{Title: "Write to the ledger?",
Description: "The agent wants to record tonight's guests.", Scope: "Write access to ~/court/ledger and its subfolders"})
if r.Choice != mujica.PermissionNone {
s.grant = [...]string{"none", "once", "always", "denied"}[r.Choice]
log("choice = " + s.grant)
}
}
type stepDemoState struct {
steps []mujica.AgentStep
disabled bool
}
func agentStepListDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "265", func() stepDemoState {
return stepDemoState{steps: []mujica.AgentStep{
{ID: "read", Title: "Read the guest list", State: mujica.AgentDone, Duration: 800 * time.Millisecond, Detail: "42 guests, 3 arrive late."},
{ID: "seat", Title: "Assign the seats", State: mujica.AgentFailed, Duration: 2 * time.Second, Detail: "Table 4 is over capacity."},
{ID: "send", Title: "Send the invitations", State: mujica.AgentPending},
}}
})
overlayControls(c, func() { overlayCheck(c, log, &s.disabled, "Disabled") })
r := mujica.AgentStepList(c, s.steps, mujica.AgentStepListOptions{Disabled: s.disabled})
if r.Toggled != "" {
log("toggled " + r.Toggled)
}
if r.Retry != "" {
log("retry " + r.Retry)
for i := range s.steps {
if s.steps[i].ID == r.Retry {
s.steps[i].State, s.steps[i].Detail = mujica.AgentRunning, "Retrying with a larger table."
}
}
}
}
type planDemoState struct {
items []mujica.PlanItem
disabled bool
}
func agentPlanDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "266", func() planDemoState {
return planDemoState{items: []mujica.PlanItem{
{ID: "a", Text: "Read the guest list", State: mujica.AgentDone},
{ID: "b", Text: "Assign the seats", State: mujica.AgentRunning},
{ID: "c", Text: "Order the flowers"},
}}
})
overlayControls(c, func() {
overlayCheck(c, log, &s.disabled, "Disabled")
if mujica.Button(c, "Agent finishes a step", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
for i := range s.items {
if s.items[i].State != mujica.AgentDone && s.items[i].State != mujica.AgentSkipped {
s.items[i].State = mujica.AgentDone
log("agent checked " + s.items[i].Text)
break
}
}
}
})
if mujica.AgentPlan(c, &s.items, mujica.AgentPlanOptions{Disabled: s.disabled}).Changed() {
log(fmt.Sprintf("plan changed (%d items)", len(s.items)))
}
}
type statusDemoState struct {
state string
still bool
}
func agentStatusDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "267", func() statusDemoState { return statusDemoState{state: "Running"} })
var st mujica.AgentState
overlayControls(c, func() {
st = agentStatePick(c, log, &s.state)
overlayCheck(c, log, &s.still, "Still")
})
ui.Row(c).Gap(8).Wrap().Children(func() {
mujica.AgentStatus(c, st, mujica.AgentStatusOptions{Still: s.still})
for _, v := range []mujica.AgentState{mujica.AgentPlanning, mujica.AgentRunning, mujica.AgentWaiting, mujica.AgentDone, mujica.AgentFailed} {
mujica.AgentStatus(c, v, mujica.AgentStatusOptions{Still: s.still})
}
})
}
type progressDemoState struct {
value float64
indeterminate bool
}
func agentProgressDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "268", func() progressDemoState { return progressDemoState{value: 0.4} })
overlayControls(c, func() {
if ui.Slider(c, &s.value, 0, 1).Width(200).Changed() {
log(fmt.Sprintf("value = %.2f", s.value))
}
overlayCheck(c, log, &s.indeterminate, "Indeterminate")
})
action := "Reading ledger.md"
if s.value > 0.5 {
action = "Writing the seating chart"
}
mujica.AgentProgress(c, s.value, mujica.AgentProgressOptions{Label: "Preparing the ball", Action: action, Indeterminate: s.indeterminate})
}
func subAgentTreeDemo(c *ui.Context, log func(string)) {
disabled := ui.Local(c.Root(), "269", func() bool { return false })
overlayControls(c, func() { overlayCheck(c, log, disabled, "Disabled") })
agents := []mujica.SubAgent{{ID: "lead", Name: "Steward", Task: "Prepare the ball", State: mujica.AgentRunning, Children: []mujica.SubAgent{
{ID: "music", Name: "Musician", Task: "Hire the quartet", State: mujica.AgentDone},
{ID: "kitchen", Name: "Cook", Task: "Plan the menu", State: mujica.AgentWaiting, Children: []mujica.SubAgent{
{ID: "wine", Name: "Sommelier", Task: "Choose the wine", State: mujica.AgentFailed},
}},
}}}
if mujica.SubAgentTree(c, agents, mujica.SubAgentTreeOptions{Disabled: *disabled}).Changed() {
log("expanded or collapsed an agent")
}
}
type fileChangeDemoState struct {
decision mujica.FileDecision
disabled bool
preview float64
}
func fileChangeCardDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "270", func() fileChangeDemoState { return fileChangeDemoState{preview: 8} })
overlayControls(c, func() {
overlayCheck(c, log, &s.disabled, "Disabled")
overlaySlider(c, log, &s.preview, "Preview", "lines", 2, 12, 1)
})
r := mujica.FileChangeCard(c, &s.decision, agentDemoChange, mujica.FileChangeCardOptions{Preview: int(s.preview), Disabled: s.disabled})
if r.Decided {
log(fmt.Sprintf("decision = %d", s.decision))
}
if r.OpenDiff {
log("open the full diff")
}
}
var agentDemoChange = mujica.FileChange{Path: "court/ledger.md",
Old: "# Ledger\nGuests: 40\nWine: claret\nMusic: none\nHall: east wing\n",
New: "# Ledger\nGuests: 42\nWine: claret\nMusic: quartet\nFlowers: lilies\nHall: east wing\n"}
+196
View File
@@ -0,0 +1,196 @@
package main
// Code snippets of the Agent pages, the same as their compiled Examples.
const toolCallCardCode = `call := mujica.ToolCall{ID: "1", Name: "read_file", Args: "{\"path\":\"ledger.md\"}",
Result: "{\"lines\":40}", State: mujica.AgentDone, Duration: 420 * time.Millisecond}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ToolCallCard(c, call, mujica.ToolCallCardOptions{OpenArgs: true})
}
ui.Render(view, 400, 300, 1)`
const toolCallGroupCode = `open := false
calls := []mujica.ToolCall{
{ID: "a", Name: "list_dir", State: mujica.AgentDone, Duration: 300 * time.Millisecond},
{ID: "b", Name: "read_file", State: mujica.AgentFailed, Error: "not found"},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ToolCallGroup(c, &open, calls, mujica.ToolCallGroupOptions{})
}
ui.Render(view, 400, 300, 1)`
const toolApprovalDialogCode = `open := true
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.ToolApprovalDialog(c, &open, mujica.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)`
const permissionPromptCode = `open := true
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.PermissionPrompt(c, &open, mujica.PermissionPromptOptions{
Title: "Write to the ledger?", Scope: "Write access to ~/court/ledger",
})
if r.Choice == mujica.PermissionAllowAlways {
fmt.Println("remember the grant")
}
}
ui.Render(view, 400, 300, 1)`
const agentStepListCode = `steps := []mujica.AgentStep{
{ID: "read", Title: "Read the guest list", State: mujica.AgentDone, Duration: time.Second},
{ID: "seat", Title: "Assign the seats", State: mujica.AgentFailed, Detail: "Table 4 is full."},
{ID: "send", Title: "Send invitations", State: mujica.AgentPending},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.AgentStepList(c, steps, mujica.AgentStepListOptions{}); r.Retry != "" {
fmt.Println("retry", r.Retry)
}
}
ui.Render(view, 400, 300, 1)`
const agentPlanCode = `items := []mujica.PlanItem{
{ID: "a", Text: "Read the guest list", State: mujica.AgentDone},
{ID: "b", Text: "Assign the seats", State: mujica.AgentRunning},
{ID: "c", Text: "Order the flowers"},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AgentPlan(c, &items, mujica.AgentPlanOptions{})
}
ui.Render(view, 400, 300, 1)`
const agentStatusCode = `view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AgentStatus(c, mujica.AgentWaiting, mujica.AgentStatusOptions{})
}
ui.Render(view, 400, 300, 1)`
const agentProgressCode = `view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AgentProgress(c, 0.4, mujica.AgentProgressOptions{Action: "Reading ledger.md"})
}
ui.Render(view, 400, 300, 1)`
const subAgentTreeCode = `agents := []mujica.SubAgent{{ID: "lead", Name: "Steward", State: mujica.AgentRunning, Children: []mujica.SubAgent{
{ID: "cook", Name: "Cook", Task: "Plan the menu", State: mujica.AgentWaiting},
}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.SubAgentTree(c, agents, mujica.SubAgentTreeOptions{})
}
ui.Render(view, 400, 300, 1)`
const fileChangeCardCode = `decision := mujica.FilePending
change := mujica.FileChange{Path: "ledger.md", Old: "Guests: 40\n", New: "Guests: 42\n"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.FileChangeCard(c, &decision, change, mujica.FileChangeCardOptions{}).OpenDiff {
fmt.Println("open the full diff")
}
}
ui.Render(view, 400, 300, 1)`
const multiFileDiffReviewCode = `files := []mujica.FileChange{
{Path: "ledger.md", Old: "Guests: 40\n", New: "Guests: 42\n"},
{Path: "menu.md", Old: "soup\n", New: "soup\ntart\n"},
}
decisions := make([]mujica.FileDecision, len(files))
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.MultiFileDiffReview(c, &decisions, files, mujica.MultiFileDiffReviewOptions{})
}
ui.Render(view, 400, 300, 1)`
const commandExecutionCardCode = `run := mujica.CommandRun{Command: "go test ./...", Dir: "~/court", Running: true, Output: "\x1b[32mok\x1b[0m court\n"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.CommandExecutionCard(c, run, mujica.CommandExecutionCardOptions{}).Changed() {
fmt.Println("abort the process")
}
}
ui.Render(view, 400, 300, 1)`
const screenshotStreamCode = `var st mujica.ScreenshotStreamState
var frames []mujica.ScreenshotFrame // appended as the agent captures them
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ScreenshotStream(c, &st, frames, mujica.ScreenshotStreamOptions{})
}
ui.Render(view, 400, 300, 1)`
const artifactPanelCode = `art := mujica.Artifact{Title: "seating.go", Language: "go", Content: "package court\n"}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ArtifactPanel(c, art, mujica.ArtifactPanelOptions{}).Exported {
fmt.Println("save the file")
}
}
ui.Render(view, 400, 300, 1)`
const artifactVersionSwitcherCode = `current := 1
versions := []mujica.ArtifactVersion{{Label: "v1", Note: "First draft"}, {Label: "v2", Note: "Added music"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ArtifactVersionSwitcher(c, &current, versions, mujica.ArtifactVersionSwitcherOptions{}).Compare {
fmt.Println("diff v1 and v2")
}
}
ui.Render(view, 400, 300, 1)`
const sandboxStatusCode = `info := mujica.SandboxInfo{Name: "sandbox-7", State: mujica.SandboxRunning,
Resources: []mujica.SandboxResource{{Name: "CPU", Used: 1.5, Limit: 2, Unit: "cores"}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.SandboxStatus(c, info, mujica.SandboxStatusOptions{})
}
ui.Render(view, 400, 300, 1)`
const mcpServerListCode = `servers := []mujica.MCPServer{{ID: "fs", Name: "filesystem", Transport: "stdio", State: mujica.MCPConnected, Tools: 12}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.MCPServerList(c, servers, mujica.MCPServerListOptions{}); r.Action == mujica.MCPStop {
fmt.Println("stop", r.Server)
}
}
ui.Render(view, 400, 300, 1)`
const memoryPanelCode = `entries := []mujica.MemoryEntry{{ID: "1", Text: "Lady Sakiko prefers the east wing.", Source: "Chat"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if r := mujica.MemoryPanel(c, &entries, mujica.MemoryPanelOptions{}); r.Deleted != "" {
fmt.Println("forget", r.Deleted)
}
}
ui.Render(view, 400, 300, 1)`
const checkpointListCode = `current := "c2"
points := []mujica.Checkpoint{{ID: "c2", Label: "Menu drafted"}, {ID: "c1", Label: "Start"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.CheckpointList(c, &current, points, mujica.CheckpointListOptions{}).Changed() {
fmt.Println("restore", current)
}
}
ui.Render(view, 400, 300, 1)`
const humanInputRequestCode = `answer := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
r := mujica.HumanInputRequest(c, &answer, mujica.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)`
+285
View File
@@ -0,0 +1,285 @@
package main
import (
"fmt"
"image"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
type multiFileDemoState struct {
decisions []mujica.FileDecision
disabled bool
}
var agentDemoFiles = []mujica.FileChange{
agentDemoChange,
{Path: "court/menu.md", Old: "soup\nroast\n", New: "soup\nroast\ntart\n"},
{Path: "court/old-plan.md", Old: "Hold the ball in the cellar.\n"},
}
func multiFileDiffDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "271", func() multiFileDemoState {
return multiFileDemoState{decisions: make([]mujica.FileDecision, len(agentDemoFiles))}
})
overlayControls(c, func() {
overlayCheck(c, log, &s.disabled, "Disabled")
if mujica.Button(c, "Reset", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
clear(s.decisions)
log("reset")
}
})
if mujica.MultiFileDiffReview(c, &s.decisions, agentDemoFiles, mujica.MultiFileDiffReviewOptions{Disabled: s.disabled}).Changed() {
log(fmt.Sprintf("decisions = %v", s.decisions))
}
}
type commandDemoState struct {
run mujica.CommandRun
disabled bool
lines int
}
func commandExecutionDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "272", func() commandDemoState {
return commandDemoState{run: mujica.CommandRun{Command: "go test ./court/...", Dir: "~/projects/court", Running: true}}
})
overlayControls(c, func() {
overlayCheck(c, log, &s.disabled, "Disabled")
if mujica.Button(c, "Print a line", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
s.lines++
color := []string{"32mok ", "33mwarn", "31mFAIL"}[s.lines%3]
s.run.Output += fmt.Sprintf("\x1b[%s\x1b[0m court/case%d 0.%02ds\n", color, s.lines, s.lines)
log("printed a line")
}
if mujica.Button(c, "Restart", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
s.run.Running, s.run.Output, s.lines = true, "", 0
log("restarted")
}
})
if mujica.CommandExecutionCard(c, s.run, mujica.CommandExecutionCardOptions{Disabled: s.disabled}).Changed() {
s.run.Running, s.run.ExitCode, s.run.Duration = false, 130, time.Duration(s.lines)*time.Second
s.run.Output += "\x1b[31maborted by user\x1b[0m\n"
log("abort requested")
}
}
type screenshotDemoState struct {
st mujica.ScreenshotStreamState
frames []mujica.ScreenshotFrame
disabled bool
}
// agentDemoFrame paints a frame with a moving block.
func agentDemoFrame(i int) mujica.ScreenshotFrame {
img := image.NewRGBA(image.Rect(0, 0, 320, 180))
for y := range 180 {
for x := range 320 {
o := img.PixOffset(x, y)
v := uint8(235)
if x >= 20+i*24%260 && x < 60+i*24%260 && y > 60 && y < 120 {
v = 120
}
img.Pix[o], img.Pix[o+1], img.Pix[o+2], img.Pix[o+3] = v, v-10, v-20, 255
}
}
return mujica.ScreenshotFrame{Image: ui.NewBitmap(img), Caption: fmt.Sprintf("Clicked button %d", i+1), At: time.Duration(i) * 2 * time.Second}
}
func screenshotStreamDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "273", func() screenshotDemoState {
return screenshotDemoState{frames: []mujica.ScreenshotFrame{agentDemoFrame(0), agentDemoFrame(1)}}
})
overlayControls(c, func() {
overlayCheck(c, log, &s.disabled, "Disabled")
if mujica.Button(c, "Capture a frame", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
s.frames = append(s.frames, agentDemoFrame(len(s.frames)))
log(fmt.Sprintf("frame %d arrived", len(s.frames)))
}
})
if mujica.ScreenshotStream(c, &s.st, s.frames, mujica.ScreenshotStreamOptions{Height: 220, Disabled: s.disabled}).Changed() {
log(fmt.Sprintf("frame %d, paused = %v", s.st.Index+1, s.st.Paused))
}
}
type artifactDemoState struct {
kind string
disabled bool
}
func artifactPanelDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "274", func() artifactDemoState { return artifactDemoState{kind: "Code"} })
overlayControls(c, func() {
if ui.Select(c, &s.kind, []string{"Code", "Document"}).Changed() {
log("kind = " + s.kind)
}
overlayCheck(c, log, &s.disabled, "Disabled")
})
art := mujica.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, \"Uika\": 2}\n}\n"}
opts := mujica.ArtifactPanelOptions{Height: 200, Disabled: s.disabled}
if s.kind == "Document" {
art = mujica.Artifact{Title: "Invitation", Kind: mujica.ArtifactDocument, Content: "# You are invited\n\nThe masquerade begins at nine.\n"}
opts.Preview = func() {
mujica.Heading(c, "You are invited", mujica.HeadingOptions{})
ui.Text(c, "The masquerade begins at nine.")
}
}
r := mujica.ArtifactPanel(c, art, opts)
if r.Copied {
log("copied to the clipboard")
}
if r.Exported {
log("export requested")
}
}
type versionDemoState struct {
current int
disabled bool
}
func artifactVersionDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "275", func() versionDemoState { return versionDemoState{current: 2} })
overlayControls(c, func() { overlayCheck(c, log, &s.disabled, "Disabled") })
versions := []mujica.ArtifactVersion{{Label: "v1", Note: "First draft"}, {Label: "v2", Note: "Added the quartet"}, {Label: "v3", Note: "Moved the wine table"}}
r := mujica.ArtifactVersionSwitcher(c, &s.current, versions, mujica.ArtifactVersionSwitcherOptions{Disabled: s.disabled})
if r.Changed {
log("version = " + versions[s.current].Label)
}
if r.Compare {
log("compare " + versions[s.current-1].Label + " and " + versions[s.current].Label)
}
}
type sandboxDemoState struct {
state string
cpu float64
expires time.Time
}
func sandboxStatusDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "276", func() sandboxDemoState {
return sandboxDemoState{state: "Running", cpu: 1.2, expires: time.Now().Add(10 * time.Minute)}
})
names := []string{"Starting", "Running", "Stopped", "Failed"}
state := mujica.SandboxStarting
overlayControls(c, func() {
if ui.Select(c, &s.state, names).Changed() {
log("state = " + s.state)
}
overlaySlider(c, log, &s.cpu, "CPU", "cores", 0, 2, 0.1)
if mujica.Button(c, "Extend 5 min", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
s.expires = s.expires.Add(5 * time.Minute)
log("extended")
}
})
for i, n := range names {
if n == s.state {
state = mujica.SandboxState(i)
}
}
mujica.SandboxStatus(c, mujica.SandboxInfo{Name: "sandbox-7", State: state, Expires: s.expires, Resources: []mujica.SandboxResource{
{Name: "CPU", Used: s.cpu, Limit: 2, Unit: "cores"}, {Name: "Memory", Used: 3.1, Limit: 4, Unit: "GB"},
}}, mujica.SandboxStatusOptions{})
}
type mcpDemoState struct {
servers []mujica.MCPServer
disabled bool
}
func mcpServerListDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "277", func() mcpDemoState {
return mcpDemoState{servers: []mujica.MCPServer{
{ID: "fs", Name: "filesystem", Transport: "stdio", State: mujica.MCPConnected, Tools: 12},
{ID: "gh", Name: "github", Transport: "http", State: mujica.MCPError, Error: "401 Unauthorized"},
{ID: "db", Name: "postgres", Transport: "stdio", State: mujica.MCPStopped},
}}
})
overlayControls(c, func() { overlayCheck(c, log, &s.disabled, "Disabled") })
r := mujica.MCPServerList(c, s.servers, mujica.MCPServerListOptions{Disabled: s.disabled})
if r.Action == mujica.MCPNone {
return
}
log(fmt.Sprintf("%s: %s", r.Server, [...]string{"", "start", "stop", "reconnect"}[r.Action]))
for i := range s.servers {
if s.servers[i].ID != r.Server {
continue
}
switch r.Action {
case mujica.MCPStop:
s.servers[i].State = mujica.MCPStopped
default:
s.servers[i].State, s.servers[i].Tools, s.servers[i].Error = mujica.MCPConnected, 5, ""
}
}
}
type memoryDemoState struct {
entries []mujica.MemoryEntry
disabled bool
}
func memoryPanelDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "278", func() memoryDemoState {
return memoryDemoState{entries: []mujica.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"},
}}
})
overlayControls(c, func() { overlayCheck(c, log, &s.disabled, "Disabled") })
r := mujica.MemoryPanel(c, &s.entries, mujica.MemoryPanelOptions{Disabled: s.disabled})
if r.Edited != "" {
log("edited " + r.Edited)
}
if r.Deleted != "" {
log("deleted " + r.Deleted)
}
}
type checkpointDemoState struct {
current string
disabled bool
}
func checkpointListDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "279", func() checkpointDemoState { return checkpointDemoState{current: "c3"} })
overlayControls(c, func() { overlayCheck(c, log, &s.disabled, "Disabled") })
points := []mujica.Checkpoint{{ID: "c3", Label: "Seats assigned", Note: "12 files changed"},
{ID: "c2", Label: "Menu drafted", Note: "3 files changed"}, {ID: "c1", Label: "Start"}}
if mujica.CheckpointList(c, &s.current, points, mujica.CheckpointListOptions{Disabled: s.disabled}).Changed() {
log("rolled back to " + s.current)
}
}
type humanDemoState struct {
answer string
free bool
deadline time.Time
disabled bool
}
func humanInputDemo(c *ui.Context, log func(string)) {
s := ui.Local(c.Root(), "280", func() humanDemoState { return humanDemoState{free: true} })
overlayControls(c, func() {
overlayCheck(c, log, &s.free, "AllowFree")
overlayCheck(c, log, &s.disabled, "Disabled")
if mujica.Button(c, "Start a 30 s timer", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
s.deadline = time.Now().Add(30 * time.Second)
log("timer started")
}
})
r := mujica.HumanInputRequest(c, &s.answer, mujica.HumanInputRequestOptions{Question: "Which hall should host the masquerade?",
Options: []string{"East wing", "Great hall"}, AllowFree: s.free, Deadline: s.deadline, Disabled: s.disabled})
if r.Submitted {
log("answer sent: " + s.answer)
}
if r.TimedOut {
log("timed out")
}
}
+85
View File
@@ -0,0 +1,85 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"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 := 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(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, msg(c, "file_change_card.new"))
case change.New == "" && change.Old != "":
agentCaption(c, 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 Button(c, msg(c, "file_change_card.open"), ButtonOptions{Variant: Ghost, Disabled: locked}).Clicked() && !locked {
r.OpenDiff = true
}
if *decision != FilePending {
if Button(c, msg(c, "file_change_card.undo"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() && !locked {
*decision, r.Decided = FilePending, true
}
return
}
if Button(c, msg(c, "file_change_card.reject"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() && !locked {
*decision, r.Decided = FileRejected, true
}
if Button(c, msg(c, "file_change_card.accept"), 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
}
+84
View File
@@ -0,0 +1,84 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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{}) })
})
}
+126
View File
@@ -0,0 +1,126 @@
package mujica
import (
"fmt"
"log/slog"
"slices"
"strings"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"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 := Tokens(c)
u := 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([]RadioItem[string], len(opts.Options))
for i, o := range opts.Options {
items[i] = RadioItem[string]{Value: o, Label: o}
}
chosen := *answer
if RadioGroup(c, &chosen, items, RadioGroupOptions{Label: opts.Question, Disabled: locked}).Changed() && !locked {
*answer, st.draft = chosen, ""
}
}
submit := false
if opts.AllowFree {
in := TextInput(c, &st.draft, TextInputOptions{Placeholder: msg(c, "human_input_request.free"),
Label: 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 Button(c, msg(c, "human_input_request.send"), 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 := Tokens(c)
text, tone := "", k.TextMuted
switch {
case expired:
text, tone = msg(c, "human_input_request.timeout"), k.Warning
case !deadline.IsZero():
text = fmt.Sprintf(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(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(FontSize(c, theme.CaptionSize+1))
})
}
+94
View File
@@ -0,0 +1,94 @@
package mujica
import (
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func humanTester(answer *string, opts *HumanInputRequestOptions, got *[]HumanInputResult) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, 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})
})
})
}
+6 -6
View File
@@ -2,7 +2,7 @@ package internal
import "strings"
// DiffOp says whether a diff line is kept, removed or added.
// DiffOp says a line is kept, removed or added.
type DiffOp uint8
const (
@@ -11,14 +11,14 @@ const (
DiffInsert
)
// DiffLine is one line of a line diff with its 1-based line numbers; 0 means absent.
// DiffLine is a numbered diff line; 0 means absent.
type DiffLine struct {
Op DiffOp
Text string
Old, New int
}
// SplitLines splits text into lines without their line breaks; "" has no lines.
// SplitLines splits text into lines; "" has none.
func SplitLines(s string) []string {
if s == "" {
return nil
@@ -26,7 +26,7 @@ func SplitLines(s string) []string {
return strings.Split(strings.TrimSuffix(s, "\n"), "\n")
}
// LineDiff returns a shortest edit script turning a into b (Myers, O((N+M)D)).
// LineDiff returns a shortest edit script from a to b.
func LineDiff(a, b []string) []DiffLine {
// Common prefix and suffix need no search.
pre := 0
@@ -49,7 +49,7 @@ func LineDiff(a, b []string) []DiffLine {
return out
}
// myers appends the edit script of a to b, numbering from offsets ao and bo.
// myers appends the edit script, numbering from ao and bo.
func myers(out []DiffLine, a, b []string, ao, bo int) []DiffLine {
n, m := len(a), len(b)
total := n + m
@@ -80,7 +80,7 @@ func myers(out []DiffLine, a, b []string, ao, bo int) []DiffLine {
}
}
}
// Walk the trace back from (n, m), collecting moves in reverse.
// Walk the trace back, collecting moves in reverse.
var rev []DiffLine
x, y := n, m
for d := end; d >= 0; d-- {
+144
View File
@@ -0,0 +1,144 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"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(msg(c, "mcp_server_list.label"))
locked := r.Element.IsDisabled()
r.Element.Children(func() {
if len(servers) == 0 {
agentCaption(c, msg(c, "mcp_server_list.empty"))
return
}
k := 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 := Tokens(c)
d := Density(c)
u := d.Unit()
words := [...]string{"stopped", "connecting", "connected", "error"}
word := 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(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(FontSize(c, theme.CaptionSize)).Selectable()
}
})
if s.State == MCPError && Button(c, msg(c, "mcp_server_list.reconnect"), ButtonOptions{Variant: Outline, Disabled: locked}).Clicked() {
act = MCPReconnect
}
if s.State == MCPStopped {
if Button(c, msg(c, "mcp_server_list.start"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() {
act = MCPStart
}
} else if Button(c, msg(c, "mcp_server_list.stop"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() {
act = MCPStop
}
})
return act
}
+91
View File
@@ -0,0 +1,91 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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}) })
})
}
+159
View File
@@ -0,0 +1,159 @@
package mujica
import (
"fmt"
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"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 := Density(c).Unit()
var r MemoryPanelResult
r.Element = agentCard(c).Disabled(opts.Disabled).Label(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, msg(c, "memory_panel.title")).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).Grow(1)
agentCaption(c, fmt.Sprintf(msg(c, "memory_panel.count"), len(*entries)))
})
SearchInput(c, &st.query, SearchInputOptions{Placeholder: msg(c, "memory_panel.search"), Disabled: locked})
memoryFilter(st, *entries)
if len(st.shown) == 0 {
agentCaption(c, 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, 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 := Density(c).Unit()
ui.Column(c).Gap(u).Padding(u*2, 0).Children(func() {
if st.editing == e.ID && !locked {
TextArea(c, &st.draft, TextAreaOptions{Label: msg(c, "memory_panel.edit"), MinHeight: 60}).Input.AutoFocus()
ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() {
if Button(c, msg(c, "memory_panel.cancel"), ButtonOptions{Variant: Secondary}).Clicked() {
st.editing = ""
}
blank := strings.TrimSpace(st.draft) == ""
if Button(c, msg(c, "memory_panel.save"), 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, msg(c, "memory_panel.edit"), "", locked) {
st.editing, st.draft = e.ID, e.Text
}
del := ui.Row(c)
del.Children(func() {
if agentToggle(c, msg(c, "memory_panel.delete"), "trash-2", locked) {
st.confirm = e.ID
}
})
open := st.confirm == e.ID && !locked
res := Popconfirm(c, del, &open, PopconfirmOptions{Title: msg(c, "memory_panel.confirm"), Description: e.Text,
ConfirmLabel: msg(c, "memory_panel.delete"), Danger: true})
if st.confirm == e.ID && !open {
st.confirm = ""
}
if res.Confirmed {
r.Deleted = e.ID
}
})
})
}
+96
View File
@@ -0,0 +1,96 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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{}) })
})
}
+127
View File
@@ -0,0 +1,127 @@
package mujica
func init() {
addMessages(map[string][2]string{
"agent_status.pending": {"Pending", "待开始"},
"agent_status.planning": {"Planning", "规划中"},
"agent_status.running": {"Running", "执行中"},
"agent_status.waiting": {"Waiting", "等待中"},
"agent_status.done": {"Done", "已完成"},
"agent_status.failed": {"Failed", "失败"},
"agent_status.skipped": {"Skipped", "已跳过"},
"tool_call_card.args": {"Arguments", "参数"},
"tool_call_card.result": {"Result", "结果"},
"tool_call_card.error": {"Error", "错误"},
"tool_call_group.count": {"%d tool calls", "%d 次工具调用"},
"tool_call_group.failed": {"%d failed", "%d 次失败"},
"tool_approval_dialog.title": {"Allow this tool to run?", "允许运行此工具?"},
"tool_approval_dialog.tool": {"Tool", "工具"},
"tool_approval_dialog.allow": {"Allow", "允许"},
"tool_approval_dialog.deny": {"Deny", "拒绝"},
"permission_prompt.deny": {"Deny", "拒绝"},
"permission_prompt.always": {"Always allow", "始终允许"},
"permission_prompt.once": {"Allow once", "允许一次"},
"permission_prompt.scope": {"Always allowing covers", "始终允许的范围"},
"agent_step_list.current": {"Current", "当前"},
"agent_step_list.retry": {"Retry", "重试"},
"agent_progress.label": {"Progress", "进度"},
"sub_agent_tree.children": {"%d sub-agents", "%d 个子智能体"},
"file_change_card.stats": {"%d added, %d removed lines", "新增 %d 行,删除 %d 行"},
"file_change_card.pending": {"Pending", "待审阅"},
"file_change_card.accepted": {"Accepted", "已接受"},
"file_change_card.rejected": {"Rejected", "已拒绝"},
"file_change_card.new": {"New file", "新文件"},
"file_change_card.deleted": {"Deleted", "已删除"},
"file_change_card.open": {"Open diff", "打开差异"},
"file_change_card.undo": {"Undo", "撤销"},
"file_change_card.reject": {"Reject", "拒绝"},
"file_change_card.accept": {"Accept", "接受"},
"multi_file_diff_review.title": {"Review changes", "审阅变更"},
"multi_file_diff_review.progress": {"%d of %d files reviewed", "已审阅 %d / %d 个文件"},
"multi_file_diff_review.accept_all": {"Accept all", "全部接受"},
"multi_file_diff_review.empty": {"No changes to review", "没有需要审阅的变更"},
"command_execution_card.abort": {"Abort", "中止"},
"command_execution_card.exit": {"Exit %d", "退出码 %d"},
"command_execution_card.output": {"Output", "输出"},
"command_execution_card.no_output": {"No output", "没有输出"},
"screenshot_stream.label": {"Agent screen", "智能体画面"},
"screenshot_stream.waiting": {"Waiting for frames", "等待画面"},
"screenshot_stream.prev": {"Previous frame", "上一帧"},
"screenshot_stream.next": {"Next frame", "下一帧"},
"screenshot_stream.pause": {"Pause", "暂停"},
"screenshot_stream.live": {"Go live", "回到实时"},
"screenshot_stream.counter": {"Frame %d of %d", "第 %d / %d 帧"},
"screenshot_stream.following": {"Live", "实时"},
"artifact_panel.copy": {"Copy", "复制"},
"artifact_panel.copied": {"Copied", "已复制"},
"artifact_panel.export": {"Export", "导出"},
"artifact_panel.preview": {"Preview", "预览"},
"artifact_panel.source": {"Source", "源码"},
"artifact_version_switcher.label": {"Versions", "版本"},
"artifact_version_switcher.older": {"Older version", "较早版本"},
"artifact_version_switcher.newer": {"Newer version", "较新版本"},
"artifact_version_switcher.count": {"%d of %d", "%d / %d"},
"artifact_version_switcher.compare": {"Compare with %s", "与 %s 比较"},
"sandbox_status.starting": {"Starting", "启动中"},
"sandbox_status.running": {"Running", "运行中"},
"sandbox_status.stopped": {"Stopped", "已停止"},
"sandbox_status.failed": {"Failed", "故障"},
"sandbox_status.high": {"Near the limit", "接近上限"},
"sandbox_status.left": {"%s left", "剩余 %s"},
"sandbox_status.expired": {"Time is up", "已到时"},
"mcp_server_list.label": {"MCP servers", "MCP 服务器"},
"mcp_server_list.empty": {"No MCP servers", "没有 MCP 服务器"},
"mcp_server_list.stopped": {"Stopped", "已停止"},
"mcp_server_list.connecting": {"Connecting", "连接中"},
"mcp_server_list.connected": {"Connected", "已连接"},
"mcp_server_list.error": {"Connection failed", "连接失败"},
"mcp_server_list.tools": {"%d tools", "%d 个工具"},
"mcp_server_list.start": {"Start", "启动"},
"mcp_server_list.stop": {"Stop", "停止"},
"mcp_server_list.reconnect": {"Reconnect", "重新连接"},
"memory_panel.title": {"Memory", "记忆"},
"memory_panel.count": {"%d entries", "%d 条"},
"memory_panel.search": {"Search memory", "搜索记忆"},
"memory_panel.empty": {"No memories match", "没有匹配的记忆"},
"memory_panel.edit": {"Edit", "编辑"},
"memory_panel.save": {"Save", "保存"},
"memory_panel.cancel": {"Cancel", "取消"},
"memory_panel.delete": {"Delete", "删除"},
"memory_panel.confirm": {"Forget this memory?", "删除这条记忆?"},
"checkpoint_list.label": {"Checkpoints", "检查点"},
"checkpoint_list.empty": {"No checkpoints yet", "暂无检查点"},
"checkpoint_list.current": {"Current", "当前"},
"checkpoint_list.rollback": {"Roll back", "回滚"},
"checkpoint_list.confirm": {"Roll back to “%s”?", "回滚到“%s”?"},
"checkpoint_list.warning": {"Changes made after this checkpoint will be discarded.", "此检查点之后的更改将被丢弃。"},
"human_input_request.free": {"Or write your own answer", "或填写你的回答"},
"human_input_request.send": {"Send answer", "发送回答"},
"human_input_request.left": {"Answer within %s", "请在 %s 内回答"},
"human_input_request.timeout": {"The agent stopped waiting", "智能体已停止等待"},
"agent_plan.title": {"Plan", "计划"},
"agent_plan.progress": {"%d / %d done", "已完成 %d / %d"},
"agent_plan.doing": {"In progress", "进行中"},
"agent_plan.skip": {"Skip", "跳过"},
"agent_plan.restore": {"Restore", "恢复"},
"agent_plan.add": {"Add", "添加"},
"agent_plan.placeholder": {"Add a step", "新增一项"},
})
}
+158
View File
@@ -0,0 +1,158 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"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) 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 := Tokens(c)
u := Density(c).Unit()
var r ChoiceResult
r.Element = agentCard(c).Height(h).Disabled(opts.Disabled).Label(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, msg(c, "multi_file_diff_review.title")).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600)
agentCaption(c, fmt.Sprintf(msg(c, "multi_file_diff_review.progress"), reviewed, len(files))).Grow(1)
all := reviewed < len(files)
if Button(c, msg(c, "multi_file_diff_review.accept_all"), ButtonOptions{Variant: Secondary, Disabled: locked || !all}).Clicked() && !locked && all {
for i := range *decisions {
if (*decisions)[i] == FilePending {
(*decisions)[i] = FileAccepted
}
}
r.changed = true
}
})
frac := 0.0
if len(files) > 0 {
frac = float64(reviewed) / float64(len(files))
}
agentBar(c, frac, fmt.Sprintf(msg(c, "multi_file_diff_review.progress"), reviewed, len(files)))
if len(files) == 0 {
agentCaption(c, 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.changed = 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 := Tokens(c)
u := Density(c).Unit()
row := ui.ButtonBase(c).Key(f.Path).Justify(ui.Start).Gap(u*2).MinHeight(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(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 := 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 Button(c, msg(c, "file_change_card.undo"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() && !locked {
*d, changed = FilePending, true
}
return
}
if Button(c, msg(c, "file_change_card.reject"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() && !locked {
*d, changed = FileRejected, true
}
if Button(c, msg(c, "file_change_card.accept"), ButtonOptions{Disabled: locked}).Clicked() && !locked {
*d, changed = FileAccepted, true
}
})
})
return changed
}
+118
View File
@@ -0,0 +1,118 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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})
})
})
}
+76
View File
@@ -0,0 +1,76 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"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 := Tokens(c)
u := Density(c).Unit()
r.Element = Dialog(c, open, DialogOptions{
Title: opts.Title, Description: opts.Description, KeepOnBackdrop: true, HideClose: true,
Actions: func() {
if Button(c, msg(c, "permission_prompt.deny"), ButtonOptions{Variant: Secondary}).AutoFocus().Clicked() {
r.Choice = PermissionDeny
}
if Button(c, msg(c, "permission_prompt.always"), ButtonOptions{Variant: Outline}).Clicked() {
r.Choice = PermissionAllowAlways
}
if Button(c, msg(c, "permission_prompt.once"), 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, 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
}
+78
View File
@@ -0,0 +1,78 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func permissionTester(open *bool, got *[]PermissionChoice) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if Button(c, "Ask", 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) {
Use(c, 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"})
})
}
+107
View File
@@ -0,0 +1,107 @@
package mujica
import (
"fmt"
"math"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"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 := Tokens(c)
u := Density(c).Unit()
word := 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(msg(c, "sandbox_status.high"))
}
ui.Text(c, fmt.Sprintf("%s / %s %s", sandboxNum(r.Used), sandboxNum(r.Limit), r.Unit)).
FontSize(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 := msg(c, "sandbox_status.expired")
if left > 0 {
text = fmt.Sprintf(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)
}
+68
View File
@@ -0,0 +1,68 @@
package mujica
import (
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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) {
Use(c, 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}) })
})
}
+125
View File
@@ -0,0 +1,125 @@
package mujica
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"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) 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 := Tokens(c)
u := 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 ChoiceResult
r.Element = agentCard(c).Disabled(opts.Disabled).Label(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.changed = true, 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(msg(c, "screenshot_stream.label"))
internal.Ring(view, k.Focus, theme.ControlRadius)
view.Children(func() {
if n == 0 {
agentCaption(c, 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.changed = !st.Paused, true
}
}
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
if iconAction(c, icons.Must("chevron-left"), msg(c, "screenshot_stream.prev")).Disabled(locked || st.Index == 0).Clicked() {
step(-1)
}
pause := msg(c, "screenshot_stream.pause")
if st.Paused {
pause = msg(c, "screenshot_stream.live")
}
if Button(c, pause, ButtonOptions{Variant: Secondary, Disabled: locked || n == 0}).Clicked() && !locked {
st.Paused, r.changed = !st.Paused, true
}
if iconAction(c, icons.Must("chevron-right"), msg(c, "screenshot_stream.next")).Disabled(locked || st.Index >= n-1).Clicked() {
step(1)
}
counter := fmt.Sprintf(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: msg(c, "screenshot_stream.following"), Still: true})
}
})
})
if r.changed {
slog.Debug("mujica: screenshot_stream move", "index", st.Index, "paused", st.Paused)
}
return r
}
+110
View File
@@ -0,0 +1,110 @@
package mujica
import (
"fmt"
"image"
"image/color"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
// 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) {
Use(c, 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})
})
})
}
+104
View File
@@ -0,0 +1,104 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"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) 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 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.changed = 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 := Tokens(c)
d := 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(FontSize(c, theme.CaptionSize+1)).SingleLine().Grow(1).Shrink(1)
agentCaption(c, agentWord(c, a.State))
if parent {
agentCaption(c, fmt.Sprintf(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)
}
+81
View File
@@ -0,0 +1,81 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
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) {
Use(c, 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) {
Use(c, 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")
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more