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