diff --git a/agent_diff.go b/agent_diff.go new file mode 100644 index 0000000..c686101 --- /dev/null +++ b/agent_diff.go @@ -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 +} diff --git a/agent_parts.go b/agent_parts.go new file mode 100644 index 0000000..d2758a0 --- /dev/null +++ b/agent_parts.go @@ -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() +} diff --git a/agent_plan.go b/agent_plan.go new file mode 100644 index 0000000..2be4683 --- /dev/null +++ b/agent_plan.go @@ -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 + } + } +} diff --git a/agent_plan_test.go b/agent_plan_test.go new file mode 100644 index 0000000..1353cc3 --- /dev/null +++ b/agent_plan_test.go @@ -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}) }) + }) +} diff --git a/agent_progress.go b/agent_progress.go new file mode 100644 index 0000000..3fb6459 --- /dev/null +++ b/agent_progress.go @@ -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 +} diff --git a/agent_progress_test.go b/agent_progress_test.go new file mode 100644 index 0000000..5103ede --- /dev/null +++ b/agent_progress_test.go @@ -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"}) + }) + }) +} diff --git a/agent_status.go b/agent_status.go new file mode 100644 index 0000000..4384112 --- /dev/null +++ b/agent_status.go @@ -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 +} diff --git a/agent_status_test.go b/agent_status_test.go new file mode 100644 index 0000000..a642c4c --- /dev/null +++ b/agent_status_test.go @@ -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{}) + } + }) + }) +} diff --git a/agent_step_list.go b/agent_step_list.go new file mode 100644 index 0000000..710dc56 --- /dev/null +++ b/agent_step_list.go @@ -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 +} diff --git a/agent_step_list_test.go b/agent_step_list_test.go new file mode 100644 index 0000000..fa9d0b4 --- /dev/null +++ b/agent_step_list_test.go @@ -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}) }) + }) +} diff --git a/artifact_panel.go b/artifact_panel.go new file mode 100644 index 0000000..189333b --- /dev/null +++ b/artifact_panel.go @@ -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) + } + } + }) +} diff --git a/artifact_panel_test.go b/artifact_panel_test.go new file mode 100644 index 0000000..a37f441 --- /dev/null +++ b/artifact_panel_test.go @@ -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() {}}) + }) + }) +} diff --git a/artifact_version_switcher.go b/artifact_version_switcher.go new file mode 100644 index 0000000..2913f14 --- /dev/null +++ b/artifact_version_switcher.go @@ -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 +} diff --git a/artifact_version_switcher_test.go b/artifact_version_switcher_test.go new file mode 100644 index 0000000..f910887 --- /dev/null +++ b/artifact_version_switcher_test.go @@ -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{}) + }) + }) +} diff --git a/checkpoint_list.go b/checkpoint_list.go new file mode 100644 index 0000000..ff721e8 --- /dev/null +++ b/checkpoint_list.go @@ -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 +} diff --git a/checkpoint_list_test.go b/checkpoint_list_test.go new file mode 100644 index 0000000..f37f863 --- /dev/null +++ b/checkpoint_list_test.go @@ -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{}) + }) + }) +} diff --git a/command_execution_card.go b/command_execution_card.go new file mode 100644 index 0000000..347f707 --- /dev/null +++ b/command_execution_card.go @@ -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() + }) +} diff --git a/command_execution_card_test.go b/command_execution_card_test.go new file mode 100644 index 0000000..82b0da5 --- /dev/null +++ b/command_execution_card_test.go @@ -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}) + }) + }) +} diff --git a/docs/components/261-tool-call-card.md b/docs/components/261-tool-call-card.md new file mode 100644 index 0000000..71f100f --- /dev/null +++ b/docs/components/261-tool-call-card.md @@ -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 结构校验提示。 diff --git a/docs/components/262-tool-call-group.md b/docs/components/262-tool-call-group.md new file mode 100644 index 0000000..76f405a --- /dev/null +++ b/docs/components/262-tool-call-group.md @@ -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 展开或折叠;组内卡片各自可折叠。 + +## 限制 + +- 总耗时是各调用耗时之和,不扣除并行重叠。 diff --git a/docs/components/263-tool-approval-dialog.md b/docs/components/263-tool-approval-dialog.md new file mode 100644 index 0000000..20932bf --- /dev/null +++ b/docs/components/263-tool-approval-dialog.md @@ -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`)。 diff --git a/docs/components/264-permission-prompt.md b/docs/components/264-permission-prompt.md new file mode 100644 index 0000000..c3b4e9e --- /dev/null +++ b/docs/components/264-permission-prompt.md @@ -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 拒绝。 + +## 限制 + +- 组件不保存授权,记住“始终允许”由调用方负责。 diff --git a/docs/components/265-agent-step-list.md b/docs/components/265-agent-step-list.md new file mode 100644 index 0000000..5f87bed --- /dev/null +++ b/docs/components/265-agent-step-list.md @@ -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 展开详情或重试。 + +## 限制 + +- 步骤数量较少时使用;列表不虚拟化。 diff --git a/docs/components/266-agent-plan.md b/docs/components/266-agent-plan.md new file mode 100644 index 0000000..d2f4ec1 --- /dev/null +++ b/docs/components/266-agent-plan.md @@ -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 添加。 + +## 限制 + +- 空白内容不会被添加。 diff --git a/docs/components/267-agent-status.md b/docs/components/267-agent-status.md new file mode 100644 index 0000000..15637fc --- /dev/null +++ b/docs/components/267-agent-status.md @@ -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` 或系统减少动画时改为静态图标。 + +## 事件 + +- 无事件;屏幕阅读器以状态角色读出文字。 + +## 键盘操作 + +- 不获取焦点。 + +## 限制 + +- 状态动画只在显示时重绘,隐藏后停止。 diff --git a/docs/components/268-agent-progress.md b/docs/components/268-agent-progress.md new file mode 100644 index 0000000..b25e5d1 --- /dev/null +++ b/docs/components/268-agent-progress.md @@ -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` | 停止执行中动画;系统减少动画时也自动静止。 | + +## 状态 + +- 确定模式显示百分比;不确定模式显示流动条,减少动画时为静态条加“进行中”。 + +## 事件 + +- 无事件;当前动作以状态角色读出。 + +## 键盘操作 + +- 不获取焦点。 + +## 限制 + +- 进度值由调用方计算。 diff --git a/docs/components/269-sub-agent-tree.md b/docs/components/269-sub-agent-tree.md new file mode 100644 index 0000000..5349566 --- /dev/null +++ b/docs/components/269-sub-agent-tree.md @@ -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。 diff --git a/docs/components/270-file-change-card.md b/docs/components/270-file-change-card.md new file mode 100644 index 0000000..045a63f --- /dev/null +++ b/docs/components/270-file-change-card.md @@ -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 组扩展。 diff --git a/docs/components/271-multi-file-diff-review.md b/docs/components/271-multi-file-diff-review.md new file mode 100644 index 0000000..b60c4cb --- /dev/null +++ b/docs/components/271-multi-file-diff-review.md @@ -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 接受或拒绝。 + +## 限制 + +- 文件很多时方向键只在已构建的可见行间移动,滚动后再继续。 diff --git a/docs/components/272-command-execution-card.md b/docs/components/272-command-execution-card.md new file mode 100644 index 0000000..8d0ca9d --- /dev/null +++ b/docs/components/272-command-execution-card.md @@ -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。 diff --git a/docs/components/273-screenshot-stream.md b/docs/components/273-screenshot-stream.md new file mode 100644 index 0000000..2c2b652 --- /dev/null +++ b/docs/components/273-screenshot-stream.md @@ -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 暂停或恢复;上一帧、下一帧按钮同样可用。 + +## 限制 + +- 不解码视频,帧图像由调用方提供。 diff --git a/docs/components/274-artifact-panel.md b/docs/components/274-artifact-panel.md new file mode 100644 index 0000000..58164e4 --- /dev/null +++ b/docs/components/274-artifact-panel.md @@ -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)。 diff --git a/docs/components/275-artifact-version-switcher.md b/docs/components/275-artifact-version-switcher.md new file mode 100644 index 0000000..0cabdb2 --- /dev/null +++ b/docs/components/275-artifact-version-switcher.md @@ -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 呈现。 diff --git a/docs/components/276-sandbox-status.md b/docs/components/276-sandbox-status.md new file mode 100644 index 0000000..495f6da --- /dev/null +++ b/docs/components/276-sandbox-status.md @@ -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 暂停帧,重新显示后立即校正。 diff --git a/docs/components/277-mcp-server-list.md b/docs/components/277-mcp-server-list.md new file mode 100644 index 0000000..82a5d34 --- /dev/null +++ b/docs/components/277-mcp-server-list.md @@ -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 触发。 + +## 限制 + +- 组件只发出请求,连接管理由调用方完成。 diff --git a/docs/components/278-memory-panel.md b/docs/components/278-memory-panel.md new file mode 100644 index 0000000..e9ff905 --- /dev/null +++ b/docs/components/278-memory-panel.md @@ -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 取消。 + +## 限制 + +- 调用方原地修改条目文本而不改变切片长度时,筛选结果到下次搜索变化才更新。 diff --git a/docs/components/279-checkpoint-list.md b/docs/components/279-checkpoint-list.md new file mode 100644 index 0000000..3854182 --- /dev/null +++ b/docs/components/279-checkpoint-list.md @@ -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。 + +## 限制 + +- 恢复文件与状态由调用方完成。 diff --git a/docs/components/280-human-input-request.md b/docs/components/280-human-input-request.md new file mode 100644 index 0000000..288fbba --- /dev/null +++ b/docs/components/280-human-input-request.md @@ -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 到“发送回答”。 + +## 限制 + +- 超时只在界面帧中检测,窗口隐藏期间推迟到重新显示。 diff --git a/example_agent_test.go b/example_agent_test.go new file mode 100644 index 0000000..0d17a41 --- /dev/null +++ b/example_agent_test.go @@ -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) +} diff --git a/examples/gallery/pages_agent.go b/examples/gallery/pages_agent.go index f1cb354..ead48c3 100644 --- a/examples/gallery/pages_agent.go +++ b/examples/gallery/pages_agent.go @@ -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"} diff --git a/examples/gallery/pages_agent_code.go b/examples/gallery/pages_agent_code.go new file mode 100644 index 0000000..1503640 --- /dev/null +++ b/examples/gallery/pages_agent_code.go @@ -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)` diff --git a/examples/gallery/pages_agent_more.go b/examples/gallery/pages_agent_more.go new file mode 100644 index 0000000..dbc4b9b --- /dev/null +++ b/examples/gallery/pages_agent_more.go @@ -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") + } +} diff --git a/file_change_card.go b/file_change_card.go new file mode 100644 index 0000000..76785dc --- /dev/null +++ b/file_change_card.go @@ -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 +} diff --git a/file_change_card_test.go b/file_change_card_test.go new file mode 100644 index 0000000..0c93ff4 --- /dev/null +++ b/file_change_card_test.go @@ -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{}) }) + }) +} diff --git a/human_input_request.go b/human_input_request.go new file mode 100644 index 0000000..1d4a6cf --- /dev/null +++ b/human_input_request.go @@ -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)) + }) +} diff --git a/human_input_request_test.go b/human_input_request_test.go new file mode 100644 index 0000000..cb82671 --- /dev/null +++ b/human_input_request_test.go @@ -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}) + }) + }) +} diff --git a/internal/diff.go b/internal/diff.go index ccc9071..332c68f 100644 --- a/internal/diff.go +++ b/internal/diff.go @@ -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-- { diff --git a/mcp_server_list.go b/mcp_server_list.go new file mode 100644 index 0000000..649fdaa --- /dev/null +++ b/mcp_server_list.go @@ -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 +} diff --git a/mcp_server_list_test.go b/mcp_server_list_test.go new file mode 100644 index 0000000..a3954ee --- /dev/null +++ b/mcp_server_list_test.go @@ -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}) }) + }) +} diff --git a/memory_panel.go b/memory_panel.go new file mode 100644 index 0000000..fd275f5 --- /dev/null +++ b/memory_panel.go @@ -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 + } + }) + }) +} diff --git a/memory_panel_test.go b/memory_panel_test.go new file mode 100644 index 0000000..3d2db38 --- /dev/null +++ b/memory_panel_test.go @@ -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{}) }) + }) +} diff --git a/messages_agent.go b/messages_agent.go new file mode 100644 index 0000000..6bd824e --- /dev/null +++ b/messages_agent.go @@ -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", "新增一项"}, + }) +} diff --git a/multi_file_diff_review.go b/multi_file_diff_review.go new file mode 100644 index 0000000..8d1eaf4 --- /dev/null +++ b/multi_file_diff_review.go @@ -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 +} diff --git a/multi_file_diff_review_test.go b/multi_file_diff_review_test.go new file mode 100644 index 0000000..a8d5455 --- /dev/null +++ b/multi_file_diff_review_test.go @@ -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}) + }) + }) +} diff --git a/permission_prompt.go b/permission_prompt.go new file mode 100644 index 0000000..ac13a5c --- /dev/null +++ b/permission_prompt.go @@ -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 +} diff --git a/permission_prompt_test.go b/permission_prompt_test.go new file mode 100644 index 0000000..bba25c7 --- /dev/null +++ b/permission_prompt_test.go @@ -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"}) + }) +} diff --git a/sandbox_status.go b/sandbox_status.go new file mode 100644 index 0000000..e9390bd --- /dev/null +++ b/sandbox_status.go @@ -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) +} diff --git a/sandbox_status_test.go b/sandbox_status_test.go new file mode 100644 index 0000000..2a8815b --- /dev/null +++ b/sandbox_status_test.go @@ -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}) }) + }) +} diff --git a/screenshot_stream.go b/screenshot_stream.go new file mode 100644 index 0000000..86df6d9 --- /dev/null +++ b/screenshot_stream.go @@ -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 +} diff --git a/screenshot_stream_test.go b/screenshot_stream_test.go new file mode 100644 index 0000000..bd52c20 --- /dev/null +++ b/screenshot_stream_test.go @@ -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}) + }) + }) +} diff --git a/sub_agent_tree.go b/sub_agent_tree.go new file mode 100644 index 0000000..c142006 --- /dev/null +++ b/sub_agent_tree.go @@ -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) +} diff --git a/sub_agent_tree_test.go b/sub_agent_tree_test.go new file mode 100644 index 0000000..8961923 --- /dev/null +++ b/sub_agent_tree_test.go @@ -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") + }) +} diff --git a/testdata/snapshots/darwin/agent_plan-dark.png b/testdata/snapshots/darwin/agent_plan-dark.png new file mode 100644 index 0000000..726c0f6 Binary files /dev/null and b/testdata/snapshots/darwin/agent_plan-dark.png differ diff --git a/testdata/snapshots/darwin/agent_plan-light.png b/testdata/snapshots/darwin/agent_plan-light.png new file mode 100644 index 0000000..79d287e Binary files /dev/null and b/testdata/snapshots/darwin/agent_plan-light.png differ diff --git a/testdata/snapshots/darwin/agent_progress-dark.png b/testdata/snapshots/darwin/agent_progress-dark.png new file mode 100644 index 0000000..32136e9 Binary files /dev/null and b/testdata/snapshots/darwin/agent_progress-dark.png differ diff --git a/testdata/snapshots/darwin/agent_progress-light.png b/testdata/snapshots/darwin/agent_progress-light.png new file mode 100644 index 0000000..c0d2aa4 Binary files /dev/null and b/testdata/snapshots/darwin/agent_progress-light.png differ diff --git a/testdata/snapshots/darwin/agent_status-dark.png b/testdata/snapshots/darwin/agent_status-dark.png new file mode 100644 index 0000000..aac7f1c Binary files /dev/null and b/testdata/snapshots/darwin/agent_status-dark.png differ diff --git a/testdata/snapshots/darwin/agent_status-light.png b/testdata/snapshots/darwin/agent_status-light.png new file mode 100644 index 0000000..753bb61 Binary files /dev/null and b/testdata/snapshots/darwin/agent_status-light.png differ diff --git a/testdata/snapshots/darwin/agent_step_list-dark.png b/testdata/snapshots/darwin/agent_step_list-dark.png new file mode 100644 index 0000000..6c0fbe6 Binary files /dev/null and b/testdata/snapshots/darwin/agent_step_list-dark.png differ diff --git a/testdata/snapshots/darwin/agent_step_list-light.png b/testdata/snapshots/darwin/agent_step_list-light.png new file mode 100644 index 0000000..e4f70fa Binary files /dev/null and b/testdata/snapshots/darwin/agent_step_list-light.png differ diff --git a/testdata/snapshots/darwin/artifact_panel-dark.png b/testdata/snapshots/darwin/artifact_panel-dark.png new file mode 100644 index 0000000..5f0cd25 Binary files /dev/null and b/testdata/snapshots/darwin/artifact_panel-dark.png differ diff --git a/testdata/snapshots/darwin/artifact_panel-light.png b/testdata/snapshots/darwin/artifact_panel-light.png new file mode 100644 index 0000000..1b1f06b Binary files /dev/null and b/testdata/snapshots/darwin/artifact_panel-light.png differ diff --git a/testdata/snapshots/darwin/artifact_version_switcher-dark.png b/testdata/snapshots/darwin/artifact_version_switcher-dark.png new file mode 100644 index 0000000..2e1db43 Binary files /dev/null and b/testdata/snapshots/darwin/artifact_version_switcher-dark.png differ diff --git a/testdata/snapshots/darwin/artifact_version_switcher-light.png b/testdata/snapshots/darwin/artifact_version_switcher-light.png new file mode 100644 index 0000000..ecdbb71 Binary files /dev/null and b/testdata/snapshots/darwin/artifact_version_switcher-light.png differ diff --git a/testdata/snapshots/darwin/checkpoint_list-dark.png b/testdata/snapshots/darwin/checkpoint_list-dark.png new file mode 100644 index 0000000..a31d523 Binary files /dev/null and b/testdata/snapshots/darwin/checkpoint_list-dark.png differ diff --git a/testdata/snapshots/darwin/checkpoint_list-light.png b/testdata/snapshots/darwin/checkpoint_list-light.png new file mode 100644 index 0000000..9496405 Binary files /dev/null and b/testdata/snapshots/darwin/checkpoint_list-light.png differ diff --git a/testdata/snapshots/darwin/command_execution_card-dark.png b/testdata/snapshots/darwin/command_execution_card-dark.png new file mode 100644 index 0000000..5dc61aa Binary files /dev/null and b/testdata/snapshots/darwin/command_execution_card-dark.png differ diff --git a/testdata/snapshots/darwin/command_execution_card-light.png b/testdata/snapshots/darwin/command_execution_card-light.png new file mode 100644 index 0000000..fac8d11 Binary files /dev/null and b/testdata/snapshots/darwin/command_execution_card-light.png differ diff --git a/testdata/snapshots/darwin/file_change_card-dark.png b/testdata/snapshots/darwin/file_change_card-dark.png new file mode 100644 index 0000000..3840158 Binary files /dev/null and b/testdata/snapshots/darwin/file_change_card-dark.png differ diff --git a/testdata/snapshots/darwin/file_change_card-light.png b/testdata/snapshots/darwin/file_change_card-light.png new file mode 100644 index 0000000..ba001c4 Binary files /dev/null and b/testdata/snapshots/darwin/file_change_card-light.png differ diff --git a/testdata/snapshots/darwin/human_input_request-dark.png b/testdata/snapshots/darwin/human_input_request-dark.png new file mode 100644 index 0000000..f9d9968 Binary files /dev/null and b/testdata/snapshots/darwin/human_input_request-dark.png differ diff --git a/testdata/snapshots/darwin/human_input_request-light.png b/testdata/snapshots/darwin/human_input_request-light.png new file mode 100644 index 0000000..b9355f2 Binary files /dev/null and b/testdata/snapshots/darwin/human_input_request-light.png differ diff --git a/testdata/snapshots/darwin/mcp_server_list-dark.png b/testdata/snapshots/darwin/mcp_server_list-dark.png new file mode 100644 index 0000000..a8a5a4d Binary files /dev/null and b/testdata/snapshots/darwin/mcp_server_list-dark.png differ diff --git a/testdata/snapshots/darwin/mcp_server_list-light.png b/testdata/snapshots/darwin/mcp_server_list-light.png new file mode 100644 index 0000000..75ca50a Binary files /dev/null and b/testdata/snapshots/darwin/mcp_server_list-light.png differ diff --git a/testdata/snapshots/darwin/memory_panel-dark.png b/testdata/snapshots/darwin/memory_panel-dark.png new file mode 100644 index 0000000..c5f6834 Binary files /dev/null and b/testdata/snapshots/darwin/memory_panel-dark.png differ diff --git a/testdata/snapshots/darwin/memory_panel-light.png b/testdata/snapshots/darwin/memory_panel-light.png new file mode 100644 index 0000000..849c3f1 Binary files /dev/null and b/testdata/snapshots/darwin/memory_panel-light.png differ diff --git a/testdata/snapshots/darwin/multi_file_diff_review-dark.png b/testdata/snapshots/darwin/multi_file_diff_review-dark.png new file mode 100644 index 0000000..5b3c55b Binary files /dev/null and b/testdata/snapshots/darwin/multi_file_diff_review-dark.png differ diff --git a/testdata/snapshots/darwin/multi_file_diff_review-light.png b/testdata/snapshots/darwin/multi_file_diff_review-light.png new file mode 100644 index 0000000..6ba5027 Binary files /dev/null and b/testdata/snapshots/darwin/multi_file_diff_review-light.png differ diff --git a/testdata/snapshots/darwin/permission_prompt-dark.png b/testdata/snapshots/darwin/permission_prompt-dark.png new file mode 100644 index 0000000..d1bd8d5 Binary files /dev/null and b/testdata/snapshots/darwin/permission_prompt-dark.png differ diff --git a/testdata/snapshots/darwin/permission_prompt-light.png b/testdata/snapshots/darwin/permission_prompt-light.png new file mode 100644 index 0000000..bce80df Binary files /dev/null and b/testdata/snapshots/darwin/permission_prompt-light.png differ diff --git a/testdata/snapshots/darwin/sandbox_status-dark.png b/testdata/snapshots/darwin/sandbox_status-dark.png new file mode 100644 index 0000000..8b02d36 Binary files /dev/null and b/testdata/snapshots/darwin/sandbox_status-dark.png differ diff --git a/testdata/snapshots/darwin/sandbox_status-light.png b/testdata/snapshots/darwin/sandbox_status-light.png new file mode 100644 index 0000000..6a92c06 Binary files /dev/null and b/testdata/snapshots/darwin/sandbox_status-light.png differ diff --git a/testdata/snapshots/darwin/screenshot_stream-dark.png b/testdata/snapshots/darwin/screenshot_stream-dark.png new file mode 100644 index 0000000..833f426 Binary files /dev/null and b/testdata/snapshots/darwin/screenshot_stream-dark.png differ diff --git a/testdata/snapshots/darwin/screenshot_stream-light.png b/testdata/snapshots/darwin/screenshot_stream-light.png new file mode 100644 index 0000000..408ca79 Binary files /dev/null and b/testdata/snapshots/darwin/screenshot_stream-light.png differ diff --git a/testdata/snapshots/darwin/sub_agent_tree-dark.png b/testdata/snapshots/darwin/sub_agent_tree-dark.png new file mode 100644 index 0000000..ab686fe Binary files /dev/null and b/testdata/snapshots/darwin/sub_agent_tree-dark.png differ diff --git a/testdata/snapshots/darwin/sub_agent_tree-light.png b/testdata/snapshots/darwin/sub_agent_tree-light.png new file mode 100644 index 0000000..6309770 Binary files /dev/null and b/testdata/snapshots/darwin/sub_agent_tree-light.png differ diff --git a/testdata/snapshots/darwin/tool_approval_dialog-dark.png b/testdata/snapshots/darwin/tool_approval_dialog-dark.png new file mode 100644 index 0000000..4dab692 Binary files /dev/null and b/testdata/snapshots/darwin/tool_approval_dialog-dark.png differ diff --git a/testdata/snapshots/darwin/tool_approval_dialog-light.png b/testdata/snapshots/darwin/tool_approval_dialog-light.png new file mode 100644 index 0000000..77c70a6 Binary files /dev/null and b/testdata/snapshots/darwin/tool_approval_dialog-light.png differ diff --git a/testdata/snapshots/darwin/tool_call_card-dark.png b/testdata/snapshots/darwin/tool_call_card-dark.png new file mode 100644 index 0000000..3424c0b Binary files /dev/null and b/testdata/snapshots/darwin/tool_call_card-dark.png differ diff --git a/testdata/snapshots/darwin/tool_call_card-light.png b/testdata/snapshots/darwin/tool_call_card-light.png new file mode 100644 index 0000000..68ca034 Binary files /dev/null and b/testdata/snapshots/darwin/tool_call_card-light.png differ diff --git a/testdata/snapshots/darwin/tool_call_group-dark.png b/testdata/snapshots/darwin/tool_call_group-dark.png new file mode 100644 index 0000000..22cce78 Binary files /dev/null and b/testdata/snapshots/darwin/tool_call_group-dark.png differ diff --git a/testdata/snapshots/darwin/tool_call_group-light.png b/testdata/snapshots/darwin/tool_call_group-light.png new file mode 100644 index 0000000..f7acfa4 Binary files /dev/null and b/testdata/snapshots/darwin/tool_call_group-light.png differ diff --git a/tool_approval_dialog.go b/tool_approval_dialog.go new file mode 100644 index 0000000..7509c91 --- /dev/null +++ b/tool_approval_dialog.go @@ -0,0 +1,65 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// ToolApprovalOptions configure a ToolApprovalDialog. Tool is required. +type ToolApprovalOptions struct { + Tool string + // Args is the call's arguments, JSON when valid. + Args string + // Reason says why the agent wants to run the tool. + Reason string +} + +// ToolApprovalResult reports the choice made in this frame. +type ToolApprovalResult struct { + Element *ui.Element + Allowed bool + Denied bool +} + +// ToolApprovalDialog asks to approve a tool call; Deny has focus. +func ToolApprovalDialog(c *ui.Context, open *bool, opts ToolApprovalOptions) ToolApprovalResult { + if opts.Tool == "" { + panic("mujica: ToolApprovalDialog needs a Tool") + } + var r ToolApprovalResult + was := *open + u := Density(c).Unit() + r.Element = Dialog(c, open, DialogOptions{ + Title: msg(c, "tool_approval_dialog.title"), Description: opts.Reason, KeepOnBackdrop: true, HideClose: true, + Actions: func() { + // Deny first: a reflexive Enter never runs the tool. + if Button(c, msg(c, "tool_approval_dialog.deny"), ButtonOptions{Variant: Secondary}).AutoFocus().Clicked() { + r.Denied = true + } + if Button(c, msg(c, "tool_approval_dialog.allow"), ButtonOptions{}).Clicked() { + r.Allowed = true + } + }, + }, func() { + ui.Column(c).Gap(u * 2).Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + agentCaption(c, msg(c, "tool_approval_dialog.tool")) + ui.Text(c, opts.Tool).Font(theme.MonoFont).FontWeight(600) + }) + if opts.Args != "" { + agentCode(c, "json", agentPrettyJSON(opts.Args), 260) + } + }) + }) + if was && !*open && !r.Allowed { + r.Denied = true // Escape + } + if r.Allowed || r.Denied { + slog.Debug("mujica: tool_approval_dialog choose", "tool", opts.Tool, "allowed", r.Allowed) + *open = false + } + return r +} diff --git a/tool_approval_dialog_test.go b/tool_approval_dialog_test.go new file mode 100644 index 0000000..331255e --- /dev/null +++ b/tool_approval_dialog_test.go @@ -0,0 +1,97 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +type approvalApp struct { + open bool + allowed, denied int +} + +func (a *approvalApp) view(c *ui.Context) { + Use(c, Settings{}) + if Button(c, "Run", ButtonOptions{}).Clicked() { + a.open = true + } + r := ToolApprovalDialog(c, &a.open, ToolApprovalOptions{Tool: "delete_files", Args: `{"glob":"*.tmp"}`, Reason: "Clean the build folder."}) + if r.Allowed { + a.allowed++ + } + if r.Denied { + a.denied++ + } +} + +func newApprovalApp() (*approvalApp, *ui.Tester) { + a := &approvalApp{} + tt := ui.NewTester(a.view, 600, 500) + _ = tt.Click("Run") + return a, tt +} + +func TestToolApprovalDialogDenyFirst(t *testing.T) { + a, tt := newApprovalApp() + for _, s := range []string{"delete_files", "Clean the build folder.", ` "glob": "*.tmp"`} { + if !tt.HasText(s) { + t.Fatalf("missing %q in %q", s, tt.Texts()) + } + } + if !tt.Focused("Deny") { + t.Fatal("Deny does not have the focus") + } + tt.Key(0, ui.KeyEnter) + if a.denied != 1 || a.allowed != 0 || a.open { + t.Fatalf("Enter: denied %d allowed %d open %v", a.denied, a.allowed, a.open) + } + if !tt.Focused("Run") { + t.Fatal("focus not restored") + } +} + +func TestToolApprovalDialogAllowByTabAndBackdrop(t *testing.T) { + a, tt := newApprovalApp() + tt.ClickAt(5, 495) + if !a.open || a.denied != 0 { + t.Fatal("backdrop click acted") + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("Allow") { + t.Fatal("Tab did not reach Allow") + } + tt.Key(0, ui.KeyEnter) + if a.allowed != 1 || a.denied != 0 || a.open { + t.Fatalf("allowed %d denied %d", a.allowed, a.denied) + } +} + +func TestToolApprovalDialogEscapeDenies(t *testing.T) { + a, tt := newApprovalApp() + tt.Key(0, ui.KeyEscape) + if a.denied != 1 || a.allowed != 0 || a.open { + t.Fatalf("Escape: denied %d allowed %d open %v", a.denied, a.allowed, a.open) + } +} + +func TestToolApprovalDialogNeedsTool(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + open := true + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ToolApprovalDialog(c, &open, ToolApprovalOptions{}) + }, 200, 200) +} + +func TestToolApprovalDialogSnapshot(t *testing.T) { + snapshot(t, "tool_approval_dialog", 560, 380, func(c *ui.Context) { + open := true + ToolApprovalDialog(c, &open, ToolApprovalOptions{Tool: "delete_files", Args: `{"glob":"build/*.tmp","dry_run":false}`, + Reason: "The agent wants to clean temporary files from the build folder."}) + }) +} diff --git a/tool_call_card.go b/tool_call_card.go new file mode 100644 index 0000000..164e6ec --- /dev/null +++ b/tool_call_card.go @@ -0,0 +1,79 @@ +package mujica + +import ( + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// ToolCall is one tool invocation; the caller owns it. +type ToolCall struct { + ID string + Name string + // Args and Result show as JSON when valid. + Args string + Result string + // Error replaces Result when the call failed. + Error string + State AgentState + Duration time.Duration +} + +// ToolCallCardOptions configure a ToolCallCard. +type ToolCallCardOptions struct { + // OpenArgs and OpenResult start the sections expanded. + OpenArgs, OpenResult bool + // Still stops the running animation. + Still bool +} + +type toolCallCardState struct{ args, result bool } + +// ToolCallCard shows a tool call with foldable arguments and result. +func ToolCallCard(c *ui.Context, call ToolCall, opts ToolCallCardOptions) ChoiceResult { + if call.Name == "" { + panic("mujica: ToolCallCard needs a Name") + } + k := Tokens(c) + u := Density(c).Unit() + var r ChoiceResult + r.Element = agentCard(c).Role(ui.RoleGroup).Label(call.Name + ", " + agentWord(c, call.State)) + st := ui.Local(r.Element, "mujica.toolcall", func() toolCallCardState { + return toolCallCardState{opts.OpenArgs, opts.OpenResult} + }) + r.Element.Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).MinHeight(theme.MinHitSize).Children(func() { + agentMark(c, call.State, 16, opts.Still) + ui.Text(c, call.Name).Font(theme.MonoFont).FontWeight(600).SingleLine().Grow(1).Shrink(1) + agentCaption(c, agentWord(c, call.State)) + if call.State == AgentDone || call.State == AgentFailed { + agentCaption(c, agentDuration(call.Duration)) + } + }) + if call.Args != "" { + r.changed = Collapsible(c, &st.args, CollapsibleOptions{Title: msg(c, "tool_call_card.args")}, func() { + agentCode(c, "json", agentPrettyJSON(call.Args), 240) + }).Changed() || r.changed + } + body, title := call.Result, msg(c, "tool_call_card.result") + if call.Error != "" { + body, title = call.Error, msg(c, "tool_call_card.error") + } + if body != "" { + r.changed = Collapsible(c, &st.result, CollapsibleOptions{Title: title}, func() { + if call.Error != "" { + ui.Text(c, call.Error).TextColor(k.Danger).Selectable() + return + } + agentCode(c, "json", agentPrettyJSON(body), 320) + }).Changed() || r.changed + } + }) + if r.changed { + slog.Debug("mujica: tool_call_card toggle", "tool", call.Name, "args", st.args, "result", st.result) + } + return r +} diff --git a/tool_call_card_test.go b/tool_call_card_test.go new file mode 100644 index 0000000..770a4c1 --- /dev/null +++ b/tool_call_card_test.go @@ -0,0 +1,103 @@ +package mujica + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +var toolCallSample = ToolCall{ID: "1", Name: "read_file", Args: `{"path":"court/ledger.md","limit":40}`, + Result: `{"lines":40,"truncated":false}`, State: AgentDone, Duration: 420 * time.Millisecond} + +func TestToolCallCardShowsAndFolds(t *testing.T) { + call := toolCallSample + changes := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ToolCallCard(c, call, ToolCallCardOptions{}).Changed() { + changes++ + } + }, 500, 500) + for _, s := range []string{"read_file", "Done", "420 ms", "Arguments", "Result"} { + if !tt.HasText(s) { + t.Fatalf("missing %q in %q", s, tt.Texts()) + } + } + if tt.HasText(` "path": "court/ledger.md",`) { + t.Fatal("arguments shown while folded") + } + _ = tt.Click("Arguments") + if changes != 1 || !tt.HasText(` "path": "court/ledger.md",`) { + t.Fatalf("changes %d, pretty JSON %q", changes, tt.Texts()) + } + tt.Key(0, ui.KeyTab) // keyboard reaches the Result fold + tt.Key(0, ui.KeyEnter) + if changes != 2 || !tt.HasText(` "lines": 40,`) { + t.Fatalf("keyboard fold: changes %d", changes) + } +} + +func TestToolCallCardErrorAndPlainText(t *testing.T) { + call := ToolCall{Name: "shell", Args: "not json at all", Error: "permission denied", State: AgentFailed, Duration: 3 * time.Second} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ToolCallCard(c, call, ToolCallCardOptions{OpenArgs: true, OpenResult: true}) + }, 500, 400) + for _, s := range []string{"Failed", "3.0 s", "Error", "permission denied", "not json at all"} { + if !tt.HasText(s) { + t.Fatalf("missing %q in %q", s, tt.Texts()) + } + } +} + +func TestToolCallCardRunningHidesDuration(t *testing.T) { + call := ToolCall{Name: "search", State: AgentRunning, Duration: time.Second} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ToolCallCard(c, call, ToolCallCardOptions{Still: true}) + }, 400, 200) + if !tt.HasText("Running") || tt.HasText("1.0 s") { + t.Fatalf("texts %q", tt.Texts()) + } +} + +func TestToolCallCardLargeResultIsVirtualized(t *testing.T) { + var b strings.Builder + for i := range 100000 { + fmt.Fprintf(&b, "row %d\n", i) + } + call := ToolCall{Name: "dump", Result: b.String(), State: AgentDone} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ToolCallCard(c, call, ToolCallCardOptions{OpenResult: true}) + }, 500, 500) + if n := len(tt.Texts()); n > 200 { + t.Fatalf("%d texts built for 100000 rows", n) + } + if !tt.HasText("row 0") { + t.Fatal("first row missing") + } +} + +func TestToolCallCardNeedsName(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ToolCallCard(c, ToolCall{}, ToolCallCardOptions{}) + }, 200, 200) +} + +func TestToolCallCardSnapshot(t *testing.T) { + snapshot(t, "tool_call_card", 460, 300, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + ToolCallCard(c, toolCallSample, ToolCallCardOptions{OpenArgs: true}) + }) + }) +} diff --git a/tool_call_group.go b/tool_call_group.go new file mode 100644 index 0000000..dbe5a61 --- /dev/null +++ b/tool_call_group.go @@ -0,0 +1,75 @@ +package mujica + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// ToolCallGroupOptions configure a ToolCallGroup. +type ToolCallGroupOptions struct { + // Title replaces the built-in "N tool calls". + Title string + Still bool +} + +// ToolCallGroup folds tool calls under a count, time, failures summary. +func ToolCallGroup(c *ui.Context, open *bool, calls []ToolCall, opts ToolCallGroupOptions) ChoiceResult { + if open == nil { + panic("mujica: ToolCallGroup needs open") + } + seen := make(map[string]bool, len(calls)) + var total time.Duration + failed, running := 0, false + for _, call := range calls { + if call.ID == "" || seen[call.ID] { + panic(fmt.Sprintf("mujica: ToolCallGroup call IDs must be unique and set, got %q", call.ID)) + } + seen[call.ID] = true + total += call.Duration + failed += agentB2I(call.State == AgentFailed) + running = running || call.State == AgentRunning + } + k := Tokens(c) + u := Density(c).Unit() + title := opts.Title + if title == "" { + title = fmt.Sprintf(msg(c, "tool_call_group.count"), len(calls)) + } + state := AgentDone + switch { + case running: + state = AgentRunning + case failed > 0: + state = AgentFailed + } + summary := title + " · " + agentDuration(total) + if failed > 0 { + summary += " · " + fmt.Sprintf(msg(c, "tool_call_group.failed"), failed) + } + return Collapsible(c, open, CollapsibleOptions{Title: summary, Trigger: func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + agentMark(c, state, 16, opts.Still) + ui.Text(c, title).FontWeight(600).SingleLine() + agentCaption(c, agentDuration(total)) + if failed > 0 { + ui.Text(c, fmt.Sprintf(msg(c, "tool_call_group.failed"), failed)).TextColor(k.Danger). + FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine() + } + }) + }}, func() { + for _, call := range calls { + ui.Column(c).Key(call.ID).Children(func() { ToolCallCard(c, call, ToolCallCardOptions{Still: opts.Still}) }) + } + }) +} + +func agentB2I(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/tool_call_group_test.go b/tool_call_group_test.go new file mode 100644 index 0000000..2e96b31 --- /dev/null +++ b/tool_call_group_test.go @@ -0,0 +1,72 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +var toolCallGroupSample = []ToolCall{ + {ID: "a", Name: "list_dir", State: AgentDone, Duration: 300 * time.Millisecond}, + {ID: "b", Name: "read_file", State: AgentFailed, Error: "not found", Duration: 1200 * time.Millisecond}, + {ID: "c", Name: "grep", State: AgentDone, Duration: 500 * time.Millisecond}, +} + +func TestToolCallGroupSummaryAndFold(t *testing.T) { + open, changes := false, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ToolCallGroup(c, &open, toolCallGroupSample, ToolCallGroupOptions{}).Changed() { + changes++ + } + }, 500, 600) + for _, s := range []string{"3 tool calls", "2.0 s", "1 failed"} { + if !tt.HasText(s) { + t.Fatalf("missing %q in %q", s, tt.Texts()) + } + } + if tt.HasText("read_file") { + t.Fatal("calls shown while folded") + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if !open || changes != 1 || !tt.HasText("read_file") || !tt.HasText("grep") { + t.Fatalf("open %v changes %d texts %q", open, changes, tt.Texts()) + } +} + +func TestToolCallGroupRunningAndEmpty(t *testing.T) { + open := false + calls := []ToolCall{{ID: "x", Name: "fetch", State: AgentRunning}} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ToolCallGroup(c, &open, calls, ToolCallGroupOptions{Title: "Research", Still: true}) + ToolCallGroup(c, &open, nil, ToolCallGroupOptions{}) + }, 500, 300) + if !tt.HasText("Research") || !tt.HasText("0 tool calls") || tt.HasText("1 failed") { + t.Fatalf("texts %q", tt.Texts()) + } +} + +func TestToolCallGroupRejectsDuplicateIDs(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + open := false + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ToolCallGroup(c, &open, []ToolCall{{ID: "a", Name: "x"}, {ID: "a", Name: "y"}}, ToolCallGroupOptions{}) + }, 200, 200) +} + +func TestToolCallGroupSnapshot(t *testing.T) { + snapshot(t, "tool_call_group", 460, 330, func(c *ui.Context) { + open := true + ui.Column(c).Padding(16).Children(func() { + ToolCallGroup(c, &open, toolCallGroupSample, ToolCallGroupOptions{}) + }) + }) +}