Agent: components 261-280
No files matched your search
@@ -0,0 +1,127 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// FileChange is a proposed edit; empty Old adds a file.
|
||||
type FileChange struct {
|
||||
Path string
|
||||
Old, New string
|
||||
}
|
||||
|
||||
// FileDecision is a review state; zero is FilePending.
|
||||
type FileDecision int
|
||||
|
||||
const (
|
||||
FilePending FileDecision = iota
|
||||
FileAccepted
|
||||
FileRejected
|
||||
)
|
||||
|
||||
// agentDiffCache holds a change's line diff until its text changes.
|
||||
type agentDiffCache struct {
|
||||
old, new string
|
||||
lines []internal.DiffLine
|
||||
add, del int
|
||||
firstEdit int
|
||||
done bool
|
||||
}
|
||||
|
||||
func (dc *agentDiffCache) update(ch FileChange) {
|
||||
if dc.done && dc.old == ch.Old && dc.new == ch.New {
|
||||
return
|
||||
}
|
||||
dc.old, dc.new, dc.done = ch.Old, ch.New, true
|
||||
dc.lines = internal.LineDiff(internal.SplitLines(ch.Old), internal.SplitLines(ch.New))
|
||||
dc.add, dc.del = internal.DiffStats(dc.lines)
|
||||
dc.firstEdit = 0
|
||||
for i, l := range dc.lines {
|
||||
if l.Op != internal.DiffEqual {
|
||||
dc.firstEdit = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// agentDiffOf returns the cached diff of ch kept on holder.
|
||||
func agentDiffOf(holder *ui.Element, ch FileChange) *agentDiffCache {
|
||||
dc := ui.Local(holder, "mujica.agent.diff", func() *agentDiffCache { return &agentDiffCache{} })
|
||||
(*dc).update(ch)
|
||||
return *dc
|
||||
}
|
||||
|
||||
// agentDiffStats shows signed added and removed counts.
|
||||
func agentDiffStats(c *ui.Context, add, del int) {
|
||||
k := Tokens(c)
|
||||
size := FontSize(c, theme.CaptionSize+1)
|
||||
ui.Row(c).Gap(Density(c).Unit() * 1.5).Shrink(0).Label(fmt.Sprintf(msg(c, "file_change_card.stats"), add, del)).Children(func() {
|
||||
ui.Text(c, fmt.Sprintf("+%d", add)).TextColor(k.Success).FontSize(size).Font(theme.MonoFont)
|
||||
ui.Text(c, fmt.Sprintf("−%d", del)).TextColor(k.Danger).FontSize(size).Font(theme.MonoFont)
|
||||
})
|
||||
}
|
||||
|
||||
// agentDiffRow draws one numbered, signed, tinted diff line.
|
||||
func agentDiffRow(c *ui.Context, l internal.DiffLine) {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
sign, bg, fg := " ", ui.Transparent, k.TextMuted
|
||||
switch l.Op {
|
||||
case internal.DiffInsert:
|
||||
sign, bg, fg = "+", k.Success.Alpha(0.14), k.Success
|
||||
case internal.DiffDelete:
|
||||
sign, bg, fg = "−", k.Danger.Alpha(0.14), k.Danger
|
||||
}
|
||||
num := func(n int) string {
|
||||
if n == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(n)
|
||||
}
|
||||
ui.Row(c).Gap(u*2).Background(bg).Padding(0, u).Children(func() {
|
||||
ui.Text(c, num(l.Old)).Width(36).TextAlign(ui.End).TextColor(k.TextMuted).Shrink(0)
|
||||
ui.Text(c, num(l.New)).Width(36).TextAlign(ui.End).TextColor(k.TextMuted).Shrink(0)
|
||||
ui.Text(c, sign).TextColor(fg).Width(10).Shrink(0)
|
||||
text := l.Text
|
||||
if text == "" {
|
||||
text = " "
|
||||
}
|
||||
ui.Text(c, text).Grow(1).Shrink(1)
|
||||
})
|
||||
}
|
||||
|
||||
// agentDiffView shows lines[from:to] in a capped well, virtualized when long.
|
||||
func agentDiffView(c *ui.Context, lines []internal.DiffLine, maxHeight float32) *ui.Element {
|
||||
w := agentWellRows(c, len(lines), func(i int) { agentDiffRow(c, lines[i]) }, maxHeight)
|
||||
return w.Padding(Density(c).Unit(), 0)
|
||||
}
|
||||
|
||||
// agentDecisionMark shows a decision by icon and word.
|
||||
func agentDecisionMark(c *ui.Context, d FileDecision) {
|
||||
ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).Shrink(0).Children(func() {
|
||||
agentCaption(c, agentDecisionIcon(c, d))
|
||||
})
|
||||
}
|
||||
|
||||
// agentDecisionIcon draws a named decision icon, returning its word.
|
||||
func agentDecisionIcon(c *ui.Context, d FileDecision) string {
|
||||
k := Tokens(c)
|
||||
tone, icon, word := k.TextMuted, "circle", msg(c, "file_change_card.pending")
|
||||
switch d {
|
||||
case FileAccepted:
|
||||
tone, icon, word = k.Success, "circle-check", msg(c, "file_change_card.accepted")
|
||||
case FileRejected:
|
||||
tone, icon, word = k.Danger, "circle-x", msg(c, "file_change_card.rejected")
|
||||
case FilePending:
|
||||
default:
|
||||
panic(fmt.Sprintf("mujica: unknown FileDecision %d", int(d)))
|
||||
}
|
||||
ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(tone).Shrink(0).Label(word)
|
||||
return word
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// AgentState is an agent or step state; zero is pending.
|
||||
type AgentState int
|
||||
|
||||
const (
|
||||
AgentPending AgentState = iota
|
||||
AgentPlanning
|
||||
AgentRunning
|
||||
AgentWaiting
|
||||
AgentDone
|
||||
AgentFailed
|
||||
AgentSkipped
|
||||
)
|
||||
|
||||
func (s AgentState) String() string {
|
||||
switch s {
|
||||
case AgentPending:
|
||||
return "pending"
|
||||
case AgentPlanning:
|
||||
return "planning"
|
||||
case AgentRunning:
|
||||
return "running"
|
||||
case AgentWaiting:
|
||||
return "waiting"
|
||||
case AgentDone:
|
||||
return "done"
|
||||
case AgentFailed:
|
||||
return "failed"
|
||||
case AgentSkipped:
|
||||
return "skipped"
|
||||
}
|
||||
panic(fmt.Sprintf("mujica: unknown AgentState %d", int(s)))
|
||||
}
|
||||
|
||||
// agentTone returns the color and icon marking s.
|
||||
func agentTone(k theme.Tokens, s AgentState) (ui.Color, *ui.SVG) {
|
||||
switch s {
|
||||
case AgentPlanning:
|
||||
return k.Info, icons.Must("list")
|
||||
case AgentRunning:
|
||||
return k.AccentText, icons.Must("loader-circle")
|
||||
case AgentWaiting:
|
||||
return k.Warning, icons.Must("clock")
|
||||
case AgentDone:
|
||||
return k.Success, icons.Must("circle-check")
|
||||
case AgentFailed:
|
||||
return k.Danger, icons.Must("circle-x")
|
||||
case AgentSkipped:
|
||||
return k.TextMuted, icons.Must("ban")
|
||||
}
|
||||
return k.TextMuted, icons.Must("circle")
|
||||
}
|
||||
|
||||
// agentMark draws s; running spins unless still.
|
||||
func agentMark(c *ui.Context, s AgentState, size float32, still bool) *ui.Element {
|
||||
tone, icon := agentTone(Tokens(c), s)
|
||||
if s == AgentRunning && !still {
|
||||
return Spinner(c, SpinnerOptions{Size: size, Color: tone, Label: msg(c, "agent_status.running")})
|
||||
}
|
||||
return ui.Icon(c, icon).Size(size, size).TextColor(tone).Shrink(0)
|
||||
}
|
||||
|
||||
// agentWord is the built-in word for s.
|
||||
func agentWord(c *ui.Context, s AgentState) string { return msg(c, "agent_status."+s.String()) }
|
||||
|
||||
// agentDuration formats d like "320 ms" or "4.2 s".
|
||||
func agentDuration(d time.Duration) string {
|
||||
if d < 0 {
|
||||
panic(fmt.Sprintf("mujica: negative duration %v", d))
|
||||
}
|
||||
switch {
|
||||
case d < time.Second:
|
||||
return fmt.Sprintf("%d ms", d.Milliseconds())
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%.1f s", d.Seconds())
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%d m %02d s", int(d.Minutes()), int(d.Seconds())%60)
|
||||
}
|
||||
return fmt.Sprintf("%d h %02d m", int(d.Hours()), int(d.Minutes())%60)
|
||||
}
|
||||
|
||||
// agentCard is the bordered surface every agent card sits on.
|
||||
func agentCard(c *ui.Context) *ui.Element {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
return ui.Column(c).Shrink(0).Padding(u*3).Gap(u*2).Radius(theme.CardRadius).
|
||||
Background(k.Surface).Border(theme.BorderWidth, k.Border)
|
||||
}
|
||||
|
||||
// agentCaption is small muted text.
|
||||
func agentCaption(c *ui.Context, s string) *ui.Element {
|
||||
return ui.Text(c, s).FontSize(FontSize(c, theme.CaptionSize)).TextColor(Tokens(c).TextMuted)
|
||||
}
|
||||
|
||||
// agentWell styles a sunken monospace well.
|
||||
func agentWell(c *ui.Context, l *ui.Element, maxHeight float32) *ui.Element {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
return l.Shrink(1).MaxHeight(maxHeight).Padding(u*2, u*3).Radius(theme.ControlRadius).
|
||||
Background(k.Background).Border(theme.BorderWidth, k.Border).Font(theme.MonoFont).
|
||||
FontSize(FontSize(c, theme.CaptionSize+1))
|
||||
}
|
||||
|
||||
// agentWellRows builds n rows in a well.
|
||||
func agentWellRows(c *ui.Context, n int, row func(i int), maxHeight float32) *ui.Element {
|
||||
return agentWell(c, agentRows(c, n, row), maxHeight)
|
||||
}
|
||||
|
||||
// agentRows builds n scrolling rows, virtualized when many.
|
||||
func agentRows(c *ui.Context, n int, row func(i int)) *ui.Element {
|
||||
if n > agentVirtualRows {
|
||||
return ui.List(c, nil, n, row)
|
||||
}
|
||||
return ui.Scroll(c).Children(func() {
|
||||
for i := range n {
|
||||
row(i)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// agentVirtualRows is the row count above which wells virtualize.
|
||||
const agentVirtualRows = 200
|
||||
|
||||
// agentCodeCache keeps lines and comment states per text.
|
||||
type agentCodeCache struct {
|
||||
language, text string
|
||||
lines []string
|
||||
open []bool
|
||||
}
|
||||
|
||||
func (cc *agentCodeCache) update(language, text string) {
|
||||
if cc.lines != nil && cc.language == language && cc.text == text {
|
||||
return
|
||||
}
|
||||
cc.language, cc.text = language, text
|
||||
cc.lines = internal.SplitLines(text)
|
||||
cc.open = make([]bool, len(cc.lines))
|
||||
block := false
|
||||
for i, line := range cc.lines {
|
||||
cc.open[i] = block
|
||||
_, block = internal.Highlight(language, line, block)
|
||||
}
|
||||
}
|
||||
|
||||
// agentPrettyJSON indents valid JSON; anything else comes back unchanged.
|
||||
func agentPrettyJSON(s string) string {
|
||||
var b bytes.Buffer
|
||||
if json.Indent(&b, []byte(s), "", " ") != nil {
|
||||
return s
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// agentTokenColor colors a highlight token from the theme.
|
||||
func agentTokenColor(k theme.Tokens, kind internal.TokenKind) ui.Color {
|
||||
switch kind {
|
||||
case internal.TokKeyword:
|
||||
return k.AccentText
|
||||
case internal.TokString:
|
||||
return k.Success
|
||||
case internal.TokNumber, internal.TokType:
|
||||
return k.Info
|
||||
case internal.TokComment, internal.TokPunct:
|
||||
return k.TextMuted
|
||||
}
|
||||
return k.Text
|
||||
}
|
||||
|
||||
// agentCodeLine builds one highlighted line of language.
|
||||
func agentCodeLine(c *ui.Context, language, line string, inBlock bool) {
|
||||
k := Tokens(c)
|
||||
toks, _ := internal.Highlight(language, line, inBlock)
|
||||
spans := make([]ui.Span, 0, len(toks))
|
||||
for _, t := range toks {
|
||||
spans = append(spans, ui.Span{Text: t.Text, Color: agentTokenColor(k, t.Kind)})
|
||||
}
|
||||
if len(spans) == 0 {
|
||||
spans = append(spans, ui.Span{Text: " "})
|
||||
}
|
||||
ui.RichText(c, spans...)
|
||||
}
|
||||
|
||||
// agentCode shows highlighted text in a capped well.
|
||||
func agentCode(c *ui.Context, language, text string, maxHeight float32) *ui.Element {
|
||||
holder := ui.Column(c).Shrink(1)
|
||||
cc := ui.Local(holder, "mujica.agent.code", func() *agentCodeCache { return &agentCodeCache{} })
|
||||
(*cc).update(language, text)
|
||||
lines, open := (*cc).lines, (*cc).open
|
||||
row := func(i int) { agentCodeLine(c, language, lines[i], open[i]) }
|
||||
holder.Children(func() {
|
||||
agentWellRows(c, len(lines), row, maxHeight)
|
||||
})
|
||||
return holder
|
||||
}
|
||||
|
||||
// agentAnsiColor maps a terminal color onto the theme.
|
||||
func agentAnsiColor(k theme.Tokens, a internal.AnsiColor, fallback ui.Color) ui.Color {
|
||||
switch a.Kind {
|
||||
case internal.AnsiRGB:
|
||||
return ui.RGB(a.R, a.G, a.B)
|
||||
case internal.AnsiIndexed:
|
||||
switch a.Index % 8 {
|
||||
case 0:
|
||||
return k.TextMuted
|
||||
case 1:
|
||||
return k.Danger
|
||||
case 2:
|
||||
return k.Success
|
||||
case 3:
|
||||
return k.Warning
|
||||
case 4, 6:
|
||||
return k.Info
|
||||
case 5:
|
||||
return k.AccentText
|
||||
case 7:
|
||||
return k.Text
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// agentAnsiSpans turns parsed terminal spans into rich text spans.
|
||||
func agentAnsiSpans(k theme.Tokens, in []internal.AnsiSpan) []ui.Span {
|
||||
out := make([]ui.Span, 0, len(in)+1)
|
||||
for _, s := range in {
|
||||
fg := agentAnsiColor(k, s.Style.FG, k.Text)
|
||||
bg := agentAnsiColor(k, s.Style.BG, ui.Transparent)
|
||||
if s.Style.Inverse {
|
||||
fg, bg = agentAnsiColor(k, s.Style.BG, k.Background), fg
|
||||
}
|
||||
if s.Style.Dim {
|
||||
fg = fg.Alpha(0.7)
|
||||
}
|
||||
sp := ui.Span{Text: s.Text, Color: fg, Background: bg, Italic: s.Style.Italic,
|
||||
Underline: s.Style.Underline, Strikethrough: s.Style.Strike}
|
||||
if s.Style.Bold {
|
||||
sp.Weight = 700
|
||||
}
|
||||
out = append(out, sp)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
out = append(out, ui.Span{Text: " "})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// agentBar is a thin progress bar named label.
|
||||
func agentBar(c *ui.Context, frac float64, label string) *ui.Element {
|
||||
if frac < 0 || frac > 1 || frac != frac {
|
||||
panic(fmt.Sprintf("mujica: progress %v outside 0..1", frac))
|
||||
}
|
||||
k := Tokens(c)
|
||||
return ui.Box(c).FillWidth().Height(4).Shrink(0).Role(ui.RoleProgress).Label(label).
|
||||
Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.Fill(r, k.Border, 2)
|
||||
if frac > 0 {
|
||||
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * float32(frac), H: r.H}, k.AccentText, 2)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// agentToggle is a small text button reporting a click.
|
||||
func agentToggle(c *ui.Context, label string, icon string, disabled bool) bool {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).MinWidth(theme.MinHitSize).Padding(0, u*2).Gap(u).
|
||||
Radius(theme.ControlRadius).Disabled(disabled).TextColor(k.AccentText)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
if !disabled && b.Hovered() {
|
||||
b.Background(k.SurfaceHover)
|
||||
}
|
||||
b.Children(func() {
|
||||
if icon != "" {
|
||||
ui.Icon(c, icons.Must(icon)).Size(14, 14)
|
||||
}
|
||||
ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine()
|
||||
})
|
||||
return !b.IsDisabled() && b.Clicked()
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// PlanItem is a plan task: pending, running, done or skipped.
|
||||
type PlanItem struct {
|
||||
ID string
|
||||
Text string
|
||||
State AgentState
|
||||
}
|
||||
|
||||
// AgentPlanOptions configure an AgentPlan.
|
||||
type AgentPlanOptions struct {
|
||||
// Title heads the checklist; empty uses "Plan".
|
||||
Title string
|
||||
Disabled bool
|
||||
Still bool
|
||||
}
|
||||
|
||||
type agentPlanState struct{ draft string }
|
||||
|
||||
// AgentPlan is a live checklist with skip, restore and add.
|
||||
func AgentPlan(c *ui.Context, items *[]PlanItem, opts AgentPlanOptions) ChoiceResult {
|
||||
if items == nil {
|
||||
panic("mujica: AgentPlan needs items")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
done, counted := 0, 0
|
||||
for i, it := range *items {
|
||||
if it.ID == "" || seen[it.ID] {
|
||||
panic(fmt.Sprintf("mujica: AgentPlan item %d needs a unique ID", i))
|
||||
}
|
||||
switch it.State {
|
||||
case AgentPending, AgentRunning, AgentDone, AgentSkipped:
|
||||
default:
|
||||
panic(fmt.Sprintf("mujica: AgentPlan item %q has state %v", it.ID, it.State))
|
||||
}
|
||||
seen[it.ID] = true
|
||||
if it.State != AgentSkipped {
|
||||
counted++
|
||||
}
|
||||
done += agentB2I(it.State == AgentDone)
|
||||
}
|
||||
u := Density(c).Unit()
|
||||
title := opts.Title
|
||||
if title == "" {
|
||||
title = msg(c, "agent_plan.title")
|
||||
}
|
||||
var r ChoiceResult
|
||||
r.Element = agentCard(c).Disabled(opts.Disabled).Label(title)
|
||||
st := ui.Local(r.Element, "mujica.plan", func() agentPlanState { return agentPlanState{} })
|
||||
locked := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).Grow(1)
|
||||
agentCaption(c, fmt.Sprintf(msg(c, "agent_plan.progress"), done, counted))
|
||||
})
|
||||
frac := 0.0
|
||||
if counted > 0 {
|
||||
frac = float64(done) / float64(counted)
|
||||
}
|
||||
agentBar(c, frac, fmt.Sprintf(msg(c, "agent_plan.progress"), done, counted))
|
||||
list := *items
|
||||
for i := range list {
|
||||
ui.Row(c).Key(list[i].ID).Gap(u * 2).AlignItems(ui.Center).MinHeight(Density(c).ControlHeight()).Children(func() {
|
||||
if agentPlanRow(c, &list[i], locked, opts.Still) {
|
||||
r.changed = true
|
||||
}
|
||||
})
|
||||
}
|
||||
if !locked && agentPlanAdd(c, &st.draft) {
|
||||
*items = append(*items, PlanItem{ID: agentPlanID(seen), Text: strings.TrimSpace(st.draft)})
|
||||
st.draft = ""
|
||||
r.changed = true
|
||||
}
|
||||
})
|
||||
if r.changed {
|
||||
slog.Debug("mujica: agent_plan change", "items", len(*items))
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// agentPlanRow builds one item, reporting a user change.
|
||||
func agentPlanRow(c *ui.Context, it *PlanItem, locked, still bool) bool {
|
||||
k := Tokens(c)
|
||||
checked := it.State == AgentDone
|
||||
box := Checkbox(c, &checked, "", CheckboxOptions{Disabled: locked || it.State == AgentSkipped}).Label(it.Text)
|
||||
changed := !locked && box.Changed()
|
||||
if changed {
|
||||
it.State = AgentPending
|
||||
if checked {
|
||||
it.State = AgentDone
|
||||
}
|
||||
}
|
||||
t := ui.Text(c, it.Text).Grow(1).Shrink(1)
|
||||
switch it.State {
|
||||
case AgentSkipped:
|
||||
t.Strikethrough().TextColor(k.TextMuted)
|
||||
case AgentRunning:
|
||||
agentMark(c, AgentRunning, 14, still)
|
||||
agentCaption(c, msg(c, "agent_plan.doing"))
|
||||
}
|
||||
if it.State == AgentDone || locked {
|
||||
return changed
|
||||
}
|
||||
label, icon, next := msg(c, "agent_plan.skip"), "ban", AgentSkipped
|
||||
if it.State == AgentSkipped {
|
||||
label, icon, next = msg(c, "agent_plan.restore"), "refresh-cw", AgentPending
|
||||
}
|
||||
if agentToggle(c, label, icon, false) {
|
||||
it.State = next
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// agentPlanAdd builds the new-item field and reports a non-empty submit.
|
||||
func agentPlanAdd(c *ui.Context, draft *string) bool {
|
||||
u := Density(c).Unit()
|
||||
add := false
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
in := TextInput(c, draft, TextInputOptions{Placeholder: msg(c, "agent_plan.placeholder"), Label: msg(c, "agent_plan.placeholder")})
|
||||
in.Element.Grow(1)
|
||||
blank := strings.TrimSpace(*draft) == ""
|
||||
add = Button(c, msg(c, "agent_plan.add"), ButtonOptions{Variant: Secondary, Disabled: blank}).Clicked()
|
||||
add = (add || in.Submitted()) && !blank
|
||||
})
|
||||
return add
|
||||
}
|
||||
|
||||
// agentPlanID returns an ID no item uses yet.
|
||||
func agentPlanID(seen map[string]bool) string {
|
||||
for n := len(seen) + 1; ; n++ {
|
||||
if id := fmt.Sprintf("item-%d", n); !seen[id] {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func agentPlanSample() []PlanItem {
|
||||
return []PlanItem{
|
||||
{ID: "a", Text: "Read the guest list", State: AgentDone},
|
||||
{ID: "b", Text: "Assign the seats", State: AgentRunning},
|
||||
{ID: "c", Text: "Order the flowers"},
|
||||
}
|
||||
}
|
||||
|
||||
func planTester(items *[]PlanItem, opts AgentPlanOptions, changes *int) *ui.Tester {
|
||||
opts.Still = true
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if AgentPlan(c, items, opts).Changed() {
|
||||
*changes++
|
||||
}
|
||||
}, 560, 500)
|
||||
}
|
||||
|
||||
func TestAgentPlanProgressCheckSkipAdd(t *testing.T) {
|
||||
items := agentPlanSample()
|
||||
changes := 0
|
||||
tt := planTester(&items, AgentPlanOptions{}, &changes)
|
||||
if !tt.HasText("1 / 3 done") || !tt.HasText("In progress") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Order the flowers") // the box is named by the item
|
||||
if items[2].State != AgentDone || changes != 1 || !tt.HasText("2 / 3 done") {
|
||||
t.Fatalf("check: %+v changes %d", items[2], changes)
|
||||
}
|
||||
skips, _ := tt.Find("Skip")
|
||||
tt.ClickAt(skips.X+4, skips.Y+4) // the running item's Skip
|
||||
if items[1].State != AgentSkipped || !tt.HasText("2 / 2 done") {
|
||||
t.Fatalf("skip: %+v texts %q", items[1], tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Restore")
|
||||
if items[1].State != AgentPending {
|
||||
t.Fatalf("restore: %+v", items[1])
|
||||
}
|
||||
_ = tt.Click("Add a step")
|
||||
tt.Type(" ")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(items) != 3 {
|
||||
t.Fatal("blank item added")
|
||||
}
|
||||
tt.Type("Light the candles")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(items) != 4 || items[3].Text != "Light the candles" || items[3].ID == "" || !tt.HasText("Light the candles") {
|
||||
t.Fatalf("add: %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentPlanLiveUpdateAndDisabled(t *testing.T) {
|
||||
items := agentPlanSample()
|
||||
changes := 0
|
||||
tt := planTester(&items, AgentPlanOptions{Disabled: true, Title: "Ball"}, &changes)
|
||||
_ = tt.Click("Order the flowers")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if changes != 0 || items[2].State != AgentPending || tt.HasText("Add a step") || tt.HasText("Skip") {
|
||||
t.Fatalf("disabled plan changed: %+v", items)
|
||||
}
|
||||
items[2].State = AgentDone // the agent checks it off
|
||||
tt.Frame()
|
||||
if !tt.HasText("2 / 3 done") || !tt.HasText("Ball") {
|
||||
t.Fatalf("live update: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentPlanRejectsBadState(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
items := []PlanItem{{ID: "a", Text: "x", State: AgentFailed}}
|
||||
n := 0
|
||||
planTester(&items, AgentPlanOptions{}, &n)
|
||||
}
|
||||
|
||||
func TestAgentPlanSnapshot(t *testing.T) {
|
||||
snapshot(t, "agent_plan", 520, 330, func(c *ui.Context) {
|
||||
items := agentPlanSample()
|
||||
items = append(items, PlanItem{ID: "d", Text: "Hire the quartet", State: AgentSkipped})
|
||||
ui.Column(c).Padding(16).Children(func() { AgentPlan(c, &items, AgentPlanOptions{Still: true}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// AgentProgressOptions configure an AgentProgress.
|
||||
type AgentProgressOptions struct {
|
||||
// Label names the work; empty uses "Progress".
|
||||
Label string
|
||||
// Action is what the agent does now.
|
||||
Action string
|
||||
// Indeterminate shows work of unknown length; the value is ignored.
|
||||
Indeterminate bool
|
||||
Still bool
|
||||
}
|
||||
|
||||
// AgentProgress shows progress 0 to 1 and the current action.
|
||||
func AgentProgress(c *ui.Context, value float64, opts AgentProgressOptions) *ui.Element {
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = msg(c, "agent_progress.label")
|
||||
}
|
||||
u := Density(c).Unit()
|
||||
col := ui.Column(c).FillWidth().Shrink(0).Gap(u * 1.5)
|
||||
col.Children(func() {
|
||||
Progress(c, value, ProgressOptions{Label: label, ShowValue: !opts.Indeterminate, Indeterminate: opts.Indeterminate})
|
||||
if opts.Action != "" {
|
||||
ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).Role(ui.RoleStatus).Label(opts.Action).Children(func() {
|
||||
agentMark(c, AgentRunning, 14, opts.Still)
|
||||
agentCaption(c, opts.Action)
|
||||
})
|
||||
}
|
||||
})
|
||||
return col
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestAgentProgressValueAndAction(t *testing.T) {
|
||||
value, action := 0.25, "Reading ledger.md"
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
AgentProgress(c, value, AgentProgressOptions{Action: action, Still: true})
|
||||
}, 400, 200)
|
||||
if !tt.HasText("25%") || !tt.HasText("Reading ledger.md") || !tt.HasText("Progress") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
value, action = 0.8, "Writing summary.md"
|
||||
tt.Frame()
|
||||
if !tt.HasText("80%") || !tt.HasText("Writing summary.md") || tt.HasText("Reading ledger.md") {
|
||||
t.Fatalf("update: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentProgressIndeterminate(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
AgentProgress(c, 0, AgentProgressOptions{Label: "Research", Indeterminate: true, Still: true})
|
||||
}, 400, 200)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if tt.HasText("0%") || !tt.HasText("Research") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentProgressRejectsOutOfRange(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
AgentProgress(c, 1.5, AgentProgressOptions{})
|
||||
}, 200, 100)
|
||||
}
|
||||
|
||||
func TestAgentProgressSnapshot(t *testing.T) {
|
||||
snapshot(t, "agent_progress", 420, 110, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
AgentProgress(c, 0.62, AgentProgressOptions{Label: "Preparing the ball", Action: "Writing the seating chart"})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// AgentStatusOptions configure an AgentStatus.
|
||||
type AgentStatusOptions struct {
|
||||
// Label replaces the built-in word, as "Writing the report".
|
||||
Label string
|
||||
// Still stops the running animation.
|
||||
Still bool
|
||||
}
|
||||
|
||||
// AgentStatus shows a state by icon shape and word.
|
||||
func AgentStatus(c *ui.Context, state AgentState, opts AgentStatusOptions) *ui.Element {
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = agentWord(c, state) // panics on an unknown state
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
tone, _ := agentTone(k, state)
|
||||
pill := ui.Row(c).Shrink(0).AlignSelf(ui.Start).AlignItems(ui.Center).Gap(u*1.5).Padding(u, u*2.5).
|
||||
Radius(theme.CardRadius).Border(theme.BorderWidth, tone).Background(k.Surface).
|
||||
Role(ui.RoleStatus).Label(label)
|
||||
pill.Children(func() {
|
||||
agentMark(c, state, 14, opts.Still)
|
||||
ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize+1)).TextColor(k.Text).SingleLine()
|
||||
})
|
||||
return pill
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestAgentStatusWords(t *testing.T) {
|
||||
states := []AgentState{AgentPlanning, AgentRunning, AgentWaiting, AgentDone, AgentFailed}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
for _, s := range states {
|
||||
AgentStatus(c, s, AgentStatusOptions{Still: true})
|
||||
}
|
||||
AgentStatus(c, AgentRunning, AgentStatusOptions{Label: "Writing the report", Still: true})
|
||||
}, 400, 400)
|
||||
for _, w := range []string{"Planning", "Running", "Waiting", "Done", "Failed", "Writing the report"} {
|
||||
if !tt.HasText(w) {
|
||||
t.Fatalf("missing %q in %q", w, tt.Texts())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentStatusAnimationStopsWhenStill(t *testing.T) {
|
||||
still := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
AgentStatus(c, AgentRunning, AgentStatusOptions{Still: still})
|
||||
}, 300, 100)
|
||||
tt.Frame()
|
||||
moving := string(tt.Image().Pix)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if moving == string(tt.Image().Pix) {
|
||||
t.Fatal("running status did not animate")
|
||||
}
|
||||
still = true
|
||||
tt.Frame()
|
||||
a := string(tt.Image().Pix)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if a != string(tt.Image().Pix) {
|
||||
t.Fatal("still status kept moving")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentStatusRejectsUnknownState(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
AgentStatus(c, AgentState(42), AgentStatusOptions{})
|
||||
}, 200, 100)
|
||||
}
|
||||
|
||||
func TestAgentStatusSnapshot(t *testing.T) {
|
||||
snapshot(t, "agent_status", 480, 120, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(16).Gap(8).Wrap().Children(func() {
|
||||
for _, s := range []AgentState{AgentPlanning, AgentRunning, AgentWaiting, AgentDone, AgentFailed} {
|
||||
AgentStatus(c, s, AgentStatusOptions{})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// AgentStep is one step of an agent run.
|
||||
type AgentStep struct {
|
||||
ID string
|
||||
Title string
|
||||
Detail string
|
||||
State AgentState
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// AgentStepListOptions configure an AgentStepList.
|
||||
type AgentStepListOptions struct {
|
||||
Disabled bool
|
||||
Still bool
|
||||
}
|
||||
|
||||
// AgentStepListResult reports this frame's events.
|
||||
type AgentStepListResult struct {
|
||||
Element *ui.Element
|
||||
// Retry is the failed step to retry, else "".
|
||||
Retry string
|
||||
// Toggled is the step whose detail toggled, else "".
|
||||
Toggled string
|
||||
}
|
||||
|
||||
// AgentStepList is a step timeline with details and retries.
|
||||
func AgentStepList(c *ui.Context, steps []AgentStep, opts AgentStepListOptions) AgentStepListResult {
|
||||
current := -1
|
||||
seen := make(map[string]bool, len(steps))
|
||||
for i, s := range steps {
|
||||
if s.ID == "" || s.Title == "" || seen[s.ID] {
|
||||
panic(fmt.Sprintf("mujica: AgentStepList step %d needs a unique ID and a Title", i))
|
||||
}
|
||||
seen[s.ID] = true
|
||||
if current < 0 && (s.State == AgentRunning || s.State == AgentWaiting || s.State == AgentPlanning) {
|
||||
current = i
|
||||
}
|
||||
}
|
||||
var r AgentStepListResult
|
||||
r.Element = ui.Column(c).Shrink(0).FocusGroup(ui.Vertical).Disabled(opts.Disabled)
|
||||
open := ui.Local(r.Element, "mujica.steps.open", func() map[string]bool { return map[string]bool{} })
|
||||
r.Element.Children(func() {
|
||||
for i, s := range steps {
|
||||
ui.Column(c).Key(s.ID).Children(func() {
|
||||
if agentStepRow(c, s, i, len(steps), i == current, (*open)[s.ID], opts.Still, &r) {
|
||||
(*open)[s.ID] = !(*open)[s.ID]
|
||||
r.Toggled = s.ID
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
if r.Element.IsDisabled() {
|
||||
r.Retry, r.Toggled = "", ""
|
||||
}
|
||||
for id := range *open {
|
||||
if !seen[id] {
|
||||
delete(*open, id)
|
||||
}
|
||||
}
|
||||
if r.Retry != "" || r.Toggled != "" {
|
||||
slog.Debug("mujica: agent_step_list event", "retry", r.Retry, "toggled", r.Toggled)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// agentStepRow builds one step, reporting a header click.
|
||||
func agentStepRow(c *ui.Context, s AgentStep, i, n int, current, open, still bool, r *AgentStepListResult) bool {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
u := d.Unit()
|
||||
clicked := false
|
||||
top := (d.ControlHeight() - 18) / 2 // centers the mark on the step header
|
||||
ui.Row(c).Gap(u * 3).AlignItems(ui.Stretch).Children(func() {
|
||||
// The rail joins the marks; it is drawn, not focusable.
|
||||
ui.Column(c).Width(20).Shrink(0).AlignItems(ui.Center).Draw(func(p *ui.Painter, rc ui.Rect) {
|
||||
x := internal.Snap(p, rc.X+rc.W/2)
|
||||
if i > 0 {
|
||||
p.Line(x, rc.Y, x, rc.Y+top-3, 1, k.ControlBorder)
|
||||
}
|
||||
if i < n-1 {
|
||||
p.Line(x, rc.Y+top+21, x, rc.Y+rc.H, 1, k.ControlBorder)
|
||||
}
|
||||
}).Children(func() {
|
||||
ui.Box(c).Height(top)
|
||||
agentMark(c, s.State, 18, still)
|
||||
})
|
||||
ui.Column(c).Grow(1).Shrink(1).Gap(u).Padding(0, 0, u*2, 0).Children(func() {
|
||||
head := ui.ButtonBase(c).Justify(ui.Start).Gap(u*2).MinHeight(d.ControlHeight()).Padding(0, u*2).
|
||||
Radius(theme.ControlRadius).Label(s.Title + ", " + agentWord(c, s.State))
|
||||
internal.Ring(head, k.Focus, theme.ControlRadius)
|
||||
if current {
|
||||
head.Background(k.Selection).Border(theme.BorderWidth, k.AccentText)
|
||||
} else if head.Hovered() {
|
||||
head.Background(k.SurfaceHover)
|
||||
}
|
||||
head.Children(func() {
|
||||
ui.Text(c, s.Title).FontWeight(600).Grow(1).Shrink(1)
|
||||
if current {
|
||||
agentCaption(c, msg(c, "agent_step_list.current"))
|
||||
}
|
||||
agentCaption(c, agentWord(c, s.State))
|
||||
if s.State == AgentDone || s.State == AgentFailed {
|
||||
agentCaption(c, agentDuration(s.Duration))
|
||||
}
|
||||
if s.Detail != "" {
|
||||
chev := "chevron-right"
|
||||
if open {
|
||||
chev = "chevron-down"
|
||||
}
|
||||
ui.Icon(c, icons.Must(chev)).Size(14, 14).TextColor(k.TextMuted)
|
||||
}
|
||||
})
|
||||
clicked = s.Detail != "" && !head.IsDisabled() && head.Clicked()
|
||||
if open && s.Detail != "" {
|
||||
ui.Text(c, s.Detail).TextColor(k.TextMuted).Selectable().Padding(0, u*2)
|
||||
}
|
||||
if s.State == AgentFailed {
|
||||
ui.Row(c).Children(func() {
|
||||
if agentToggle(c, msg(c, "agent_step_list.retry"), "refresh-cw", head.IsDisabled()) {
|
||||
r.Retry = s.ID
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
return clicked
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
var agentStepsSample = []AgentStep{
|
||||
{ID: "plan", Title: "Read the guest list", State: AgentDone, Duration: 800 * time.Millisecond, Detail: "42 guests, 3 late."},
|
||||
{ID: "seat", Title: "Assign the seats", State: AgentFailed, Duration: 2 * time.Second, Detail: "Table 4 is over capacity."},
|
||||
{ID: "send", Title: "Send the invitations", State: AgentRunning},
|
||||
{ID: "log", Title: "Record the ledger", State: AgentPending},
|
||||
}
|
||||
|
||||
func stepTester(steps []AgentStep, opts AgentStepListOptions, got *[]AgentStepListResult) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
r := AgentStepList(c, steps, opts)
|
||||
if r.Retry != "" || r.Toggled != "" {
|
||||
*got = append(*got, r)
|
||||
}
|
||||
}, 600, 500)
|
||||
}
|
||||
|
||||
func TestAgentStepListCurrentDetailRetry(t *testing.T) {
|
||||
var got []AgentStepListResult
|
||||
tt := stepTester(agentStepsSample, AgentStepListOptions{Still: true}, &got)
|
||||
if !tt.HasText("Current") || !tt.HasText("Running") || !tt.HasText("800 ms") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
if tt.HasText("Table 4 is over capacity.") {
|
||||
t.Fatal("detail shown while closed")
|
||||
}
|
||||
_ = tt.Click("Assign the seats")
|
||||
if len(got) != 1 || got[0].Toggled != "seat" || !tt.HasText("Table 4 is over capacity.") {
|
||||
t.Fatalf("toggle: %+v", got)
|
||||
}
|
||||
_ = tt.Click("Retry")
|
||||
if len(got) != 2 || got[1].Retry != "seat" {
|
||||
t.Fatalf("retry: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentStepListKeyboard(t *testing.T) {
|
||||
var got []AgentStepListResult
|
||||
tt := stepTester(agentStepsSample, AgentStepListOptions{Still: true}, &got)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(got) != 1 || got[0].Toggled != "plan" || !tt.HasText("42 guests, 3 late.") {
|
||||
t.Fatalf("Enter on the first step: %+v", got)
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyDown) // past Assign the seats onto its Retry
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(got) != 2 || got[1].Retry != "seat" {
|
||||
t.Fatalf("arrows to Retry: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentStepListDisabled(t *testing.T) {
|
||||
var got []AgentStepListResult
|
||||
tt := stepTester(agentStepsSample, AgentStepListOptions{Disabled: true, Still: true}, &got)
|
||||
_ = tt.Click("Assign the seats")
|
||||
_ = tt.Click("Retry")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("disabled list reported %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentStepListRejectsDuplicateIDs(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
stepTester([]AgentStep{{ID: "a", Title: "x"}, {ID: "a", Title: "y"}}, AgentStepListOptions{}, nil)
|
||||
}
|
||||
|
||||
func TestAgentStepListSnapshot(t *testing.T) {
|
||||
snapshot(t, "agent_step_list", 520, 340, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { AgentStepList(c, agentStepsSample, AgentStepListOptions{Still: true}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ArtifactKind is what an artifact holds; zero is code.
|
||||
type ArtifactKind int
|
||||
|
||||
const (
|
||||
ArtifactCode ArtifactKind = iota
|
||||
ArtifactDocument
|
||||
ArtifactWeb
|
||||
)
|
||||
|
||||
// Artifact is something an agent made; the caller owns it.
|
||||
type Artifact struct {
|
||||
Title string
|
||||
Kind ArtifactKind
|
||||
// Language highlights code, as "go" or "json".
|
||||
Language string
|
||||
Content string
|
||||
}
|
||||
|
||||
// ArtifactPanelOptions configure an ArtifactPanel.
|
||||
type ArtifactPanelOptions struct {
|
||||
// Preview renders documents and web pages; the caller supplies it.
|
||||
Preview func()
|
||||
// Height of the body; 320 DIPs when zero.
|
||||
Height float32
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// ArtifactPanelResult reports this frame's events.
|
||||
type ArtifactPanelResult struct {
|
||||
Element *ui.Element
|
||||
// Copied reports the content went to the clipboard.
|
||||
Copied bool
|
||||
// Exported asks the caller to export the artifact.
|
||||
Exported bool
|
||||
}
|
||||
|
||||
type artifactPanelState struct {
|
||||
source bool
|
||||
copiedAt time.Time
|
||||
}
|
||||
|
||||
// ArtifactPanel shows an artifact, with copy and export.
|
||||
func ArtifactPanel(c *ui.Context, art Artifact, opts ArtifactPanelOptions) ArtifactPanelResult {
|
||||
if art.Title == "" {
|
||||
panic("mujica: ArtifactPanel needs a Title")
|
||||
}
|
||||
if art.Kind < ArtifactCode || art.Kind > ArtifactWeb {
|
||||
panic(fmt.Sprintf("mujica: unknown ArtifactKind %d", int(art.Kind)))
|
||||
}
|
||||
if art.Kind != ArtifactCode && opts.Preview == nil {
|
||||
panic("mujica: ArtifactPanel needs a Preview for documents and web pages")
|
||||
}
|
||||
if opts.Height < 0 {
|
||||
panic("mujica: ArtifactPanel Height is negative")
|
||||
}
|
||||
h := opts.Height
|
||||
if h == 0 {
|
||||
h = 320
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
var r ArtifactPanelResult
|
||||
r.Element = agentCard(c).Disabled(opts.Disabled).Label(art.Title)
|
||||
st := ui.Local(r.Element, "mujica.artifact", func() artifactPanelState { return artifactPanelState{source: art.Kind == ArtifactCode} })
|
||||
locked := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, art.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).SingleLine().Grow(1).Shrink(1)
|
||||
if opts.Preview != nil {
|
||||
artifactViewSwitch(c, &st.source, locked)
|
||||
}
|
||||
copied := !st.copiedAt.IsZero() && c.Now().Sub(st.copiedAt) < 2*time.Second
|
||||
label, icon := msg(c, "artifact_panel.copy"), "copy"
|
||||
if copied {
|
||||
label, icon = msg(c, "artifact_panel.copied"), "check"
|
||||
c.After(2*time.Second - c.Now().Sub(st.copiedAt))
|
||||
}
|
||||
if agentToggle(c, label, icon, locked) {
|
||||
c.WriteClipboard(art.Content)
|
||||
st.copiedAt, r.Copied = c.Now(), true
|
||||
}
|
||||
if agentToggle(c, msg(c, "artifact_panel.export"), "upload", locked) {
|
||||
r.Exported = true
|
||||
}
|
||||
})
|
||||
ui.Column(c).Height(h).Shrink(0).Children(func() {
|
||||
if st.source || opts.Preview == nil {
|
||||
agentCode(c, art.Language, art.Content, h).Grow(1)
|
||||
return
|
||||
}
|
||||
ui.Scroll(c).Grow(1).Padding(u*3).Radius(theme.ControlRadius).Background(k.Background).
|
||||
Border(theme.BorderWidth, k.Border).Children(opts.Preview)
|
||||
})
|
||||
})
|
||||
if r.Copied || r.Exported {
|
||||
slog.Debug("mujica: artifact_panel event", "title", art.Title, "copied", r.Copied, "exported", r.Exported)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// artifactViewSwitch is the Preview / Source segmented switch.
|
||||
func artifactViewSwitch(c *ui.Context, source *bool, locked bool) {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
ui.Row(c).Shrink(0).FocusGroup(ui.Horizontal).Border(theme.BorderWidth, k.ControlBorder).Radius(theme.ControlRadius).
|
||||
Role(ui.RoleRadioGroup).Children(func() {
|
||||
for _, src := range []bool{false, true} {
|
||||
label, icon := msg(c, "artifact_panel.preview"), "eye"
|
||||
if src {
|
||||
label, icon = msg(c, "artifact_panel.source"), "file-text"
|
||||
}
|
||||
b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, u*2).Gap(u).Role(ui.RoleRadio).Label(label).
|
||||
Disabled(locked)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
on := *source == src
|
||||
if on {
|
||||
b.Background(k.Selection)
|
||||
} else if b.Hovered() {
|
||||
b.Background(k.SurfaceHover)
|
||||
}
|
||||
b.Children(func() {
|
||||
if on {
|
||||
ui.Icon(c, icons.Must("check")).Size(12, 12).TextColor(k.AccentText)
|
||||
}
|
||||
ui.Icon(c, icons.Must(icon)).Size(14, 14)
|
||||
ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize+1))
|
||||
})
|
||||
if !b.IsDisabled() && b.Clicked() && !on {
|
||||
*source = src
|
||||
slog.Debug("mujica: artifact_panel view", "source", src)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
var artifactCodeSample = Artifact{Title: "seating.go", Language: "go", Content: "package court\n\n// Seats maps guests to tables.\nfunc Seats() map[string]int {\n\treturn map[string]int{\"Sakiko\": 1}\n}\n"}
|
||||
|
||||
func artifactTester(art Artifact, opts ArtifactPanelOptions, got *[]ArtifactPanelResult) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
r := ArtifactPanel(c, art, opts)
|
||||
if r.Copied || r.Exported {
|
||||
*got = append(*got, r)
|
||||
}
|
||||
}, 600, 460)
|
||||
}
|
||||
|
||||
func TestArtifactPanelCodeCopyExport(t *testing.T) {
|
||||
var got []ArtifactPanelResult
|
||||
tt := artifactTester(artifactCodeSample, ArtifactPanelOptions{}, &got)
|
||||
if !tt.HasText("// Seats maps guests to tables.") || tt.HasText("Preview") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Copy")
|
||||
if len(got) != 1 || !got[0].Copied || tt.Clipboard() != artifactCodeSample.Content || !tt.HasText("Copied") {
|
||||
t.Fatalf("copy: %+v clipboard %q", got, tt.Clipboard())
|
||||
}
|
||||
_ = tt.Click("Export")
|
||||
if len(got) != 2 || !got[1].Exported {
|
||||
t.Fatalf("export: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactPanelPreviewSourceSwitch(t *testing.T) {
|
||||
doc := Artifact{Title: "Invitation", Kind: ArtifactDocument, Content: "# You are invited\n"}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ArtifactPanel(c, doc, ArtifactPanelOptions{Preview: func() { ui.Text(c, "Rendered invitation") }})
|
||||
}, 600, 460)
|
||||
if !tt.HasText("Rendered invitation") || tt.HasText("# You are invited") {
|
||||
t.Fatalf("preview first: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Source")
|
||||
if !tt.HasText("# You are invited") || tt.HasText("Rendered invitation") {
|
||||
t.Fatalf("source: %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyLeft) // the switch is a focus group
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if !tt.HasText("Rendered invitation") {
|
||||
t.Fatal("keyboard back to preview failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactPanelDisabledAndRules(t *testing.T) {
|
||||
var got []ArtifactPanelResult
|
||||
tt := artifactTester(artifactCodeSample, ArtifactPanelOptions{Disabled: true}, &got)
|
||||
_ = tt.Click("Copy")
|
||||
_ = tt.Click("Export")
|
||||
if len(got) != 0 || tt.Clipboard() != "" {
|
||||
t.Fatalf("disabled panel acted: %+v", got)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("web artifact without Preview did not panic")
|
||||
}
|
||||
}()
|
||||
artifactTester(Artifact{Title: "x", Kind: ArtifactWeb}, ArtifactPanelOptions{}, &got)
|
||||
}
|
||||
|
||||
func TestArtifactPanelSnapshot(t *testing.T) {
|
||||
snapshot(t, "artifact_panel", 560, 300, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
ArtifactPanel(c, artifactCodeSample, ArtifactPanelOptions{Height: 170, Preview: func() {}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
)
|
||||
|
||||
// ArtifactVersion is one saved version of an artifact.
|
||||
type ArtifactVersion struct {
|
||||
Label string
|
||||
Note string
|
||||
}
|
||||
|
||||
// ArtifactVersionSwitcherOptions configure an ArtifactVersionSwitcher.
|
||||
type ArtifactVersionSwitcherOptions struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// ArtifactVersionResult reports this frame's events.
|
||||
type ArtifactVersionResult struct {
|
||||
Element *ui.Element
|
||||
// Changed reports *current moved.
|
||||
Changed bool
|
||||
// Compare asks to diff current against the previous version.
|
||||
Compare bool
|
||||
}
|
||||
|
||||
// ArtifactVersionSwitcher steps between artifact versions.
|
||||
func ArtifactVersionSwitcher(c *ui.Context, current *int, versions []ArtifactVersion, opts ArtifactVersionSwitcherOptions) ArtifactVersionResult {
|
||||
if current == nil || len(versions) == 0 || *current < 0 || *current >= len(versions) {
|
||||
panic("mujica: ArtifactVersionSwitcher needs versions and a current index inside them")
|
||||
}
|
||||
for i, v := range versions {
|
||||
if v.Label == "" {
|
||||
panic(fmt.Sprintf("mujica: ArtifactVersionSwitcher version %d needs a Label", i))
|
||||
}
|
||||
}
|
||||
u := Density(c).Unit()
|
||||
n := len(versions)
|
||||
var r ArtifactVersionResult
|
||||
r.Element = ui.Row(c).Shrink(0).Gap(u * 2).AlignItems(ui.Center).Disabled(opts.Disabled).
|
||||
Label(msg(c, "artifact_version_switcher.label"))
|
||||
locked := r.Element.IsDisabled()
|
||||
move := func(d int) {
|
||||
if to := *current + d; !locked && to >= 0 && to < n {
|
||||
*current, r.Changed = to, true
|
||||
}
|
||||
}
|
||||
r.Element.Children(func() {
|
||||
if iconAction(c, icons.Must("chevron-left"), msg(c, "artifact_version_switcher.older")).Disabled(locked || *current == 0).Clicked() {
|
||||
move(-1)
|
||||
}
|
||||
v := versions[*current]
|
||||
ui.Column(c).Shrink(1).Role(ui.RoleStatus).Label(v.Label).Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, v.Label).FontWeight(600).SingleLine()
|
||||
agentCaption(c, fmt.Sprintf(msg(c, "artifact_version_switcher.count"), *current+1, n))
|
||||
})
|
||||
if v.Note != "" {
|
||||
agentCaption(c, v.Note).SingleLine()
|
||||
}
|
||||
})
|
||||
if iconAction(c, icons.Must("chevron-right"), msg(c, "artifact_version_switcher.newer")).Disabled(locked || *current == n-1).Clicked() {
|
||||
move(1)
|
||||
}
|
||||
if *current > 0 {
|
||||
label := fmt.Sprintf(msg(c, "artifact_version_switcher.compare"), versions[*current-1].Label)
|
||||
if agentToggle(c, label, "arrow-up-down", locked) {
|
||||
r.Compare = true
|
||||
}
|
||||
}
|
||||
})
|
||||
if !locked {
|
||||
if r.Element.Shortcut(0, ui.KeyLeft) {
|
||||
move(-1)
|
||||
}
|
||||
if r.Element.Shortcut(0, ui.KeyRight) {
|
||||
move(1)
|
||||
}
|
||||
}
|
||||
if r.Changed || r.Compare {
|
||||
slog.Debug("mujica: artifact_version_switcher event", "current", *current, "compare", r.Compare)
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
var artifactVersionsSample = []ArtifactVersion{
|
||||
{Label: "v1", Note: "First draft"},
|
||||
{Label: "v2", Note: "Added the quartet"},
|
||||
{Label: "v3", Note: "Moved the wine table"},
|
||||
}
|
||||
|
||||
func versionTester(cur *int, opts ArtifactVersionSwitcherOptions, got *[]ArtifactVersionResult) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
r := ArtifactVersionSwitcher(c, cur, artifactVersionsSample, opts)
|
||||
if r.Changed || r.Compare {
|
||||
*got = append(*got, r)
|
||||
}
|
||||
}, 600, 120)
|
||||
}
|
||||
|
||||
func TestArtifactVersionSwitcherStepsAndCompare(t *testing.T) {
|
||||
cur := 2
|
||||
var got []ArtifactVersionResult
|
||||
tt := versionTester(&cur, ArtifactVersionSwitcherOptions{}, &got)
|
||||
if !tt.HasText("3 of 3") || !tt.HasText("Moved the wine table") || !tt.HasText("Compare with v2") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Compare with v2")
|
||||
if len(got) != 1 || !got[0].Compare || cur != 2 {
|
||||
t.Fatalf("compare: %+v", got)
|
||||
}
|
||||
_ = tt.Click("Older version")
|
||||
_ = tt.Click("Older version")
|
||||
if cur != 0 || tt.HasText("Compare with v1") || !tt.HasText("First draft") {
|
||||
t.Fatalf("older: cur %d texts %q", cur, tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Older version") // disabled at the first version
|
||||
if cur != 0 || len(got) != 3 {
|
||||
t.Fatalf("past the first: cur %d events %d", cur, len(got))
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if cur != 1 {
|
||||
t.Fatalf("Right: cur %d", cur)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactVersionSwitcherDisabledAndRange(t *testing.T) {
|
||||
cur := 1
|
||||
var got []ArtifactVersionResult
|
||||
tt := versionTester(&cur, ArtifactVersionSwitcherOptions{Disabled: true}, &got)
|
||||
_ = tt.Click("Older version")
|
||||
_ = tt.Click("Compare with v1")
|
||||
if cur != 1 || len(got) != 0 {
|
||||
t.Fatalf("disabled moved: cur %d %+v", cur, got)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("out-of-range current did not panic")
|
||||
}
|
||||
}()
|
||||
cur = 5
|
||||
versionTester(&cur, ArtifactVersionSwitcherOptions{}, &got)
|
||||
}
|
||||
|
||||
func TestArtifactVersionSwitcherSnapshot(t *testing.T) {
|
||||
snapshot(t, "artifact_version_switcher", 560, 90, func(c *ui.Context) {
|
||||
cur := 1
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
ArtifactVersionSwitcher(c, &cur, artifactVersionsSample, ArtifactVersionSwitcherOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// Checkpoint is a saved point to roll back to.
|
||||
type Checkpoint struct {
|
||||
ID string
|
||||
Label string
|
||||
Note string
|
||||
}
|
||||
|
||||
// CheckpointListOptions configure a CheckpointList.
|
||||
type CheckpointListOptions struct {
|
||||
// MaxHeight caps the scrolling list; zero means 360.
|
||||
MaxHeight float32
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type checkpointListState struct {
|
||||
asking bool
|
||||
target string
|
||||
}
|
||||
|
||||
// CheckpointList marks the current point and confirms rollbacks.
|
||||
func CheckpointList(c *ui.Context, current *string, points []Checkpoint, opts CheckpointListOptions) ChoiceResult {
|
||||
if current == nil {
|
||||
panic("mujica: CheckpointList needs current")
|
||||
}
|
||||
if opts.MaxHeight < 0 {
|
||||
panic("mujica: CheckpointList MaxHeight is negative")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i, p := range points {
|
||||
if p.ID == "" || p.Label == "" || seen[p.ID] {
|
||||
panic(fmt.Sprintf("mujica: CheckpointList point %d needs a unique ID and a Label", i))
|
||||
}
|
||||
seen[p.ID] = true
|
||||
}
|
||||
maxH := opts.MaxHeight
|
||||
if maxH == 0 {
|
||||
maxH = 360
|
||||
}
|
||||
k := Tokens(c)
|
||||
var r ChoiceResult
|
||||
r.Element = ui.Column(c).Shrink(0).Disabled(opts.Disabled).Label(msg(c, "checkpoint_list.label"))
|
||||
st := ui.Local(r.Element, "mujica.checkpoints", func() checkpointListState { return checkpointListState{} })
|
||||
locked := r.Element.IsDisabled()
|
||||
if locked {
|
||||
st.asking = false // a disabled list closes its confirmation
|
||||
}
|
||||
r.Element.Children(func() {
|
||||
if len(points) == 0 {
|
||||
agentCaption(c, msg(c, "checkpoint_list.empty"))
|
||||
return
|
||||
}
|
||||
agentRows(c, len(points), func(i int) {
|
||||
if checkpointRow(c, points[i], points[i].ID == *current, locked) {
|
||||
st.asking, st.target = true, points[i].ID
|
||||
}
|
||||
}).Shrink(1).MaxHeight(maxH).Background(k.Surface).Border(theme.BorderWidth, k.Border).
|
||||
Radius(theme.CardRadius).Dividers(theme.BorderWidth, k.Border)
|
||||
label := st.target
|
||||
for _, p := range points {
|
||||
if p.ID == st.target {
|
||||
label = p.Label
|
||||
}
|
||||
}
|
||||
res := AlertDialog(c, &st.asking, AlertDialogOptions{Title: fmt.Sprintf(msg(c, "checkpoint_list.confirm"), label),
|
||||
Description: msg(c, "checkpoint_list.warning"), ConfirmLabel: msg(c, "checkpoint_list.rollback")})
|
||||
if res.Confirmed && !locked && seen[st.target] {
|
||||
*current, r.changed = st.target, true
|
||||
slog.Debug("mujica: checkpoint_list rollback", "to", st.target)
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// checkpointRow shows one checkpoint and reports a rollback request.
|
||||
func checkpointRow(c *ui.Context, p Checkpoint, current, locked bool) bool {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
u := d.Unit()
|
||||
asked := false
|
||||
row := ui.Row(c).Key(p.ID).Gap(u*3).AlignItems(ui.Center).MinHeight(d.ControlHeight()+u*2).Padding(u*2, u*3)
|
||||
if current {
|
||||
row.Background(k.Selection)
|
||||
}
|
||||
row.Children(func() {
|
||||
ui.Box(c).Size(14, 14).Shrink(0).Draw(func(pt *ui.Painter, r ui.Rect) {
|
||||
cx, cy := internal.Snap(pt, r.X+r.W/2), internal.Snap(pt, r.Y+r.H/2)
|
||||
if current {
|
||||
pt.FillPath(internal.Diamond(cx, cy, 12), k.AccentText)
|
||||
return
|
||||
}
|
||||
pt.StrokePath(internal.Diamond(cx, cy, 10), 1.5, k.ControlBorder)
|
||||
})
|
||||
ui.Column(c).Grow(1).Shrink(1).Gap(u / 2).Children(func() {
|
||||
ui.Text(c, p.Label).FontWeight(600).SingleLine()
|
||||
if p.Note != "" {
|
||||
agentCaption(c, p.Note)
|
||||
}
|
||||
})
|
||||
if current {
|
||||
agentCaption(c, msg(c, "checkpoint_list.current"))
|
||||
return
|
||||
}
|
||||
asked = agentToggle(c, msg(c, "checkpoint_list.rollback"), "refresh-cw", locked)
|
||||
})
|
||||
row.Label(p.Label)
|
||||
return asked
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
var checkpointsSample = []Checkpoint{
|
||||
{ID: "c3", Label: "Seats assigned", Note: "12 files changed"},
|
||||
{ID: "c2", Label: "Menu drafted", Note: "3 files changed"},
|
||||
{ID: "c1", Label: "Start"},
|
||||
}
|
||||
|
||||
func checkpointTester(cur *string, points []Checkpoint, opts CheckpointListOptions, changes *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if CheckpointList(c, cur, points, opts).Changed() {
|
||||
*changes++
|
||||
}
|
||||
}, 560, 420)
|
||||
}
|
||||
|
||||
func TestCheckpointListRollbackConfirm(t *testing.T) {
|
||||
cur, changes := "c3", 0
|
||||
tt := checkpointTester(&cur, checkpointsSample, CheckpointListOptions{}, &changes)
|
||||
if !tt.HasText("Current") || !tt.HasText("12 files changed") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
rolls, _ := tt.Find("Roll back")
|
||||
tt.ClickAt(rolls.X+4, rolls.Y+4) // Menu drafted
|
||||
if !tt.HasText("Roll back to “Menu drafted”?") || !tt.Focused("Cancel") {
|
||||
t.Fatalf("confirm: %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter) // Enter on Cancel cancels
|
||||
if cur != "c3" || changes != 0 {
|
||||
t.Fatalf("Enter rolled back: %s", cur)
|
||||
}
|
||||
tt.ClickAt(rolls.X+4, rolls.Y+4)
|
||||
_ = tt.Click("Roll back to “Menu drafted”?")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if cur != "c2" || changes != 1 {
|
||||
t.Fatalf("rollback: cur %s changes %d", cur, changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointListDisabledEmptyAndMany(t *testing.T) {
|
||||
cur, n := "c3", 0
|
||||
tt := checkpointTester(&cur, checkpointsSample, CheckpointListOptions{Disabled: true}, &n)
|
||||
_ = tt.Click("Roll back")
|
||||
if tt.HasText("Roll back to “Menu drafted”?") || cur != "c3" {
|
||||
t.Fatal("disabled list asked")
|
||||
}
|
||||
tt = checkpointTester(&cur, nil, CheckpointListOptions{}, &n)
|
||||
if !tt.HasText("No checkpoints yet") {
|
||||
t.Fatalf("empty: %q", tt.Texts())
|
||||
}
|
||||
many := make([]Checkpoint, 100000)
|
||||
for i := range many {
|
||||
many[i] = Checkpoint{ID: fmt.Sprint(i), Label: fmt.Sprintf("point %d", i)}
|
||||
}
|
||||
tt = checkpointTester(&cur, many, CheckpointListOptions{}, &n)
|
||||
if got := len(tt.Texts()); got > 300 {
|
||||
t.Fatalf("%d texts built for 100000 checkpoints", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointListSnapshot(t *testing.T) {
|
||||
snapshot(t, "checkpoint_list", 520, 240, func(c *ui.Context) {
|
||||
cur := "c2"
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
CheckpointList(c, &cur, checkpointsSample, CheckpointListOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// CommandRun is a command run; the caller appends Output.
|
||||
type CommandRun struct {
|
||||
Command string
|
||||
Dir string
|
||||
// Output is the raw terminal output; ANSI colors are kept.
|
||||
Output string
|
||||
Running bool
|
||||
ExitCode int
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// CommandExecutionCardOptions configure a CommandExecutionCard.
|
||||
type CommandExecutionCardOptions struct {
|
||||
// Height of the output well; 240 DIPs when zero.
|
||||
Height float32
|
||||
Disabled bool
|
||||
Still bool
|
||||
}
|
||||
|
||||
// commandOutput keeps parsed lines; appends parse only new lines.
|
||||
type commandOutput struct {
|
||||
src string
|
||||
lines [][]internal.AnsiSpan
|
||||
style internal.AnsiStyle
|
||||
list ui.ListState
|
||||
}
|
||||
|
||||
func (o *commandOutput) update(out string) {
|
||||
if out == o.src {
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(out, o.src) || o.src != "" && !strings.HasSuffix(o.src, "\n") {
|
||||
o.lines, o.style = nil, internal.AnsiStyle{}
|
||||
o.src = ""
|
||||
}
|
||||
for _, line := range internal.SplitLines(out[len(o.src):]) {
|
||||
var spans []internal.AnsiSpan
|
||||
spans, o.style = internal.ParseAnsi(line, o.style)
|
||||
o.lines = append(o.lines, spans)
|
||||
}
|
||||
o.src = out
|
||||
}
|
||||
|
||||
// CommandExecutionCard shows a command run; Changed requests an abort.
|
||||
func CommandExecutionCard(c *ui.Context, run CommandRun, opts CommandExecutionCardOptions) ChoiceResult {
|
||||
if run.Command == "" {
|
||||
panic("mujica: CommandExecutionCard needs a Command")
|
||||
}
|
||||
if opts.Height < 0 {
|
||||
panic("mujica: CommandExecutionCard Height is negative")
|
||||
}
|
||||
h := opts.Height
|
||||
if h == 0 {
|
||||
h = 240
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
var r ChoiceResult
|
||||
r.Element = agentCard(c).Disabled(opts.Disabled).Label(run.Command)
|
||||
out := ui.Local(r.Element, "mujica.cmd.output", func() *commandOutput { return &commandOutput{} })
|
||||
o := *out
|
||||
o.list.FollowEnd = true
|
||||
o.update(run.Output)
|
||||
r.Element.Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).MinHeight(theme.MinHitSize).Children(func() {
|
||||
ui.Text(c, "$").Font(theme.MonoFont).TextColor(k.TextMuted).Shrink(0)
|
||||
ui.Text(c, run.Command).Font(theme.MonoFont).FontWeight(600).Grow(1).Shrink(1).Selectable()
|
||||
if run.Running {
|
||||
agentMark(c, AgentRunning, 16, opts.Still)
|
||||
agentCaption(c, msg(c, "agent_status.running"))
|
||||
locked := r.Element.IsDisabled()
|
||||
if Button(c, msg(c, "command_execution_card.abort"), ButtonOptions{Variant: Outline, Disabled: locked}).Clicked() && !locked {
|
||||
r.changed = true
|
||||
}
|
||||
return
|
||||
}
|
||||
commandExit(c, run.ExitCode)
|
||||
agentCaption(c, agentDuration(run.Duration))
|
||||
})
|
||||
if run.Dir != "" {
|
||||
ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).Children(func() {
|
||||
ui.Icon(c, icons.Must("folder")).Size(14, 14).TextColor(k.TextMuted)
|
||||
agentCaption(c, run.Dir).Font(theme.MonoFont)
|
||||
})
|
||||
}
|
||||
well := ui.List(c, &o.list, len(o.lines), func(i int) {
|
||||
ui.RichText(c, agentAnsiSpans(k, o.lines[i])...)
|
||||
})
|
||||
agentWell(c, well, h).Height(h).Label(msg(c, "command_execution_card.output")).Children(func() {
|
||||
if len(o.lines) == 0 {
|
||||
agentCaption(c, msg(c, "command_execution_card.no_output"))
|
||||
}
|
||||
})
|
||||
})
|
||||
if r.changed {
|
||||
slog.Debug("mujica: command_execution_card abort", "command", run.Command)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// commandExit shows the exit code by icon and word.
|
||||
func commandExit(c *ui.Context, code int) {
|
||||
k := Tokens(c)
|
||||
tone, icon := k.Success, "circle-check"
|
||||
if code != 0 {
|
||||
tone, icon = k.Danger, "circle-x"
|
||||
}
|
||||
ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).Shrink(0).Children(func() {
|
||||
ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(tone)
|
||||
ui.Text(c, fmt.Sprintf(msg(c, "command_execution_card.exit"), code)).TextColor(tone).
|
||||
FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func cmdTester(run *CommandRun, opts CommandExecutionCardOptions, aborts *int) *ui.Tester {
|
||||
opts.Still = true
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if CommandExecutionCard(c, *run, opts).Changed() {
|
||||
*aborts++
|
||||
}
|
||||
}, 600, 420)
|
||||
}
|
||||
|
||||
func TestCommandExecutionCardRunningAbortAndFinish(t *testing.T) {
|
||||
run := CommandRun{Command: "go test ./...", Dir: "~/court", Running: true, Output: "\x1b[32mok\x1b[0m court\n"}
|
||||
aborts := 0
|
||||
tt := cmdTester(&run, CommandExecutionCardOptions{}, &aborts)
|
||||
for _, s := range []string{"go test ./...", "~/court", "ok", " court", "Running"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Fatalf("missing %q in %q", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
if tt.HasText("\x1b[32mok") {
|
||||
t.Fatal("escape codes shown as text")
|
||||
}
|
||||
_ = tt.Click("Abort")
|
||||
if aborts != 1 {
|
||||
t.Fatalf("aborts %d", aborts)
|
||||
}
|
||||
run.Output += "FAIL ledger\n"
|
||||
run.Running, run.ExitCode, run.Duration = false, 1, 3200*time.Millisecond
|
||||
tt.Frame()
|
||||
if !tt.HasText("FAIL ledger") || !tt.HasText("Exit 1") || !tt.HasText("3.2 s") || tt.HasText("Abort") {
|
||||
t.Fatalf("finished: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandExecutionCardAnsiAcrossLinesAndReset(t *testing.T) {
|
||||
var o commandOutput
|
||||
o.update("\x1b[31mred\nstill red\x1b[0m plain\n")
|
||||
if len(o.lines) != 2 || o.lines[1][0].Style.FG.Index != 1 || o.lines[1][1].Style.FG.Index != 0 {
|
||||
t.Fatalf("lines %+v", o.lines)
|
||||
}
|
||||
o.update("\x1b[31mred\nstill red\x1b[0m plain\nnext\n")
|
||||
if len(o.lines) != 3 || o.lines[2][0].Text != "next" {
|
||||
t.Fatalf("append: %+v", o.lines)
|
||||
}
|
||||
o.update("fresh\n")
|
||||
if len(o.lines) != 1 || o.lines[0][0].Text != "fresh" {
|
||||
t.Fatalf("replace: %+v", o.lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandExecutionCardDisabledAndEmpty(t *testing.T) {
|
||||
run := CommandRun{Command: "sleep 10", Running: true}
|
||||
aborts := 0
|
||||
tt := cmdTester(&run, CommandExecutionCardOptions{Disabled: true}, &aborts)
|
||||
_ = tt.Click("Abort")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if aborts != 0 || !tt.HasText("No output") {
|
||||
t.Fatalf("aborts %d texts %q", aborts, tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandExecutionCardLargeOutputVirtualized(t *testing.T) {
|
||||
var b strings.Builder
|
||||
for i := range 100000 {
|
||||
fmt.Fprintf(&b, "\x1b[2mline %d\x1b[0m\n", i)
|
||||
}
|
||||
run := CommandRun{Command: "yes", Output: b.String()}
|
||||
n := 0
|
||||
tt := cmdTester(&run, CommandExecutionCardOptions{}, &n)
|
||||
if got := len(tt.Texts()); got > 200 {
|
||||
t.Fatalf("%d texts built for 100000 lines", got)
|
||||
}
|
||||
if !tt.HasText("line 99999") {
|
||||
t.Fatal("output does not follow its end")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandExecutionCardSnapshot(t *testing.T) {
|
||||
snapshot(t, "command_execution_card", 560, 300, func(c *ui.Context) {
|
||||
run := CommandRun{Command: "go test ./court/...", Dir: "~/projects/court", ExitCode: 1, Duration: 4200 * time.Millisecond,
|
||||
Output: "\x1b[32mok\x1b[0m court/guests 0.21s\n\x1b[1;31mFAIL\x1b[0m court/ledger 0.08s\n ledger_test.go:14: want 42 guests, got 40\n\x1b[33mwarning:\x1b[0m 1 test skipped\n"}
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
CommandExecutionCard(c, run, CommandExecutionCardOptions{Height: 140})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# 261 ToolCallCard 工具调用卡片
|
||||
|
||||
## 用途
|
||||
|
||||
显示一次工具调用:工具名、状态、耗时,参数与结果可折叠,JSON 自动格式化并高亮。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
call := mujica.ToolCall{ID: "1", Name: "read_file", Args: "{\"path\":\"ledger.md\"}",
|
||||
Result: "{\"lines\":40}", State: mujica.AgentDone, Duration: 420 * time.Millisecond}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ToolCallCard(c, call, mujica.ToolCallCardOptions{OpenArgs: true})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func ToolCallCard(c *ui.Context, call ToolCall, opts ToolCallCardOptions) ChoiceResult`。`call.Name` 为空时 panic。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `OpenArgs` | `bool` | `false` | 参数区初始展开。 |
|
||||
| `OpenResult` | `bool` | `false` | 结果区初始展开。 |
|
||||
| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- `ToolCall` 字段:`ID`、`Name`、`Args`(JSON 或纯文本)、`Result`、`Error`(非空时替代结果并以错误色显示)、`State`、`Duration`。
|
||||
- `AgentState`:`AgentPending`、`AgentPlanning`、`AgentRunning`、`AgentWaiting`、`AgentDone`、`AgentFailed`、`AgentSkipped`,越界 panic。状态同时由图标形状和文字表达,不只靠颜色。
|
||||
- 仅完成或失败时显示耗时;合法 JSON 以两格缩进格式化,非 JSON 原样显示。
|
||||
- 结果超过 200 行时虚拟化,只构建可见行。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Changed()`:用户展开或折叠参数、结果。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 到“参数”“结果”折叠按钮,Enter / Space 展开或折叠。
|
||||
|
||||
## 限制
|
||||
|
||||
- 展开状态保存在卡片元素上;在列表中请用稳定 `Key` 包裹每张卡片。
|
||||
- 高亮只区分关键字、字符串、数字和标点,不做 JSON 结构校验提示。
|
||||
@@ -0,0 +1,47 @@
|
||||
# 262 ToolCallGroup 工具调用组
|
||||
|
||||
## 用途
|
||||
|
||||
把连续的工具调用折叠成一组,标题行显示调用次数、总耗时与失败数。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
open := false
|
||||
calls := []mujica.ToolCall{
|
||||
{ID: "a", Name: "list_dir", State: mujica.AgentDone, Duration: 300 * time.Millisecond},
|
||||
{ID: "b", Name: "read_file", State: mujica.AgentFailed, Error: "not found"},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ToolCallGroup(c, &open, calls, mujica.ToolCallGroupOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func ToolCallGroup(c *ui.Context, open *bool, calls []ToolCall, opts ToolCallGroupOptions) ChoiceResult`。`open` 为 nil、调用 `ID` 为空或重复时 panic。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Title` | `string` | “N 次工具调用” | 替换内置标题。 |
|
||||
| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- 组的状态:有调用执行中为执行中,否则有失败为失败,否则为完成。
|
||||
- 失败数以文字“N 次失败”显示。
|
||||
- 展开后每次调用用 ToolCallCard 显示。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Changed()`:用户展开或折叠该组。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 到组标题,Enter / Space 展开或折叠;组内卡片各自可折叠。
|
||||
|
||||
## 限制
|
||||
|
||||
- 总耗时是各调用耗时之和,不扣除并行重叠。
|
||||
@@ -0,0 +1,51 @@
|
||||
# 263 ToolApprovalDialog 工具审批对话框
|
||||
|
||||
## 用途
|
||||
|
||||
工具执行前请求批准:显示工具名、执行理由和参数预览,允许或拒绝。遵循 Dialog 与 AlertDialog 规则。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
open := true
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.ToolApprovalDialog(c, &open, mujica.ToolApprovalOptions{
|
||||
Tool: "delete_files", Args: "{\"glob\":\"*.tmp\"}", Reason: "Clean the build folder.",
|
||||
})
|
||||
if r.Allowed {
|
||||
fmt.Println("run the tool")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func ToolApprovalDialog(c *ui.Context, open *bool, opts ToolApprovalOptions) ToolApprovalResult`。`Tool` 为空时 panic。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Tool` | `string` | 必填 | 工具名。 |
|
||||
| `Args` | `string` | `""` | 参数,合法 JSON 会格式化高亮。 |
|
||||
| `Reason` | `string` | `""` | Agent 给出的理由,作为对话框说明。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- 打开时焦点在“拒绝”;点击遮罩不做任何事。
|
||||
- 参数过长时在预览区内滚动,按钮始终可见。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Allowed`:点击允许,或焦点在允许上按 Enter / Space。
|
||||
- `Denied`:点击拒绝、焦点在拒绝上按 Enter、或按 Escape。
|
||||
- `Element`:对话框面板,关闭时为 nil。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 打开时焦点在拒绝,Enter 只触发聚焦按钮,必须 Tab 到“允许”才能批准。
|
||||
- Escape 等同拒绝;Tab 在对话框内循环;关闭后焦点回到打开者。
|
||||
|
||||
## 限制
|
||||
|
||||
- 输入法组合期间按 Escape 仍会拒绝(MyGo 不公开组合状态,`ui/editor.go:73`)。
|
||||
@@ -0,0 +1,48 @@
|
||||
# 264 PermissionPrompt 权限请求
|
||||
|
||||
## 用途
|
||||
|
||||
请求权限:允许一次、始终允许或拒绝,并说明“始终允许”覆盖的范围。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
open := true
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.PermissionPrompt(c, &open, mujica.PermissionPromptOptions{
|
||||
Title: "Write to the ledger?", Scope: "Write access to ~/court/ledger",
|
||||
})
|
||||
if r.Choice == mujica.PermissionAllowAlways {
|
||||
fmt.Println("remember the grant")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func PermissionPrompt(c *ui.Context, open *bool, opts PermissionPromptOptions) PermissionResult`。`Title` 或 `Scope` 为空时 panic。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Title` | `string` | 必填 | 问题。 |
|
||||
| `Description` | `string` | `""` | 补充说明。 |
|
||||
| `Scope` | `string` | 必填 | “始终允许”覆盖的范围。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- 范围说明放在带锁形图标的框内。
|
||||
- 打开时焦点在“拒绝”,点击遮罩无效。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Choice`:本帧的选择,`PermissionAllowOnce`、`PermissionAllowAlways`、`PermissionDeny`;无选择为 `PermissionNone`。Escape 记为拒绝。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 打开时焦点在拒绝;Tab 依次到“始终允许”“允许一次”;Enter 触发聚焦按钮;Escape 拒绝。
|
||||
|
||||
## 限制
|
||||
|
||||
- 组件不保存授权,记住“始终允许”由调用方负责。
|
||||
@@ -0,0 +1,51 @@
|
||||
# 265 AgentStepList 执行步骤列表
|
||||
|
||||
## 用途
|
||||
|
||||
以时间线显示 Agent 的执行步骤:当前步加框标出,步骤可展开详情,失败步提供重试。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
steps := []mujica.AgentStep{
|
||||
{ID: "read", Title: "Read the guest list", State: mujica.AgentDone, Duration: time.Second},
|
||||
{ID: "seat", Title: "Assign the seats", State: mujica.AgentFailed, Detail: "Table 4 is full."},
|
||||
{ID: "send", Title: "Send invitations", State: mujica.AgentPending},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if r := mujica.AgentStepList(c, steps, mujica.AgentStepListOptions{}); r.Retry != "" {
|
||||
fmt.Println("retry", r.Retry)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func AgentStepList(c *ui.Context, steps []AgentStep, opts AgentStepListOptions) AgentStepListResult`。步骤 `ID` 为空或重复、`Title` 为空时 panic。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Disabled` | `bool` | `false` | 禁止展开与重试。 |
|
||||
| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- `AgentStep` 字段:`ID`、`Title`、`Detail`、`State`、`Duration`。
|
||||
- `AgentState`:`AgentPending`、`AgentPlanning`、`AgentRunning`、`AgentWaiting`、`AgentDone`、`AgentFailed`、`AgentSkipped`,越界 panic。状态同时由图标形状和文字表达,不只靠颜色。
|
||||
- 当前步是第一个执行中、等待或规划中的步骤,用选中底色、边框和“当前”文字标出。
|
||||
- 失败步下方显示“重试”。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Toggled`:本帧展开或折叠的步骤 ID。
|
||||
- `Retry`:本帧请求重试的步骤 ID。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 进入列表;↑ ↓ 在步骤与重试按钮间移动;Enter / Space 展开详情或重试。
|
||||
|
||||
## 限制
|
||||
|
||||
- 步骤数量较少时使用;列表不虚拟化。
|
||||
@@ -0,0 +1,49 @@
|
||||
# 266 AgentPlan 执行计划
|
||||
|
||||
## 用途
|
||||
|
||||
显示 Agent 的计划清单与进度:随 Agent 实时勾选,用户可勾选、跳过、恢复和新增项。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
items := []mujica.PlanItem{
|
||||
{ID: "a", Text: "Read the guest list", State: mujica.AgentDone},
|
||||
{ID: "b", Text: "Assign the seats", State: mujica.AgentRunning},
|
||||
{ID: "c", Text: "Order the flowers"},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.AgentPlan(c, &items, mujica.AgentPlanOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func AgentPlan(c *ui.Context, items *[]PlanItem, opts AgentPlanOptions) ChoiceResult`。`items` 为 nil、项 `ID` 重复或状态不是待办、进行中、完成、跳过之一时 panic。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Title` | `string` | “计划” | 标题。 |
|
||||
| `Disabled` | `bool` | `false` | 只读显示,隐藏跳过与新增。 |
|
||||
| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- `PlanItem` 字段:`ID`、`Text`、`State`(`AgentPending`、`AgentRunning`、`AgentDone`、`AgentSkipped`)。
|
||||
- 进度为“已完成 / 未跳过总数”,以文字和细进度条显示。
|
||||
- 跳过的项加删除线并禁用勾选;进行中的项显示“进行中”。
|
||||
- 新增项的 ID 自动生成为未使用的 `item-N`。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Changed()`:用户勾选、跳过、恢复或新增了项,`*items` 已更新。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 到每项的勾选框(Space 切换)、跳过或恢复按钮;在新增输入框中按 Enter 添加。
|
||||
|
||||
## 限制
|
||||
|
||||
- 空白内容不会被添加。
|
||||
@@ -0,0 +1,41 @@
|
||||
# 267 AgentStatus Agent 状态
|
||||
|
||||
## 用途
|
||||
|
||||
显示 Agent 当前状态:规划中、执行中、等待、完成、失败,用图标形状和文字表达,不只靠颜色。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.AgentStatus(c, mujica.AgentWaiting, mujica.AgentStatusOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func AgentStatus(c *ui.Context, state AgentState, opts AgentStatusOptions) *ui.Element`。未知状态 panic。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Label` | `string` | 内置状态词 | 替换显示的文字。 |
|
||||
| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- `AgentState`:`AgentPending`、`AgentPlanning`、`AgentRunning`、`AgentWaiting`、`AgentDone`、`AgentFailed`、`AgentSkipped`,越界 panic。状态同时由图标形状和文字表达,不只靠颜色。
|
||||
- 执行中显示旋转的菱形;`Still` 或系统减少动画时改为静态图标。
|
||||
|
||||
## 事件
|
||||
|
||||
- 无事件;屏幕阅读器以状态角色读出文字。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 不获取焦点。
|
||||
|
||||
## 限制
|
||||
|
||||
- 状态动画只在显示时重绘,隐藏后停止。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 268 AgentProgress Agent 进度
|
||||
|
||||
## 用途
|
||||
|
||||
显示整体进度和当前动作文字;时长未知时用不确定模式。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.AgentProgress(c, 0.4, mujica.AgentProgressOptions{Action: "Reading ledger.md"})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func AgentProgress(c *ui.Context, value float64, opts AgentProgressOptions) *ui.Element`。确定模式下 `value` 不在 0 到 1 时 panic。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Label` | `string` | “进度” | 进度名称。 |
|
||||
| `Action` | `string` | `""` | 当前动作,如“正在读取 ledger.md”。 |
|
||||
| `Indeterminate` | `bool` | `false` | 不确定模式,忽略 value。 |
|
||||
| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- 确定模式显示百分比;不确定模式显示流动条,减少动画时为静态条加“进行中”。
|
||||
|
||||
## 事件
|
||||
|
||||
- 无事件;当前动作以状态角色读出。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 不获取焦点。
|
||||
|
||||
## 限制
|
||||
|
||||
- 进度值由调用方计算。
|
||||
@@ -0,0 +1,45 @@
|
||||
# 269 SubAgentTree 子 Agent 树
|
||||
|
||||
## 用途
|
||||
|
||||
以树显示子 Agent 层级、各自任务与状态,可展开查看下级。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
agents := []mujica.SubAgent{{ID: "lead", Name: "Steward", State: mujica.AgentRunning, Children: []mujica.SubAgent{
|
||||
{ID: "cook", Name: "Cook", Task: "Plan the menu", State: mujica.AgentWaiting},
|
||||
}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.SubAgentTree(c, agents, mujica.SubAgentTreeOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func SubAgentTree(c *ui.Context, roots []SubAgent, opts SubAgentTreeOptions) ChoiceResult`。`ID` 为空或重复、`Name` 为空时 panic。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Disabled` | `bool` | `false` | 禁止展开与折叠。 |
|
||||
| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- `SubAgent` 字段:`ID`、`Name`、`Task`、`State`、`Children`。
|
||||
- `AgentState`:`AgentPending`、`AgentPlanning`、`AgentRunning`、`AgentWaiting`、`AgentDone`、`AgentFailed`、`AgentSkipped`,越界 panic。状态同时由图标形状和文字表达,不只靠颜色。
|
||||
- 有下级的行显示箭头和“N 个子智能体”。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Changed()`:用户展开或折叠了某个 Agent。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 进入树;↑ ↓ 移动;→ 展开,← 折叠;Enter / Space 切换。
|
||||
|
||||
## 限制
|
||||
|
||||
- 树不虚拟化,适合几十到几百个 Agent。
|
||||
@@ -0,0 +1,48 @@
|
||||
# 270 FileChangeCard 文件变更卡片
|
||||
|
||||
## 用途
|
||||
|
||||
预览一个文件的变更:增删行数、首处修改附近的差异行,提供接受、拒绝和打开完整差异的入口。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
decision := mujica.FilePending
|
||||
change := mujica.FileChange{Path: "ledger.md", Old: "Guests: 40\n", New: "Guests: 42\n"}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.FileChangeCard(c, &decision, change, mujica.FileChangeCardOptions{}).OpenDiff {
|
||||
fmt.Println("open the full diff")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func FileChangeCard(c *ui.Context, decision *FileDecision, change FileChange, opts FileChangeCardOptions) FileChangeResult`。`decision` 为 nil、`Path` 为空或 `Preview` 为负时 panic。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Preview` | `int` | 8 | 从首处修改前两行起显示的差异行数。 |
|
||||
| `Disabled` | `bool` | `false` | 禁止决定与打开差异。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- `FileChange` 字段:`Path`、`Old`、`New`;`Old` 为空表示新文件,`New` 为空表示删除。
|
||||
- 差异由 `internal.LineDiff`(Myers 行差异)计算,文本不变时只算一次。
|
||||
- 新增行带“+”、删除行带“−”和行号,颜色只是辅助。
|
||||
- 决定状态 `FilePending`、`FileAccepted`、`FileRejected` 以图标和文字显示;已决定后显示“撤销”。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Decided`:`*decision` 被用户改变。
|
||||
- `OpenDiff`:请求调用方打开完整差异。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 依次到“打开差异”“拒绝”“接受”(或“撤销”),Enter / Space 触发。
|
||||
|
||||
## 限制
|
||||
|
||||
- 只做行级差异;词级高亮由 Git 组扩展。
|
||||
@@ -0,0 +1,48 @@
|
||||
# 271 MultiFileDiffReview 多文件差异审阅
|
||||
|
||||
## 用途
|
||||
|
||||
审阅多个文件的变更:左侧文件列表,右侧所选文件的完整差异,逐文件接受或拒绝,全部接受,并显示审阅进度。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
files := []mujica.FileChange{
|
||||
{Path: "ledger.md", Old: "Guests: 40\n", New: "Guests: 42\n"},
|
||||
{Path: "menu.md", Old: "soup\n", New: "soup\ntart\n"},
|
||||
}
|
||||
decisions := make([]mujica.FileDecision, len(files))
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.MultiFileDiffReview(c, &decisions, files, mujica.MultiFileDiffReviewOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func MultiFileDiffReview(c *ui.Context, decisions *[]FileDecision, files []FileChange, opts MultiFileDiffReviewOptions) ChoiceResult`。`decisions` 与 `files` 数量不同、`Path` 为空或重复、`Height` 为负时 panic。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Height` | `float32` | 420 | 组件高度。 |
|
||||
| `Disabled` | `bool` | `false` | 禁止选择与决定。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- 进度为“已审阅 N / 总数”,以文字和细进度条显示。
|
||||
- 所选文件用选中底色和左侧竖条标出。
|
||||
- 文件超过 200 个或差异超过 200 行时虚拟化。
|
||||
- “全部接受”只接受尚未决定的文件,全部决定后禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Changed()`:用户改变了任一文件的决定。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 进入文件列表;↑ ↓ 移动,Enter 选择;Tab 到右侧按钮后 Enter 接受或拒绝。
|
||||
|
||||
## 限制
|
||||
|
||||
- 文件很多时方向键只在已构建的可见行间移动,滚动后再继续。
|
||||
@@ -0,0 +1,48 @@
|
||||
# 272 CommandExecutionCard 命令执行卡片
|
||||
|
||||
## 用途
|
||||
|
||||
显示一条命令的执行:命令、工作目录、带 ANSI 颜色的输出、退出码与耗时,执行中可请求中止。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
run := mujica.CommandRun{Command: "go test ./...", Dir: "~/court", Running: true, Output: "\x1b[32mok\x1b[0m court\n"}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.CommandExecutionCard(c, run, mujica.CommandExecutionCardOptions{}).Changed() {
|
||||
fmt.Println("abort the process")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func CommandExecutionCard(c *ui.Context, run CommandRun, opts CommandExecutionCardOptions) ChoiceResult`。`Command` 为空或 `Height` 为负时 panic。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Height` | `float32` | 240 | 输出区高度。 |
|
||||
| `Disabled` | `bool` | `false` | 禁止中止。 |
|
||||
| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- `CommandRun` 字段:`Command`、`Dir`、`Output`、`Running`、`ExitCode`、`Duration`。
|
||||
- 输出由 `internal.ParseAnsi` 解析,颜色映射到主题;样式可跨行延续;追加输出只解析新增行。
|
||||
- 输出虚拟化并跟随末尾,用户向上滚动后停止跟随。
|
||||
- 退出码以图标和“退出码 N”文字显示。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Changed()`:用户点击了“中止”;终止进程由调用方完成。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 到“中止”,Enter / Space 触发;输出区可滚动。
|
||||
|
||||
## 限制
|
||||
|
||||
- 只支持 SGR 颜色与样式,其它转义序列原样显示。
|
||||
- 不运行命令,也不提供 PTY。
|
||||
@@ -0,0 +1,45 @@
|
||||
# 273 ScreenshotStream Agent 画面流
|
||||
|
||||
## 用途
|
||||
|
||||
查看 Agent 的画面帧序列:默认跟随最新帧,可暂停并逐帧前后查看。帧由调用方提供。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
var st mujica.ScreenshotStreamState
|
||||
var frames []mujica.ScreenshotFrame // appended as the agent captures them
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ScreenshotStream(c, &st, frames, mujica.ScreenshotStreamOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func ScreenshotStream(c *ui.Context, st *ScreenshotStreamState, frames []ScreenshotFrame, opts ScreenshotStreamOptions) ChoiceResult`。`st` 为 nil、帧没有图像或 `Height` 为负时 panic。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Height` | `float32` | 280 | 画面区高度。 |
|
||||
| `Disabled` | `bool` | `false` | 禁止暂停与逐帧。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- `ScreenshotFrame` 字段:`Image`(`ui.ImageSource`)、`Caption`、`At`。
|
||||
- `ScreenshotStreamState` 字段:`Index`、`Paused`;未暂停时 `Index` 总是最后一帧。
|
||||
- 逐帧操作会自动暂停;“回到实时”恢复跟随。
|
||||
- 没有帧时显示“等待画面”。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Changed()`:用户暂停、恢复或移动了帧。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 到画面区:← → 逐帧,Space 暂停或恢复;上一帧、下一帧按钮同样可用。
|
||||
|
||||
## 限制
|
||||
|
||||
- 不解码视频,帧图像由调用方提供。
|
||||
@@ -0,0 +1,47 @@
|
||||
# 274 ArtifactPanel 产物面板
|
||||
|
||||
## 用途
|
||||
|
||||
展示 Agent 生成的产物:代码高亮显示,文档与网页由调用方的预览插槽渲染,可在预览与源码间切换,提供复制与导出。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
art := mujica.Artifact{Title: "seating.go", Language: "go", Content: "package court\n"}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.ArtifactPanel(c, art, mujica.ArtifactPanelOptions{}).Exported {
|
||||
fmt.Println("save the file")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func ArtifactPanel(c *ui.Context, art Artifact, opts ArtifactPanelOptions) ArtifactPanelResult`。`Title` 为空、`Kind` 越界、文档或网页缺少 `Preview`、`Height` 为负时 panic。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Preview` | `func()` | `nil` | 预览插槽;文档与网页必填。 |
|
||||
| `Height` | `float32` | 320 | 内容区高度。 |
|
||||
| `Disabled` | `bool` | `false` | 禁止复制、导出与切换。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- `Artifact` 字段:`Title`、`Kind`(`ArtifactCode`、`ArtifactDocument`、`ArtifactWeb`)、`Language`、`Content`。
|
||||
- 预览与源码切换用选中底色加勾号标出当前项。
|
||||
- 复制后两秒内按钮显示“已复制”。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Copied`:内容已写入剪贴板。
|
||||
- `Exported`:请求调用方导出。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 到切换组,← → 移动,Enter / Space 选择;Tab 到复制、导出。
|
||||
|
||||
## 限制
|
||||
|
||||
- 不内置网页渲染,网页预览须由调用方提供(无 WebView)。
|
||||
@@ -0,0 +1,45 @@
|
||||
# 275 ArtifactVersionSwitcher 产物版本切换
|
||||
|
||||
## 用途
|
||||
|
||||
在产物的多个版本之间切换,并提供与上一版本比较差异的入口。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
current := 1
|
||||
versions := []mujica.ArtifactVersion{{Label: "v1", Note: "First draft"}, {Label: "v2", Note: "Added music"}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.ArtifactVersionSwitcher(c, ¤t, versions, mujica.ArtifactVersionSwitcherOptions{}).Compare {
|
||||
fmt.Println("diff v1 and v2")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func ArtifactVersionSwitcher(c *ui.Context, current *int, versions []ArtifactVersion, opts ArtifactVersionSwitcherOptions) ArtifactVersionResult`。没有版本、`*current` 越界或版本 `Label` 为空时 panic。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Disabled` | `bool` | `false` | 禁止切换与比较。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- 显示版本名、“N / 总数”和说明;第一个版本时“较早版本”禁用,最后一个时“较新版本”禁用。
|
||||
- 非第一个版本显示“与上一版本比较”。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Changed`:`*current` 改变。
|
||||
- `Compare`:请求比较当前版本与上一版本。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 焦点在组件内时 ← → 切换版本;Tab 到各按钮,Enter / Space 触发。
|
||||
|
||||
## 限制
|
||||
|
||||
- 差异视图由调用方打开,可用 FileChangeCard 或 MultiFileDiffReview 呈现。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 276 SandboxStatus 沙箱状态
|
||||
|
||||
## 用途
|
||||
|
||||
显示沙箱的运行状态、资源占用和剩余时间。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
info := mujica.SandboxInfo{Name: "sandbox-7", State: mujica.SandboxRunning,
|
||||
Resources: []mujica.SandboxResource{{Name: "CPU", Used: 1.5, Limit: 2, Unit: "cores"}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.SandboxStatus(c, info, mujica.SandboxStatusOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func SandboxStatus(c *ui.Context, info SandboxInfo, opts SandboxStatusOptions) *ui.Element`。状态未知、资源名为空、`Limit` 不大于 0 或 `Used` 不在 0 到 `Limit` 之间时 panic。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- `SandboxInfo` 字段:`Name`、`State`(`SandboxStarting`、`SandboxRunning`、`SandboxStopped`、`SandboxFailed`)、`Resources`、`Expires`。
|
||||
- 每项资源显示“已用 / 上限 单位”与细条;达到 90% 时加警示图标和“接近上限”说明。
|
||||
- 设置 `Expires` 且运行中时显示剩余时间,每秒刷新;到时显示“已到时”。
|
||||
|
||||
## 事件
|
||||
|
||||
- 无事件。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- 不获取焦点。
|
||||
|
||||
## 限制
|
||||
|
||||
- 倒计时依赖界面帧,窗口隐藏时 MyGo 暂停帧,重新显示后立即校正。
|
||||
@@ -0,0 +1,46 @@
|
||||
# 277 MCPServerList MCP 服务器列表
|
||||
|
||||
## 用途
|
||||
|
||||
列出 MCP 服务器:连接状态、传输方式、工具数,提供启动、停止与重新连接回调。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
servers := []mujica.MCPServer{{ID: "fs", Name: "filesystem", Transport: "stdio", State: mujica.MCPConnected, Tools: 12}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if r := mujica.MCPServerList(c, servers, mujica.MCPServerListOptions{}); r.Action == mujica.MCPStop {
|
||||
fmt.Println("stop", r.Server)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func MCPServerList(c *ui.Context, servers []MCPServer, opts MCPServerListOptions) MCPServerListResult`。`ID` 为空或重复、`Name` 为空、状态未知、`Tools` 为负、`MaxHeight` 为负时 panic。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `MaxHeight` | `float32` | 360 | 列表最大高度,超出后滚动。 |
|
||||
| `Disabled` | `bool` | `false` | 禁止所有操作。 |
|
||||
| `Still` | `bool` | `false` | 停止执行中动画;系统减少动画时也自动静止。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- `MCPServer` 字段:`ID`、`Name`、`Transport`、`State`(`MCPStopped`、`MCPConnecting`、`MCPConnected`、`MCPError`)、`Tools`、`Error`。
|
||||
- 已连接时显示“N 个工具”;连接失败时显示错误文字与“重新连接”。
|
||||
- 超过 200 台服务器时虚拟化。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Action`:本帧请求 `MCPStart`、`MCPStop` 或 `MCPReconnect`,`Server` 为对应 ID。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 依次到各行按钮,Enter / Space 触发。
|
||||
|
||||
## 限制
|
||||
|
||||
- 组件只发出请求,连接管理由调用方完成。
|
||||
@@ -0,0 +1,47 @@
|
||||
# 278 MemoryPanel 记忆面板
|
||||
|
||||
## 用途
|
||||
|
||||
列出 Agent 的记忆条目,支持搜索、行内编辑和确认后删除。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
entries := []mujica.MemoryEntry{{ID: "1", Text: "Lady Sakiko prefers the east wing.", Source: "Chat"}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if r := mujica.MemoryPanel(c, &entries, mujica.MemoryPanelOptions{}); r.Deleted != "" {
|
||||
fmt.Println("forget", r.Deleted)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func MemoryPanel(c *ui.Context, entries *[]MemoryEntry, opts MemoryPanelOptions) MemoryPanelResult`。`entries` 为 nil、条目 `ID` 为空或 `MaxHeight` 为负时 panic。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `MaxHeight` | `float32` | 360 | 列表最大高度。 |
|
||||
| `Disabled` | `bool` | `false` | 只读,禁止搜索、编辑与删除。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- `MemoryEntry` 字段:`ID`、`Text`、`Source`。
|
||||
- 搜索不区分大小写,匹配内容与来源;只在搜索词或条目变化时重新筛选。
|
||||
- 编辑时出现文本框和保存、取消;删除前用 Popconfirm 确认,确认按钮为危险样式且默认聚焦取消。
|
||||
- 条目超过 200 条时虚拟化。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Edited`:本帧保存的条目 ID,`*entries` 已更新。
|
||||
- `Deleted`:本帧删除的条目 ID,已从 `*entries` 移除。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 到搜索框、各条的编辑与删除;删除确认中 Escape 取消。
|
||||
|
||||
## 限制
|
||||
|
||||
- 调用方原地修改条目文本而不改变切片长度时,筛选结果到下次搜索变化才更新。
|
||||
@@ -0,0 +1,46 @@
|
||||
# 279 CheckpointList 检查点列表
|
||||
|
||||
## 用途
|
||||
|
||||
列出 Agent 运行的检查点,标出当前点,回滚到其它检查点前要求确认。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
current := "c2"
|
||||
points := []mujica.Checkpoint{{ID: "c2", Label: "Menu drafted"}, {ID: "c1", Label: "Start"}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.CheckpointList(c, ¤t, points, mujica.CheckpointListOptions{}).Changed() {
|
||||
fmt.Println("restore", current)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func CheckpointList(c *ui.Context, current *string, points []Checkpoint, opts CheckpointListOptions) ChoiceResult`。`current` 为 nil、`ID` 为空或重复、`Label` 为空、`MaxHeight` 为负时 panic。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `MaxHeight` | `float32` | 360 | 列表最大高度。 |
|
||||
| `Disabled` | `bool` | `false` | 禁止回滚。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- 当前点用实心菱形、选中底色和“当前”文字标出;其它点为空心菱形。
|
||||
- 回滚用 AlertDialog 确认:默认聚焦取消,Enter 不会直接回滚。
|
||||
- 超过 200 个检查点时虚拟化。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Changed()`:确认回滚后 `*current` 已改为目标 ID。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 到各“回滚”,Enter 打开确认;确认框内 Tab 到“回滚”再按 Enter。
|
||||
|
||||
## 限制
|
||||
|
||||
- 恢复文件与状态由调用方完成。
|
||||
@@ -0,0 +1,52 @@
|
||||
# 280 HumanInputRequest 人工输入请求
|
||||
|
||||
## 用途
|
||||
|
||||
Agent 向用户提问:显示问题、可选答案与自由回答,并提示剩余时间与超时。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
answer := ""
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.HumanInputRequest(c, &answer, mujica.HumanInputRequestOptions{
|
||||
Question: "Which hall?", Options: []string{"East wing", "Great hall"}, AllowFree: true,
|
||||
})
|
||||
if r.Submitted {
|
||||
fmt.Println("reply", answer)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
签名:`func HumanInputRequest(c *ui.Context, answer *string, opts HumanInputRequestOptions) HumanInputResult`。`answer` 为 nil、`Question` 为空、既无选项又不允许自由回答时 panic。
|
||||
|
||||
## 参数
|
||||
|
||||
| 字段 | 类型 | 默认值 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Question` | `string` | 必填 | 问题。 |
|
||||
| `Options` | `[]string` | `nil` | 可选答案。 |
|
||||
| `AllowFree` | `bool` | `false` | 允许自由回答。 |
|
||||
| `Deadline` | `time.Time` | 零值 | 截止时间;零值不限时。 |
|
||||
| `Disabled` | `bool` | `false` | 禁止回答。 |
|
||||
|
||||
## 状态
|
||||
|
||||
- 选择选项会清空自由回答;输入自由回答会取代所选选项。
|
||||
- 剩余时间每秒刷新;到时显示“智能体已停止等待”并禁用。
|
||||
- 回答为空白时“发送回答”禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
- `Submitted`:用户发送了 `*answer`。
|
||||
- `TimedOut`:截止时间在本帧到达(只报告一次)。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab 进入选项组,方向键选择;Tab 到输入框,Enter 发送;或 Tab 到“发送回答”。
|
||||
|
||||
## 限制
|
||||
|
||||
- 超时只在界面帧中检测,窗口隐藏期间推迟到重新显示。
|
||||
@@ -0,0 +1,243 @@
|
||||
package mujica_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
func ExampleToolCallCard() {
|
||||
call := mujica.ToolCall{ID: "1", Name: "read_file", Args: "{\"path\":\"ledger.md\"}",
|
||||
Result: "{\"lines\":40}", State: mujica.AgentDone, Duration: 420 * time.Millisecond}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ToolCallCard(c, call, mujica.ToolCallCardOptions{OpenArgs: true})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleToolCallGroup() {
|
||||
open := false
|
||||
calls := []mujica.ToolCall{
|
||||
{ID: "a", Name: "list_dir", State: mujica.AgentDone, Duration: 300 * time.Millisecond},
|
||||
{ID: "b", Name: "read_file", State: mujica.AgentFailed, Error: "not found"},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ToolCallGroup(c, &open, calls, mujica.ToolCallGroupOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleToolApprovalDialog() {
|
||||
open := true
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.ToolApprovalDialog(c, &open, mujica.ToolApprovalOptions{
|
||||
Tool: "delete_files", Args: "{\"glob\":\"*.tmp\"}", Reason: "Clean the build folder.",
|
||||
})
|
||||
if r.Allowed {
|
||||
fmt.Println("run the tool")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExamplePermissionPrompt() {
|
||||
open := true
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.PermissionPrompt(c, &open, mujica.PermissionPromptOptions{
|
||||
Title: "Write to the ledger?", Scope: "Write access to ~/court/ledger",
|
||||
})
|
||||
if r.Choice == mujica.PermissionAllowAlways {
|
||||
fmt.Println("remember the grant")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAgentStepList() {
|
||||
steps := []mujica.AgentStep{
|
||||
{ID: "read", Title: "Read the guest list", State: mujica.AgentDone, Duration: time.Second},
|
||||
{ID: "seat", Title: "Assign the seats", State: mujica.AgentFailed, Detail: "Table 4 is full."},
|
||||
{ID: "send", Title: "Send invitations", State: mujica.AgentPending},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if r := mujica.AgentStepList(c, steps, mujica.AgentStepListOptions{}); r.Retry != "" {
|
||||
fmt.Println("retry", r.Retry)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAgentPlan() {
|
||||
items := []mujica.PlanItem{
|
||||
{ID: "a", Text: "Read the guest list", State: mujica.AgentDone},
|
||||
{ID: "b", Text: "Assign the seats", State: mujica.AgentRunning},
|
||||
{ID: "c", Text: "Order the flowers"},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.AgentPlan(c, &items, mujica.AgentPlanOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAgentStatus() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.AgentStatus(c, mujica.AgentWaiting, mujica.AgentStatusOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAgentProgress() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.AgentProgress(c, 0.4, mujica.AgentProgressOptions{Action: "Reading ledger.md"})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSubAgentTree() {
|
||||
agents := []mujica.SubAgent{{ID: "lead", Name: "Steward", State: mujica.AgentRunning, Children: []mujica.SubAgent{
|
||||
{ID: "cook", Name: "Cook", Task: "Plan the menu", State: mujica.AgentWaiting},
|
||||
}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.SubAgentTree(c, agents, mujica.SubAgentTreeOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleFileChangeCard() {
|
||||
decision := mujica.FilePending
|
||||
change := mujica.FileChange{Path: "ledger.md", Old: "Guests: 40\n", New: "Guests: 42\n"}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.FileChangeCard(c, &decision, change, mujica.FileChangeCardOptions{}).OpenDiff {
|
||||
fmt.Println("open the full diff")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleMultiFileDiffReview() {
|
||||
files := []mujica.FileChange{
|
||||
{Path: "ledger.md", Old: "Guests: 40\n", New: "Guests: 42\n"},
|
||||
{Path: "menu.md", Old: "soup\n", New: "soup\ntart\n"},
|
||||
}
|
||||
decisions := make([]mujica.FileDecision, len(files))
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.MultiFileDiffReview(c, &decisions, files, mujica.MultiFileDiffReviewOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCommandExecutionCard() {
|
||||
run := mujica.CommandRun{Command: "go test ./...", Dir: "~/court", Running: true, Output: "\x1b[32mok\x1b[0m court\n"}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.CommandExecutionCard(c, run, mujica.CommandExecutionCardOptions{}).Changed() {
|
||||
fmt.Println("abort the process")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleScreenshotStream() {
|
||||
var st mujica.ScreenshotStreamState
|
||||
var frames []mujica.ScreenshotFrame // appended as the agent captures them
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ScreenshotStream(c, &st, frames, mujica.ScreenshotStreamOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleArtifactPanel() {
|
||||
art := mujica.Artifact{Title: "seating.go", Language: "go", Content: "package court\n"}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.ArtifactPanel(c, art, mujica.ArtifactPanelOptions{}).Exported {
|
||||
fmt.Println("save the file")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleArtifactVersionSwitcher() {
|
||||
current := 1
|
||||
versions := []mujica.ArtifactVersion{{Label: "v1", Note: "First draft"}, {Label: "v2", Note: "Added music"}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.ArtifactVersionSwitcher(c, ¤t, versions, mujica.ArtifactVersionSwitcherOptions{}).Compare {
|
||||
fmt.Println("diff v1 and v2")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSandboxStatus() {
|
||||
info := mujica.SandboxInfo{Name: "sandbox-7", State: mujica.SandboxRunning,
|
||||
Resources: []mujica.SandboxResource{{Name: "CPU", Used: 1.5, Limit: 2, Unit: "cores"}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.SandboxStatus(c, info, mujica.SandboxStatusOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleMCPServerList() {
|
||||
servers := []mujica.MCPServer{{ID: "fs", Name: "filesystem", Transport: "stdio", State: mujica.MCPConnected, Tools: 12}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if r := mujica.MCPServerList(c, servers, mujica.MCPServerListOptions{}); r.Action == mujica.MCPStop {
|
||||
fmt.Println("stop", r.Server)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleMemoryPanel() {
|
||||
entries := []mujica.MemoryEntry{{ID: "1", Text: "Lady Sakiko prefers the east wing.", Source: "Chat"}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if r := mujica.MemoryPanel(c, &entries, mujica.MemoryPanelOptions{}); r.Deleted != "" {
|
||||
fmt.Println("forget", r.Deleted)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCheckpointList() {
|
||||
current := "c2"
|
||||
points := []mujica.Checkpoint{{ID: "c2", Label: "Menu drafted"}, {ID: "c1", Label: "Start"}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.CheckpointList(c, ¤t, points, mujica.CheckpointListOptions{}).Changed() {
|
||||
fmt.Println("restore", current)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleHumanInputRequest() {
|
||||
answer := ""
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.HumanInputRequest(c, &answer, mujica.HumanInputRequestOptions{
|
||||
Question: "Which hall?", Options: []string{"East wing", "Great hall"}, AllowFree: true,
|
||||
})
|
||||
if r.Submitted {
|
||||
fmt.Println("reply", answer)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -1,3 +1,282 @@
|
||||
package main
|
||||
|
||||
var agentPages = []page{}
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
const agentCategory = "Agent"
|
||||
|
||||
var agentPages = []page{
|
||||
{ID: "261", Name: "ToolCallCard", ZhName: "工具调用卡片", Category: agentCategory, Demo: toolCallCardDemo, Code: toolCallCardCode,
|
||||
Keys: "Tab 到参数、结果折叠按钮,Enter / Space 展开或折叠。"},
|
||||
{ID: "262", Name: "ToolCallGroup", ZhName: "工具调用组", Category: agentCategory, Demo: toolCallGroupDemo, Code: toolCallGroupCode,
|
||||
Keys: "Tab 到组标题,Enter / Space 展开或折叠。"},
|
||||
{ID: "263", Name: "ToolApprovalDialog", ZhName: "工具审批对话框", Category: agentCategory, Demo: toolApprovalDemo, Code: toolApprovalDialogCode,
|
||||
Keys: "打开时焦点在拒绝;Enter 只触发聚焦按钮,Tab 到允许才能批准;Escape 拒绝。"},
|
||||
{ID: "264", Name: "PermissionPrompt", ZhName: "权限请求", Category: agentCategory, Demo: permissionDemo, Code: permissionPromptCode,
|
||||
Keys: "打开时焦点在拒绝;Tab 到始终允许、允许一次;Escape 拒绝。"},
|
||||
{ID: "265", Name: "AgentStepList", ZhName: "执行步骤列表", Category: agentCategory, Demo: agentStepListDemo, Code: agentStepListCode,
|
||||
Keys: "↑ ↓ 在步骤与重试间移动;Enter 展开详情或重试。"},
|
||||
{ID: "266", Name: "AgentPlan", ZhName: "执行计划", Category: agentCategory, Demo: agentPlanDemo, Code: agentPlanCode,
|
||||
Keys: "Space 勾选;Tab 到跳过或恢复;在输入框中按 Enter 新增。"},
|
||||
{ID: "267", Name: "AgentStatus", ZhName: "Agent 状态", Category: agentCategory, Demo: agentStatusDemo, Code: agentStatusCode,
|
||||
Keys: "不获取焦点;屏幕阅读器读出状态文字。"},
|
||||
{ID: "268", Name: "AgentProgress", ZhName: "Agent 进度", Category: agentCategory, Demo: agentProgressDemo, Code: agentProgressCode,
|
||||
Keys: "不获取焦点;当前动作以状态读出。"},
|
||||
{ID: "269", Name: "SubAgentTree", ZhName: "子 Agent 树", Category: agentCategory, Demo: subAgentTreeDemo, Code: subAgentTreeCode,
|
||||
Keys: "↑ ↓ 移动;→ 展开,← 折叠;Enter / Space 切换。"},
|
||||
{ID: "270", Name: "FileChangeCard", ZhName: "文件变更卡片", Category: agentCategory, Demo: fileChangeCardDemo, Code: fileChangeCardCode,
|
||||
Keys: "Tab 到打开差异、拒绝、接受或撤销,Enter / Space 触发。"},
|
||||
{ID: "271", Name: "MultiFileDiffReview", ZhName: "多文件差异审阅", Category: agentCategory, Demo: multiFileDiffDemo, Code: multiFileDiffReviewCode,
|
||||
Keys: "文件列表中 ↑ ↓ 移动,Enter 选择;Tab 到接受、拒绝、全部接受。"},
|
||||
{ID: "272", Name: "CommandExecutionCard", ZhName: "命令执行卡片", Category: agentCategory, Demo: commandExecutionDemo, Code: commandExecutionCardCode,
|
||||
Keys: "Tab 到中止,Enter / Space 请求中止;输出区可滚动。"},
|
||||
{ID: "273", Name: "ScreenshotStream", ZhName: "Agent 画面流", Category: agentCategory, Demo: screenshotStreamDemo, Code: screenshotStreamCode,
|
||||
Keys: "画面区中 ← → 逐帧,Space 暂停或恢复。"},
|
||||
{ID: "274", Name: "ArtifactPanel", ZhName: "产物面板", Category: agentCategory, Demo: artifactPanelDemo, Code: artifactPanelCode,
|
||||
Keys: "切换组中 ← → 移动、Enter 选择;Tab 到复制、导出。"},
|
||||
{ID: "275", Name: "ArtifactVersionSwitcher", ZhName: "产物版本切换", Category: agentCategory, Demo: artifactVersionDemo, Code: artifactVersionSwitcherCode,
|
||||
Keys: "焦点在组件内时 ← → 切换版本;Tab 到比较按钮。"},
|
||||
{ID: "276", Name: "SandboxStatus", ZhName: "沙箱状态", Category: agentCategory, Demo: sandboxStatusDemo, Code: sandboxStatusCode,
|
||||
Keys: "不获取焦点;剩余时间每秒刷新。"},
|
||||
{ID: "277", Name: "MCPServerList", ZhName: "MCP 服务器列表", Category: agentCategory, Demo: mcpServerListDemo, Code: mcpServerListCode,
|
||||
Keys: "Tab 到各行的启动、停止、重新连接,Enter / Space 触发。"},
|
||||
{ID: "278", Name: "MemoryPanel", ZhName: "记忆面板", Category: agentCategory, Demo: memoryPanelDemo, Code: memoryPanelCode,
|
||||
Keys: "Tab 到搜索、编辑、删除;删除确认默认聚焦取消,Escape 取消。"},
|
||||
{ID: "279", Name: "CheckpointList", ZhName: "检查点列表", Category: agentCategory, Demo: checkpointListDemo, Code: checkpointListCode,
|
||||
Keys: "Tab 到回滚,Enter 打开确认;确认框默认聚焦取消。"},
|
||||
{ID: "280", Name: "HumanInputRequest", ZhName: "人工输入请求", Category: agentCategory, Demo: humanInputDemo, Code: humanInputRequestCode,
|
||||
Keys: "方向键选择选项;输入框中 Enter 发送;Tab 到发送回答。"},
|
||||
}
|
||||
|
||||
// agentStates are the states a demo can pick from.
|
||||
var agentStates = []string{"Pending", "Planning", "Running", "Waiting", "Done", "Failed", "Skipped"}
|
||||
|
||||
// agentStatePick is a select over agentStates, logging changes.
|
||||
func agentStatePick(c *ui.Context, log func(string), v *string) mujica.AgentState {
|
||||
if ui.Select(c, v, agentStates).Changed() {
|
||||
log("state = " + *v)
|
||||
}
|
||||
for i, s := range agentStates {
|
||||
if s == *v {
|
||||
return mujica.AgentState(i)
|
||||
}
|
||||
}
|
||||
panic("gallery: unknown state " + *v)
|
||||
}
|
||||
|
||||
type toolCallDemoState struct {
|
||||
state string
|
||||
failed bool
|
||||
still bool
|
||||
}
|
||||
|
||||
func toolCallCardDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "261", func() toolCallDemoState { return toolCallDemoState{state: "Done"} })
|
||||
var st mujica.AgentState
|
||||
overlayControls(c, func() {
|
||||
st = agentStatePick(c, log, &s.state)
|
||||
overlayCheck(c, log, &s.failed, "Error")
|
||||
overlayCheck(c, log, &s.still, "Still")
|
||||
})
|
||||
call := mujica.ToolCall{ID: "1", Name: "read_file", Args: `{"path":"court/ledger.md","limit":40}`,
|
||||
Result: `{"lines":40,"truncated":false}`, State: st, Duration: 420 * time.Millisecond}
|
||||
if s.failed {
|
||||
call.Error = "permission denied: court/ledger.md"
|
||||
}
|
||||
if mujica.ToolCallCard(c, call, mujica.ToolCallCardOptions{Still: s.still}).Changed() {
|
||||
log("folded or unfolded a section")
|
||||
}
|
||||
}
|
||||
|
||||
func toolCallGroupDemo(c *ui.Context, log func(string)) {
|
||||
open := ui.Local(c.Root(), "262", func() bool { return true })
|
||||
calls := []mujica.ToolCall{
|
||||
{ID: "a", Name: "list_dir", Args: `{"path":"court"}`, Result: `["ledger.md","menu.md"]`, State: mujica.AgentDone, Duration: 300 * time.Millisecond},
|
||||
{ID: "b", Name: "read_file", Args: `{"path":"court/wine.md"}`, Error: "not found", State: mujica.AgentFailed, Duration: 1200 * time.Millisecond},
|
||||
{ID: "c", Name: "grep", Args: `{"pattern":"Sakiko"}`, Result: `{"matches":3}`, State: mujica.AgentDone, Duration: 500 * time.Millisecond},
|
||||
}
|
||||
if mujica.ToolCallGroup(c, open, calls, mujica.ToolCallGroupOptions{}).Changed() {
|
||||
log(fmt.Sprintf("group open = %v", *open))
|
||||
}
|
||||
}
|
||||
|
||||
func toolApprovalDemo(c *ui.Context, log func(string)) {
|
||||
open := ui.Local(c.Root(), "263", func() bool { return false })
|
||||
if mujica.Button(c, "Run delete_files", mujica.ButtonOptions{}).Clicked() {
|
||||
*open = true
|
||||
log("asked for approval")
|
||||
}
|
||||
r := mujica.ToolApprovalDialog(c, open, mujica.ToolApprovalOptions{Tool: "delete_files",
|
||||
Args: `{"glob":"build/*.tmp","dry_run":false}`, Reason: "The agent wants to clean temporary files from the build folder."})
|
||||
if r.Allowed {
|
||||
log("allowed")
|
||||
}
|
||||
if r.Denied {
|
||||
log("denied")
|
||||
}
|
||||
}
|
||||
|
||||
type permissionDemoState struct {
|
||||
open bool
|
||||
grant string
|
||||
}
|
||||
|
||||
func permissionDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "264", func() permissionDemoState { return permissionDemoState{grant: "none"} })
|
||||
ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() {
|
||||
if mujica.Button(c, "Ask to write", mujica.ButtonOptions{}).Clicked() {
|
||||
s.open = true
|
||||
log("asked for permission")
|
||||
}
|
||||
ui.Text(c, "Grant: "+s.grant)
|
||||
})
|
||||
r := mujica.PermissionPrompt(c, &s.open, mujica.PermissionPromptOptions{Title: "Write to the ledger?",
|
||||
Description: "The agent wants to record tonight's guests.", Scope: "Write access to ~/court/ledger and its subfolders"})
|
||||
if r.Choice != mujica.PermissionNone {
|
||||
s.grant = [...]string{"none", "once", "always", "denied"}[r.Choice]
|
||||
log("choice = " + s.grant)
|
||||
}
|
||||
}
|
||||
|
||||
type stepDemoState struct {
|
||||
steps []mujica.AgentStep
|
||||
disabled bool
|
||||
}
|
||||
|
||||
func agentStepListDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "265", func() stepDemoState {
|
||||
return stepDemoState{steps: []mujica.AgentStep{
|
||||
{ID: "read", Title: "Read the guest list", State: mujica.AgentDone, Duration: 800 * time.Millisecond, Detail: "42 guests, 3 arrive late."},
|
||||
{ID: "seat", Title: "Assign the seats", State: mujica.AgentFailed, Duration: 2 * time.Second, Detail: "Table 4 is over capacity."},
|
||||
{ID: "send", Title: "Send the invitations", State: mujica.AgentPending},
|
||||
}}
|
||||
})
|
||||
overlayControls(c, func() { overlayCheck(c, log, &s.disabled, "Disabled") })
|
||||
r := mujica.AgentStepList(c, s.steps, mujica.AgentStepListOptions{Disabled: s.disabled})
|
||||
if r.Toggled != "" {
|
||||
log("toggled " + r.Toggled)
|
||||
}
|
||||
if r.Retry != "" {
|
||||
log("retry " + r.Retry)
|
||||
for i := range s.steps {
|
||||
if s.steps[i].ID == r.Retry {
|
||||
s.steps[i].State, s.steps[i].Detail = mujica.AgentRunning, "Retrying with a larger table."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type planDemoState struct {
|
||||
items []mujica.PlanItem
|
||||
disabled bool
|
||||
}
|
||||
|
||||
func agentPlanDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "266", func() planDemoState {
|
||||
return planDemoState{items: []mujica.PlanItem{
|
||||
{ID: "a", Text: "Read the guest list", State: mujica.AgentDone},
|
||||
{ID: "b", Text: "Assign the seats", State: mujica.AgentRunning},
|
||||
{ID: "c", Text: "Order the flowers"},
|
||||
}}
|
||||
})
|
||||
overlayControls(c, func() {
|
||||
overlayCheck(c, log, &s.disabled, "Disabled")
|
||||
if mujica.Button(c, "Agent finishes a step", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
for i := range s.items {
|
||||
if s.items[i].State != mujica.AgentDone && s.items[i].State != mujica.AgentSkipped {
|
||||
s.items[i].State = mujica.AgentDone
|
||||
log("agent checked " + s.items[i].Text)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
if mujica.AgentPlan(c, &s.items, mujica.AgentPlanOptions{Disabled: s.disabled}).Changed() {
|
||||
log(fmt.Sprintf("plan changed (%d items)", len(s.items)))
|
||||
}
|
||||
}
|
||||
|
||||
type statusDemoState struct {
|
||||
state string
|
||||
still bool
|
||||
}
|
||||
|
||||
func agentStatusDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "267", func() statusDemoState { return statusDemoState{state: "Running"} })
|
||||
var st mujica.AgentState
|
||||
overlayControls(c, func() {
|
||||
st = agentStatePick(c, log, &s.state)
|
||||
overlayCheck(c, log, &s.still, "Still")
|
||||
})
|
||||
ui.Row(c).Gap(8).Wrap().Children(func() {
|
||||
mujica.AgentStatus(c, st, mujica.AgentStatusOptions{Still: s.still})
|
||||
for _, v := range []mujica.AgentState{mujica.AgentPlanning, mujica.AgentRunning, mujica.AgentWaiting, mujica.AgentDone, mujica.AgentFailed} {
|
||||
mujica.AgentStatus(c, v, mujica.AgentStatusOptions{Still: s.still})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type progressDemoState struct {
|
||||
value float64
|
||||
indeterminate bool
|
||||
}
|
||||
|
||||
func agentProgressDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "268", func() progressDemoState { return progressDemoState{value: 0.4} })
|
||||
overlayControls(c, func() {
|
||||
if ui.Slider(c, &s.value, 0, 1).Width(200).Changed() {
|
||||
log(fmt.Sprintf("value = %.2f", s.value))
|
||||
}
|
||||
overlayCheck(c, log, &s.indeterminate, "Indeterminate")
|
||||
})
|
||||
action := "Reading ledger.md"
|
||||
if s.value > 0.5 {
|
||||
action = "Writing the seating chart"
|
||||
}
|
||||
mujica.AgentProgress(c, s.value, mujica.AgentProgressOptions{Label: "Preparing the ball", Action: action, Indeterminate: s.indeterminate})
|
||||
}
|
||||
|
||||
func subAgentTreeDemo(c *ui.Context, log func(string)) {
|
||||
disabled := ui.Local(c.Root(), "269", func() bool { return false })
|
||||
overlayControls(c, func() { overlayCheck(c, log, disabled, "Disabled") })
|
||||
agents := []mujica.SubAgent{{ID: "lead", Name: "Steward", Task: "Prepare the ball", State: mujica.AgentRunning, Children: []mujica.SubAgent{
|
||||
{ID: "music", Name: "Musician", Task: "Hire the quartet", State: mujica.AgentDone},
|
||||
{ID: "kitchen", Name: "Cook", Task: "Plan the menu", State: mujica.AgentWaiting, Children: []mujica.SubAgent{
|
||||
{ID: "wine", Name: "Sommelier", Task: "Choose the wine", State: mujica.AgentFailed},
|
||||
}},
|
||||
}}}
|
||||
if mujica.SubAgentTree(c, agents, mujica.SubAgentTreeOptions{Disabled: *disabled}).Changed() {
|
||||
log("expanded or collapsed an agent")
|
||||
}
|
||||
}
|
||||
|
||||
type fileChangeDemoState struct {
|
||||
decision mujica.FileDecision
|
||||
disabled bool
|
||||
preview float64
|
||||
}
|
||||
|
||||
func fileChangeCardDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "270", func() fileChangeDemoState { return fileChangeDemoState{preview: 8} })
|
||||
overlayControls(c, func() {
|
||||
overlayCheck(c, log, &s.disabled, "Disabled")
|
||||
overlaySlider(c, log, &s.preview, "Preview", "lines", 2, 12, 1)
|
||||
})
|
||||
r := mujica.FileChangeCard(c, &s.decision, agentDemoChange, mujica.FileChangeCardOptions{Preview: int(s.preview), Disabled: s.disabled})
|
||||
if r.Decided {
|
||||
log(fmt.Sprintf("decision = %d", s.decision))
|
||||
}
|
||||
if r.OpenDiff {
|
||||
log("open the full diff")
|
||||
}
|
||||
}
|
||||
|
||||
var agentDemoChange = mujica.FileChange{Path: "court/ledger.md",
|
||||
Old: "# Ledger\nGuests: 40\nWine: claret\nMusic: none\nHall: east wing\n",
|
||||
New: "# Ledger\nGuests: 42\nWine: claret\nMusic: quartet\nFlowers: lilies\nHall: east wing\n"}
|
||||
@@ -0,0 +1,196 @@
|
||||
package main
|
||||
|
||||
// Code snippets of the Agent pages, the same as their compiled Examples.
|
||||
|
||||
const toolCallCardCode = `call := mujica.ToolCall{ID: "1", Name: "read_file", Args: "{\"path\":\"ledger.md\"}",
|
||||
Result: "{\"lines\":40}", State: mujica.AgentDone, Duration: 420 * time.Millisecond}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ToolCallCard(c, call, mujica.ToolCallCardOptions{OpenArgs: true})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const toolCallGroupCode = `open := false
|
||||
calls := []mujica.ToolCall{
|
||||
{ID: "a", Name: "list_dir", State: mujica.AgentDone, Duration: 300 * time.Millisecond},
|
||||
{ID: "b", Name: "read_file", State: mujica.AgentFailed, Error: "not found"},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ToolCallGroup(c, &open, calls, mujica.ToolCallGroupOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const toolApprovalDialogCode = `open := true
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.ToolApprovalDialog(c, &open, mujica.ToolApprovalOptions{
|
||||
Tool: "delete_files", Args: "{\"glob\":\"*.tmp\"}", Reason: "Clean the build folder.",
|
||||
})
|
||||
if r.Allowed {
|
||||
fmt.Println("run the tool")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const permissionPromptCode = `open := true
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.PermissionPrompt(c, &open, mujica.PermissionPromptOptions{
|
||||
Title: "Write to the ledger?", Scope: "Write access to ~/court/ledger",
|
||||
})
|
||||
if r.Choice == mujica.PermissionAllowAlways {
|
||||
fmt.Println("remember the grant")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const agentStepListCode = `steps := []mujica.AgentStep{
|
||||
{ID: "read", Title: "Read the guest list", State: mujica.AgentDone, Duration: time.Second},
|
||||
{ID: "seat", Title: "Assign the seats", State: mujica.AgentFailed, Detail: "Table 4 is full."},
|
||||
{ID: "send", Title: "Send invitations", State: mujica.AgentPending},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if r := mujica.AgentStepList(c, steps, mujica.AgentStepListOptions{}); r.Retry != "" {
|
||||
fmt.Println("retry", r.Retry)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const agentPlanCode = `items := []mujica.PlanItem{
|
||||
{ID: "a", Text: "Read the guest list", State: mujica.AgentDone},
|
||||
{ID: "b", Text: "Assign the seats", State: mujica.AgentRunning},
|
||||
{ID: "c", Text: "Order the flowers"},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.AgentPlan(c, &items, mujica.AgentPlanOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const agentStatusCode = `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.AgentStatus(c, mujica.AgentWaiting, mujica.AgentStatusOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const agentProgressCode = `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.AgentProgress(c, 0.4, mujica.AgentProgressOptions{Action: "Reading ledger.md"})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const subAgentTreeCode = `agents := []mujica.SubAgent{{ID: "lead", Name: "Steward", State: mujica.AgentRunning, Children: []mujica.SubAgent{
|
||||
{ID: "cook", Name: "Cook", Task: "Plan the menu", State: mujica.AgentWaiting},
|
||||
}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.SubAgentTree(c, agents, mujica.SubAgentTreeOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const fileChangeCardCode = `decision := mujica.FilePending
|
||||
change := mujica.FileChange{Path: "ledger.md", Old: "Guests: 40\n", New: "Guests: 42\n"}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.FileChangeCard(c, &decision, change, mujica.FileChangeCardOptions{}).OpenDiff {
|
||||
fmt.Println("open the full diff")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const multiFileDiffReviewCode = `files := []mujica.FileChange{
|
||||
{Path: "ledger.md", Old: "Guests: 40\n", New: "Guests: 42\n"},
|
||||
{Path: "menu.md", Old: "soup\n", New: "soup\ntart\n"},
|
||||
}
|
||||
decisions := make([]mujica.FileDecision, len(files))
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.MultiFileDiffReview(c, &decisions, files, mujica.MultiFileDiffReviewOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const commandExecutionCardCode = `run := mujica.CommandRun{Command: "go test ./...", Dir: "~/court", Running: true, Output: "\x1b[32mok\x1b[0m court\n"}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.CommandExecutionCard(c, run, mujica.CommandExecutionCardOptions{}).Changed() {
|
||||
fmt.Println("abort the process")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const screenshotStreamCode = `var st mujica.ScreenshotStreamState
|
||||
var frames []mujica.ScreenshotFrame // appended as the agent captures them
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ScreenshotStream(c, &st, frames, mujica.ScreenshotStreamOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const artifactPanelCode = `art := mujica.Artifact{Title: "seating.go", Language: "go", Content: "package court\n"}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.ArtifactPanel(c, art, mujica.ArtifactPanelOptions{}).Exported {
|
||||
fmt.Println("save the file")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const artifactVersionSwitcherCode = `current := 1
|
||||
versions := []mujica.ArtifactVersion{{Label: "v1", Note: "First draft"}, {Label: "v2", Note: "Added music"}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.ArtifactVersionSwitcher(c, ¤t, versions, mujica.ArtifactVersionSwitcherOptions{}).Compare {
|
||||
fmt.Println("diff v1 and v2")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const sandboxStatusCode = `info := mujica.SandboxInfo{Name: "sandbox-7", State: mujica.SandboxRunning,
|
||||
Resources: []mujica.SandboxResource{{Name: "CPU", Used: 1.5, Limit: 2, Unit: "cores"}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.SandboxStatus(c, info, mujica.SandboxStatusOptions{})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const mcpServerListCode = `servers := []mujica.MCPServer{{ID: "fs", Name: "filesystem", Transport: "stdio", State: mujica.MCPConnected, Tools: 12}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if r := mujica.MCPServerList(c, servers, mujica.MCPServerListOptions{}); r.Action == mujica.MCPStop {
|
||||
fmt.Println("stop", r.Server)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const memoryPanelCode = `entries := []mujica.MemoryEntry{{ID: "1", Text: "Lady Sakiko prefers the east wing.", Source: "Chat"}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if r := mujica.MemoryPanel(c, &entries, mujica.MemoryPanelOptions{}); r.Deleted != "" {
|
||||
fmt.Println("forget", r.Deleted)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const checkpointListCode = `current := "c2"
|
||||
points := []mujica.Checkpoint{{ID: "c2", Label: "Menu drafted"}, {ID: "c1", Label: "Start"}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
if mujica.CheckpointList(c, ¤t, points, mujica.CheckpointListOptions{}).Changed() {
|
||||
fmt.Println("restore", current)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const humanInputRequestCode = `answer := ""
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
r := mujica.HumanInputRequest(c, &answer, mujica.HumanInputRequestOptions{
|
||||
Question: "Which hall?", Options: []string{"East wing", "Great hall"}, AllowFree: true,
|
||||
})
|
||||
if r.Submitted {
|
||||
fmt.Println("reply", answer)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
@@ -0,0 +1,285 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
type multiFileDemoState struct {
|
||||
decisions []mujica.FileDecision
|
||||
disabled bool
|
||||
}
|
||||
|
||||
var agentDemoFiles = []mujica.FileChange{
|
||||
agentDemoChange,
|
||||
{Path: "court/menu.md", Old: "soup\nroast\n", New: "soup\nroast\ntart\n"},
|
||||
{Path: "court/old-plan.md", Old: "Hold the ball in the cellar.\n"},
|
||||
}
|
||||
|
||||
func multiFileDiffDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "271", func() multiFileDemoState {
|
||||
return multiFileDemoState{decisions: make([]mujica.FileDecision, len(agentDemoFiles))}
|
||||
})
|
||||
overlayControls(c, func() {
|
||||
overlayCheck(c, log, &s.disabled, "Disabled")
|
||||
if mujica.Button(c, "Reset", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
clear(s.decisions)
|
||||
log("reset")
|
||||
}
|
||||
})
|
||||
if mujica.MultiFileDiffReview(c, &s.decisions, agentDemoFiles, mujica.MultiFileDiffReviewOptions{Disabled: s.disabled}).Changed() {
|
||||
log(fmt.Sprintf("decisions = %v", s.decisions))
|
||||
}
|
||||
}
|
||||
|
||||
type commandDemoState struct {
|
||||
run mujica.CommandRun
|
||||
disabled bool
|
||||
lines int
|
||||
}
|
||||
|
||||
func commandExecutionDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "272", func() commandDemoState {
|
||||
return commandDemoState{run: mujica.CommandRun{Command: "go test ./court/...", Dir: "~/projects/court", Running: true}}
|
||||
})
|
||||
overlayControls(c, func() {
|
||||
overlayCheck(c, log, &s.disabled, "Disabled")
|
||||
if mujica.Button(c, "Print a line", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
s.lines++
|
||||
color := []string{"32mok ", "33mwarn", "31mFAIL"}[s.lines%3]
|
||||
s.run.Output += fmt.Sprintf("\x1b[%s\x1b[0m court/case%d 0.%02ds\n", color, s.lines, s.lines)
|
||||
log("printed a line")
|
||||
}
|
||||
if mujica.Button(c, "Restart", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
s.run.Running, s.run.Output, s.lines = true, "", 0
|
||||
log("restarted")
|
||||
}
|
||||
})
|
||||
if mujica.CommandExecutionCard(c, s.run, mujica.CommandExecutionCardOptions{Disabled: s.disabled}).Changed() {
|
||||
s.run.Running, s.run.ExitCode, s.run.Duration = false, 130, time.Duration(s.lines)*time.Second
|
||||
s.run.Output += "\x1b[31maborted by user\x1b[0m\n"
|
||||
log("abort requested")
|
||||
}
|
||||
}
|
||||
|
||||
type screenshotDemoState struct {
|
||||
st mujica.ScreenshotStreamState
|
||||
frames []mujica.ScreenshotFrame
|
||||
disabled bool
|
||||
}
|
||||
|
||||
// agentDemoFrame paints a frame with a moving block.
|
||||
func agentDemoFrame(i int) mujica.ScreenshotFrame {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 320, 180))
|
||||
for y := range 180 {
|
||||
for x := range 320 {
|
||||
o := img.PixOffset(x, y)
|
||||
v := uint8(235)
|
||||
if x >= 20+i*24%260 && x < 60+i*24%260 && y > 60 && y < 120 {
|
||||
v = 120
|
||||
}
|
||||
img.Pix[o], img.Pix[o+1], img.Pix[o+2], img.Pix[o+3] = v, v-10, v-20, 255
|
||||
}
|
||||
}
|
||||
return mujica.ScreenshotFrame{Image: ui.NewBitmap(img), Caption: fmt.Sprintf("Clicked button %d", i+1), At: time.Duration(i) * 2 * time.Second}
|
||||
}
|
||||
|
||||
func screenshotStreamDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "273", func() screenshotDemoState {
|
||||
return screenshotDemoState{frames: []mujica.ScreenshotFrame{agentDemoFrame(0), agentDemoFrame(1)}}
|
||||
})
|
||||
overlayControls(c, func() {
|
||||
overlayCheck(c, log, &s.disabled, "Disabled")
|
||||
if mujica.Button(c, "Capture a frame", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
s.frames = append(s.frames, agentDemoFrame(len(s.frames)))
|
||||
log(fmt.Sprintf("frame %d arrived", len(s.frames)))
|
||||
}
|
||||
})
|
||||
if mujica.ScreenshotStream(c, &s.st, s.frames, mujica.ScreenshotStreamOptions{Height: 220, Disabled: s.disabled}).Changed() {
|
||||
log(fmt.Sprintf("frame %d, paused = %v", s.st.Index+1, s.st.Paused))
|
||||
}
|
||||
}
|
||||
|
||||
type artifactDemoState struct {
|
||||
kind string
|
||||
disabled bool
|
||||
}
|
||||
|
||||
func artifactPanelDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "274", func() artifactDemoState { return artifactDemoState{kind: "Code"} })
|
||||
overlayControls(c, func() {
|
||||
if ui.Select(c, &s.kind, []string{"Code", "Document"}).Changed() {
|
||||
log("kind = " + s.kind)
|
||||
}
|
||||
overlayCheck(c, log, &s.disabled, "Disabled")
|
||||
})
|
||||
art := mujica.Artifact{Title: "seating.go", Language: "go",
|
||||
Content: "package court\n\n// Seats maps guests to tables.\nfunc Seats() map[string]int {\n\treturn map[string]int{\"Sakiko\": 1, \"Uika\": 2}\n}\n"}
|
||||
opts := mujica.ArtifactPanelOptions{Height: 200, Disabled: s.disabled}
|
||||
if s.kind == "Document" {
|
||||
art = mujica.Artifact{Title: "Invitation", Kind: mujica.ArtifactDocument, Content: "# You are invited\n\nThe masquerade begins at nine.\n"}
|
||||
opts.Preview = func() {
|
||||
mujica.Heading(c, "You are invited", mujica.HeadingOptions{})
|
||||
ui.Text(c, "The masquerade begins at nine.")
|
||||
}
|
||||
}
|
||||
r := mujica.ArtifactPanel(c, art, opts)
|
||||
if r.Copied {
|
||||
log("copied to the clipboard")
|
||||
}
|
||||
if r.Exported {
|
||||
log("export requested")
|
||||
}
|
||||
}
|
||||
|
||||
type versionDemoState struct {
|
||||
current int
|
||||
disabled bool
|
||||
}
|
||||
|
||||
func artifactVersionDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "275", func() versionDemoState { return versionDemoState{current: 2} })
|
||||
overlayControls(c, func() { overlayCheck(c, log, &s.disabled, "Disabled") })
|
||||
versions := []mujica.ArtifactVersion{{Label: "v1", Note: "First draft"}, {Label: "v2", Note: "Added the quartet"}, {Label: "v3", Note: "Moved the wine table"}}
|
||||
r := mujica.ArtifactVersionSwitcher(c, &s.current, versions, mujica.ArtifactVersionSwitcherOptions{Disabled: s.disabled})
|
||||
if r.Changed {
|
||||
log("version = " + versions[s.current].Label)
|
||||
}
|
||||
if r.Compare {
|
||||
log("compare " + versions[s.current-1].Label + " and " + versions[s.current].Label)
|
||||
}
|
||||
}
|
||||
|
||||
type sandboxDemoState struct {
|
||||
state string
|
||||
cpu float64
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
func sandboxStatusDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "276", func() sandboxDemoState {
|
||||
return sandboxDemoState{state: "Running", cpu: 1.2, expires: time.Now().Add(10 * time.Minute)}
|
||||
})
|
||||
names := []string{"Starting", "Running", "Stopped", "Failed"}
|
||||
state := mujica.SandboxStarting
|
||||
overlayControls(c, func() {
|
||||
if ui.Select(c, &s.state, names).Changed() {
|
||||
log("state = " + s.state)
|
||||
}
|
||||
overlaySlider(c, log, &s.cpu, "CPU", "cores", 0, 2, 0.1)
|
||||
if mujica.Button(c, "Extend 5 min", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
s.expires = s.expires.Add(5 * time.Minute)
|
||||
log("extended")
|
||||
}
|
||||
})
|
||||
for i, n := range names {
|
||||
if n == s.state {
|
||||
state = mujica.SandboxState(i)
|
||||
}
|
||||
}
|
||||
mujica.SandboxStatus(c, mujica.SandboxInfo{Name: "sandbox-7", State: state, Expires: s.expires, Resources: []mujica.SandboxResource{
|
||||
{Name: "CPU", Used: s.cpu, Limit: 2, Unit: "cores"}, {Name: "Memory", Used: 3.1, Limit: 4, Unit: "GB"},
|
||||
}}, mujica.SandboxStatusOptions{})
|
||||
}
|
||||
|
||||
type mcpDemoState struct {
|
||||
servers []mujica.MCPServer
|
||||
disabled bool
|
||||
}
|
||||
|
||||
func mcpServerListDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "277", func() mcpDemoState {
|
||||
return mcpDemoState{servers: []mujica.MCPServer{
|
||||
{ID: "fs", Name: "filesystem", Transport: "stdio", State: mujica.MCPConnected, Tools: 12},
|
||||
{ID: "gh", Name: "github", Transport: "http", State: mujica.MCPError, Error: "401 Unauthorized"},
|
||||
{ID: "db", Name: "postgres", Transport: "stdio", State: mujica.MCPStopped},
|
||||
}}
|
||||
})
|
||||
overlayControls(c, func() { overlayCheck(c, log, &s.disabled, "Disabled") })
|
||||
r := mujica.MCPServerList(c, s.servers, mujica.MCPServerListOptions{Disabled: s.disabled})
|
||||
if r.Action == mujica.MCPNone {
|
||||
return
|
||||
}
|
||||
log(fmt.Sprintf("%s: %s", r.Server, [...]string{"", "start", "stop", "reconnect"}[r.Action]))
|
||||
for i := range s.servers {
|
||||
if s.servers[i].ID != r.Server {
|
||||
continue
|
||||
}
|
||||
switch r.Action {
|
||||
case mujica.MCPStop:
|
||||
s.servers[i].State = mujica.MCPStopped
|
||||
default:
|
||||
s.servers[i].State, s.servers[i].Tools, s.servers[i].Error = mujica.MCPConnected, 5, ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type memoryDemoState struct {
|
||||
entries []mujica.MemoryEntry
|
||||
disabled bool
|
||||
}
|
||||
|
||||
func memoryPanelDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "278", func() memoryDemoState {
|
||||
return memoryDemoState{entries: []mujica.MemoryEntry{
|
||||
{ID: "1", Text: "Lady Sakiko prefers the east wing.", Source: "Chat, 3 Oct"},
|
||||
{ID: "2", Text: "The quartet needs two days' notice.", Source: "Plan, 1 Oct"},
|
||||
{ID: "3", Text: "Claret is served after nine.", Source: "Ledger"},
|
||||
}}
|
||||
})
|
||||
overlayControls(c, func() { overlayCheck(c, log, &s.disabled, "Disabled") })
|
||||
r := mujica.MemoryPanel(c, &s.entries, mujica.MemoryPanelOptions{Disabled: s.disabled})
|
||||
if r.Edited != "" {
|
||||
log("edited " + r.Edited)
|
||||
}
|
||||
if r.Deleted != "" {
|
||||
log("deleted " + r.Deleted)
|
||||
}
|
||||
}
|
||||
|
||||
type checkpointDemoState struct {
|
||||
current string
|
||||
disabled bool
|
||||
}
|
||||
|
||||
func checkpointListDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "279", func() checkpointDemoState { return checkpointDemoState{current: "c3"} })
|
||||
overlayControls(c, func() { overlayCheck(c, log, &s.disabled, "Disabled") })
|
||||
points := []mujica.Checkpoint{{ID: "c3", Label: "Seats assigned", Note: "12 files changed"},
|
||||
{ID: "c2", Label: "Menu drafted", Note: "3 files changed"}, {ID: "c1", Label: "Start"}}
|
||||
if mujica.CheckpointList(c, &s.current, points, mujica.CheckpointListOptions{Disabled: s.disabled}).Changed() {
|
||||
log("rolled back to " + s.current)
|
||||
}
|
||||
}
|
||||
|
||||
type humanDemoState struct {
|
||||
answer string
|
||||
free bool
|
||||
deadline time.Time
|
||||
disabled bool
|
||||
}
|
||||
|
||||
func humanInputDemo(c *ui.Context, log func(string)) {
|
||||
s := ui.Local(c.Root(), "280", func() humanDemoState { return humanDemoState{free: true} })
|
||||
overlayControls(c, func() {
|
||||
overlayCheck(c, log, &s.free, "AllowFree")
|
||||
overlayCheck(c, log, &s.disabled, "Disabled")
|
||||
if mujica.Button(c, "Start a 30 s timer", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
|
||||
s.deadline = time.Now().Add(30 * time.Second)
|
||||
log("timer started")
|
||||
}
|
||||
})
|
||||
r := mujica.HumanInputRequest(c, &s.answer, mujica.HumanInputRequestOptions{Question: "Which hall should host the masquerade?",
|
||||
Options: []string{"East wing", "Great hall"}, AllowFree: s.free, Deadline: s.deadline, Disabled: s.disabled})
|
||||
if r.Submitted {
|
||||
log("answer sent: " + s.answer)
|
||||
}
|
||||
if r.TimedOut {
|
||||
log("timed out")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// FileChangeCardOptions configure a FileChangeCard.
|
||||
type FileChangeCardOptions struct {
|
||||
// Preview is the diff lines shown; zero means 8.
|
||||
Preview int
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// FileChangeResult reports this frame's events.
|
||||
type FileChangeResult struct {
|
||||
Element *ui.Element
|
||||
// Decided reports that *decision changed.
|
||||
Decided bool
|
||||
// OpenDiff asks the caller to show the full diff.
|
||||
OpenDiff bool
|
||||
}
|
||||
|
||||
// FileChangeCard previews a file change to accept or reject.
|
||||
func FileChangeCard(c *ui.Context, decision *FileDecision, change FileChange, opts FileChangeCardOptions) FileChangeResult {
|
||||
if decision == nil || change.Path == "" {
|
||||
panic("mujica: FileChangeCard needs a decision and a Path")
|
||||
}
|
||||
if opts.Preview < 0 {
|
||||
panic("mujica: FileChangeCard Preview is negative")
|
||||
}
|
||||
preview := opts.Preview
|
||||
if preview == 0 {
|
||||
preview = 8
|
||||
}
|
||||
u := Density(c).Unit()
|
||||
var r FileChangeResult
|
||||
r.Element = agentCard(c).Disabled(opts.Disabled).Label(change.Path)
|
||||
dc := agentDiffOf(r.Element, change)
|
||||
locked := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).MinHeight(theme.MinHitSize).Children(func() {
|
||||
ui.Icon(c, icons.Must("file-text")).Size(16, 16).TextColor(Tokens(c).TextMuted).Shrink(0)
|
||||
ui.Text(c, change.Path).Font(theme.MonoFont).FontWeight(600).SingleLine().Grow(1).Shrink(1)
|
||||
switch {
|
||||
case change.Old == "" && change.New != "":
|
||||
agentCaption(c, msg(c, "file_change_card.new"))
|
||||
case change.New == "" && change.Old != "":
|
||||
agentCaption(c, msg(c, "file_change_card.deleted"))
|
||||
}
|
||||
agentDiffStats(c, dc.add, dc.del)
|
||||
agentDecisionMark(c, *decision)
|
||||
})
|
||||
from := max(0, dc.firstEdit-2)
|
||||
shown := dc.lines[from:min(len(dc.lines), from+preview)]
|
||||
if len(shown) > 0 {
|
||||
agentDiffView(c, shown, 320).Font(theme.MonoFont)
|
||||
}
|
||||
ui.Row(c).Gap(u * 2).Justify(ui.End).Wrap().Children(func() {
|
||||
if Button(c, msg(c, "file_change_card.open"), ButtonOptions{Variant: Ghost, Disabled: locked}).Clicked() && !locked {
|
||||
r.OpenDiff = true
|
||||
}
|
||||
if *decision != FilePending {
|
||||
if Button(c, msg(c, "file_change_card.undo"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() && !locked {
|
||||
*decision, r.Decided = FilePending, true
|
||||
}
|
||||
return
|
||||
}
|
||||
if Button(c, msg(c, "file_change_card.reject"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() && !locked {
|
||||
*decision, r.Decided = FileRejected, true
|
||||
}
|
||||
if Button(c, msg(c, "file_change_card.accept"), ButtonOptions{Disabled: locked}).Clicked() && !locked {
|
||||
*decision, r.Decided = FileAccepted, true
|
||||
}
|
||||
})
|
||||
})
|
||||
if r.Decided || r.OpenDiff {
|
||||
slog.Debug("mujica: file_change_card event", "path", change.Path, "decision", int(*decision), "open", r.OpenDiff)
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
var fileChangeSample = FileChange{Path: "court/ledger.md",
|
||||
Old: "# Ledger\nGuests: 40\nWine: claret\nMusic: none\n",
|
||||
New: "# Ledger\nGuests: 42\nWine: claret\nMusic: quartet\nFlowers: lilies\n"}
|
||||
|
||||
func fileChangeTester(d *FileDecision, ch FileChange, opts FileChangeCardOptions, got *[]FileChangeResult) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
r := FileChangeCard(c, d, ch, opts)
|
||||
if r.Decided || r.OpenDiff {
|
||||
*got = append(*got, r)
|
||||
}
|
||||
}, 560, 400)
|
||||
}
|
||||
|
||||
func TestFileChangeCardStatsPreviewAndDecide(t *testing.T) {
|
||||
d := FilePending
|
||||
var got []FileChangeResult
|
||||
tt := fileChangeTester(&d, fileChangeSample, FileChangeCardOptions{}, &got)
|
||||
for _, s := range []string{"court/ledger.md", "+3", "−2", "Pending", "Guests: 40", "Guests: 42", "Flowers: lilies"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Fatalf("missing %q in %q", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
_ = tt.Click("Open diff")
|
||||
if len(got) != 1 || !got[0].OpenDiff || d != FilePending {
|
||||
t.Fatalf("open diff: %+v", got)
|
||||
}
|
||||
_ = tt.Click("Accept")
|
||||
if d != FileAccepted || !got[1].Decided || !tt.HasText("Accepted") {
|
||||
t.Fatalf("accept: %v", d)
|
||||
}
|
||||
_ = tt.Click("Undo")
|
||||
if d != FilePending {
|
||||
t.Fatalf("undo: %v", d)
|
||||
}
|
||||
tt = fileChangeTester(&d, fileChangeSample, FileChangeCardOptions{}, &got)
|
||||
for range 3 {
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if tt.Focused("Reject") {
|
||||
break
|
||||
}
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if d != FileRejected || !tt.HasText("Rejected") {
|
||||
t.Fatalf("keyboard reject: %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileChangeCardPreviewLimitAndNewFile(t *testing.T) {
|
||||
d := FilePending
|
||||
var got []FileChangeResult
|
||||
tt := fileChangeTester(&d, FileChange{Path: "a.txt", New: "1\n2\n3\n4\n5\n"}, FileChangeCardOptions{Preview: 2}, &got)
|
||||
if !tt.HasText("New file") || !tt.HasText("+5") || !tt.HasText("2") || tt.HasText("3") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileChangeCardDisabled(t *testing.T) {
|
||||
d := FilePending
|
||||
var got []FileChangeResult
|
||||
tt := fileChangeTester(&d, fileChangeSample, FileChangeCardOptions{Disabled: true}, &got)
|
||||
_ = tt.Click("Accept")
|
||||
_ = tt.Click("Open diff")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(got) != 0 || d != FilePending {
|
||||
t.Fatalf("disabled card acted: %+v %v", got, d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileChangeCardSnapshot(t *testing.T) {
|
||||
snapshot(t, "file_change_card", 520, 330, func(c *ui.Context) {
|
||||
d := FilePending
|
||||
ui.Column(c).Padding(16).Children(func() { FileChangeCard(c, &d, fileChangeSample, FileChangeCardOptions{}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// HumanInputRequestOptions configure a HumanInputRequest. Question is required.
|
||||
type HumanInputRequestOptions struct {
|
||||
Question string
|
||||
// Options are suggested answers; AllowFree adds a free one.
|
||||
Options []string
|
||||
AllowFree bool
|
||||
// Deadline is when the agent stops waiting; zero waits forever.
|
||||
Deadline time.Time
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// HumanInputResult reports this frame's events.
|
||||
type HumanInputResult struct {
|
||||
Element *ui.Element
|
||||
Submitted bool
|
||||
// TimedOut reports the frame the deadline passed.
|
||||
TimedOut bool
|
||||
}
|
||||
|
||||
type humanInputState struct {
|
||||
draft string
|
||||
expired bool
|
||||
}
|
||||
|
||||
// HumanInputRequest asks the user a question before a deadline.
|
||||
func HumanInputRequest(c *ui.Context, answer *string, opts HumanInputRequestOptions) HumanInputResult {
|
||||
if answer == nil || opts.Question == "" {
|
||||
panic("mujica: HumanInputRequest needs an answer and a Question")
|
||||
}
|
||||
if len(opts.Options) == 0 && !opts.AllowFree {
|
||||
panic("mujica: HumanInputRequest needs Options or AllowFree")
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
var r HumanInputResult
|
||||
r.Element = agentCard(c).Border(theme.BorderWidth, k.Info).Role(ui.RoleGroup).Label(opts.Question)
|
||||
st := ui.Local(r.Element, "mujica.humaninput", func() humanInputState { return humanInputState{} })
|
||||
left := time.Duration(0)
|
||||
if !opts.Deadline.IsZero() {
|
||||
left = opts.Deadline.Sub(c.Now())
|
||||
if left <= 0 && !st.expired {
|
||||
st.expired, r.TimedOut = true, true
|
||||
}
|
||||
}
|
||||
r.Element.Disabled(opts.Disabled || st.expired)
|
||||
locked := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Children(func() {
|
||||
ui.Icon(c, icons.Must("user")).Size(18, 18).TextColor(k.Info).Shrink(0).Margin(2, 0, 0, 0)
|
||||
ui.Text(c, opts.Question).FontWeight(600).Grow(1).Shrink(1)
|
||||
})
|
||||
if len(opts.Options) > 0 {
|
||||
items := make([]RadioItem[string], len(opts.Options))
|
||||
for i, o := range opts.Options {
|
||||
items[i] = RadioItem[string]{Value: o, Label: o}
|
||||
}
|
||||
chosen := *answer
|
||||
if RadioGroup(c, &chosen, items, RadioGroupOptions{Label: opts.Question, Disabled: locked}).Changed() && !locked {
|
||||
*answer, st.draft = chosen, ""
|
||||
}
|
||||
}
|
||||
submit := false
|
||||
if opts.AllowFree {
|
||||
in := TextInput(c, &st.draft, TextInputOptions{Placeholder: msg(c, "human_input_request.free"),
|
||||
Label: msg(c, "human_input_request.free"), Disabled: locked})
|
||||
if in.Changed() && !locked {
|
||||
*answer = st.draft
|
||||
}
|
||||
submit = in.Submitted()
|
||||
}
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
humanInputClock(c, opts.Deadline, left, st.expired)
|
||||
blank := strings.TrimSpace(*answer) == ""
|
||||
if Button(c, msg(c, "human_input_request.send"), ButtonOptions{Disabled: locked || blank}).Clicked() {
|
||||
submit = true
|
||||
}
|
||||
r.Submitted = submit && !locked && !blank
|
||||
})
|
||||
})
|
||||
if len(opts.Options) > 0 && !opts.AllowFree && *answer != "" && !slices.Contains(opts.Options, *answer) {
|
||||
panic(fmt.Sprintf("mujica: HumanInputRequest answer %q is not an option", *answer))
|
||||
}
|
||||
if r.Submitted || r.TimedOut {
|
||||
slog.Debug("mujica: human_input_request event", "submitted", r.Submitted, "timed_out", r.TimedOut)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// humanInputClock shows the time left, or that it ran out.
|
||||
func humanInputClock(c *ui.Context, deadline time.Time, left time.Duration, expired bool) {
|
||||
k := Tokens(c)
|
||||
text, tone := "", k.TextMuted
|
||||
switch {
|
||||
case expired:
|
||||
text, tone = msg(c, "human_input_request.timeout"), k.Warning
|
||||
case !deadline.IsZero():
|
||||
text = fmt.Sprintf(msg(c, "human_input_request.left"), agentDuration(left.Truncate(time.Second)))
|
||||
wait := left % time.Second
|
||||
if wait == 0 {
|
||||
wait = time.Second
|
||||
}
|
||||
c.After(wait)
|
||||
}
|
||||
ui.Row(c).Grow(1).Gap(Density(c).Unit()).AlignItems(ui.Center).Role(ui.RoleStatus).Label(text).Children(func() {
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
ui.Icon(c, icons.Must("clock")).Size(14, 14).TextColor(tone)
|
||||
ui.Text(c, text).TextColor(tone).FontSize(FontSize(c, theme.CaptionSize+1))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func humanTester(answer *string, opts *HumanInputRequestOptions, got *[]HumanInputResult) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
r := HumanInputRequest(c, answer, *opts)
|
||||
if r.Submitted || r.TimedOut {
|
||||
*got = append(*got, r)
|
||||
}
|
||||
}, 560, 400)
|
||||
}
|
||||
|
||||
func TestHumanInputRequestOptionAndFree(t *testing.T) {
|
||||
answer := ""
|
||||
opts := HumanInputRequestOptions{Question: "Which hall for the ball?", Options: []string{"East wing", "Great hall"}, AllowFree: true}
|
||||
var got []HumanInputResult
|
||||
tt := humanTester(&answer, &opts, &got)
|
||||
_ = tt.Click("Send answer") // disabled while blank
|
||||
if len(got) != 0 {
|
||||
t.Fatal("blank answer sent")
|
||||
}
|
||||
_ = tt.Click("Great hall")
|
||||
if answer != "Great hall" {
|
||||
t.Fatalf("option: %q", answer)
|
||||
}
|
||||
_ = tt.Click("Or write your own answer")
|
||||
tt.Type("The garden")
|
||||
if answer != "The garden" {
|
||||
t.Fatalf("free: %q", answer)
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(got) != 1 || !got[0].Submitted {
|
||||
t.Fatalf("Enter: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanInputRequestDeadline(t *testing.T) {
|
||||
answer := "East wing"
|
||||
opts := HumanInputRequestOptions{Question: "Which hall?", Options: []string{"East wing"}, Deadline: time.Now().Add(75 * time.Second)}
|
||||
var got []HumanInputResult
|
||||
tt := humanTester(&answer, &opts, &got)
|
||||
found := false
|
||||
for _, s := range tt.Texts() {
|
||||
found = found || strings.HasPrefix(s, "Answer within 1 m 1")
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("countdown missing: %q", tt.Texts())
|
||||
}
|
||||
opts.Deadline = time.Now().Add(-time.Second)
|
||||
tt.Frame()
|
||||
tt.Frame()
|
||||
if len(got) != 1 || !got[0].TimedOut || !tt.HasText("The agent stopped waiting") {
|
||||
t.Fatalf("timeout: %+v", got)
|
||||
}
|
||||
_ = tt.Click("Send answer")
|
||||
if len(got) != 1 {
|
||||
t.Fatal("answer sent after the deadline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanInputRequestDisabledAndRules(t *testing.T) {
|
||||
answer := ""
|
||||
opts := HumanInputRequestOptions{Question: "Which hall?", Options: []string{"East wing"}, Disabled: true}
|
||||
var got []HumanInputResult
|
||||
tt := humanTester(&answer, &opts, &got)
|
||||
_ = tt.Click("East wing")
|
||||
if answer != "" {
|
||||
t.Fatal("disabled request changed the answer")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no options and no free answer did not panic")
|
||||
}
|
||||
}()
|
||||
humanTester(&answer, &HumanInputRequestOptions{Question: "x"}, &got)
|
||||
}
|
||||
|
||||
func TestHumanInputRequestSnapshot(t *testing.T) {
|
||||
snapshot(t, "human_input_request", 520, 300, func(c *ui.Context) {
|
||||
answer := "Great hall"
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
HumanInputRequest(c, &answer, HumanInputRequestOptions{Question: "Which hall should host the masquerade?",
|
||||
Options: []string{"East wing", "Great hall"}, AllowFree: true})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package internal
|
||||
|
||||
import "strings"
|
||||
|
||||
// DiffOp says whether a diff line is kept, removed or added.
|
||||
// DiffOp says a line is kept, removed or added.
|
||||
type DiffOp uint8
|
||||
|
||||
const (
|
||||
@@ -11,14 +11,14 @@ const (
|
||||
DiffInsert
|
||||
)
|
||||
|
||||
// DiffLine is one line of a line diff with its 1-based line numbers; 0 means absent.
|
||||
// DiffLine is a numbered diff line; 0 means absent.
|
||||
type DiffLine struct {
|
||||
Op DiffOp
|
||||
Text string
|
||||
Old, New int
|
||||
}
|
||||
|
||||
// SplitLines splits text into lines without their line breaks; "" has no lines.
|
||||
// SplitLines splits text into lines; "" has none.
|
||||
func SplitLines(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
@@ -26,7 +26,7 @@ func SplitLines(s string) []string {
|
||||
return strings.Split(strings.TrimSuffix(s, "\n"), "\n")
|
||||
}
|
||||
|
||||
// LineDiff returns a shortest edit script turning a into b (Myers, O((N+M)D)).
|
||||
// LineDiff returns a shortest edit script from a to b.
|
||||
func LineDiff(a, b []string) []DiffLine {
|
||||
// Common prefix and suffix need no search.
|
||||
pre := 0
|
||||
@@ -49,7 +49,7 @@ func LineDiff(a, b []string) []DiffLine {
|
||||
return out
|
||||
}
|
||||
|
||||
// myers appends the edit script of a to b, numbering from offsets ao and bo.
|
||||
// myers appends the edit script, numbering from ao and bo.
|
||||
func myers(out []DiffLine, a, b []string, ao, bo int) []DiffLine {
|
||||
n, m := len(a), len(b)
|
||||
total := n + m
|
||||
@@ -80,7 +80,7 @@ func myers(out []DiffLine, a, b []string, ao, bo int) []DiffLine {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Walk the trace back from (n, m), collecting moves in reverse.
|
||||
// Walk the trace back, collecting moves in reverse.
|
||||
var rev []DiffLine
|
||||
x, y := n, m
|
||||
for d := end; d >= 0; d-- {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// MCPState is a server connection; zero is stopped.
|
||||
type MCPState int
|
||||
|
||||
const (
|
||||
MCPStopped MCPState = iota
|
||||
MCPConnecting
|
||||
MCPConnected
|
||||
MCPError
|
||||
)
|
||||
|
||||
// MCPServer is one configured MCP server; the caller owns it.
|
||||
type MCPServer struct {
|
||||
ID string
|
||||
Name string
|
||||
Transport string
|
||||
State MCPState
|
||||
Tools int
|
||||
// Error explains MCPError.
|
||||
Error string
|
||||
}
|
||||
|
||||
// MCPAction is a list request; zero is none.
|
||||
type MCPAction int
|
||||
|
||||
const (
|
||||
MCPNone MCPAction = iota
|
||||
MCPStart
|
||||
MCPStop
|
||||
MCPReconnect
|
||||
)
|
||||
|
||||
// MCPServerListOptions configure an MCPServerList.
|
||||
type MCPServerListOptions struct {
|
||||
// MaxHeight caps the scrolling list; zero means 360.
|
||||
MaxHeight float32
|
||||
Disabled bool
|
||||
Still bool
|
||||
}
|
||||
|
||||
// MCPServerListResult reports this frame's request.
|
||||
type MCPServerListResult struct {
|
||||
Element *ui.Element
|
||||
Action MCPAction
|
||||
// Server is the ID the action is for.
|
||||
Server string
|
||||
}
|
||||
|
||||
// MCPServerList lists MCP servers with start, stop and reconnect.
|
||||
func MCPServerList(c *ui.Context, servers []MCPServer, opts MCPServerListOptions) MCPServerListResult {
|
||||
seen := map[string]bool{}
|
||||
for i, s := range servers {
|
||||
if s.ID == "" || s.Name == "" || seen[s.ID] || s.Tools < 0 || s.State < MCPStopped || s.State > MCPError {
|
||||
panic(fmt.Sprintf("mujica: MCPServerList server %d needs a unique ID, a Name, a known State and Tools >= 0", i))
|
||||
}
|
||||
seen[s.ID] = true
|
||||
}
|
||||
if opts.MaxHeight < 0 {
|
||||
panic("mujica: MCPServerList MaxHeight is negative")
|
||||
}
|
||||
maxH := opts.MaxHeight
|
||||
if maxH == 0 {
|
||||
maxH = 360
|
||||
}
|
||||
var r MCPServerListResult
|
||||
r.Element = ui.Column(c).Shrink(0).Disabled(opts.Disabled).Label(msg(c, "mcp_server_list.label"))
|
||||
locked := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
if len(servers) == 0 {
|
||||
agentCaption(c, msg(c, "mcp_server_list.empty"))
|
||||
return
|
||||
}
|
||||
k := Tokens(c)
|
||||
agentRows(c, len(servers), func(i int) {
|
||||
if a := mcpServerRow(c, servers[i], locked, opts.Still); a != MCPNone && !locked {
|
||||
r.Action, r.Server = a, servers[i].ID
|
||||
}
|
||||
}).Shrink(1).MaxHeight(maxH).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius).
|
||||
Dividers(theme.BorderWidth, k.Border)
|
||||
})
|
||||
if r.Action != MCPNone {
|
||||
slog.Debug("mujica: mcp_server_list action", "server", r.Server, "action", int(r.Action))
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// mcpServerRow builds one server, returning its pressed action.
|
||||
func mcpServerRow(c *ui.Context, s MCPServer, locked, still bool) MCPAction {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
u := d.Unit()
|
||||
words := [...]string{"stopped", "connecting", "connected", "error"}
|
||||
word := msg(c, "mcp_server_list."+words[s.State])
|
||||
act := MCPNone
|
||||
ui.Row(c).Key(s.ID).Gap(u*3).AlignItems(ui.Center).MinHeight(d.ControlHeight()+u*2).Padding(u*2, u*3).
|
||||
Label(s.Name + ", " + word).Children(func() {
|
||||
switch s.State {
|
||||
case MCPConnecting:
|
||||
agentMark(c, AgentRunning, 16, still)
|
||||
case MCPConnected:
|
||||
agentMark(c, AgentDone, 16, still)
|
||||
case MCPError:
|
||||
agentMark(c, AgentFailed, 16, still)
|
||||
default:
|
||||
ui.Icon(c, icons.Must("wifi-off")).Size(16, 16).TextColor(k.TextMuted)
|
||||
}
|
||||
ui.Column(c).Grow(1).Shrink(1).Gap(u / 2).Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, s.Name).FontWeight(600).SingleLine()
|
||||
agentCaption(c, s.Transport).Font(theme.MonoFont)
|
||||
})
|
||||
line := word
|
||||
if s.State == MCPConnected {
|
||||
line += " · " + fmt.Sprintf(msg(c, "mcp_server_list.tools"), s.Tools)
|
||||
}
|
||||
agentCaption(c, line)
|
||||
if s.State == MCPError && s.Error != "" {
|
||||
ui.Text(c, s.Error).TextColor(k.Danger).FontSize(FontSize(c, theme.CaptionSize)).Selectable()
|
||||
}
|
||||
})
|
||||
if s.State == MCPError && Button(c, msg(c, "mcp_server_list.reconnect"), ButtonOptions{Variant: Outline, Disabled: locked}).Clicked() {
|
||||
act = MCPReconnect
|
||||
}
|
||||
if s.State == MCPStopped {
|
||||
if Button(c, msg(c, "mcp_server_list.start"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() {
|
||||
act = MCPStart
|
||||
}
|
||||
} else if Button(c, msg(c, "mcp_server_list.stop"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() {
|
||||
act = MCPStop
|
||||
}
|
||||
})
|
||||
return act
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
var mcpServersSample = []MCPServer{
|
||||
{ID: "fs", Name: "filesystem", Transport: "stdio", State: MCPConnected, Tools: 12},
|
||||
{ID: "gh", Name: "github", Transport: "http", State: MCPError, Error: "401 Unauthorized"},
|
||||
{ID: "db", Name: "postgres", Transport: "stdio", State: MCPStopped},
|
||||
{ID: "web", Name: "browser", Transport: "sse", State: MCPConnecting},
|
||||
}
|
||||
|
||||
func mcpTester(servers []MCPServer, opts MCPServerListOptions, got *[]MCPServerListResult) *ui.Tester {
|
||||
opts.Still = true
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
r := MCPServerList(c, servers, opts)
|
||||
if r.Action != MCPNone {
|
||||
*got = append(*got, r)
|
||||
}
|
||||
}, 600, 460)
|
||||
}
|
||||
|
||||
func TestMCPServerListStatesAndActions(t *testing.T) {
|
||||
var got []MCPServerListResult
|
||||
tt := mcpTester(mcpServersSample, MCPServerListOptions{}, &got)
|
||||
for _, s := range []string{"Connected · 12 tools", "Connection failed", "401 Unauthorized", "Stopped", "Connecting"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Fatalf("missing %q in %q", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
_ = tt.Click("Reconnect")
|
||||
_ = tt.Click("Start")
|
||||
if len(got) != 2 || got[0].Action != MCPReconnect || got[0].Server != "gh" || got[1].Action != MCPStart || got[1].Server != "db" {
|
||||
t.Fatalf("actions %+v", got)
|
||||
}
|
||||
tt = mcpTester(mcpServersSample, MCPServerListOptions{}, &got)
|
||||
tt.Key(0, ui.KeyTab) // filesystem's Stop
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(got) != 3 || got[2].Action != MCPStop || got[2].Server != "fs" {
|
||||
t.Fatalf("keyboard stop: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPServerListDisabledAndEmpty(t *testing.T) {
|
||||
var got []MCPServerListResult
|
||||
tt := mcpTester(mcpServersSample, MCPServerListOptions{Disabled: true}, &got)
|
||||
_ = tt.Click("Reconnect")
|
||||
_ = tt.Click("Start")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("disabled list acted: %+v", got)
|
||||
}
|
||||
tt = mcpTester(nil, MCPServerListOptions{}, &got)
|
||||
if !tt.HasText("No MCP servers") {
|
||||
t.Fatalf("empty: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPServerListManyServersVirtualized(t *testing.T) {
|
||||
servers := make([]MCPServer, 100000)
|
||||
for i := range servers {
|
||||
servers[i] = MCPServer{ID: fmt.Sprint(i), Name: fmt.Sprintf("server-%d", i), State: MCPConnected, Tools: i % 30}
|
||||
}
|
||||
var got []MCPServerListResult
|
||||
tt := mcpTester(servers, MCPServerListOptions{}, &got)
|
||||
if n := len(tt.Texts()); n > 300 {
|
||||
t.Fatalf("%d texts built for 100000 servers", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPServerListRejectsDuplicateIDs(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
var got []MCPServerListResult
|
||||
mcpTester([]MCPServer{{ID: "a", Name: "x"}, {ID: "a", Name: "y"}}, MCPServerListOptions{}, &got)
|
||||
}
|
||||
|
||||
func TestMCPServerListSnapshot(t *testing.T) {
|
||||
snapshot(t, "mcp_server_list", 560, 330, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { MCPServerList(c, mcpServersSample, MCPServerListOptions{Still: true}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// MemoryEntry is one memory; the caller persists it.
|
||||
type MemoryEntry struct {
|
||||
ID string
|
||||
Text string
|
||||
// Source says where it came from, as "Chat, 3 Oct".
|
||||
Source string
|
||||
}
|
||||
|
||||
// MemoryPanelOptions configure a MemoryPanel.
|
||||
type MemoryPanelOptions struct {
|
||||
// MaxHeight caps the scrolling list; zero means 360.
|
||||
MaxHeight float32
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// MemoryPanelResult reports this frame's in-place edits.
|
||||
type MemoryPanelResult struct {
|
||||
Element *ui.Element
|
||||
// Edited and Deleted are changed IDs, else "".
|
||||
Edited string
|
||||
Deleted string
|
||||
}
|
||||
|
||||
type memoryPanelState struct {
|
||||
query, editing, draft string
|
||||
confirm string
|
||||
// The filter reruns only when query or entries change.
|
||||
key string
|
||||
shown []int
|
||||
}
|
||||
|
||||
// MemoryPanel lists memories with search, inline edit and confirmed delete.
|
||||
func MemoryPanel(c *ui.Context, entries *[]MemoryEntry, opts MemoryPanelOptions) MemoryPanelResult {
|
||||
if entries == nil {
|
||||
panic("mujica: MemoryPanel needs entries")
|
||||
}
|
||||
if opts.MaxHeight < 0 {
|
||||
panic("mujica: MemoryPanel MaxHeight is negative")
|
||||
}
|
||||
maxH := opts.MaxHeight
|
||||
if maxH == 0 {
|
||||
maxH = 360
|
||||
}
|
||||
u := Density(c).Unit()
|
||||
var r MemoryPanelResult
|
||||
r.Element = agentCard(c).Disabled(opts.Disabled).Label(msg(c, "memory_panel.title"))
|
||||
st := ui.Local(r.Element, "mujica.memory", func() memoryPanelState { return memoryPanelState{} })
|
||||
locked := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, msg(c, "memory_panel.title")).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).Grow(1)
|
||||
agentCaption(c, fmt.Sprintf(msg(c, "memory_panel.count"), len(*entries)))
|
||||
})
|
||||
SearchInput(c, &st.query, SearchInputOptions{Placeholder: msg(c, "memory_panel.search"), Disabled: locked})
|
||||
memoryFilter(st, *entries)
|
||||
if len(st.shown) == 0 {
|
||||
agentCaption(c, msg(c, "memory_panel.empty"))
|
||||
return
|
||||
}
|
||||
agentRows(c, len(st.shown), func(i int) {
|
||||
idx := st.shown[i]
|
||||
ui.Column(c).Key((*entries)[idx].ID).Children(func() { memoryRow(c, entries, idx, st, locked, &r) })
|
||||
}).Shrink(1).MaxHeight(maxH).Dividers(theme.BorderWidth, Tokens(c).Border)
|
||||
})
|
||||
if r.Deleted != "" {
|
||||
for i, e := range *entries {
|
||||
if e.ID == r.Deleted {
|
||||
*entries = append((*entries)[:i:i], (*entries)[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
st.key = "" // refilter
|
||||
}
|
||||
if r.Edited != "" || r.Deleted != "" {
|
||||
slog.Debug("mujica: memory_panel change", "edited", r.Edited, "deleted", r.Deleted)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// memoryFilter recomputes matches when inputs change.
|
||||
func memoryFilter(st *memoryPanelState, entries []MemoryEntry) {
|
||||
key := fmt.Sprintf("%s\x00%d", st.query, len(entries))
|
||||
if len(entries) > 0 {
|
||||
key += fmt.Sprintf("\x00%p", &entries[0])
|
||||
}
|
||||
if key == st.key && st.shown != nil {
|
||||
return
|
||||
}
|
||||
st.key = key
|
||||
q := strings.ToLower(strings.TrimSpace(st.query))
|
||||
st.shown = st.shown[:0]
|
||||
for i, e := range entries {
|
||||
if q == "" || strings.Contains(strings.ToLower(e.Text), q) || strings.Contains(strings.ToLower(e.Source), q) {
|
||||
st.shown = append(st.shown, i)
|
||||
}
|
||||
}
|
||||
if st.shown == nil {
|
||||
st.shown = []int{}
|
||||
}
|
||||
}
|
||||
|
||||
// memoryRow shows one entry or its editor.
|
||||
func memoryRow(c *ui.Context, entries *[]MemoryEntry, idx int, st *memoryPanelState, locked bool, r *MemoryPanelResult) {
|
||||
e := &(*entries)[idx]
|
||||
if e.ID == "" {
|
||||
panic(fmt.Sprintf("mujica: MemoryPanel entry %d needs an ID", idx))
|
||||
}
|
||||
u := Density(c).Unit()
|
||||
ui.Column(c).Gap(u).Padding(u*2, 0).Children(func() {
|
||||
if st.editing == e.ID && !locked {
|
||||
TextArea(c, &st.draft, TextAreaOptions{Label: msg(c, "memory_panel.edit"), MinHeight: 60}).Input.AutoFocus()
|
||||
ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() {
|
||||
if Button(c, msg(c, "memory_panel.cancel"), ButtonOptions{Variant: Secondary}).Clicked() {
|
||||
st.editing = ""
|
||||
}
|
||||
blank := strings.TrimSpace(st.draft) == ""
|
||||
if Button(c, msg(c, "memory_panel.save"), ButtonOptions{Disabled: blank}).Clicked() && !blank {
|
||||
e.Text, st.editing, st.key = strings.TrimSpace(st.draft), "", ""
|
||||
r.Edited = e.ID
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
ui.Text(c, e.Text).Selectable()
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
agentCaption(c, e.Source).Grow(1)
|
||||
if agentToggle(c, msg(c, "memory_panel.edit"), "", locked) {
|
||||
st.editing, st.draft = e.ID, e.Text
|
||||
}
|
||||
del := ui.Row(c)
|
||||
del.Children(func() {
|
||||
if agentToggle(c, msg(c, "memory_panel.delete"), "trash-2", locked) {
|
||||
st.confirm = e.ID
|
||||
}
|
||||
})
|
||||
open := st.confirm == e.ID && !locked
|
||||
res := Popconfirm(c, del, &open, PopconfirmOptions{Title: msg(c, "memory_panel.confirm"), Description: e.Text,
|
||||
ConfirmLabel: msg(c, "memory_panel.delete"), Danger: true})
|
||||
if st.confirm == e.ID && !open {
|
||||
st.confirm = ""
|
||||
}
|
||||
if res.Confirmed {
|
||||
r.Deleted = e.ID
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func memorySample() []MemoryEntry {
|
||||
return []MemoryEntry{
|
||||
{ID: "1", Text: "Lady Sakiko prefers the east wing.", Source: "Chat, 3 Oct"},
|
||||
{ID: "2", Text: "The quartet needs two days' notice.", Source: "Plan, 1 Oct"},
|
||||
{ID: "3", Text: "Claret is served after nine.", Source: "Ledger"},
|
||||
}
|
||||
}
|
||||
|
||||
func memoryTester(entries *[]MemoryEntry, opts MemoryPanelOptions, got *[]MemoryPanelResult) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
r := MemoryPanel(c, entries, opts)
|
||||
if r.Edited != "" || r.Deleted != "" {
|
||||
*got = append(*got, r)
|
||||
}
|
||||
}, 560, 560)
|
||||
}
|
||||
|
||||
func TestMemoryPanelSearchEditDelete(t *testing.T) {
|
||||
entries := memorySample()
|
||||
var got []MemoryPanelResult
|
||||
tt := memoryTester(&entries, MemoryPanelOptions{}, &got)
|
||||
if !tt.HasText("3 entries") || !tt.HasText("Claret is served after nine.") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Search memory")
|
||||
tt.Type("quartet")
|
||||
if tt.HasText("Claret is served after nine.") || !tt.HasText("The quartet needs two days' notice.") {
|
||||
t.Fatalf("search: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Edit")
|
||||
tt.Key(ui.Cmd, ui.KeyA)
|
||||
tt.Type("The quartet needs a week's notice.")
|
||||
_ = tt.Click("Save")
|
||||
if entries[1].Text != "The quartet needs a week's notice." || len(got) != 1 || got[0].Edited != "2" {
|
||||
t.Fatalf("edit: %+v %+v", entries[1], got)
|
||||
}
|
||||
_ = tt.Click("Delete")
|
||||
if !tt.HasText("Forget this memory?") || len(entries) != 3 {
|
||||
t.Fatal("delete did not ask first")
|
||||
}
|
||||
_ = tt.Click("Cancel")
|
||||
if len(entries) != 3 {
|
||||
t.Fatal("cancel deleted")
|
||||
}
|
||||
_ = tt.Click("Delete")
|
||||
tt.Key(0, ui.KeyTab) // Cancel has the focus; Tab to Delete
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(entries) != 2 || got[1].Deleted != "2" || !tt.HasText("No memories match") {
|
||||
t.Fatalf("delete: %+v %+v %q", entries, got, tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryPanelDisabled(t *testing.T) {
|
||||
entries := memorySample()
|
||||
var got []MemoryPanelResult
|
||||
tt := memoryTester(&entries, MemoryPanelOptions{Disabled: true}, &got)
|
||||
_ = tt.Click("Edit")
|
||||
_ = tt.Click("Delete")
|
||||
if tt.HasText("Save") || tt.HasText("Forget this memory?") || len(got) != 0 {
|
||||
t.Fatal("disabled panel acted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryPanelManyEntriesVirtualized(t *testing.T) {
|
||||
entries := make([]MemoryEntry, 100000)
|
||||
for i := range entries {
|
||||
entries[i] = MemoryEntry{ID: fmt.Sprint(i), Text: fmt.Sprintf("fact %d", i), Source: "import"}
|
||||
}
|
||||
var got []MemoryPanelResult
|
||||
tt := memoryTester(&entries, MemoryPanelOptions{}, &got)
|
||||
if n := len(tt.Texts()); n > 300 {
|
||||
t.Fatalf("%d texts built for 100000 entries", n)
|
||||
}
|
||||
_ = tt.Click("Search memory")
|
||||
tt.Type("fact 99999")
|
||||
if !tt.HasText("fact 99999") || tt.HasText("fact 0") {
|
||||
t.Fatal("search over many entries failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryPanelSnapshot(t *testing.T) {
|
||||
snapshot(t, "memory_panel", 520, 360, func(c *ui.Context) {
|
||||
entries := memorySample()
|
||||
ui.Column(c).Padding(16).Children(func() { MemoryPanel(c, &entries, MemoryPanelOptions{}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package mujica
|
||||
|
||||
func init() {
|
||||
addMessages(map[string][2]string{
|
||||
"agent_status.pending": {"Pending", "待开始"},
|
||||
"agent_status.planning": {"Planning", "规划中"},
|
||||
"agent_status.running": {"Running", "执行中"},
|
||||
"agent_status.waiting": {"Waiting", "等待中"},
|
||||
"agent_status.done": {"Done", "已完成"},
|
||||
"agent_status.failed": {"Failed", "失败"},
|
||||
"agent_status.skipped": {"Skipped", "已跳过"},
|
||||
|
||||
"tool_call_card.args": {"Arguments", "参数"},
|
||||
"tool_call_card.result": {"Result", "结果"},
|
||||
"tool_call_card.error": {"Error", "错误"},
|
||||
|
||||
"tool_call_group.count": {"%d tool calls", "%d 次工具调用"},
|
||||
"tool_call_group.failed": {"%d failed", "%d 次失败"},
|
||||
|
||||
"tool_approval_dialog.title": {"Allow this tool to run?", "允许运行此工具?"},
|
||||
"tool_approval_dialog.tool": {"Tool", "工具"},
|
||||
"tool_approval_dialog.allow": {"Allow", "允许"},
|
||||
"tool_approval_dialog.deny": {"Deny", "拒绝"},
|
||||
|
||||
"permission_prompt.deny": {"Deny", "拒绝"},
|
||||
"permission_prompt.always": {"Always allow", "始终允许"},
|
||||
"permission_prompt.once": {"Allow once", "允许一次"},
|
||||
"permission_prompt.scope": {"Always allowing covers", "始终允许的范围"},
|
||||
|
||||
"agent_step_list.current": {"Current", "当前"},
|
||||
"agent_step_list.retry": {"Retry", "重试"},
|
||||
|
||||
"agent_progress.label": {"Progress", "进度"},
|
||||
"sub_agent_tree.children": {"%d sub-agents", "%d 个子智能体"},
|
||||
|
||||
"file_change_card.stats": {"%d added, %d removed lines", "新增 %d 行,删除 %d 行"},
|
||||
"file_change_card.pending": {"Pending", "待审阅"},
|
||||
"file_change_card.accepted": {"Accepted", "已接受"},
|
||||
"file_change_card.rejected": {"Rejected", "已拒绝"},
|
||||
"file_change_card.new": {"New file", "新文件"},
|
||||
"file_change_card.deleted": {"Deleted", "已删除"},
|
||||
"file_change_card.open": {"Open diff", "打开差异"},
|
||||
"file_change_card.undo": {"Undo", "撤销"},
|
||||
"file_change_card.reject": {"Reject", "拒绝"},
|
||||
"file_change_card.accept": {"Accept", "接受"},
|
||||
|
||||
"multi_file_diff_review.title": {"Review changes", "审阅变更"},
|
||||
"multi_file_diff_review.progress": {"%d of %d files reviewed", "已审阅 %d / %d 个文件"},
|
||||
"multi_file_diff_review.accept_all": {"Accept all", "全部接受"},
|
||||
"multi_file_diff_review.empty": {"No changes to review", "没有需要审阅的变更"},
|
||||
|
||||
"command_execution_card.abort": {"Abort", "中止"},
|
||||
"command_execution_card.exit": {"Exit %d", "退出码 %d"},
|
||||
"command_execution_card.output": {"Output", "输出"},
|
||||
"command_execution_card.no_output": {"No output", "没有输出"},
|
||||
|
||||
"screenshot_stream.label": {"Agent screen", "智能体画面"},
|
||||
"screenshot_stream.waiting": {"Waiting for frames", "等待画面"},
|
||||
"screenshot_stream.prev": {"Previous frame", "上一帧"},
|
||||
"screenshot_stream.next": {"Next frame", "下一帧"},
|
||||
"screenshot_stream.pause": {"Pause", "暂停"},
|
||||
"screenshot_stream.live": {"Go live", "回到实时"},
|
||||
"screenshot_stream.counter": {"Frame %d of %d", "第 %d / %d 帧"},
|
||||
"screenshot_stream.following": {"Live", "实时"},
|
||||
|
||||
"artifact_panel.copy": {"Copy", "复制"},
|
||||
"artifact_panel.copied": {"Copied", "已复制"},
|
||||
"artifact_panel.export": {"Export", "导出"},
|
||||
"artifact_panel.preview": {"Preview", "预览"},
|
||||
"artifact_panel.source": {"Source", "源码"},
|
||||
|
||||
"artifact_version_switcher.label": {"Versions", "版本"},
|
||||
"artifact_version_switcher.older": {"Older version", "较早版本"},
|
||||
"artifact_version_switcher.newer": {"Newer version", "较新版本"},
|
||||
"artifact_version_switcher.count": {"%d of %d", "%d / %d"},
|
||||
"artifact_version_switcher.compare": {"Compare with %s", "与 %s 比较"},
|
||||
|
||||
"sandbox_status.starting": {"Starting", "启动中"},
|
||||
"sandbox_status.running": {"Running", "运行中"},
|
||||
"sandbox_status.stopped": {"Stopped", "已停止"},
|
||||
"sandbox_status.failed": {"Failed", "故障"},
|
||||
"sandbox_status.high": {"Near the limit", "接近上限"},
|
||||
"sandbox_status.left": {"%s left", "剩余 %s"},
|
||||
"sandbox_status.expired": {"Time is up", "已到时"},
|
||||
|
||||
"mcp_server_list.label": {"MCP servers", "MCP 服务器"},
|
||||
"mcp_server_list.empty": {"No MCP servers", "没有 MCP 服务器"},
|
||||
"mcp_server_list.stopped": {"Stopped", "已停止"},
|
||||
"mcp_server_list.connecting": {"Connecting", "连接中"},
|
||||
"mcp_server_list.connected": {"Connected", "已连接"},
|
||||
"mcp_server_list.error": {"Connection failed", "连接失败"},
|
||||
"mcp_server_list.tools": {"%d tools", "%d 个工具"},
|
||||
"mcp_server_list.start": {"Start", "启动"},
|
||||
"mcp_server_list.stop": {"Stop", "停止"},
|
||||
"mcp_server_list.reconnect": {"Reconnect", "重新连接"},
|
||||
|
||||
"memory_panel.title": {"Memory", "记忆"},
|
||||
"memory_panel.count": {"%d entries", "%d 条"},
|
||||
"memory_panel.search": {"Search memory", "搜索记忆"},
|
||||
"memory_panel.empty": {"No memories match", "没有匹配的记忆"},
|
||||
"memory_panel.edit": {"Edit", "编辑"},
|
||||
"memory_panel.save": {"Save", "保存"},
|
||||
"memory_panel.cancel": {"Cancel", "取消"},
|
||||
"memory_panel.delete": {"Delete", "删除"},
|
||||
"memory_panel.confirm": {"Forget this memory?", "删除这条记忆?"},
|
||||
|
||||
"checkpoint_list.label": {"Checkpoints", "检查点"},
|
||||
"checkpoint_list.empty": {"No checkpoints yet", "暂无检查点"},
|
||||
"checkpoint_list.current": {"Current", "当前"},
|
||||
"checkpoint_list.rollback": {"Roll back", "回滚"},
|
||||
"checkpoint_list.confirm": {"Roll back to “%s”?", "回滚到“%s”?"},
|
||||
"checkpoint_list.warning": {"Changes made after this checkpoint will be discarded.", "此检查点之后的更改将被丢弃。"},
|
||||
|
||||
"human_input_request.free": {"Or write your own answer", "或填写你的回答"},
|
||||
"human_input_request.send": {"Send answer", "发送回答"},
|
||||
"human_input_request.left": {"Answer within %s", "请在 %s 内回答"},
|
||||
"human_input_request.timeout": {"The agent stopped waiting", "智能体已停止等待"},
|
||||
|
||||
"agent_plan.title": {"Plan", "计划"},
|
||||
"agent_plan.progress": {"%d / %d done", "已完成 %d / %d"},
|
||||
"agent_plan.doing": {"In progress", "进行中"},
|
||||
"agent_plan.skip": {"Skip", "跳过"},
|
||||
"agent_plan.restore": {"Restore", "恢复"},
|
||||
"agent_plan.add": {"Add", "添加"},
|
||||
"agent_plan.placeholder": {"Add a step", "新增一项"},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// MultiFileDiffReviewOptions configure a MultiFileDiffReview.
|
||||
type MultiFileDiffReviewOptions struct {
|
||||
// Height is 420 DIPs when zero.
|
||||
Height float32
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type multiFileDiffState struct{ selected int }
|
||||
|
||||
// MultiFileDiffReview reviews many file changes, one diff at a time.
|
||||
func MultiFileDiffReview(c *ui.Context, decisions *[]FileDecision, files []FileChange, opts MultiFileDiffReviewOptions) ChoiceResult {
|
||||
if decisions == nil || len(*decisions) != len(files) {
|
||||
panic("mujica: MultiFileDiffReview needs one decision per file")
|
||||
}
|
||||
if opts.Height < 0 {
|
||||
panic("mujica: MultiFileDiffReview Height is negative")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
reviewed := 0
|
||||
for i, f := range files {
|
||||
if f.Path == "" || seen[f.Path] {
|
||||
panic(fmt.Sprintf("mujica: MultiFileDiffReview file %d needs a unique Path", i))
|
||||
}
|
||||
seen[f.Path] = true
|
||||
reviewed += agentB2I((*decisions)[i] != FilePending)
|
||||
}
|
||||
h := opts.Height
|
||||
if h == 0 {
|
||||
h = 420
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
var r ChoiceResult
|
||||
r.Element = agentCard(c).Height(h).Disabled(opts.Disabled).Label(msg(c, "multi_file_diff_review.title"))
|
||||
st := ui.Local(r.Element, "mujica.mfdr", func() multiFileDiffState { return multiFileDiffState{} })
|
||||
st.selected = max(0, min(st.selected, len(files)-1))
|
||||
locked := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
ui.Row(c).Gap(u * 3).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, msg(c, "multi_file_diff_review.title")).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600)
|
||||
agentCaption(c, fmt.Sprintf(msg(c, "multi_file_diff_review.progress"), reviewed, len(files))).Grow(1)
|
||||
all := reviewed < len(files)
|
||||
if Button(c, msg(c, "multi_file_diff_review.accept_all"), ButtonOptions{Variant: Secondary, Disabled: locked || !all}).Clicked() && !locked && all {
|
||||
for i := range *decisions {
|
||||
if (*decisions)[i] == FilePending {
|
||||
(*decisions)[i] = FileAccepted
|
||||
}
|
||||
}
|
||||
r.changed = true
|
||||
}
|
||||
})
|
||||
frac := 0.0
|
||||
if len(files) > 0 {
|
||||
frac = float64(reviewed) / float64(len(files))
|
||||
}
|
||||
agentBar(c, frac, fmt.Sprintf(msg(c, "multi_file_diff_review.progress"), reviewed, len(files)))
|
||||
if len(files) == 0 {
|
||||
agentCaption(c, msg(c, "multi_file_diff_review.empty"))
|
||||
return
|
||||
}
|
||||
ui.Row(c).Grow(1).Shrink(1).Gap(u * 3).AlignItems(ui.Stretch).Children(func() {
|
||||
row := func(i int) {
|
||||
if multiFileRow(c, files[i], (*decisions)[i], i == st.selected) && !locked {
|
||||
st.selected = i
|
||||
slog.Debug("mujica: multi_file_diff_review select", "path", files[i].Path)
|
||||
}
|
||||
}
|
||||
var list *ui.Element
|
||||
if len(files) > agentVirtualRows {
|
||||
list = ui.List(c, nil, len(files), row)
|
||||
} else {
|
||||
list = ui.Scroll(c).Children(func() {
|
||||
for i := range files {
|
||||
row(i)
|
||||
}
|
||||
})
|
||||
}
|
||||
list.Width(240).Shrink(0).FocusGroup(ui.Vertical).Border(theme.BorderWidth, k.Border).
|
||||
Radius(theme.ControlRadius).Padding(u)
|
||||
ui.Column(c).Grow(1).Shrink(1).Gap(u * 2).Children(func() {
|
||||
i := st.selected
|
||||
if fileChangeDecide(c, &(*decisions)[i], files[i], locked) {
|
||||
r.changed = true
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
if r.changed {
|
||||
slog.Debug("mujica: multi_file_diff_review decide", "reviewed", reviewed)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// multiFileRow is one listed file; it reports a choice.
|
||||
func multiFileRow(c *ui.Context, f FileChange, d FileDecision, selected bool) bool {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
row := ui.ButtonBase(c).Key(f.Path).Justify(ui.Start).Gap(u*2).MinHeight(Density(c).ControlHeight()).
|
||||
Padding(0, u*2).Radius(theme.ControlRadius).Label(f.Path)
|
||||
internal.Ring(row, k.Focus, theme.ControlRadius)
|
||||
dc := agentDiffOf(row, f)
|
||||
switch {
|
||||
case selected:
|
||||
row.Background(k.Selection).DrawOver(func(p *ui.Painter, r ui.Rect) {
|
||||
p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 2, H: r.H - 8}, k.AccentText, 1)
|
||||
})
|
||||
case row.Hovered():
|
||||
row.Background(k.SurfaceHover)
|
||||
}
|
||||
row.Children(func() {
|
||||
agentDecisionIcon(c, d)
|
||||
ui.Text(c, f.Path).Font(theme.MonoFont).FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine().Grow(1).Shrink(1)
|
||||
agentDiffStats(c, dc.add, dc.del)
|
||||
})
|
||||
return row.Clicked()
|
||||
}
|
||||
|
||||
// fileChangeDecide shows a full diff and its decision buttons.
|
||||
func fileChangeDecide(c *ui.Context, d *FileDecision, f FileChange, locked bool) bool {
|
||||
u := Density(c).Unit()
|
||||
changed := false
|
||||
holder := ui.Column(c).Key(f.Path).Grow(1).Shrink(1).Gap(u * 2)
|
||||
dc := agentDiffOf(holder, f)
|
||||
holder.Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, f.Path).Font(theme.MonoFont).FontWeight(600).SingleLine().Grow(1).Shrink(1)
|
||||
agentDecisionMark(c, *d)
|
||||
})
|
||||
agentDiffView(c, dc.lines, 1e5).Grow(1).Font(theme.MonoFont)
|
||||
ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() {
|
||||
if *d != FilePending {
|
||||
if Button(c, msg(c, "file_change_card.undo"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() && !locked {
|
||||
*d, changed = FilePending, true
|
||||
}
|
||||
return
|
||||
}
|
||||
if Button(c, msg(c, "file_change_card.reject"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() && !locked {
|
||||
*d, changed = FileRejected, true
|
||||
}
|
||||
if Button(c, msg(c, "file_change_card.accept"), ButtonOptions{Disabled: locked}).Clicked() && !locked {
|
||||
*d, changed = FileAccepted, true
|
||||
}
|
||||
})
|
||||
})
|
||||
return changed
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func reviewFiles() []FileChange {
|
||||
return []FileChange{
|
||||
fileChangeSample,
|
||||
{Path: "court/menu.md", Old: "soup\nroast\n", New: "soup\nroast\ntart\n"},
|
||||
{Path: "court/old.md", Old: "gone\n"},
|
||||
}
|
||||
}
|
||||
|
||||
func reviewTester(d *[]FileDecision, files []FileChange, opts MultiFileDiffReviewOptions, changes *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Padding(8).Children(func() {
|
||||
if MultiFileDiffReview(c, d, files, opts).Changed() {
|
||||
*changes++
|
||||
}
|
||||
})
|
||||
}, 760, 520)
|
||||
}
|
||||
|
||||
func TestMultiFileDiffReviewPerFileAndAll(t *testing.T) {
|
||||
files := reviewFiles()
|
||||
d := make([]FileDecision, len(files))
|
||||
changes := 0
|
||||
tt := reviewTester(&d, files, MultiFileDiffReviewOptions{}, &changes)
|
||||
if !tt.HasText("0 of 3 files reviewed") || !tt.HasText("Guests: 42") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("court/menu.md")
|
||||
if !tt.HasText("tart") || tt.HasText("Guests: 42") {
|
||||
t.Fatal("choosing a file did not show its diff")
|
||||
}
|
||||
_ = tt.Click("Reject")
|
||||
if d[1] != FileRejected || changes != 1 || !tt.HasText("1 of 3 files reviewed") {
|
||||
t.Fatalf("reject: %v", d)
|
||||
}
|
||||
_ = tt.Click("Accept all")
|
||||
if d[0] != FileAccepted || d[1] != FileRejected || d[2] != FileAccepted || !tt.HasText("3 of 3 files reviewed") {
|
||||
t.Fatalf("accept all: %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiFileDiffReviewKeyboard(t *testing.T) {
|
||||
files := reviewFiles()
|
||||
d := make([]FileDecision, len(files))
|
||||
n := 0
|
||||
tt := reviewTester(&d, files, MultiFileDiffReviewOptions{}, &n)
|
||||
_ = tt.Click("court/ledger.md")
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if !tt.HasText("gone") || tt.HasText("Guests: 42") {
|
||||
t.Fatalf("keyboard choice: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiFileDiffReviewDisabledAndEmpty(t *testing.T) {
|
||||
files := reviewFiles()
|
||||
d := make([]FileDecision, len(files))
|
||||
n := 0
|
||||
tt := reviewTester(&d, files, MultiFileDiffReviewOptions{Disabled: true}, &n)
|
||||
_ = tt.Click("Accept all")
|
||||
_ = tt.Click("Accept")
|
||||
_ = tt.Click("court/menu.md")
|
||||
if n != 0 || d[0] != FilePending || tt.HasText("tart") {
|
||||
t.Fatalf("disabled review acted: %v", d)
|
||||
}
|
||||
var none []FileDecision
|
||||
tt = reviewTester(&none, nil, MultiFileDiffReviewOptions{}, &n)
|
||||
if !tt.HasText("No changes to review") {
|
||||
t.Fatalf("empty: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiFileDiffReviewManyFilesVirtualized(t *testing.T) {
|
||||
files := make([]FileChange, 100000)
|
||||
for i := range files {
|
||||
files[i] = FileChange{Path: fmt.Sprintf("f%06d.txt", i), Old: "a\n", New: "b\n"}
|
||||
}
|
||||
d := make([]FileDecision, len(files))
|
||||
n := 0
|
||||
tt := reviewTester(&d, files, MultiFileDiffReviewOptions{}, &n)
|
||||
if got := len(tt.Texts()); got > 400 {
|
||||
t.Fatalf("%d texts built for 100000 files", got)
|
||||
}
|
||||
if !tt.HasText("f000000.txt") {
|
||||
t.Fatal("first file missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiFileDiffReviewNeedsMatchingDecisions(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
d := []FileDecision{}
|
||||
n := 0
|
||||
reviewTester(&d, reviewFiles(), MultiFileDiffReviewOptions{}, &n)
|
||||
}
|
||||
|
||||
func TestMultiFileDiffReviewSnapshot(t *testing.T) {
|
||||
snapshot(t, "multi_file_diff_review", 760, 420, func(c *ui.Context) {
|
||||
files := reviewFiles()
|
||||
d := []FileDecision{FilePending, FileAccepted, FileRejected}
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
MultiFileDiffReview(c, &d, files, MultiFileDiffReviewOptions{Height: 390})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// PermissionChoice answers a PermissionPrompt; zero is no answer.
|
||||
type PermissionChoice int
|
||||
|
||||
const (
|
||||
PermissionNone PermissionChoice = iota
|
||||
PermissionAllowOnce
|
||||
PermissionAllowAlways
|
||||
PermissionDeny
|
||||
)
|
||||
|
||||
// PermissionPromptOptions configure a PermissionPrompt. Title and Scope are required.
|
||||
type PermissionPromptOptions struct {
|
||||
Title string
|
||||
Description string
|
||||
// Scope says what "always" covers, as "Write access to ~/court".
|
||||
Scope string
|
||||
}
|
||||
|
||||
// PermissionResult reports the choice made in this frame.
|
||||
type PermissionResult struct {
|
||||
Element *ui.Element
|
||||
Choice PermissionChoice
|
||||
}
|
||||
|
||||
// PermissionPrompt asks allow once, always, or deny; Deny has focus.
|
||||
func PermissionPrompt(c *ui.Context, open *bool, opts PermissionPromptOptions) PermissionResult {
|
||||
if opts.Title == "" || opts.Scope == "" {
|
||||
panic("mujica: PermissionPrompt needs a Title and a Scope")
|
||||
}
|
||||
var r PermissionResult
|
||||
was := *open
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
r.Element = Dialog(c, open, DialogOptions{
|
||||
Title: opts.Title, Description: opts.Description, KeepOnBackdrop: true, HideClose: true,
|
||||
Actions: func() {
|
||||
if Button(c, msg(c, "permission_prompt.deny"), ButtonOptions{Variant: Secondary}).AutoFocus().Clicked() {
|
||||
r.Choice = PermissionDeny
|
||||
}
|
||||
if Button(c, msg(c, "permission_prompt.always"), ButtonOptions{Variant: Outline}).Clicked() {
|
||||
r.Choice = PermissionAllowAlways
|
||||
}
|
||||
if Button(c, msg(c, "permission_prompt.once"), ButtonOptions{}).Clicked() {
|
||||
r.Choice = PermissionAllowOnce
|
||||
}
|
||||
},
|
||||
}, func() {
|
||||
ui.Row(c).Gap(u*2).AlignItems(ui.Start).Padding(u*2, u*3).Background(k.Background).Radius(theme.ControlRadius).
|
||||
Border(1, k.Border).Children(func() {
|
||||
ui.Icon(c, icons.Must("lock")).Size(16, 16).TextColor(k.Info).Shrink(0).Margin(2, 0, 0, 0)
|
||||
ui.Column(c).Gap(u / 2).Shrink(1).Children(func() {
|
||||
agentCaption(c, msg(c, "permission_prompt.scope"))
|
||||
ui.Text(c, opts.Scope)
|
||||
})
|
||||
})
|
||||
})
|
||||
if was && !*open && r.Choice == PermissionNone {
|
||||
r.Choice = PermissionDeny // Escape
|
||||
}
|
||||
if r.Choice != PermissionNone {
|
||||
slog.Debug("mujica: permission_prompt choose", "choice", int(r.Choice))
|
||||
*open = false
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func permissionTester(open *bool, got *[]PermissionChoice) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if Button(c, "Ask", ButtonOptions{}).Clicked() {
|
||||
*open = true
|
||||
}
|
||||
r := PermissionPrompt(c, open, PermissionPromptOptions{Title: "Write to the ledger?", Scope: "Write access to ~/court/ledger"})
|
||||
if r.Choice != PermissionNone {
|
||||
*got = append(*got, r.Choice)
|
||||
}
|
||||
}, 600, 400)
|
||||
}
|
||||
|
||||
func TestPermissionPromptChoices(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
tabs int
|
||||
want PermissionChoice
|
||||
}{{0, PermissionDeny}, {1, PermissionAllowAlways}, {2, PermissionAllowOnce}} {
|
||||
open := false
|
||||
var got []PermissionChoice
|
||||
tt := permissionTester(&open, &got)
|
||||
_ = tt.Click("Ask")
|
||||
if !tt.Focused("Deny") || !tt.HasText("Write access to ~/court/ledger") {
|
||||
t.Fatalf("Deny focused %v, texts %q", tt.Focused("Deny"), tt.Texts())
|
||||
}
|
||||
for range tc.tabs {
|
||||
tt.Key(0, ui.KeyTab)
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(got) != 1 || got[0] != tc.want || open {
|
||||
t.Fatalf("tabs %d: got %v open %v", tc.tabs, got, open)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionPromptEscapeAndBackdrop(t *testing.T) {
|
||||
open := false
|
||||
var got []PermissionChoice
|
||||
tt := permissionTester(&open, &got)
|
||||
_ = tt.Click("Ask")
|
||||
tt.ClickAt(5, 395)
|
||||
if !open || len(got) != 0 {
|
||||
t.Fatal("backdrop acted")
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if len(got) != 1 || got[0] != PermissionDeny {
|
||||
t.Fatalf("Escape gave %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionPromptNeedsScope(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
open := true
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
PermissionPrompt(c, &open, PermissionPromptOptions{Title: "x"})
|
||||
}, 200, 200)
|
||||
}
|
||||
|
||||
func TestPermissionPromptSnapshot(t *testing.T) {
|
||||
snapshot(t, "permission_prompt", 600, 340, func(c *ui.Context) {
|
||||
open := true
|
||||
PermissionPrompt(c, &open, PermissionPromptOptions{Title: "Write to the ledger?",
|
||||
Description: "The agent wants to record tonight's guests.", Scope: "Write access to ~/court/ledger and its subfolders"})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// SandboxState is a sandbox's lifecycle. The zero value is SandboxStarting.
|
||||
type SandboxState int
|
||||
|
||||
const (
|
||||
SandboxStarting SandboxState = iota
|
||||
SandboxRunning
|
||||
SandboxStopped
|
||||
SandboxFailed
|
||||
)
|
||||
|
||||
// SandboxResource is one metered resource, as CPU or memory.
|
||||
type SandboxResource struct {
|
||||
Name string
|
||||
Used, Limit float64
|
||||
// Unit follows the numbers, as "GB" or "cores".
|
||||
Unit string
|
||||
}
|
||||
|
||||
// SandboxInfo is the sandbox the caller reports.
|
||||
type SandboxInfo struct {
|
||||
Name string
|
||||
State SandboxState
|
||||
Resources []SandboxResource
|
||||
// Expires is the shutdown time; zero means none.
|
||||
Expires time.Time
|
||||
}
|
||||
|
||||
// SandboxStatusOptions configure a SandboxStatus.
|
||||
type SandboxStatusOptions struct {
|
||||
Still bool
|
||||
}
|
||||
|
||||
// SandboxStatus shows sandbox state, resources and time left.
|
||||
func SandboxStatus(c *ui.Context, info SandboxInfo, opts SandboxStatusOptions) *ui.Element {
|
||||
agent := map[SandboxState]AgentState{SandboxStarting: AgentPlanning, SandboxRunning: AgentRunning, SandboxStopped: AgentSkipped, SandboxFailed: AgentFailed}
|
||||
state, ok := agent[info.State]
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("mujica: unknown SandboxState %d", int(info.State)))
|
||||
}
|
||||
for _, r := range info.Resources {
|
||||
if r.Name == "" || !(r.Limit > 0) || r.Used < 0 || math.IsNaN(r.Used) || r.Used > r.Limit {
|
||||
panic(fmt.Sprintf("mujica: SandboxStatus resource %q needs a name and 0 <= Used <= Limit", r.Name))
|
||||
}
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
word := msg(c, "sandbox_status."+[...]string{"starting", "running", "stopped", "failed"}[info.State])
|
||||
card := agentCard(c).Label(info.Name + ", " + word)
|
||||
card.Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, info.Name).FontWeight(600).SingleLine().Grow(1).Shrink(1)
|
||||
AgentStatus(c, state, AgentStatusOptions{Label: word, Still: opts.Still})
|
||||
})
|
||||
for _, r := range info.Resources {
|
||||
frac := r.Used / r.Limit
|
||||
ui.Column(c).Gap(u).Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
agentCaption(c, r.Name).Grow(1)
|
||||
if frac >= 0.9 {
|
||||
ui.Icon(c, icons.Must("triangle-alert")).Size(12, 12).TextColor(k.Warning).Label(msg(c, "sandbox_status.high"))
|
||||
}
|
||||
ui.Text(c, fmt.Sprintf("%s / %s %s", sandboxNum(r.Used), sandboxNum(r.Limit), r.Unit)).
|
||||
FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").TextColor(k.Text)
|
||||
})
|
||||
agentBar(c, frac, fmt.Sprintf("%s %.0f%%", r.Name, frac*100))
|
||||
})
|
||||
}
|
||||
if !info.Expires.IsZero() && info.State != SandboxStopped && info.State != SandboxFailed {
|
||||
left := info.Expires.Sub(c.Now())
|
||||
text := msg(c, "sandbox_status.expired")
|
||||
if left > 0 {
|
||||
text = fmt.Sprintf(msg(c, "sandbox_status.left"), agentDuration(left.Truncate(time.Second)))
|
||||
wait := left % time.Second
|
||||
if wait == 0 {
|
||||
wait = time.Second
|
||||
}
|
||||
c.After(wait)
|
||||
}
|
||||
ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).Role(ui.RoleStatus).Label(text).Children(func() {
|
||||
ui.Icon(c, icons.Must("clock")).Size(14, 14).TextColor(k.TextMuted)
|
||||
agentCaption(c, text)
|
||||
})
|
||||
}
|
||||
})
|
||||
return card
|
||||
}
|
||||
|
||||
// sandboxNum prints a resource amount with at most one decimal.
|
||||
func sandboxNum(v float64) string {
|
||||
if v == math.Trunc(v) {
|
||||
return fmt.Sprintf("%.0f", v)
|
||||
}
|
||||
return fmt.Sprintf("%.1f", v)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func sandboxSample(expires time.Time) SandboxInfo {
|
||||
return SandboxInfo{Name: "sandbox-7", State: SandboxRunning, Expires: expires, Resources: []SandboxResource{
|
||||
{Name: "CPU", Used: 1.5, Limit: 2, Unit: "cores"},
|
||||
{Name: "Memory", Used: 3.8, Limit: 4, Unit: "GB"},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestSandboxStatusResourcesAndCountdown(t *testing.T) {
|
||||
info := sandboxSample(time.Now().Add(90 * time.Second))
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
SandboxStatus(c, info, SandboxStatusOptions{Still: true})
|
||||
}, 480, 300)
|
||||
for _, s := range []string{"sandbox-7", "Running", "1.5 / 2 cores", "3.8 / 4 GB", "Near the limit"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Fatalf("missing %q in %q", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
left := ""
|
||||
for _, s := range tt.Texts() {
|
||||
if strings.HasSuffix(s, " left") {
|
||||
left = s
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(left, "1 m ") {
|
||||
t.Fatalf("countdown %q", left)
|
||||
}
|
||||
info.Expires = time.Now().Add(-time.Second)
|
||||
tt.Frame()
|
||||
if !tt.HasText("Time is up") {
|
||||
t.Fatalf("expired: %q", tt.Texts())
|
||||
}
|
||||
info.State = SandboxStopped
|
||||
tt.Frame()
|
||||
if !tt.HasText("Stopped") || tt.HasText("Time is up") {
|
||||
t.Fatalf("stopped: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxStatusRejectsBadResource(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
SandboxStatus(c, SandboxInfo{Name: "x", Resources: []SandboxResource{{Name: "CPU", Used: 3, Limit: 2}}}, SandboxStatusOptions{})
|
||||
}, 200, 200)
|
||||
}
|
||||
|
||||
func TestSandboxStatusSnapshot(t *testing.T) {
|
||||
snapshot(t, "sandbox_status", 440, 230, func(c *ui.Context) {
|
||||
info := sandboxSample(time.Time{})
|
||||
info.Resources = append(info.Resources, SandboxResource{Name: "Disk", Used: 12, Limit: 40, Unit: "GB"})
|
||||
ui.Column(c).Padding(16).Children(func() { SandboxStatus(c, info, SandboxStatusOptions{Still: true}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ScreenshotFrame is one caller-supplied frame of the agent's screen.
|
||||
type ScreenshotFrame struct {
|
||||
Image ui.ImageSource
|
||||
Caption string
|
||||
// At is the time since the run started.
|
||||
At time.Duration
|
||||
}
|
||||
|
||||
// ScreenshotStreamState is the shown frame; unpaused, it follows the newest.
|
||||
type ScreenshotStreamState struct {
|
||||
Index int
|
||||
Paused bool
|
||||
}
|
||||
|
||||
// ScreenshotStreamOptions configure a ScreenshotStream.
|
||||
type ScreenshotStreamOptions struct {
|
||||
// Height of the viewport; 280 DIPs when zero.
|
||||
Height float32
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// ScreenshotStream shows screen frames live, paused or stepped.
|
||||
func ScreenshotStream(c *ui.Context, st *ScreenshotStreamState, frames []ScreenshotFrame, opts ScreenshotStreamOptions) ChoiceResult {
|
||||
if st == nil {
|
||||
panic("mujica: ScreenshotStream needs a state")
|
||||
}
|
||||
if opts.Height < 0 {
|
||||
panic("mujica: ScreenshotStream Height is negative")
|
||||
}
|
||||
for i, f := range frames {
|
||||
if f.Image == nil {
|
||||
panic(fmt.Sprintf("mujica: ScreenshotStream frame %d has no Image", i))
|
||||
}
|
||||
}
|
||||
h := opts.Height
|
||||
if h == 0 {
|
||||
h = 280
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
n := len(frames)
|
||||
if st.Index < 0 {
|
||||
panic(fmt.Sprintf("mujica: ScreenshotStream Index %d is negative", st.Index))
|
||||
}
|
||||
if !st.Paused || st.Index >= n { // frames may be dropped while paused
|
||||
|
||||
st.Index = max(0, n-1)
|
||||
}
|
||||
var r ChoiceResult
|
||||
r.Element = agentCard(c).Disabled(opts.Disabled).Label(msg(c, "screenshot_stream.label"))
|
||||
locked := r.Element.IsDisabled()
|
||||
step := func(d int) {
|
||||
if n == 0 || locked {
|
||||
return
|
||||
}
|
||||
st.Index = max(0, min(n-1, st.Index+d))
|
||||
st.Paused, r.changed = true, true
|
||||
}
|
||||
r.Element.Children(func() {
|
||||
view := ui.Box(c).FillWidth().Height(h).Center().Clip().Radius(theme.ControlRadius).
|
||||
Background(k.Background).Border(theme.BorderWidth, k.Border).Focusable().
|
||||
Label(msg(c, "screenshot_stream.label"))
|
||||
internal.Ring(view, k.Focus, theme.ControlRadius)
|
||||
view.Children(func() {
|
||||
if n == 0 {
|
||||
agentCaption(c, msg(c, "screenshot_stream.waiting"))
|
||||
return
|
||||
}
|
||||
ui.Image(c, frames[st.Index].Image).Fill().Fit(ui.Contain).Label(frames[st.Index].Caption)
|
||||
})
|
||||
if !locked {
|
||||
switch {
|
||||
case view.Shortcut(0, ui.KeyLeft):
|
||||
step(-1)
|
||||
case view.Shortcut(0, ui.KeyRight):
|
||||
step(1)
|
||||
case view.Shortcut(0, ui.KeySpace):
|
||||
st.Paused, r.changed = !st.Paused, true
|
||||
}
|
||||
}
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
if iconAction(c, icons.Must("chevron-left"), msg(c, "screenshot_stream.prev")).Disabled(locked || st.Index == 0).Clicked() {
|
||||
step(-1)
|
||||
}
|
||||
pause := msg(c, "screenshot_stream.pause")
|
||||
if st.Paused {
|
||||
pause = msg(c, "screenshot_stream.live")
|
||||
}
|
||||
if Button(c, pause, ButtonOptions{Variant: Secondary, Disabled: locked || n == 0}).Clicked() && !locked {
|
||||
st.Paused, r.changed = !st.Paused, true
|
||||
}
|
||||
if iconAction(c, icons.Must("chevron-right"), msg(c, "screenshot_stream.next")).Disabled(locked || st.Index >= n-1).Clicked() {
|
||||
step(1)
|
||||
}
|
||||
counter := fmt.Sprintf(msg(c, "screenshot_stream.counter"), min(st.Index+1, n), n)
|
||||
agentCaption(c, counter).Role(ui.RoleStatus)
|
||||
if n > 0 {
|
||||
f := frames[st.Index]
|
||||
ui.Text(c, f.Caption).SingleLine().Grow(1).Shrink(1)
|
||||
agentCaption(c, agentDuration(f.At))
|
||||
}
|
||||
if !st.Paused && n > 0 {
|
||||
AgentStatus(c, AgentRunning, AgentStatusOptions{Label: msg(c, "screenshot_stream.following"), Still: true})
|
||||
}
|
||||
})
|
||||
})
|
||||
if r.changed {
|
||||
slog.Debug("mujica: screenshot_stream move", "index", st.Index, "paused", st.Paused)
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// screenshotFrames makes n solid frames of shifting colors.
|
||||
func screenshotFrames(n int) []ScreenshotFrame {
|
||||
out := make([]ScreenshotFrame, n)
|
||||
for i := range out {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 160, 90))
|
||||
for p := 0; p < len(img.Pix); p += 4 {
|
||||
img.Pix[p], img.Pix[p+1], img.Pix[p+2], img.Pix[p+3] = uint8(60+i*40), 90, uint8(160-i*20), 255
|
||||
}
|
||||
img.Set(10, 10, color.White)
|
||||
out[i] = ScreenshotFrame{Image: ui.NewBitmap(img), Caption: fmt.Sprintf("Step %d", i+1), At: time.Duration(i) * time.Second}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func screenshotTester(st *ScreenshotStreamState, frames *[]ScreenshotFrame, opts ScreenshotStreamOptions, changes *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if ScreenshotStream(c, st, *frames, opts).Changed() {
|
||||
*changes++
|
||||
}
|
||||
}, 600, 420)
|
||||
}
|
||||
|
||||
func TestScreenshotStreamFollowsPausesAndSteps(t *testing.T) {
|
||||
var st ScreenshotStreamState
|
||||
frames := screenshotFrames(3)
|
||||
changes := 0
|
||||
tt := screenshotTester(&st, &frames, ScreenshotStreamOptions{}, &changes)
|
||||
if st.Index != 2 || !tt.HasText("Frame 3 of 3") || !tt.HasText("Live") {
|
||||
t.Fatalf("live: %+v %q", st, tt.Texts())
|
||||
}
|
||||
frames = append(frames, screenshotFrames(4)[3])
|
||||
tt.Frame()
|
||||
if st.Index != 3 || !tt.HasText("Step 4") {
|
||||
t.Fatalf("new frame not followed: %+v", st)
|
||||
}
|
||||
_ = tt.Click("Previous frame")
|
||||
if !st.Paused || st.Index != 2 || changes != 1 || !tt.HasText("Step 3") {
|
||||
t.Fatalf("step back: %+v", st)
|
||||
}
|
||||
frames = append(frames, screenshotFrames(5)[4])
|
||||
tt.Frame()
|
||||
if st.Index != 2 {
|
||||
t.Fatal("paused viewer moved")
|
||||
}
|
||||
_ = tt.Click("Go live")
|
||||
if st.Paused || st.Index != 4 {
|
||||
t.Fatalf("go live: %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenshotStreamKeyboard(t *testing.T) {
|
||||
var st ScreenshotStreamState
|
||||
frames := screenshotFrames(3)
|
||||
n := 0
|
||||
tt := screenshotTester(&st, &frames, ScreenshotStreamOptions{}, &n)
|
||||
tt.Key(0, ui.KeyTab) // the viewport
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
tt.Key(0, ui.KeyLeft) // stops at the first
|
||||
if st.Index != 0 || !st.Paused {
|
||||
t.Fatalf("left: %+v", st)
|
||||
}
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if st.Paused || st.Index != 2 {
|
||||
t.Fatalf("space: %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenshotStreamEmptyAndDisabled(t *testing.T) {
|
||||
var st ScreenshotStreamState
|
||||
var frames []ScreenshotFrame
|
||||
n := 0
|
||||
tt := screenshotTester(&st, &frames, ScreenshotStreamOptions{}, &n)
|
||||
if !tt.HasText("Waiting for frames") || !tt.HasText("Frame 0 of 0") {
|
||||
t.Fatalf("empty: %q", tt.Texts())
|
||||
}
|
||||
frames = screenshotFrames(3)
|
||||
st = ScreenshotStreamState{}
|
||||
tt = screenshotTester(&st, &frames, ScreenshotStreamOptions{Disabled: true}, &n)
|
||||
_ = tt.Click("Previous frame")
|
||||
_ = tt.Click("Pause")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
if n != 0 || st.Paused || st.Index != 2 {
|
||||
t.Fatalf("disabled moved: %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenshotStreamSnapshot(t *testing.T) {
|
||||
snapshot(t, "screenshot_stream", 520, 330, func(c *ui.Context) {
|
||||
st := ScreenshotStreamState{Index: 1, Paused: true}
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
ScreenshotStream(c, &st, screenshotFrames(3), ScreenshotStreamOptions{Height: 200})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// SubAgent is one agent in a hierarchy.
|
||||
type SubAgent struct {
|
||||
ID string
|
||||
Name string
|
||||
Task string
|
||||
State AgentState
|
||||
Children []SubAgent
|
||||
}
|
||||
|
||||
// SubAgentTreeOptions configure a SubAgentTree.
|
||||
type SubAgentTreeOptions struct {
|
||||
Disabled bool
|
||||
Still bool
|
||||
}
|
||||
|
||||
// SubAgentTree shows nested agents and their states.
|
||||
func SubAgentTree(c *ui.Context, roots []SubAgent, opts SubAgentTreeOptions) ChoiceResult {
|
||||
seen := map[string]bool{}
|
||||
var check func([]SubAgent)
|
||||
check = func(list []SubAgent) {
|
||||
for _, a := range list {
|
||||
if a.ID == "" || a.Name == "" || seen[a.ID] {
|
||||
panic(fmt.Sprintf("mujica: SubAgentTree agent %q needs a unique ID and a Name", a.ID))
|
||||
}
|
||||
seen[a.ID] = true
|
||||
check(a.Children)
|
||||
}
|
||||
}
|
||||
check(roots)
|
||||
var r ChoiceResult
|
||||
r.Element = ui.Column(c).Shrink(0).FocusGroup(ui.Vertical).Disabled(opts.Disabled)
|
||||
open := ui.Local(r.Element, "mujica.subagents.open", func() map[string]bool { return map[string]bool{} })
|
||||
r.Element.Children(func() {
|
||||
var rows func([]SubAgent, int)
|
||||
rows = func(list []SubAgent, depth int) {
|
||||
for _, a := range list {
|
||||
if subAgentRow(c, a, depth, (*open)[a.ID], opts.Still) {
|
||||
(*open)[a.ID] = !(*open)[a.ID]
|
||||
r.changed = true
|
||||
slog.Debug("mujica: sub_agent_tree toggle", "id", a.ID, "open", (*open)[a.ID])
|
||||
}
|
||||
if (*open)[a.ID] {
|
||||
rows(a.Children, depth+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
rows(roots, 0)
|
||||
})
|
||||
for id := range *open {
|
||||
if !seen[id] {
|
||||
delete(*open, id)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// subAgentRow builds one row, reporting an expand or collapse.
|
||||
func subAgentRow(c *ui.Context, a SubAgent, depth int, open, still bool) bool {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
u := d.Unit()
|
||||
parent := len(a.Children) > 0
|
||||
row := ui.ButtonBase(c).Key(a.ID).Justify(ui.Start).Gap(u*2).MinHeight(d.ControlHeight()).
|
||||
Padding(0, u*2, 0, u*2+float32(depth)*u*5).Radius(theme.ControlRadius).
|
||||
Label(a.Name + ", " + agentWord(c, a.State))
|
||||
internal.Ring(row, k.Focus, theme.ControlRadius)
|
||||
if row.Hovered() {
|
||||
row.Background(k.SurfaceHover)
|
||||
}
|
||||
row.Children(func() {
|
||||
chev := ui.Box(c).Size(14, 14).Shrink(0)
|
||||
if parent {
|
||||
name := "chevron-right"
|
||||
if open {
|
||||
name = "chevron-down"
|
||||
}
|
||||
chev.Children(func() { ui.Icon(c, icons.Must(name)).Size(14, 14).TextColor(k.TextMuted) })
|
||||
}
|
||||
agentMark(c, a.State, 16, still)
|
||||
ui.Text(c, a.Name).FontWeight(600).SingleLine().Shrink(0)
|
||||
ui.Text(c, a.Task).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine().Grow(1).Shrink(1)
|
||||
agentCaption(c, agentWord(c, a.State))
|
||||
if parent {
|
||||
agentCaption(c, fmt.Sprintf(msg(c, "sub_agent_tree.children"), len(a.Children)))
|
||||
}
|
||||
})
|
||||
if !parent || row.IsDisabled() {
|
||||
return false
|
||||
}
|
||||
return row.Clicked() || !open && row.Shortcut(0, ui.KeyRight) || open && row.Shortcut(0, ui.KeyLeft)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
var subAgentSample = []SubAgent{
|
||||
{ID: "lead", Name: "Steward", Task: "Prepare the ball", State: AgentRunning, Children: []SubAgent{
|
||||
{ID: "music", Name: "Musician", Task: "Hire the quartet", State: AgentDone},
|
||||
{ID: "kitchen", Name: "Cook", Task: "Plan the menu", State: AgentWaiting, Children: []SubAgent{
|
||||
{ID: "wine", Name: "Sommelier", Task: "Choose the wine", State: AgentFailed},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
func subAgentTester(opts SubAgentTreeOptions, changes *int) *ui.Tester {
|
||||
opts.Still = true
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if SubAgentTree(c, subAgentSample, opts).Changed() {
|
||||
*changes++
|
||||
}
|
||||
}, 560, 300)
|
||||
}
|
||||
|
||||
func TestSubAgentTreeExpandByPointerAndKeys(t *testing.T) {
|
||||
changes := 0
|
||||
tt := subAgentTester(SubAgentTreeOptions{}, &changes)
|
||||
if tt.HasText("Musician") || !tt.HasText("Running") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Steward")
|
||||
if changes != 1 || !tt.HasText("Musician") || !tt.HasText("Waiting") || tt.HasText("Sommelier") {
|
||||
t.Fatalf("click: changes %d texts %q", changes, tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyDown) // Cook
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if !tt.HasText("Sommelier") || !tt.HasText("Failed") {
|
||||
t.Fatal("Right did not expand Cook")
|
||||
}
|
||||
tt.Key(0, ui.KeyRight) // already open: nothing
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
if tt.HasText("Sommelier") || changes != 3 {
|
||||
t.Fatalf("Left: changes %d", changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubAgentTreeDisabled(t *testing.T) {
|
||||
changes := 0
|
||||
tt := subAgentTester(SubAgentTreeOptions{Disabled: true}, &changes)
|
||||
_ = tt.Click("Steward")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if changes != 0 || tt.HasText("Musician") {
|
||||
t.Fatal("disabled tree expanded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubAgentTreeRejectsDuplicateIDs(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
SubAgentTree(c, []SubAgent{{ID: "a", Name: "x", Children: []SubAgent{{ID: "a", Name: "y"}}}}, SubAgentTreeOptions{})
|
||||
}, 200, 200)
|
||||
}
|
||||
|
||||
func TestSubAgentTreeSnapshot(t *testing.T) {
|
||||
snapshotAfter(t, "sub_agent_tree", 540, 200, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() { SubAgentTree(c, subAgentSample, SubAgentTreeOptions{Still: true}) })
|
||||
}, func(tt *ui.Tester) {
|
||||
_ = tt.Click("Steward")
|
||||
_ = tt.Click("Cook")
|
||||
})
|
||||
}
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |