diff --git a/agent/agent.go b/agent/agent.go new file mode 100644 index 0000000..aa855ff --- /dev/null +++ b/agent/agent.go @@ -0,0 +1,72 @@ +// Package agent dresses AI agent work as an RPG party. +package agent + +import ( + "fmt" + "strconv" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Status is where an agent stands. +type Status int + +const ( + Idle Status = iota + Working + Blocked + Done +) + +// look is a status's word and slot; unknown ones panic. +func (s Status) look(k pixel.Kit) (string, pixel.Slot) { + if s < Idle || s > Done { + panic(fmt.Sprintf("agent: unknown Status %d", s)) + } + return k.Msg([...]string{"idle", "working", "blocked", "done"}[s]), [...]pixel.Slot{pixel.Faint, pixel.Info, pixel.Warn, pixel.Good}[s] +} + +// tokens prints n short: 950, 1.5K, 128K, 2M. +func tokens(n int) string { + if n < 1000 { + return strconv.Itoa(n) + } + div, unit := 1000, 0 + for n/div >= 1000 { + div, unit = div*1000, unit+1 + } + s := strconv.Itoa(n / div) + if t := n % div / (div / 10); n/div < 10 && t > 0 { + s += "." + strconv.Itoa(t) + } + return s + "KMBTPE"[unit:unit+1] +} + +// gauge frames a bar eight dots high and fills part. +func gauge(g pixel.Canvas, x, y, w int, part float64, ink pixel.Slot) { + g.Frame(x, y, w, 8, pixel.Sunken, pixel.Well) + g.Rect(x+2, y+2, int(part*float64(w-4)), 4, ink) +} + +// health: Good past half, Warn past a quarter, else Bad. +func health(left, full int) pixel.Slot { + switch { + case left <= full/4: + return pixel.Bad + case left <= full/2: + return pixel.Warn + } + return pixel.Good +} + +// portrait is p or the robot; over 12 dots panics. +func portrait(name string, p *pixel.Sprite) *pixel.Sprite { + if p == nil { + return icon.Robot + } + if p.W > 12 || p.H > 12 { + panic(fmt.Sprintf("agent: %s portrait is %dx%d, over 12", name, p.W, p.H)) + } + return p +} diff --git a/agent/agentcard.go b/agent/agentcard.go new file mode 100644 index 0000000..fc9e5f1 --- /dev/null +++ b/agent/agentcard.go @@ -0,0 +1,54 @@ +package agent + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Agent is a party member; its context window is HP. +type Agent struct { + Name, Class string + Level int + // Used and Window count context tokens. + Used, Window int +} + +type AgentCardOptions struct { + // Portrait fits 12 dots square; zero means a robot. + Portrait *pixel.Sprite +} + +const cardW, cardH = 136, 48 + +// AgentCard is a party card: class, level, context as HP. +func AgentCard(c *ui.Context, a Agent, o AgentCardOptions) *ui.Element { + k := pixel.K(c) + if a.Name == "" || a.Class == "" || a.Level < 1 || a.Window < 1 || a.Used < 0 || a.Used > a.Window { + panic(fmt.Sprintf("agent: AgentCard %q, %q, LV %d, %d of %d tokens", a.Name, a.Class, a.Level, a.Used, a.Window)) + } + face := portrait("AgentCard", o.Portrait) + left := a.Window - a.Used + lv, hp := fmt.Sprintf(k.Msg("level"), a.Level), tokens(left)+"/"+tokens(a.Window) + if pixel.Measure(lv) > cardW-40-28 { + panic(fmt.Sprintf("agent: AgentCard level %d is too wide", a.Level)) + } + ink := health(left, a.Window) + said := lv + " " + a.Class + ", " + k.Msg("hp") + " " + hp + return ui.Box(c).Size(k.D(cardW), k.D(cardH)).Role(ui.RoleImage).Label(a.Name).Description(said).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, cardW, cardH, pixel.Raised, pixel.Face) + g.Frame(4, 10, 28, 28, pixel.Sunken, pixel.Well) + g.Sprite(18-face.W, 24-face.H, face, 0, pixel.Ink, 2) + x, tw := 36, cardW-40 + g.Text(x, 1, pixel.Wrap(a.Name, tw, 1)[0], pixel.Ink, 1) + lvW := pixel.Measure(lv) + g.Text(x, 15, pixel.Wrap(a.Class, tw-lvW-4, 1)[0], pixel.Faint, 1) + g.Text(cardW-4-lvW, 15, lv, pixel.Ink, 1) + g.Micro(x, 32, k.Msg("hp"), pixel.Ink, 1) + g.Micro(cardW-4-pixel.MicroWidth(hp), 32, hp, pixel.Ink, 1) + gauge(g, x, 38, tw, float64(left)/float64(a.Window), ink) + }) +} diff --git a/agent/agentlog.go b/agent/agentlog.go new file mode 100644 index 0000000..9f9a51f --- /dev/null +++ b/agent/agentlog.go @@ -0,0 +1,102 @@ +package agent + +import ( + "fmt" + "math" + "unicode/utf8" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Kind is what a log step did. +type Kind int + +const ( + Thought Kind = iota + Cast + Hit + Miss + Said +) + +var kinds = [...]struct { + msg string + slot pixel.Slot +}{{"log_thought", pixel.Faint}, {"log_cast", pixel.Info}, {"log_hit", pixel.Good}, {"log_miss", pixel.Bad}, {"log_said", pixel.Ink}} + +// LogStep is a line of the transcript. +type LogStep struct { + Kind Kind + Text string +} + +type AgentLogOptions struct { + // Width and Height are dots; zeros mean 240 by 120. + Width, Height int +} + +type agentLogKey struct{} + +// AgentLog tells steps as a battle log, the newest typed. +func AgentLog(c *ui.Context, text, agent string, steps []LogStep, o AgentLogOptions) *ui.Element { + k := pixel.K(c) + if agent == "" { + panic("agent: AgentLog has no agent") + } + lines := make([]string, len(steps)) + for i, s := range steps { + if s.Kind < Thought || s.Kind > Said || s.Text == "" { + panic(fmt.Sprintf("agent: AgentLog step %d is kind %d with %q", i, s.Kind, s.Text)) + } + // Hits and misses tell the outcome, not who. + args := []any{agent, s.Text} + if s.Kind == Hit || s.Kind == Miss { + args = args[1:] + } + lines[i] = fmt.Sprintf(k.Msg(kinds[s.Kind].msg), args...) + } + w, h := pixel.Or("AgentLog Width", o.Width, 240), pixel.Or("AgentLog Height", o.Height, 120) + box := ui.Box(c).Size(k.D(w), k.D(h)) + s := ui.Local(box, agentLogKey{}, func() ui.ScrollState { return ui.ScrollState{} }) + return box.Children(func() { + layout.ScrollArea(c, s, layout.ScrollAreaOptions{Label: text, Follow: true}, func() { + col := ui.Column(c).FillWidth().Padding(k.D(4)).Gap(k.D(2)) + // A new line types out, three glyphs a step. + newest := 0 + if len(lines) > 0 { + newest = utf8.RuneCountInString(lines[len(lines)-1]) + } + shown := 3 * k.Shot(c, col, len(steps), 15, (newest+2)/3) + col.Children(func() { + for i, l := range lines { + n := math.MaxInt + if i == len(lines)-1 { + n = shown + } + typed(c, k, l, kinds[steps[i].Kind].slot, n) + } + }) + }).Element.Fill() + }) +} + +// typed draws s wrapped, but only its first n glyphs. +func typed(c *ui.Context, k pixel.Kit, s string, ink pixel.Slot, n int) { + e := ui.Box(c).FillWidth().Role(ui.RoleText).Label(s) + width, check := pixel.WrapWidth(c, e) + lines := pixel.Wrap(s, width, 0) + e.Height(k.D(len(lines) * pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) { + check(r) + g, left := k.Canvas(p, r), n + for i, l := range lines { + rs := []rune(l) + g.Text(0, i*pixel.LineHeight, string(rs[:min(left, len(rs))]), ink, 1) + if left -= len(rs); left <= 0 { + return + } + } + }) +} diff --git a/agent/agentstatus.go b/agent/agentstatus.go new file mode 100644 index 0000000..e3133b2 --- /dev/null +++ b/agent/agentstatus.go @@ -0,0 +1,86 @@ +package agent + +import ( + "cmp" + "math" + "slices" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type AgentStatusOptions struct { + // Portrait fits 12 dots square; zero means a robot. + Portrait *pixel.Sprite +} + +// halo is the status ring's dots from twelve o'clock, clockwise. +var halo = ring(17) + +// AgentStatus rings a portrait: idle, working, blocked or done. +func AgentStatus(c *ui.Context, text string, s Status, o AgentStatusOptions) *ui.Element { + k := pixel.K(c) + face := portrait("AgentStatus", o.Portrait) + word, slot := s.look(k) + // The widest word sets the width, so it never jumps. + w := 40 + for st := Idle; st <= Done; st++ { + wd, _ := st.look(k) + w = max(w, pixel.Measure(wd)) + } + cx, cy := w/2, 18 + return ui.Box(c).Size(k.D(w), k.D(52)).Role(ui.RoleStatus).Label(text).Description(word).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + switch s { + case Idle: + for i, d := range halo { + if i%2 == 0 { + g.Dot(cx+d[0], cy+d[1], pixel.Faint) + } + } + // A sleepy Z drifts up; Reduce Motion holds it. + g.Micro(cx+13, 2-g.Step(4)%3, "Z", pixel.Faint, 1) + case Working: + // A busy arc chases round, even under Reduce Motion. + at := g.Busy(8) % 12 * len(halo) / 12 + for i, d := range halo { + ink := pixel.Shade + if (i-at+len(halo))%len(halo) < len(halo)/4 { + ink = pixel.Info + } + g.Dot(cx+d[0], cy+d[1], ink) + } + case Blocked: + g.Circle(cx, cy, 17, pixel.Warn) + g.Rect(cx+12, 0, 7, 9, pixel.Warn) + g.Micro(cx+14, 2, "!", pixel.Ink, 1) + case Done: + g.Circle(cx, cy, 17, pixel.Good) + g.Circle(cx, cy, 16, pixel.Good) + } + g.Sprite(cx-face.W, cy-face.H, face, 0, pixel.Ink, 2) + g.Text((w-pixel.Measure(word))/2, 36, word, slot, 1) + }) +} + +// ring is a midpoint circle's dots, clockwise from the top. +func ring(r int) [][2]int { + var pts [][2]int + x, y, e := r, 0, 1-r + for x >= y { + pts = append(pts, [2]int{x, y}, [2]int{y, x}, [2]int{-x, y}, [2]int{-y, x}, [2]int{x, -y}, [2]int{y, -x}, [2]int{-x, -y}, [2]int{-y, -x}) + y++ + if e < 0 { + e += 2*y + 1 + } else { + x-- + e += 2*(y-x) + 1 + } + } + turn := func(p [2]int) float64 { + return math.Mod(math.Atan2(float64(p[0]), float64(-p[1]))+2*math.Pi, 2*math.Pi) + } + slices.SortFunc(pts, func(a, b [2]int) int { return cmp.Compare(turn(a), turn(b)) }) + return slices.Compact(pts) +} diff --git a/agent/approvalprompt.go b/agent/approvalprompt.go new file mode 100644 index 0000000..c733172 --- /dev/null +++ b/agent/approvalprompt.go @@ -0,0 +1,88 @@ +package agent + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ApprovalPromptOptions struct { + // Danger inks the command and YES in Bad. + Danger bool + // Width is in dots; zero means 240. + Width int + Disabled bool +} + +type ApprovalPromptResult struct { + Element *ui.Element + // Allowed or Denied is the answer given this frame. + Allowed, Denied bool +} + +// ApprovalPrompt asks ALLOW SPELL? over a command: YES or NO. +func ApprovalPrompt(c *ui.Context, text, command string, o ApprovalPromptOptions) ApprovalPromptResult { + k := pixel.K(c) + if command == "" { + panic("agent: ApprovalPrompt has no command") + } + w := pixel.Or("ApprovalPrompt Width", o.Width, 240) + ask, warn := k.Msg("allow_spell"), k.Msg("danger") + need := 28 + pixel.Measure(ask) + if o.Danger { + need += 20 + pixel.Measure(warn) + } + if w < need { + panic(fmt.Sprintf("agent: ApprovalPrompt Width %d is under %d", w, need)) + } + // The command shows whole to readers, three lines to eyes. + lines, ink, yes := pixel.Wrap(command, w-20, 3), pixel.Ink, input.Primary + if o.Danger { + ink, yes = pixel.Bad, input.Danger + } + var res ApprovalPromptResult + panel := ui.Column(c).Width(k.D(w)).Padding(k.D(6)).Gap(k.D(6)).Role(ui.RoleGroup).Label(text) + if o.Disabled { + panel.Description(k.Msg("disabled")) + } + res.Element = panel.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Double, pixel.Face) + }).Children(func() { + ui.Row(c).Gap(k.D(4)).AlignItems(ui.Center).Children(func() { + sprite(c, k, icon.Robot, pixel.Ink) + pixel.Text(c, ask, pixel.TextOptions{}) + if o.Danger { + sprite(c, k, icon.Warning, pixel.Bad) + pixel.Text(c, warn, pixel.TextOptions{Slot: pixel.Bad}) + } + }) + ui.Box(c).Size(k.D(w-12), k.D(len(lines)*pixel.LineHeight+6)).Role(ui.RoleText).Label(command).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Well) + for i, l := range lines { + g.Text(4, 3+i*pixel.LineHeight, l, ink, 1) + } + }) + ui.Row(c).Gap(k.D(6)).Children(func() { + res.Allowed = input.Button(c, k.Msg("yes"), input.ButtonOptions{Variant: yes, Disabled: o.Disabled}).Clicked + res.Denied = input.Button(c, k.Msg("no"), input.ButtonOptions{Disabled: o.Disabled}).Clicked + }) + }) + if res.Allowed || res.Denied { + slog.Debug("8bit: approval answered", "label", text, "allowed", res.Allowed) + } + return res +} + +// sprite is a 12-dot icon, unnamed, in ink. +func sprite(c *ui.Context, k pixel.Kit, sp *pixel.Sprite, ink pixel.Slot) *ui.Element { + return ui.Box(c).Size(k.D(sp.W), k.D(sp.H)).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) { + k.Canvas(p, r).Sprite(0, 0, sp, 0, ink, 1) + }) +} diff --git a/agent/checkpoints.go b/agent/checkpoints.go new file mode 100644 index 0000000..4cfe0c8 --- /dev/null +++ b/agent/checkpoints.go @@ -0,0 +1,90 @@ +package agent + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/datetime" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Checkpoint is a save point to go back to. +type Checkpoint struct { + Name string + At time.Time +} + +type CheckpointsOptions struct { + // Width is in dots; zero means 240. + Width int + Disabled bool +} + +type CheckpointsResult struct { + Element *ui.Element + // Restored is the checkpoint chosen this frame, else -1. + Restored int +} + +// Checkpoints lists save points, the newest aglow; RESTORE goes back. +func Checkpoints(c *ui.Context, text string, points []Checkpoint, o CheckpointsOptions) CheckpointsResult { + k := pixel.K(c) + newest, seen := -1, map[string]bool{} + for i, p := range points { + if p.Name == "" || p.At.IsZero() || seen[p.Name] { + panic(fmt.Sprintf("agent: Checkpoints point %d %q is blank, undated or twice", i, p.Name)) + } + seen[p.Name] = true + if newest < 0 || points[newest].At.Before(p.At) { + newest = i + } + } + w := pixel.Or("Checkpoints Width", o.Width, 240) + // Names and times need room beside the crystal and RESTORE. + if need := 12 + 4 + 96 + 4 + pixel.Measure(k.Msg("restore")) + 8; w < need { + panic(fmt.Sprintf("agent: Checkpoints Width %d is under %d", w, need)) + } + res := CheckpointsResult{Restored: -1} + col := ui.Column(c).Width(k.D(w)).Gap(k.D(4)).Role(ui.RoleGroup).Label(text) + if o.Disabled { + col.Description(k.Msg("disabled")) + } + res.Element = col.Children(func() { + if len(points) == 0 { + pixel.Text(c, k.Msg("empty"), pixel.TextOptions{Slot: pixel.Faint}) + } + for i, p := range points { + // Keyed by name: points come and go. + ui.Box(c).Key(p.Name).FillWidth().Children(func() { + ui.Row(c).FillWidth().Gap(k.D(4)).AlignItems(ui.Center).Children(func() { + glow := i == newest + ui.Box(c).Size(k.D(12), k.D(12)).Role(ui.RoleNone).Draw(func(pt *ui.Painter, r ui.Rect) { + g := k.Canvas(pt, r) + if !glow { + g.Sprite(0, 0, icon.Crystal, 0, pixel.Info, 1) + return + } + // The newest save point shimmers in Magic and Accent. + g.Sprite(0, 0, icon.Crystal, 0, pixel.Cycle([]pixel.Slot{pixel.Magic, pixel.Accent}, g.Step(4))[0], 1) + }) + ui.Column(c).Grow(1).Children(func() { + pixel.Text(c, p.Name, pixel.TextOptions{Wrap: true, MaxLines: 1}) + datetime.RelativeTime(c, p.At, datetime.RelativeTimeOptions{}) + }) + if input.Button(c, k.Msg("restore"), input.ButtonOptions{Size: input.Small, Label: k.Msg("restore") + " " + p.Name, Disabled: o.Disabled}).Clicked { + res.Restored = i + } + }) + }) + } + }) + if res.Restored >= 0 { + slog.Debug("8bit: checkpoint restored", "label", text, "name", points[res.Restored].Name) + } + return res +} diff --git a/agent/controls_test.go b/agent/controls_test.go new file mode 100644 index 0000000..019bc01 --- /dev/null +++ b/agent/controls_test.go @@ -0,0 +1,198 @@ +package agent_test + +import ( + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/agent" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestApprovalPromptAnswersAndKeepsTheCommandSecret(t *testing.T) { + allowed, denied, off := 0, 0, false + cmd := "curl -H 'Authorization: Bearer SECRET' https://example.com/deploy && rm -rf build/cache/old/artifacts" + tt := view(600, 300, newWorld(), func(c *ui.Context) { + r := agent.ApprovalPrompt(c, "Approval", cmd, agent.ApprovalPromptOptions{Danger: true, Disabled: off}) + if r.Allowed { + allowed++ + } + if r.Denied { + denied++ + } + }) + log := logged(func() { + click(t, tt, "YES") + click(t, tt, "NO") + }) + if allowed != 1 || denied != 1 || !strings.Contains(log, "allowed=true") || strings.Contains(log, "SECRET") { + t.Fatalf("allowed %d, denied %d, log:\n%s", allowed, denied, log) + } + // The whole command reads out; Danger inks it Bad. + x, y, w, h := dots(find(t, tt, cmd)) + if count(tt.Image(), x, y, w, h, pixel.Bad) == 0 || h != 3*16+6 { + t.Fatalf("the command well is %d dots high, or not Bad", h) + } + off = true + tt.Frame() + click(t, tt, "YES") + if allowed != 1 { + t.Fatal("a disabled prompt took a YES") + } +} + +func TestAgentLogTypesTheNewestStep(t *testing.T) { + for _, reduce := range []bool{false, true} { + wd := newWorld() + wd.reduce = reduce + steps := []agent.LogStep{{Kind: agent.Cast, Text: "read_file"}} + tt := view(600, 300, wd, func(c *ui.Context) { agent.AgentLog(c, "Log", "ROBO", steps, agent.AgentLogOptions{}) }) + ink := func(label string, s pixel.Slot) int { + x, y, w, h := dots(find(t, tt, label)) + return count(tt.Image(), x, y, w, h, s) + } + // Opened, the log shows whole: nothing types. + if ink("ROBO casts read_file!", pixel.Info) < 20 { + t.Fatal("the first line is not shown whole") + } + steps = append(steps, agent.LogStep{Kind: agent.Hit, Text: "3 files found"}) + tt.Frame() + first := ink("A hit! 3 files found", pixel.Good) + later(tt, wd, time.Second) + full := ink("A hit! 3 files found", pixel.Good) + if full == 0 || reduce != (first == full) { + t.Errorf("reduce %v: %d dots typed at once, %d after a second", reduce, first, full) + } + } +} + +func TestMemoryCrystalsBrowseAndPick(t *testing.T) { + memories := []string{"Prefers tabs", "Ships on Fridays"} + picked, off := -1, false + tt := view(400, 200, newWorld(), func(c *ui.Context) { + if r := agent.MemoryCrystals(c, "Memory", memories, agent.MemoryCrystalsOptions{Slots: 4, Disabled: off}); r.Picked >= 0 { + picked = r.Picked + } + }) + x, y, _, _ := dots(find(t, tt, "Memory")) + img := tt.Image() + for i := range 4 { + if held := count(img, x+i*26, y, 26, 30, pixel.Magic) > 0; held != (i < 2) { + t.Errorf("crystal %d lit is %v", i, held) + } + } + // A click picks the crystal under it; so does Enter. + click(t, tt, "Memory") + tt.Key(0, ui.KeyHome) + tt.Key(0, ui.KeyRight) + if picked != 2 { + t.Fatalf("the click picked %d", picked) + } + tt.Key(0, ui.KeyEnter) + if picked != 1 { + t.Fatalf("Home, Right, Enter picked %d", picked) + } + off = true + tt.Frame() + tt.Key(0, ui.KeyEnd) + tt.Key(0, ui.KeyEnter) + if picked != 1 { + t.Fatalf("a disabled shelf picked %d", picked) + } +} + +func TestModelSelectPicksLikeAFighter(t *testing.T) { + models := []agent.Model{{Name: "SWIFT", Speed: 5, Power: 2, Cost: 1}, {Name: "TITAN", Speed: 2, Power: 5, Cost: 5}, {Name: "SAGE", Speed: 3, Power: 4, Cost: 3}} + chosen, changed, off := 0, 0, false + tt := view(600, 300, newWorld(), func(c *ui.Context) { + if agent.ModelSelect(c, "Models", models, &chosen, agent.ModelSelectOptions{Disabled: off}).Changed { + changed++ + } + }) + click(t, tt, "Models") + if chosen != 1 || changed != 1 { + t.Fatalf("the click chose %d, %d changes", chosen, changed) + } + tt.Key(0, ui.KeyRight) + x, y, w, _ := dots(find(t, tt, "Models")) + img := tt.Image() + // The panel shows SAGE, under the cursor: three SPD pips. + for j := range 5 { + if want := j < 3; (at(img, x+w+8+50+j*12+1, y+46+6) == pixel.Info) != want { + t.Errorf("SPD pip %d lit is not %v", j, want) + } + } + tt.Key(0, ui.KeyEnter) + if chosen != 2 || changed != 2 || at(tt.Image(), x+60+3, y+3) != pixel.Accent { + t.Fatalf("Right and Enter chose %d, %d changes, or no Accent corner", chosen, changed) + } + off = true + tt.Frame() + tt.Key(0, ui.KeyLeft) + tt.Key(0, ui.KeyEnter) + if chosen != 2 { + t.Fatalf("a disabled select chose %d", chosen) + } +} + +func TestPromptBoxCastsOnEnterOrCast(t *testing.T) { + draft, cast := "", []string{} + tt := view(600, 300, newWorld(), func(c *ui.Context) { + if r := agent.PromptBox(c, "Prompt", &draft, agent.PromptBoxOptions{}); r.Cast != "" { + cast = append(cast, r.Cast) + } + }) + click(t, tt, "CAST") + click(t, tt, "Prompt") + log := logged(func() { + tt.Type("fix") + tt.Key(ui.Shift, ui.KeyEnter) + tt.Type("it") + tt.Key(0, ui.KeyEnter) + }) + if len(cast) != 1 || cast[0] != "fix\nit" || draft != "" { + t.Fatalf("cast %q, draft %q", cast, draft) + } + if !strings.Contains(log, "runes=6") || strings.Contains(log, "fix") { + t.Fatalf("the log should count, not quote:\n%s", log) + } + tt.Type("ship") + click(t, tt, "CAST") + if len(cast) != 2 || cast[1] != "ship" || draft != "" { + t.Fatalf("cast %q, draft %q", cast, draft) + } +} + +func TestCheckpointsRestoreByName(t *testing.T) { + wd := newWorld() + now := wd.clk.t + points := []agent.Checkpoint{{Name: "Before refactor", At: now.Add(-2 * time.Hour)}, {Name: "Tests green", At: now.Add(-5 * time.Minute)}} + restored, off := -1, false + tt := view(600, 300, wd, func(c *ui.Context) { + if r := agent.Checkpoints(c, "Saves", points, agent.CheckpointsOptions{Disabled: off}); r.Restored >= 0 { + restored = r.Restored + } + }) + find(t, tt, "5 MIN AGO") + click(t, tt, "RESTORE Tests green") + if restored != 1 { + t.Fatalf("restored %d", restored) + } + // Rows are keyed: losing the first keeps the focus put. + points = points[1:] + tt.Frame() + if !tt.Focused("RESTORE Tests green") { + t.Fatal("the focus left with the removed row") + } + off, restored = true, -1 + tt.Frame() + click(t, tt, "RESTORE Tests green") + if restored != -1 { + t.Fatal("a disabled list restored") + } + points = nil + tt.Frame() + find(t, tt, "NO ITEMS") +} diff --git a/agent/costcoins.go b/agent/costcoins.go new file mode 100644 index 0000000..11183a5 --- /dev/null +++ b/agent/costcoins.go @@ -0,0 +1,75 @@ +package agent + +import ( + "fmt" + "strconv" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type CostCoinsOptions struct { + // Symbol leads the amount; zero means "$". + Symbol string +} + +// CostCoins stacks cost as gold dollars, silver dimes, bronze cents. +func CostCoins(c *ui.Context, text string, cents int, o CostCoinsOptions) *ui.Element { + k := pixel.K(c) + if cents < 0 { + panic(fmt.Sprintf("agent: CostCoins of %d cents", cents)) + } + sym := o.Symbol + if sym == "" { + sym = "$" + } + amount := fmt.Sprintf("%s%d.%02d", sym, cents/100, cents%100) + counts := [3]int{cents / 100, cents / 10 % 10, cents % 10} + var cols [3]int + stacks := 8 + for i, n := range counts { + cols[i] = max(14, pixel.MicroWidth(strconv.Itoa(n))+2) + stacks += cols[i] + } + w := max(stacks, pixel.Measure(amount)) + e := ui.Box(c).Size(k.D(w), k.D(66)).Role(ui.RoleImage).Label(text).Description(amount) + // A change drops each stack's top coin in. + drop := []int{-6, -3, -1, 0}[k.Shot(c, e, cents, 12, 3)] + return e.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + x := (w - stacks) / 2 + for i, n := range counts { + cx := x + (cols[i]-12)/2 + g.HLine(cx, 42, 12, pixel.Shade) + top := min(n, 10) + for j := range top { + y := 38 - 3*j + if j == top-1 { + y += drop + } + coin(g, cx, y, i) + } + s := strconv.Itoa(n) + g.Micro(x+(cols[i]-pixel.MicroWidth(s))/2, 45, s, pixel.Ink, 1) + x += cols[i] + 4 + } + g.Text((w-pixel.Measure(amount))/2, 50, amount, pixel.Ink, 1) + }) +} + +// coin draws one edge-on: 0 gold, 1 silver, 2 bronze. +func coin(g pixel.Canvas, x, y, metal int) { + fill := pixel.Warn + if metal == 1 { + fill = pixel.Face + } + g.HLine(x+1, y, 10, pixel.Edge) + g.Rect(x, y+1, 12, 2, pixel.Edge) + g.Rect(x+1, y+1, 10, 2, fill) + if metal == 2 { + g.Checker(x+1, y+1, 10, 2, pixel.Bad) + } + g.Dot(x+2, y+1, pixel.Lit) + g.HLine(x+1, y+3, 10, pixel.Edge) +} diff --git a/agent/diffscroll.go b/agent/diffscroll.go new file mode 100644 index 0000000..701b28d --- /dev/null +++ b/agent/diffscroll.go @@ -0,0 +1,121 @@ +package agent + +import ( + "fmt" + "math" + "strconv" + "strings" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// FileDiff is a changed file: lines added and removed. +type FileDiff struct { + Path string + Added, Removed int +} + +type DiffScrollOptions struct { + // Width is in dots; zero means 220. + Width int +} + +// DiffScroll unrolls a parchment of changed files, lines counted. +func DiffScroll(c *ui.Context, text string, files []FileDiff, o DiffScrollOptions) *ui.Element { + k := pixel.K(c) + if len(files) == 0 { + panic("agent: DiffScroll has no files") + } + add, del := 0, 0 + said := make([]string, 0, len(files)+1) + for _, f := range files { + if f.Path == "" || f.Added < 0 || f.Removed < 0 || f.Added > math.MaxInt-add || f.Removed > math.MaxInt-del { + panic(fmt.Sprintf("agent: DiffScroll file %q +%d -%d", f.Path, f.Added, f.Removed)) + } + add, del = add+f.Added, del+f.Removed + said = append(said, f.Path+" +"+strconv.Itoa(f.Added)+" -"+strconv.Itoa(f.Removed)) + } + w := pixel.Or("DiffScroll Width", o.Width, 220) + total := fmt.Sprintf(k.Msg("files_changed"), len(files)) + said = append(said, total+" +"+strconv.Itoa(add)+" -"+strconv.Itoa(del)) + // Count columns fit the totals, the widest of them. + aw, dw := pixel.MicroWidth("+"+strconv.Itoa(add)), pixel.MicroWidth("-"+strconv.Itoa(del)) + bx := w - 8 - 24 + dx := bx - 6 - dw + ax := dx - 4 - aw + if ax-12 < 24 || pixel.Measure(total) > ax-12 { + panic(fmt.Sprintf("agent: DiffScroll Width %d is too narrow", w)) + } + h := 6 + 4 + (len(files)+1)*pixel.LineHeight + 4 + 6 + return ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleImage).Label(text).Description(strings.Join(said, "; ")).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Rect(4, 6, w-8, h-12, pixel.Lit) + g.VLine(4, 6, h-12, pixel.Shade) + g.VLine(w-5, 6, h-12, pixel.Shade) + rod(g, 0, w) + rod(g, h-6, w) + row := func(y int, name string, a, d int) { + g.Text(8, y, name, pixel.Ink, 1) + plus, minus := "+"+strconv.Itoa(a), "-"+strconv.Itoa(d) + g.Micro(ax+aw-pixel.MicroWidth(plus), y+6, plus, ink(a, pixel.Good), 1) + g.Micro(dx+dw-pixel.MicroWidth(minus), y+6, minus, ink(d, pixel.Bad), 1) + blocks(g, bx, y+6, a, d) + } + y := 10 + for _, f := range files { + row(y, tail(f.Path, ax-12), f.Added, f.Removed) + y += pixel.LineHeight + } + g.Checker(8, y+1, w-16, 1, pixel.Shade) + row(y+4, total, add, del) + }) +} + +// ink is s for a count, Faint for none. +func ink(n int, s pixel.Slot) pixel.Slot { + if n == 0 { + return pixel.Faint + } + return s +} + +// rod is a scroll's wooden roller, capped past the paper. +func rod(g pixel.Canvas, y, w int) { + g.Rect(1, y+1, w-2, 4, pixel.Shade) + g.HLine(1, y, w-2, pixel.Edge) + g.HLine(1, y+5, w-2, pixel.Edge) + g.Rect(0, y, 3, 6, pixel.Edge) + g.Rect(w-3, y, 3, 6, pixel.Edge) +} + +// blocks shares five squares between added and removed. +func blocks(g pixel.Canvas, x, y, a, d int) { + good := 0 + if a > 0 || d > 0 { + good = int(math.Round(5 * float64(a) / (float64(a) + float64(d)))) + } + for i := range 5 { + ink := pixel.Bad + switch { + case a == 0 && d == 0: + ink = pixel.Shade + case i < good: + ink = pixel.Good + } + g.Rect(x+i*5, y, 4, 4, ink) + } +} + +// tail cuts s from the left, so the name shows. +func tail(s string, width int) string { + if pixel.Measure(s) <= width { + return s + } + rs := []rune(s) + for len(rs) > 0 && pixel.Measure("…"+string(rs)) > width { + rs = rs[1:] + } + return "…" + string(rs) +} diff --git a/agent/extremes_test.go b/agent/extremes_test.go new file mode 100644 index 0000000..0bb8e79 --- /dev/null +++ b/agent/extremes_test.go @@ -0,0 +1,93 @@ +package agent_test + +import ( + "fmt" + "math" + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/agent" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestBadInputPanics(t *testing.T) { + big := pixel.MustSprite("#############\n" + "#############") + ok := agent.Agent{Name: "R", Class: "C", Level: 1, Window: 10} + deep := []agent.Member{{Name: "A", Summoned: []agent.Member{{Name: "B", Summoned: []agent.Member{{Name: "C"}}}}}} + n := 0 + for name, build := range map[string]func(c *ui.Context){ + "over window": func(c *ui.Context) { a := ok; a.Used = 11; agent.AgentCard(c, a, agent.AgentCardOptions{}) }, + "level 0": func(c *ui.Context) { a := ok; a.Level = 0; agent.AgentCard(c, a, agent.AgentCardOptions{}) }, + "no class": func(c *ui.Context) { a := ok; a.Class = ""; agent.AgentCard(c, a, agent.AgentCardOptions{}) }, + "big face": func(c *ui.Context) { agent.AgentCard(c, ok, agent.AgentCardOptions{Portrait: big}) }, + "no tool": func(c *ui.Context) { agent.ToolCall(c, "", agent.OK, agent.ToolCallOptions{}) }, + "outcome": func(c *ui.Context) { agent.ToolCall(c, "t", agent.Outcome(9), agent.ToolCallOptions{}) }, + "thin card": func(c *ui.Context) { agent.ToolCall(c, "t", agent.OK, agent.ToolCallOptions{Width: 40}) }, + "no steps": func(c *ui.Context) { agent.PlanQuest(c, "Q", nil, agent.PlanQuestOptions{}) }, + "blank step": func(c *ui.Context) { agent.PlanQuest(c, "Q", []agent.PlanStep{{}}, agent.PlanQuestOptions{}) }, + "thin quest": func(c *ui.Context) { + agent.PlanQuest(c, "Q", []agent.PlanStep{{Text: "a"}}, agent.PlanQuestOptions{Width: 30}) + }, + "negative mana": func(c *ui.Context) { agent.TokenMana(c, "M", -1, 10, agent.TokenManaOptions{}) }, + "no budget": func(c *ui.Context) { agent.TokenMana(c, "M", 0, 0, agent.TokenManaOptions{}) }, + "thin mana": func(c *ui.Context) { agent.TokenMana(c, "M", 0, 10, agent.TokenManaOptions{Width: 10}) }, + "no command": func(c *ui.Context) { agent.ApprovalPrompt(c, "A", "", agent.ApprovalPromptOptions{}) }, + "thin prompt": func(c *ui.Context) { agent.ApprovalPrompt(c, "A", "ls", agent.ApprovalPromptOptions{Width: 50}) }, + "no agent": func(c *ui.Context) { agent.AgentLog(c, "L", "", nil, agent.AgentLogOptions{}) }, + "kind": func(c *ui.Context) { + agent.AgentLog(c, "L", "R", []agent.LogStep{{Kind: agent.Kind(9), Text: "x"}}, agent.AgentLogOptions{}) + }, + "blank line": func(c *ui.Context) { agent.AgentLog(c, "L", "R", []agent.LogStep{{}}, agent.AgentLogOptions{}) }, + "nobody": func(c *ui.Context) { agent.SubAgentParty(c, "P", nil, agent.SubAgentPartyOptions{}) }, + "nameless": func(c *ui.Context) { agent.SubAgentParty(c, "P", []agent.Member{{}}, agent.SubAgentPartyOptions{}) }, + "too deep": func(c *ui.Context) { agent.SubAgentParty(c, "P", deep, agent.SubAgentPartyOptions{Width: 80}) }, + "status": func(c *ui.Context) { agent.AgentStatus(c, "S", agent.Status(7), agent.AgentStatusOptions{}) }, + "full shelf": func(c *ui.Context) { + agent.MemoryCrystals(c, "M", []string{"a", "b"}, agent.MemoryCrystalsOptions{Slots: 1}) + }, + "blank mem": func(c *ui.Context) { agent.MemoryCrystals(c, "M", []string{""}, agent.MemoryCrystalsOptions{}) }, + "no files": func(c *ui.Context) { agent.DiffScroll(c, "D", nil, agent.DiffScrollOptions{}) }, + "minus lines": func(c *ui.Context) { + agent.DiffScroll(c, "D", []agent.FileDiff{{Path: "a", Added: -1}}, agent.DiffScrollOptions{}) + }, + "line flood": func(c *ui.Context) { + agent.DiffScroll(c, "D", []agent.FileDiff{{Path: "a", Added: math.MaxInt}, {Path: "b", Added: 1}}, agent.DiffScrollOptions{Width: 2000}) + }, + "thin scroll": func(c *ui.Context) { + agent.DiffScroll(c, "D", []agent.FileDiff{{Path: "a"}}, agent.DiffScrollOptions{Width: 60}) + }, + "chosen": func(c *ui.Context) { + n := 1 + agent.ModelSelect(c, "M", []agent.Model{{Name: "A", Speed: 1, Power: 1, Cost: 1}}, &n, agent.ModelSelectOptions{}) + }, + "stat 6": func(c *ui.Context) { + n := 0 + agent.ModelSelect(c, "M", []agent.Model{{Name: "A", Speed: 6, Power: 1, Cost: 1}}, &n, agent.ModelSelectOptions{}) + }, + "minus cost": func(c *ui.Context) { agent.CostCoins(c, "C", -1, agent.CostCoinsOptions{}) }, + "undated": func(c *ui.Context) { + agent.Checkpoints(c, "S", []agent.Checkpoint{{Name: "A"}}, agent.CheckpointsOptions{}) + }, + } { + n++ + mustPanic(t, name, func() { view(600, 300, newWorld(), build) }) + } + if n != 32 { + t.Fatalf("%d cases", n) + } +} + +func TestCheckpointsRefuseTwins(t *testing.T) { + defer func() { + if r := fmt.Sprint(recover()); !strings.Contains(r, "agent: Checkpoints") { + t.Fatalf("twins panicked with %q", r) + } + }() + at := time.Unix(1, 0) + view(600, 300, newWorld(), func(c *ui.Context) { + agent.Checkpoints(c, "S", []agent.Checkpoint{{Name: "A", At: at}, {Name: "A", At: at}}, agent.CheckpointsOptions{}) + }) +} diff --git a/agent/memorycrystals.go b/agent/memorycrystals.go new file mode 100644 index 0000000..7bb83d8 --- /dev/null +++ b/agent/memorycrystals.go @@ -0,0 +1,90 @@ +package agent + +import ( + "fmt" + "log/slog" + "slices" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type MemoryCrystalsOptions struct { + // Slots is how many crystals; zero means 8. + Slots int + Disabled bool +} + +type MemoryCrystalsResult struct { + Element *ui.Element + // Picked is the crystal chosen this frame, else -1. + Picked int +} + +var ( + twinkle = pixel.MustSprite(` + ..#.. + ..#.. + ##.## + ..#.. + ..#..`) +) + +type crystalKey struct{} + +const crystalW, crystalH = 26, 30 + +// MemoryCrystals keeps memories in crystals; empty ones stay dark. +func MemoryCrystals(c *ui.Context, text string, memories []string, o MemoryCrystalsOptions) MemoryCrystalsResult { + k := pixel.K(c) + n := pixel.Or("MemoryCrystals Slots", o.Slots, 8) + if len(memories) > n || slices.Contains(memories, "") { + panic(fmt.Sprintf("agent: MemoryCrystals has %d memories for %d slots, or a blank", len(memories), n)) + } + cols := min(n, 8) + col := ui.Column(c).Gap(k.D(4)) + cur := ui.Local(col, crystalKey{}, func() int { return 0 }) + *cur = min(*cur, n-1) + res := MemoryCrystalsResult{Picked: -1} + res.Element = col.Children(func() { + shell, base, st, picked := pixel.Pad(c, text, cols, n, crystalW, crystalH, cur, o.Disabled) + res.Picked = picked + at, held := *cur, len(memories) + line, ink := fmt.Sprintf("%d/%d: %s", at+1, n, k.Msg("empty_crystal")), pixel.Faint + if at < held { + line, ink = fmt.Sprintf("%d/%d: %s", at+1, n, memories[at]), pixel.Ink + } + base.Description(line) + shell.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + if st.Focus { + g.Cursor(at%cols*crystalW, at/cols*crystalH, crystalW, crystalH) + } + // Held crystals twinkle in turn; Reduce Motion stills them. + step := g.Step(4) + for i := range n { + x, y := i%cols*crystalW, i/cols*crystalH + if i >= held { + g.Sprite(x+1, y+3, icon.Crystal, 0, pixel.Well, 2) + continue + } + g.Sprite(x+1, y+3, icon.Crystal, 0, pixel.Magic, 2) + if (step+i*3)%8 == 0 { + g.Sprite(x+15, y+5, twinkle, 0, pixel.Lit, 1) + } + } + g.Finish(st) + }) + ui.Box(c).Size(k.D(cols*crystalW), k.D(pixel.LineHeight+6)).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Sunken, pixel.Well) + g.Text(4, 3, pixel.Wrap(line, g.W-8, 1)[0], ink, 1) + }) + }) + if res.Picked >= 0 { + slog.Debug("8bit: memory crystal picked", "label", text, "slot", res.Picked) + } + return res +} diff --git a/agent/modelselect.go b/agent/modelselect.go new file mode 100644 index 0000000..a0e37ad --- /dev/null +++ b/agent/modelselect.go @@ -0,0 +1,125 @@ +package agent + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Model is a fighter to pick; stats run 1–5. +type Model struct { + Name string + // Portrait fits 12 dots square; zero means a robot. + Portrait *pixel.Sprite + Speed, Power, Cost int +} + +type ModelSelectOptions struct { + Disabled bool +} + +type ModelSelectResult struct { + Element *ui.Element + Changed bool +} + +type pickKey struct{} + +const fighter = 30 + +// ModelSelect picks a model the way fighters are picked. +func ModelSelect(c *ui.Context, text string, models []Model, chosen *int, o ModelSelectOptions) ModelSelectResult { + k := pixel.K(c) + if len(models) == 0 || *chosen < 0 || *chosen >= len(models) { + panic(fmt.Sprintf("agent: ModelSelect chose %d of %d", *chosen, len(models))) + } + faces := make([]*pixel.Sprite, len(models)) + for i, m := range models { + for _, s := range []int{m.Speed, m.Power, m.Cost} { + if m.Name == "" || s < 1 || s > 5 { + panic(fmt.Sprintf("agent: ModelSelect model %q has a stat of %d", m.Name, s)) + } + } + faces[i] = portrait("ModelSelect", m.Portrait) + } + cols, old := min(len(models), 4), *chosen + row := ui.Row(c).Gap(k.D(8)).AlignItems(ui.Start) + cur := ui.Local(row, pickKey{}, func() int { return *chosen }) + *cur = min(*cur, len(models)-1) + res := ModelSelectResult{} + res.Element = row.Children(func() { + shell, base, st, picked := pixel.Pad(c, text, cols, len(models), fighter, fighter, cur, o.Disabled) + if picked >= 0 { + *chosen = picked + } + at, pick := *cur, *chosen + m := models[at] + stats := []struct { + name string + value int + ink pixel.Slot + }{{k.Msg("speed"), m.Speed, pixel.Info}, {k.Msg("power"), m.Power, pixel.Bad}, {k.Msg("cost"), m.Cost, pixel.Warn}} + said := m.Name + for _, s := range stats { + said += fmt.Sprintf(", %s %d", s.name, s.value) + } + if at == pick { + said += ", " + k.Msg("chosen") + } + base.Description(said) + shell.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i, f := range faces { + x, y := i%cols*fighter, i/cols*fighter + // The cursor lights its cell; the portrait shows on it. + if i == at && st.Focus { + g.Cursor(x, y, fighter, fighter) + } else { + g.Frame(x+1, y+1, 28, 28, pixel.Sunken, pixel.Well) + } + g.Sprite(x+15-f.W, y+15-f.H, f, 0, pixel.Ink, 2) + // The chosen fighter wears an Accent ring and corner. + if i == pick { + g.Rect(x+1, y+1, 28, 1, pixel.Accent) + g.Rect(x+1, y+28, 28, 1, pixel.Accent) + g.Rect(x+1, y+1, 1, 28, pixel.Accent) + g.Rect(x+28, y+1, 1, 28, pixel.Accent) + g.Rect(x+2, y+2, 6, 2, pixel.Accent) + g.Rect(x+2, y+4, 4, 2, pixel.Accent) + g.Rect(x+2, y+6, 2, 2, pixel.Accent) + } + } + g.Finish(st) + }) + // The panel shows the fighter under the cursor, bobbing. + face := faces[at] + ui.Box(c).Size(k.D(120), k.D(98)).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, g.W, g.H, pixel.Double, pixel.Face) + g.Sprite(6+3*(12-face.W)/2, 6-g.Step(4)%2+3*(12-face.H)/2, face, 0, pixel.Ink, 3) + g.Text(48, 6, pixel.Wrap(m.Name, g.W-52, 1)[0], pixel.Ink, 1) + if at == pick { + g.Text(48, 22, pixel.Wrap(k.Msg("chosen"), g.W-52, 1)[0], pixel.Good, 1) + } + for i, s := range stats { + y := 46 + i*pixel.LineHeight + g.Text(6, y, pixel.Wrap(s.name, 40, 1)[0], pixel.Faint, 1) + for j := range 5 { + ink := pixel.Shade + if j < s.value { + ink = s.ink + } + g.Rect(50+j*12, y+5, 10, 6, ink) + } + } + }) + }) + res.Changed = *chosen != old + if res.Changed { + slog.Debug("8bit: model chosen", "label", text, "model", models[*chosen].Name) + } + return res +} diff --git a/agent/party_test.go b/agent/party_test.go new file mode 100644 index 0000000..daa3a7f --- /dev/null +++ b/agent/party_test.go @@ -0,0 +1,183 @@ +package agent_test + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/agent" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestAgentCardInksHPByContextLeft(t *testing.T) { + for used, want := range map[int]pixel.Slot{20_000: pixel.Good, 120_000: pixel.Warn, 180_000: pixel.Bad} { + a := agent.Agent{Name: "ROBO", Class: "CODER", Level: 7, Used: used, Window: 200_000} + tt := view(300, 120, newWorld(), func(c *ui.Context) { agent.AgentCard(c, a, agent.AgentCardOptions{}) }) + x, y, _, _ := dots(find(t, tt, "ROBO")) + // The fill starts two dots into the gauge's well. + fill := count(tt.Image(), x+38, y+40, 92, 4, want) + if left := 92 * (200_000 - used) / 200_000; fill < left*4-8 || fill > left*4+8 { + t.Errorf("%d of 200K used: %d dots of %v, want about %d", used, fill, want, left*4) + } + } +} + +func TestToolCallStripesItsOutcome(t *testing.T) { + for out, ink := range map[agent.Outcome]pixel.Slot{agent.Casting: pixel.Info, agent.OK: pixel.Good, agent.Failed: pixel.Bad} { + wd := newWorld() + wd.reduce = true + tt := view(500, 100, wd, func(c *ui.Context) { + agent.ToolCall(c, "read_file", out, agent.ToolCallOptions{Args: "main.go"}) + }) + x, y, w, h := dots(find(t, tt, "CAST read_file")) + if got := at(tt.Image(), x+2, y+5); got != ink || h != 38 { + t.Errorf("outcome %d: stripe %v, want %v; %d dots high", out, got, ink, h) + } + // Casting is busy: it moves even under Reduce Motion. + if moving := moves(tt, wd); moving != (out == agent.Casting) { + t.Errorf("outcome %d: moving is %v", out, moving) + } + if out == agent.Failed && count(tt.Image(), x+w-60, y, 56, 22, pixel.Good) > 0 { + t.Error("a failed cast shows Good") + } + } +} + +func TestPlanQuestMarksEachObjective(t *testing.T) { + steps := []agent.PlanStep{{Text: "Read the code", Done: true}, {Text: "Write a test"}, {Text: "Ship it"}} + tt := view(400, 200, newWorld(), func(c *ui.Context) { agent.PlanQuest(c, "Fix the bug", steps, agent.PlanQuestOptions{}) }) + x, y, _, h := dots(find(t, tt, "Fix the bug")) + row := func(i int, s pixel.Slot) int { return count(tt.Image(), x, y+20+i*16, 12, 16, s) } + if row(0, pixel.Good) == 0 || row(1, pixel.Accent) == 0 || row(2, pixel.Well) == 0 || row(2, pixel.Accent) > 0 { + t.Fatal("not a check, a ▶ and an open box") + } + for i := range steps { + steps[i].Done = true + } + tt.Frame() + _, _, _, done := dots(find(t, tt, "Fix the bug")) + if done != h+18 || count(tt.Image(), x, y+h, 200, 18, pixel.Good) == 0 { + t.Fatalf("all done: %d dots high, was %d, and no QUEST COMPLETE!", done, h) + } +} + +func TestThinkingOrbSwirlsOnlyWhileThinking(t *testing.T) { + for _, thinking := range []bool{false, true} { + wd := newWorld() + wd.reduce = true + tt := view(100, 100, wd, func(c *ui.Context) { agent.ThinkingOrb(c, "Mind", thinking, agent.ThinkingOrbOptions{}) }) + x, y, _, _ := dots(find(t, tt, "Mind")) + if moves(tt, wd) != thinking || (count(tt.Image(), x+3, y+1, 39, 39, pixel.Magic) > 0) != thinking { + t.Errorf("thinking %v: the swirl does not match", thinking) + } + } +} + +func TestTokenManaRunsDry(t *testing.T) { + for used, dry := range map[int]bool{0: false, 99_999: false, 100_000: true, 250_000: true} { + tt := view(300, 100, newWorld(), func(c *ui.Context) { + agent.TokenMana(c, "Budget", used, 100_000, agent.TokenManaOptions{}) + }) + x, y, _, _ := dots(find(t, tt, "Budget")) + img := tt.Image() + if got := count(img, x+2, y+9, 116, 4, pixel.Bad) > 0; got != dry { + t.Errorf("%d used: dry veil %v", used, got) + } + if full := count(img, x+2, y+9, 116, 4, pixel.Info); used == 0 && full < 116*4-9*4 { + t.Errorf("a full budget fills %d dots", full) + } + } +} + +func TestAgentStatusRings(t *testing.T) { + for s, want := range map[agent.Status]struct { + ink pixel.Slot + moves, still bool + }{agent.Idle: {pixel.Faint, true, false}, agent.Working: {pixel.Info, true, true}, agent.Blocked: {pixel.Warn, false, false}, agent.Done: {pixel.Good, false, false}} { + for _, reduce := range []bool{false, true} { + wd := newWorld() + wd.reduce = reduce + tt := view(200, 200, wd, func(c *ui.Context) { agent.AgentStatus(c, "Robo", s, agent.AgentStatusOptions{}) }) + x, y, w, _ := dots(find(t, tt, "Robo")) + if count(tt.Image(), x, y, w, 36, want.ink) == 0 { + t.Errorf("status %d: no %v ring", s, want.ink) + } + // Working runs on; Idle's Z rests under Reduce Motion. + if got, wantMove := moves(tt, wd), want.moves && (!reduce || want.still); got != wantMove { + t.Errorf("status %d, reduce %v: moving %v", s, reduce, got) + } + } + } +} + +func TestSubAgentPartyDrawsTheSummoning(t *testing.T) { + party := []agent.Member{{Name: "LEAD", Status: agent.Working, Summoned: []agent.Member{ + {Name: "SCOUT", Status: agent.Done}, {Name: "SMITH", Status: agent.Blocked, Summoned: []agent.Member{{Name: "IMP", Status: agent.Idle}}}, + }}} + tt := view(400, 200, newWorld(), func(c *ui.Context) { agent.SubAgentParty(c, "Party", party, agent.SubAgentPartyOptions{}) }) + x, y, _, h := dots(find(t, tt, "Party")) + img := tt.Image() + if h != 4*16 { + t.Fatalf("four members in %d dots", h) + } + // Robot inks: LEAD Info, SCOUT Good, SMITH Warn, IMP Faint. + for i, s := range []pixel.Slot{pixel.Info, pixel.Good, pixel.Warn, pixel.Faint} { + if count(img, x+[]int{0, 12, 12, 24}[i], y+i*16+2, 12, 12, s) == 0 { + t.Errorf("row %d has no %v robot", i, s) + } + } + // SCOUT's line runs on to SMITH; SMITH's ends at IMP's. + if at(img, x+5, y+16+12) != pixel.Edge || at(img, x+17, y+48+12) == pixel.Edge { + t.Error("the summoning lines run wrong") + } +} + +func TestDiffScrollSharesBlocks(t *testing.T) { + files := []agent.FileDiff{{Path: "a.go", Added: 10}, {Path: "b.go", Removed: 10}, {Path: "c.go"}} + tt := view(500, 200, newWorld(), func(c *ui.Context) { agent.DiffScroll(c, "Changes", files, agent.DiffScrollOptions{}) }) + x, y, w, _ := dots(find(t, tt, "Changes")) + img := tt.Image() + for i, want := range [][5]pixel.Slot{ + {pixel.Good, pixel.Good, pixel.Good, pixel.Good, pixel.Good}, {pixel.Bad, pixel.Bad, pixel.Bad, pixel.Bad, pixel.Bad}, + {pixel.Shade, pixel.Shade, pixel.Shade, pixel.Shade, pixel.Shade}, {pixel.Good, pixel.Good, pixel.Good, pixel.Bad, pixel.Bad}, + } { + // The total row sits four dots lower, past the rule. + ry := y + 10 + i*16 + 6 + if i == 3 { + ry += 4 + } + for j, s := range want { + if got := at(img, x+w-32+j*5+1, ry+1); got != s { + t.Errorf("row %d block %d is %v, want %v", i, j, got, s) + } + } + } +} + +func TestCostCoinsDropOnARise(t *testing.T) { + for _, reduce := range []bool{false, true} { + wd := newWorld() + wd.reduce = reduce + cents := 1234 + tt := view(200, 200, wd, func(c *ui.Context) { agent.CostCoins(c, "Spent", cents, agent.CostCoinsOptions{}) }) + x, y, _, _ := dots(find(t, tt, "Spent")) + // Bronze cents stack third; a fifth coin lands at 26. + air := func() int { + img := tt.Image() + return count(img, x+38, y+21, 10, 2, pixel.Warn) + count(img, x+38, y+21, 10, 2, pixel.Bad) + } + if air() > 0 { + t.Fatal("a coin hangs before any change") + } + cents = 1235 + tt.Frame() + if got := air() > 0; got == reduce { + t.Errorf("reduce %v: the new coin falling is %v", reduce, got) + } + later(tt, wd, time.Second) + if air() > 0 { + t.Errorf("reduce %v: the coin never lands", reduce) + } + } +} diff --git a/agent/planquest.go b/agent/planquest.go new file mode 100644 index 0000000..d2ee017 --- /dev/null +++ b/agent/planquest.go @@ -0,0 +1,86 @@ +package agent + +import ( + "fmt" + "strings" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// PlanStep is an objective; the first undone is under way. +type PlanStep struct { + Text string + Done bool +} + +type PlanQuestOptions struct { + // Width is in dots; zero means 200. + Width int +} + +// PlanQuest shows a plan's steps as quest objectives. +func PlanQuest(c *ui.Context, title string, steps []PlanStep, o PlanQuestOptions) *ui.Element { + k := pixel.K(c) + if title == "" || len(steps) == 0 { + panic(fmt.Sprintf("agent: PlanQuest %q has %d steps", title, len(steps))) + } + w := pixel.Or("PlanQuest Width", o.Width, 200) + if need := max(64, pixel.Measure(k.Msg("quest_complete"))); w < need { + panic(fmt.Sprintf("agent: PlanQuest Width %d is under %d", w, need)) + } + cur, done, h := len(steps), 0, pixel.LineHeight+4 + lines, said := make([][]string, len(steps)), make([]string, len(steps)) + for i, s := range steps { + if s.Text == "" { + panic(fmt.Sprintf("agent: PlanQuest step %d has no text", i)) + } + lines[i] = pixel.Wrap(s.Text, w-16, 0) + h += len(lines[i]) * pixel.LineHeight + said[i] = s.Text + switch { + case s.Done: + done++ + said[i] += ", " + k.Msg("done") + case cur == len(steps): + cur = i + said[i] += ", " + k.Msg("doing") + } + } + if cur == len(steps) { + h += pixel.LineHeight + 2 + } + count := fmt.Sprintf("%d/%d", done, len(steps)) + return ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleImage).Label(title).Description(count + "; " + strings.Join(said, "; ")).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + cw := pixel.MicroWidth(count) + g.Sprite(0, 2, icon.Scroll, 0, pixel.Warn, 1) + g.Text(16, 0, pixel.Wrap(title, w-20-cw, 1)[0], pixel.Ink, 1) + g.Micro(w-cw, 6, count, pixel.Ink, 1) + g.Checker(0, pixel.LineHeight+1, w, 1, pixel.Edge) + y := pixel.LineHeight + 4 + for i, s := range steps { + ink := pixel.Ink + switch { + case s.Done: + ink = pixel.Faint + g.Sprite(0, y+2, icon.Check, 0, pixel.Good, 1) + case i == cur: + g.Sprite(0, y+2, icon.TriangleRight, 0, pixel.Accent, 1) + default: + g.Rect(2, y+5, 8, 8, pixel.Edge) + g.Rect(3, y+6, 6, 6, pixel.Well) + } + for j, l := range lines[i] { + g.Text(16, y+j*pixel.LineHeight, l, ink, 1) + } + y += len(lines[i]) * pixel.LineHeight + } + if cur == len(steps) { + q := k.Msg("quest_complete") + g.Text((w-pixel.Measure(q))/2, y+2, q, pixel.Good, 1) + } + }) +} diff --git a/agent/promptbox.go b/agent/promptbox.go new file mode 100644 index 0000000..5b04dc0 --- /dev/null +++ b/agent/promptbox.go @@ -0,0 +1,49 @@ +package agent + +import ( + "log/slog" + "strings" + "unicode/utf8" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type PromptBoxOptions struct { + Placeholder string + // Width is in dots, zero 240; Lines zero means 3. + Width, Lines int + Disabled bool +} + +type PromptBoxResult struct { + Element *ui.Element + Changed bool + // Cast is the prompt cast this frame, else empty. + Cast string +} + +// PromptBox: Enter or CAST casts the prompt; Shift+Enter breaks lines. +func PromptBox(c *ui.Context, text string, value *string, o PromptBoxOptions) PromptBoxResult { + k := pixel.K(c) + w, lines := pixel.Or("PromptBox Width", o.Width, 240), pixel.Or("PromptBox Lines", o.Lines, 3) + var res PromptBoxResult + res.Element = ui.Column(c).Gap(k.D(4)).AlignItems(ui.End).Children(func() { + f := input.TextArea(c, text, value, input.TextAreaOptions{Placeholder: o.Placeholder, Width: w, Lines: lines, Disabled: o.Disabled, EnterSends: true}) + empty := strings.TrimSpace(*value) == "" + b := input.Button(c, k.Msg("cast"), input.ButtonOptions{Variant: input.Primary, Icon: icon.Sparkle, Disabled: o.Disabled || empty}) + res.Changed = f.Changed + // Off or veiled, a key sent this frame casts nothing. + if (f.Submitted || b.Clicked) && !empty && !f.Input.IsDisabled() { + res.Cast, *value = *value, "" + // The cleared field shows next frame and keeps focus. + f.Input.Focus() + c.Invalidate() + slog.Debug("8bit: prompt cast", "label", text, "runes", utf8.RuneCountInString(res.Cast)) + } + }) + return res +} diff --git a/agent/round1_test.go b/agent/round1_test.go new file mode 100644 index 0000000..7261295 --- /dev/null +++ b/agent/round1_test.go @@ -0,0 +1,102 @@ +package agent_test + +import ( + "math" + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/agent" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestPromptBoxHoldsUnderAVeil(t *testing.T) { + wd := newWorld() + draft, cast := "", 0 + tt := view(600, 300, wd, func(c *ui.Context) { + if agent.PromptBox(c, "Prompt", &draft, agent.PromptBoxOptions{}).Cast != "" { + cast++ + } + }) + click(t, tt, "Prompt") + tt.Type("wait") + wd.inert = true + tt.Key(0, ui.KeyEnter) + if cast != 0 || draft != "wait" { + t.Fatalf("a veiled box cast %d times, draft %q", cast, draft) + } +} + +func TestFocusedCellsKeepTheirArt(t *testing.T) { + n := 0 + tt := view(600, 300, newWorld(), func(c *ui.Context) { + agent.MemoryCrystals(c, "Memory", []string{"a"}, agent.MemoryCrystalsOptions{}) + agent.ModelSelect(c, "Models", []agent.Model{{Name: "A", Speed: 1, Power: 1, Cost: 1}}, &n, agent.ModelSelectOptions{}) + }) + tt.Key(0, ui.KeyTab) + x, y, _, _ := dots(find(t, tt, "Memory")) + // The cursor lights the cell; the art shows on it. + if !tt.Focused("Memory") || count(tt.Image(), x, y, 26, 30, pixel.Magic) == 0 || count(tt.Image(), x, y, 26, 30, pixel.Accent) == 0 { + t.Error("the cursor hides the crystal, or is missing") + } + tt.Key(0, ui.KeyTab) + x, y, _, _ = dots(find(t, tt, "Models")) + if !tt.Focused("Models") || count(tt.Image(), x, y, 30, 30, pixel.Ink) == 0 || count(tt.Image(), x, y, 30, 30, pixel.Lit) < 200 { + t.Error("the cursor hides the portrait, or is missing") + } +} + +func TestExtremesStayInside(t *testing.T) { + long, at := strings.Repeat("W", 80), time.Unix(1, 0) + quest := pixel.Measure("QUEST COMPLETE!") + saves := 12 + 4 + 96 + 4 + pixel.Measure("RESTORE") + 8 + for _, tc := range []struct { + label string + build func(c *ui.Context) + }{ + {long, func(c *ui.Context) { + agent.AgentCard(c, agent.Agent{Name: long, Class: long, Level: 99999, Used: math.MaxInt, Window: math.MaxInt}, agent.AgentCardOptions{}) + }}, + {"CAST " + long, func(c *ui.Context) { agent.ToolCall(c, long, agent.Failed, agent.ToolCallOptions{Args: long}) }}, + {"Q", func(c *ui.Context) { + agent.PlanQuest(c, "Q", []agent.PlanStep{{Text: long, Done: true}}, agent.PlanQuestOptions{Width: quest}) + }}, + {"M", func(c *ui.Context) { agent.TokenMana(c, "M", math.MaxInt, math.MaxInt, agent.TokenManaOptions{}) }}, + {"A", func(c *ui.Context) { + agent.ApprovalPrompt(c, "A", long+long, agent.ApprovalPromptOptions{Danger: true}) + }}, + {"P", func(c *ui.Context) { + agent.SubAgentParty(c, "P", []agent.Member{{Name: long, Summoned: []agent.Member{{Name: long}}}}, agent.SubAgentPartyOptions{}) + }}, + {"S", func(c *ui.Context) { agent.AgentStatus(c, "S", agent.Blocked, agent.AgentStatusOptions{}) }}, + {"D", func(c *ui.Context) { + agent.DiffScroll(c, "D", []agent.FileDiff{{Path: long, Added: math.MaxInt, Removed: math.MaxInt}}, agent.DiffScrollOptions{Width: 400}) + }}, + {"C", func(c *ui.Context) { agent.CostCoins(c, "C", math.MaxInt, agent.CostCoinsOptions{}) }}, + {"V", func(c *ui.Context) { + agent.Checkpoints(c, "V", []agent.Checkpoint{{Name: long, At: at}}, agent.CheckpointsOptions{Width: saves}) + }}, + } { + tt := view(1400, 700, newWorld(), tc.build) + x, y, w, h := dots(find(t, tt, tc.label)) + if n := spill(tt.Image(), x, y, w, h); n > 0 { + t.Errorf("%.12s spills %d dots", tc.label, n) + } + } +} + +func TestTooNarrowPanics(t *testing.T) { + for name, build := range map[string]func(c *ui.Context){ + "level": func(c *ui.Context) { + agent.AgentCard(c, agent.Agent{Name: "R", Class: "C", Level: math.MaxInt, Window: 1}, agent.AgentCardOptions{}) + }, + "quest": func(c *ui.Context) { + agent.PlanQuest(c, "Q", []agent.PlanStep{{Text: "a", Done: true}}, agent.PlanQuestOptions{Width: 64}) + }, + "saves": func(c *ui.Context) { agent.Checkpoints(c, "V", nil, agent.CheckpointsOptions{Width: 1}) }, + } { + mustPanic(t, name, func() { view(600, 300, newWorld(), build) }) + } +} diff --git a/agent/subagentparty.go b/agent/subagentparty.go new file mode 100644 index 0000000..10c7248 --- /dev/null +++ b/agent/subagentparty.go @@ -0,0 +1,87 @@ +package agent + +import ( + "fmt" + "slices" + "strings" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Member is a summoned agent and those it summoned. +type Member struct { + Name string + Status Status + Summoned []Member +} + +type SubAgentPartyOptions struct { + // Width is in dots; zero means 200. + Width int +} + +// partyRow: a member, and per depth whether its branch ends. +type partyRow struct { + m Member + last []bool +} + +// SubAgentParty draws the summoning tree, each agent with its status. +func SubAgentParty(c *ui.Context, text string, party []Member, o SubAgentPartyOptions) *ui.Element { + k := pixel.K(c) + w := pixel.Or("SubAgentParty Width", o.Width, 200) + var rows []partyRow + var flat func(ms []Member, last []bool) + flat = func(ms []Member, last []bool) { + for i, m := range ms { + l := append(slices.Clone(last), i == len(ms)-1) + rows = append(rows, partyRow{m, l}) + flat(m.Summoned, l) + } + } + flat(party, nil) + if len(rows) == 0 { + panic("agent: SubAgentParty has nobody") + } + said := make([]string, len(rows)) + for i, r := range rows { + word, _ := r.m.Status.look(k) + depth := len(r.last) - 1 + if need := depth*12 + 18 + 24 + pixel.Measure(word); r.m.Name == "" || w < need { + panic(fmt.Sprintf("agent: SubAgentParty member %q needs Width %d", r.m.Name, need)) + } + said[i] = r.m.Name + ", " + word + } + return ui.Box(c).Size(k.D(w), k.D(len(rows)*pixel.LineHeight)).Role(ui.RoleImage).Label(text).Description(strings.Join(said, "; ")).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + for i, row := range rows { + y, depth := i*pixel.LineHeight, len(row.last)-1 + // Lines run from each summoner down to its summoned. + for a := 1; a < depth; a++ { + if !row.last[a] { + g.VLine((a-1)*12+5, y, pixel.LineHeight, pixel.Edge) + } + } + if depth > 0 { + x := (depth-1)*12 + 5 + g.VLine(x, y, 8, pixel.Edge) + if !row.last[depth] { + g.VLine(x, y+8, pixel.LineHeight-8, pixel.Edge) + } + g.HLine(x, y+7, 7, pixel.Edge) + } + word, slot := row.m.Status.look(k) + x := depth * 12 + g.Sprite(x, y+2, icon.Robot, 0, slot, 1) + if len(row.m.Summoned) > 0 { + g.VLine(x+5, y+14, 2, pixel.Edge) + } + ww := pixel.Measure(word) + g.Text(x+14, y, pixel.Wrap(row.m.Name, w-x-18-ww, 1)[0], pixel.Ink, 1) + g.Text(w-ww, y, word, slot, 1) + } + }) +} diff --git a/agent/thinkingorb.go b/agent/thinkingorb.go new file mode 100644 index 0000000..f3bcb80 --- /dev/null +++ b/agent/thinkingorb.go @@ -0,0 +1,67 @@ +package agent + +import ( + "math" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ThinkingOrbOptions struct{} + +var ( + // Swirl arms run Lit at the heart, Info, then Magic. + swirl = []pixel.Slot{pixel.Lit, pixel.Info, pixel.Magic} + // mist is the glow's half width, row by row. + mist = func() map[int]int { + half := map[int]int{} + for _, d := range ring(15) { + half[d[1]] = max(half[d[1]], abs(d[0])) + } + return half + }() +) + +// ThinkingOrb is a crystal ball that swirls while thinking. +func ThinkingOrb(c *ui.Context, text string, thinking bool, o ThinkingOrbOptions) *ui.Element { + k := pixel.K(c) + word := k.Msg("idle") + if thinking { + word = k.Msg("thinking") + } + return ui.Box(c).Size(k.D(44), k.D(48)).Role(ui.RoleStatus).Label(text).Description(word).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Disc(22, 20, 19, pixel.Well) + // A busy sign: it swirls on under Reduce Motion. + if thinking { + for y, h := range mist { + g.Veil(22-h, 20+y, 2*h+1, 1, pixel.Magic, 4) + } + turn := float64(g.Busy(8)%12) * math.Pi / 6 + for arm := range 3 { + px, py := 22, 20 + for i := 1; i <= 16; i++ { + t := float64(i)*0.38 + turn + float64(arm)*2*math.Pi/3 + x, y := 22+int(math.Round(float64(i)*math.Cos(t))), 20+int(math.Round(float64(i)*math.Sin(t))) + g.Line(px, py, x, y, swirl[(i-1)*3/16]) + px, py = x, y + } + } + g.Disc(22, 20, 2, pixel.Lit) + } + g.Circle(22, 20, 19, pixel.Edge) + g.Rect(11, 8, 4, 2, pixel.Lit) + g.Rect(10, 10, 2, 2, pixel.Lit) + g.Rect(13, 38, 18, 3, pixel.Edge) + g.Rect(8, 41, 28, 6, pixel.Shade) + g.HLine(8, 41, 28, pixel.Edge) + }) +} + +func abs(n int) int { + if n < 0 { + return -n + } + return n +} diff --git a/agent/tokenmana.go b/agent/tokenmana.go new file mode 100644 index 0000000..e6b24d8 --- /dev/null +++ b/agent/tokenmana.go @@ -0,0 +1,45 @@ +package agent + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type TokenManaOptions struct { + // Width is in dots; zero means 120. + Width int +} + +// TokenMana shows a token budget's remainder as MP. +func TokenMana(c *ui.Context, text string, used, budget int, o TokenManaOptions) *ui.Element { + k := pixel.K(c) + if used < 0 || budget < 1 { + panic(fmt.Sprintf("agent: TokenMana %d of %d", used, budget)) + } + w := pixel.Or("TokenMana Width", o.Width, 120) + left := max(0, budget-used) + mp, value := k.Msg("mp"), tokens(left)+"/"+tokens(budget) + if need := pixel.MicroWidth(mp) + 4 + pixel.MicroWidth(value); w < need { + panic(fmt.Sprintf("agent: TokenMana Width %d is under %d", w, need)) + } + ink, said := pixel.Ink, value + if left == 0 { + ink, said = pixel.Bad, value+", "+k.Msg("out_of_mana") + } + return ui.Box(c).Size(k.D(w), k.D(15)).Role(ui.RoleMeter).Label(text).Description(said).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Micro(0, 0, mp, pixel.Ink, 1) + g.Micro(w-pixel.MicroWidth(value), 0, value, ink, 1) + gauge(g, 0, 7, w, float64(left)/float64(budget), pixel.Info) + // Ticks mark tenths; an empty well wears a Bad veil. + for i := 1; i < 10; i++ { + g.VLine(2+i*(w-4)/10, 9, 4, pixel.Edge) + } + if left == 0 { + g.Checker(2, 9, w-4, 4, pixel.Bad) + } + }) +} diff --git a/agent/tokens_internal_test.go b/agent/tokens_internal_test.go new file mode 100644 index 0000000..cb8b6bf --- /dev/null +++ b/agent/tokens_internal_test.go @@ -0,0 +1,50 @@ +package agent + +import ( + "math" + "testing" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestTokensPrintShort(t *testing.T) { + for n, want := range map[int]string{ + 0: "0", 950: "950", 1000: "1K", 1500: "1.5K", 9999: "9.9K", 12345: "12K", 999999: "999K", + 1_000_000: "1M", 2_500_000: "2.5M", 1_000_000_000: "1B", math.MaxInt: "9.2E", + } { + if got := tokens(n); got != want { + t.Errorf("tokens(%d) = %q, want %q", n, got, want) + } + } +} + +func TestRingRunsClockwiseOnce(t *testing.T) { + pts := ring(17) + seen := map[[2]int]bool{} + for i, p := range pts { + if seen[p] { + t.Fatalf("dot %v twice", p) + } + seen[p] = true + if d := math.Hypot(float64(p[0]), float64(p[1])); math.Abs(d-17) > 1 { + t.Fatalf("dot %v is %.1f from the middle", p, d) + } + // Twelve o'clock first, then right before left. + if i == 0 && p != [2]int{0, -17} { + t.Fatalf("the ring starts at %v", p) + } + } + if q := pts[len(pts)/4]; q[0] < 10 || q[1] < -10 { + t.Fatalf("a quarter round is at %v, not near three o'clock", q) + } +} + +func TestTailKeepsTheFileName(t *testing.T) { + long := "pixel/very/long/path/canvas.go" + if got := tail(long, pixel.Measure("…/canvas.go")); got != "…/canvas.go" { + t.Errorf("tail = %q", got) + } + if got := tail("a.go", 100); got != "a.go" { + t.Errorf("a short path became %q", got) + } +} diff --git a/agent/toolcall.go b/agent/toolcall.go new file mode 100644 index 0000000..678cfb0 --- /dev/null +++ b/agent/toolcall.go @@ -0,0 +1,83 @@ +package agent + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// Outcome is how a cast went. +type Outcome int + +const ( + Casting Outcome = iota + OK + Failed +) + +type ToolCallOptions struct { + // Args show under the spell, cut to a line. + Args string + // Width is in dots; zero means 200. + Width int +} + +var glow = []pixel.Slot{pixel.Magic, pixel.Info, pixel.Accent} + +// ToolCall is a spell card: CAST, then OK or FAILED. +func ToolCall(c *ui.Context, tool string, out Outcome, o ToolCallOptions) *ui.Element { + k := pixel.K(c) + if tool == "" || out < Casting || out > Failed { + panic(fmt.Sprintf("agent: ToolCall %q with outcome %d", tool, out)) + } + w := pixel.Or("ToolCall Width", o.Width, 200) + spell := fmt.Sprintf(k.Msg("cast_tool"), tool) + word, ink, mark := k.Msg("casting"), pixel.Info, (*pixel.Sprite)(nil) + switch out { + case OK: + word, ink, mark = k.Msg("ok"), pixel.Good, icon.Check + case Failed: + word, ink, mark = k.Msg("failed"), pixel.Bad, icon.Close + } + // The outcome takes the right; the spell keeps some room. + side := 13 + if mark != nil { + side = 16 + pixel.Measure(word) + } + if w < side+60 { + panic(fmt.Sprintf("agent: ToolCall Width %d is under %d", w, side+60)) + } + h, said := pixel.LineHeight+6, word + if o.Args != "" { + h, said = h+pixel.LineHeight, o.Args+", "+word + } + return ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleImage).Label(spell).Description(said).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Frame(0, 0, w, h, pixel.Raised, pixel.Face) + g.Rect(2, 2, 2, h-4, ink) + x, rune := w-4-side, pixel.Magic + if mark == nil { + // The rune and dots glow in turn while it casts. + step := g.Busy(8) + rune = pixel.Cycle(glow, step)[0] + for i := range 3 { + dot := pixel.Faint + if i == step%3 { + dot = pixel.Info + } + g.Rect(x+i*5, 10, 3, 3, dot) + } + } else { + g.Sprite(x, 5, mark, 0, ink, 1) + g.Text(x+16, 3, word, ink, 1) + } + g.Sprite(7, 5, icon.Sparkle, 0, rune, 1) + g.Text(22, 3, pixel.Wrap(spell, x-26, 1)[0], pixel.Ink, 1) + if o.Args != "" { + g.Text(22, 3+pixel.LineHeight, pixel.Wrap(o.Args, w-26, 1)[0], pixel.Faint, 1) + } + }) +} diff --git a/agent/view_test.go b/agent/view_test.go new file mode 100644 index 0000000..71b25a8 --- /dev/null +++ b/agent/view_test.go @@ -0,0 +1,135 @@ +package agent_test + +import ( + "bytes" + "image" + "log/slog" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type clock struct{ t time.Time } + +func (c *clock) now() time.Time { return c.t } + +// world is the settings a test view runs under. +type world struct { + clk *clock + reduce, inert bool +} + +func newWorld() *world { + return &world{clk: &clock{time.Date(2026, 9, 2, 10, 30, 0, 0, time.UTC)}} +} + +// view builds on a fake clock, two pixels a dot. +func view(w, h int, wd *world, build func(c *ui.Context)) *ui.Tester { + tt := ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: wd.clk.now, Inert: wd.inert, Reduce: wd.reduce}) + build(c) + }, w, h) + return tt +} + +// later moves the clock on and builds a frame. +func later(tt *ui.Tester, wd *world, d time.Duration) { + wd.clk.t = wd.clk.t.Add(d) + tt.Frame() +} + +func click(t *testing.T, tt *ui.Tester, label string) { + t.Helper() + if err := tt.Click(label); err != nil { + t.Fatalf("%v in %q", err, tt.Texts()) + } +} + +func find(t *testing.T, tt *ui.Tester, label string) ui.Rect { + t.Helper() + r, ok := tt.Find(label) + if !ok { + t.Fatalf("no %q in %q", label, tt.Texts()) + } + return r +} + +// none is a color outside the palette. +const none pixel.Slot = 255 + +// at is the slot at dot x, y, or none. +func at(img *image.RGBA, x, y int) pixel.Slot { + c := img.RGBAAt(2*x, 2*y) + for s, want := range pixel.SpringDay.Colors { + if c.R == want.R && c.G == want.G && c.B == want.B { + return pixel.Slot(s) + } + } + return none +} + +// count is how many dots in a box hold s. +func count(img *image.RGBA, x, y, w, h int, s pixel.Slot) int { + n := 0 + for j := y; j < y+h; j++ { + for i := x; i < x+w; i++ { + if at(img, i, j) == s { + n++ + } + } + } + return n +} + +// dots is a found box in dots. +func dots(r ui.Rect) (x, y, w, h int) { + return int(r.X / 2), int(r.Y / 2), int(r.W / 2), int(r.H / 2) +} + +func mustPanic(t *testing.T, name string, fn func()) { + t.Helper() + defer func() { + if recover() == nil { + t.Errorf("%s did not panic", name) + } + }() + fn() +} + +// logged runs fn with the debug log captured. +func logged(fn func()) string { + var buf bytes.Buffer + old := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + defer slog.SetDefault(old) + fn() + return buf.String() +} + +// moves reports whether a view's picture changes over a second. +func moves(tt *ui.Tester, wd *world) bool { + first := append([]byte(nil), tt.Image().Pix...) + for range 15 { + later(tt, wd, time.Second/15) + if !bytes.Equal(first, tt.Image().Pix) { + return true + } + } + return false +} + +// spill counts dots drawn outside a box. +func spill(img *image.RGBA, x, y, w, h int) int { + n, b := 0, img.Bounds() + for j := range b.Dy() / 2 { + for i := range b.Dx() / 2 { + if (i < x || i >= x+w || j < y || j >= y+h) && at(img, i, j) != pixel.Back { + n++ + } + } + } + return n +} diff --git a/gallery/agent_demo_test.go b/gallery/agent_demo_test.go new file mode 100644 index 0000000..e96cc9f --- /dev/null +++ b/gallery/agent_demo_test.go @@ -0,0 +1,43 @@ +package gallery + +import ( + "strings" + "testing" +) + +func TestAgentDemosLogTheirActions(t *testing.T) { + // Clicks that only focus a component may log nothing. + quiet := map[string]bool{"Prompt": true} + for _, tc := range []struct { + path string + steps []string + }{ + {"/c/451-agentcard", []string{"READ A BIG FILE", "COMPACT"}}, {"/c/452-toolcall", []string{"CAST GREP"}}, + {"/c/453-planquest", []string{"COMPLETE STEP"}}, {"/c/454-thinkingorb", []string{"PONDER"}}, + {"/c/455-tokenmana", []string{"CAST (12K)", "DRINK ETHER"}}, {"/c/456-approvalprompt", []string{"YES", "NO"}}, + {"/c/457-agentlog", []string{"NEXT STEP"}}, {"/c/458-subagentparty", []string{"SUMMON", "NEXT TURN"}}, + {"/c/459-memorycrystals", []string{"Memories"}}, {"/c/462-modelselect", []string{"Models"}}, + {"/c/463-promptbox", []string{"Prompt", "type:ship it", "key:Enter"}}, {"/c/464-costcoins", []string{"RUN THE AGENT", "NEW MONTH"}}, + {"/c/465-checkpoints", []string{"RESTORE Tests green", "SAVE HERE"}}, + } { + a, tt := open(tc.path) + slug := tc.path[3:] + for _, step := range tc.steps { + before := len(a.logs[slug]) + if k, ok := strings.CutPrefix(step, "key:"); ok { + tt.Key(0, letters[k]) + } else if s, ok := strings.CutPrefix(step, "type:"); ok { + tt.Type(s) + } else { + click(t, tt, step) + } + if len(a.logs[slug]) <= before && !quiet[step] { + t.Errorf("%s: %s logged nothing: %q", slug, step, a.logs[slug]) + } + } + // A prompt may hold secrets: the log counts, never quotes. + if strings.Contains(strings.Join(a.logs[slug], " "), "ship it") { + t.Errorf("%s logged the prompt: %q", slug, a.logs[slug]) + } + } +} diff --git a/gallery/demos_agent.go b/gallery/demos_agent.go new file mode 100644 index 0000000..ee7abca --- /dev/null +++ b/gallery/demos_agent.go @@ -0,0 +1,213 @@ +package gallery + +import ( + "fmt" + "slices" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/agent" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var agentEntries = []Entry{ + {451, "AgentCard", "智能体卡", "agent", "A party card. The context window is the HP bar: the more it reads, the less it has left.", demoAgentCard}, + {452, "ToolCall", "工具调用", "agent", "Every tool call is a spell: CAST, a moment of glowing, then OK or FAILED.", demoToolCall}, + {453, "PlanQuest", "计划任务", "agent", "The plan as quest objectives. Finish them all for a fanfare.", demoPlanQuest}, + {454, "ThinkingOrb", "思考水晶球", "agent", "A crystal ball that swirls while the agent thinks.", demoThinkingOrb}, + {455, "TokenMana", "令牌魔力", "agent", "The token budget as MP. Spend it all and the well runs dry.", demoTokenMana}, + {456, "ApprovalPrompt", "授权提示", "agent", "ALLOW SPELL? The agent asks before it runs a command; risky ones come in red.", demoApprovalPrompt}, + {457, "AgentLog", "智能体日志", "agent", "The transcript as a battle log. The newest line types itself out.", demoAgentLog}, + {458, "SubAgentParty", "子智能体", "agent", "Agents summon helpers. The tree shows who summoned whom, and how each is doing.", demoSubAgentParty}, +} + +type agentDemo struct { + used, done, mana, next int + cast time.Time + told, thinking bool + log []agent.LogStep + party []agent.Member +} + +func agentState(c *ui.Context) *agentDemo { + return ui.Local(c.Root(), demoKey("agent"), func() agentDemo { + return agentDemo{used: 64_000, done: 1, mana: 28_000, told: true, next: 3, log: slices.Clone(story[:3]), + party: []agent.Member{{Name: "LEAD", Status: agent.Working, Summoned: []agent.Member{ + {Name: "SCOUT", Status: agent.Done}, {Name: "SMITH", Status: agent.Working, Summoned: []agent.Member{{Name: "IMP", Status: agent.Blocked}}}, + }}}} + }) +} + +func demoAgentCard(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := agentState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + agent.AgentCard(c, agent.Agent{Name: "ROBO", Class: "CODER", Level: 12, Used: s.used, Window: 200_000}, agent.AgentCardOptions{}) + agent.AgentCard(c, agent.Agent{Name: "SAGE", Class: "REVIEWER", Level: 30, Used: 182_000, Window: 200_000}, agent.AgentCardOptions{}) + ui.Row(c).Gap(k.D(6)).Children(func() { + if input.Button(c, "READ A BIG FILE", input.ButtonOptions{Size: input.Small}).Clicked { + s.used = min(200_000, s.used+24_000) + log(fmt.Sprintf("ROBO used %d tokens", s.used)) + } + // Compacting is a night at the inn: HP comes back. + if input.Button(c, "COMPACT", input.ButtonOptions{Size: input.Small}).Clicked { + s.used = 12_000 + log("ROBO compacted its context") + } + }) + }) +} + +func demoToolCall(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := agentState(c) + grep := agent.OK + if done := s.cast.Add(1500 * time.Millisecond); k.Now().Before(done) { + grep = agent.Casting + k.At(c, done) + } else if !s.told { + s.told = true + log("grep: OK") + } + ui.Column(c).Gap(k.D(4)).Children(func() { + agent.ToolCall(c, "read_file", agent.OK, agent.ToolCallOptions{Args: "pixel/canvas.go"}) + agent.ToolCall(c, "grep", grep, agent.ToolCallOptions{Args: `"TODO" in 512 files`}) + agent.ToolCall(c, "bash", agent.Failed, agent.ToolCallOptions{Args: "npm test: 2 tests fainted"}) + if input.Button(c, "CAST GREP", input.ButtonOptions{Size: input.Small, Disabled: grep == agent.Casting}).Clicked { + s.cast, s.told = k.Now(), false + log("casting grep") + } + }) +} + +func demoPlanQuest(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := agentState(c) + steps := []agent.PlanStep{{Text: "Reproduce the save bug"}, {Text: "Write a failing test"}, {Text: "Fix the root cause"}, {Text: "Run every test"}, {Text: "Ship it"}} + for i := range s.done { + steps[i].Done = true + } + ui.Column(c).Gap(k.D(6)).Children(func() { + agent.PlanQuest(c, "The Lost Save", steps, agent.PlanQuestOptions{}) + if s.done < len(steps) { + if input.Button(c, "COMPLETE STEP", input.ButtonOptions{Size: input.Small}).Clicked { + s.done++ + log(fmt.Sprintf("objective %d done", s.done)) + } + return + } + if input.Button(c, "NEW QUEST", input.ButtonOptions{Size: input.Small}).Clicked { + s.done = 0 + log("a new quest begins") + } + }) +} + +func demoThinkingOrb(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := agentState(c) + ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() { + agent.ThinkingOrb(c, "Mind", s.thinking, agent.ThinkingOrbOptions{}) + label := "PONDER" + if s.thinking { + label = "SNAP OUT OF IT" + } + if input.Button(c, label, input.ButtonOptions{Size: input.Small}).Clicked { + s.thinking = !s.thinking + log(fmt.Sprintf("thinking %v", s.thinking)) + } + }) +} + +func demoTokenMana(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := agentState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + agent.TokenMana(c, "Token budget", s.mana, 100_000, agent.TokenManaOptions{Width: 160}) + ui.Row(c).Gap(k.D(6)).Children(func() { + if input.Button(c, "CAST (12K)", input.ButtonOptions{Size: input.Small}).Clicked { + s.mana += 12_000 + log(fmt.Sprintf("%d tokens spent", s.mana)) + } + if input.Button(c, "DRINK ETHER", input.ButtonOptions{Size: input.Small}).Clicked { + s.mana = 0 + log("MP restored") + } + }) + }) +} + +func demoApprovalPrompt(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Column(c).Gap(k.D(8)).Children(func() { + for _, cmd := range []struct { + line string + danger bool + }{{"go test ./...", false}, {"rm -rf ~/saves", true}} { + r := agent.ApprovalPrompt(c, cmd.line, cmd.line, agent.ApprovalPromptOptions{Danger: cmd.danger}) + if r.Allowed { + log("allowed: " + cmd.line) + } + if r.Denied { + log("denied: " + cmd.line) + } + } + }) +} + +// story is what the log demo's agent does, in turn. +var story = []agent.LogStep{ + {Kind: agent.Thought, Text: "the save file looks corrupt"}, {Kind: agent.Cast, Text: "read_file"}, + {Kind: agent.Hit, Text: "a stray byte at 0x2A"}, {Kind: agent.Cast, Text: "edit_file"}, + {Kind: agent.Miss, Text: "one test still fails"}, {Kind: agent.Cast, Text: "edit_file"}, + {Kind: agent.Hit, Text: "all 64 tests pass"}, {Kind: agent.Said, Text: "Fixed! Shall I ship it?"}, +} + +func demoAgentLog(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := agentState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + agent.AgentLog(c, "Transcript", "ROBO", s.log, agent.AgentLogOptions{Width: 260}) + if input.Button(c, "NEXT STEP", input.ButtonOptions{Size: input.Small}).Clicked { + step := story[s.next%len(story)] + s.log, s.next = append(s.log, step), s.next+1 + log(fmt.Sprintf("step %d: %s", s.next, step.Text)) + } + }) +} + +func demoSubAgentParty(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := agentState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + agent.SubAgentParty(c, "Party", s.party, agent.SubAgentPartyOptions{}) + ui.Row(c).Gap(k.D(6)).Children(func() { + lead := &s.party[0] + if input.Button(c, "SUMMON", input.ButtonOptions{Size: input.Small, Disabled: len(lead.Summoned) == 4}).Clicked { + name := []string{"GOLEM", "WISP"}[len(lead.Summoned)-2] + lead.Summoned = append(lead.Summoned, agent.Member{Name: name, Status: agent.Working}) + log("LEAD summons " + name) + } + // A turn passes: the blocked wake, the busy finish. + if input.Button(c, "NEXT TURN", input.ButtonOptions{Size: input.Small}).Clicked { + advance(lead.Summoned) + log("a turn passes") + } + }) + }) +} + +// advance moves each summoned member on by a turn. +func advance(ms []agent.Member) { + for i := range ms { + switch ms[i].Status { + case agent.Blocked, agent.Idle: + ms[i].Status = agent.Working + case agent.Working: + ms[i].Status = agent.Done + } + advance(ms[i].Summoned) + } +} diff --git a/gallery/demos_guild.go b/gallery/demos_guild.go new file mode 100644 index 0000000..8ecdb24 --- /dev/null +++ b/gallery/demos_guild.go @@ -0,0 +1,127 @@ +package gallery + +import ( + "fmt" + "time" + "unicode/utf8" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/agent" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var guildEntries = []Entry{ + {459, "MemoryCrystals", "记忆水晶", "agent", "Memory slots as crystals. Lit ones hold something; the arrows peek inside.", demoMemoryCrystals}, + {460, "AgentStatus", "状态环", "agent", "A ring that says it all: sleepy, busy, stuck or done.", demoAgentStatus}, + {461, "DiffScroll", "改动卷轴", "agent", "What changed, on a scroll: each file with lines added and removed.", demoDiffScroll}, + {462, "ModelSelect", "模型选择", "agent", "Pick a model like a fighter: stats on the right, a flag on your pick.", demoModelSelect}, + {463, "PromptBox", "咒语输入", "agent", "Write the spell, then CAST. Enter casts; Shift+Enter starts a new line.", demoPromptBox}, + {464, "CostCoins", "花费", "agent", "What it cost, in coins: gold dollars, silver dimes, bronze cents.", demoCostCoins}, + {465, "Checkpoints", "存档点", "agent", "Save points along the way. RESTORE takes you back.", demoCheckpoints}, +} + +type guildDemo struct { + model, cents int + prompt string + saves []agent.Checkpoint +} + +func guildState(c *ui.Context) *guildDemo { + return ui.Local(c.Root(), demoKey("guild"), func() guildDemo { + now := pixel.K(c).Now() + return guildDemo{model: 1, cents: 1234, saves: []agent.Checkpoint{ + {Name: "Project start", At: now.Add(-50 * time.Hour)}, + {Name: "Before the refactor", At: now.Add(-3 * time.Hour)}, + {Name: "Tests green", At: now.Add(-5 * time.Minute)}, + }} + }) +} + +func demoMemoryCrystals(c *ui.Context, log func(string)) { + memories := []string{"Prefers tabs", "Tests before commits", "Never force-push", "Coffee: black", "Hates dark patterns"} + if i := agent.MemoryCrystals(c, "Memories", memories, agent.MemoryCrystalsOptions{}).Picked; i >= 0 { + if i < len(memories) { + log("recalled: " + memories[i]) + return + } + log(fmt.Sprintf("crystal %d is empty", i+1)) + } +} + +func demoAgentStatus(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Row(c).Gap(k.D(8)).Children(func() { + for _, s := range []agent.Status{agent.Idle, agent.Working, agent.Blocked, agent.Done} { + agent.AgentStatus(c, fmt.Sprintf("Robo %d", s+1), s, agent.AgentStatusOptions{}) + } + }) +} + +func demoDiffScroll(c *ui.Context, log func(string)) { + agent.DiffScroll(c, "Changes", []agent.FileDiff{ + {Path: "pixel/canvas.go", Added: 42, Removed: 7}, + {Path: "agent/agentstatus.go", Added: 88}, + {Path: "README.md", Added: 3, Removed: 1}, + {Path: "old/clever_hack.go", Removed: 120}, + }, agent.DiffScrollOptions{Width: 260}) +} + +func demoModelSelect(c *ui.Context, log func(string)) { + s := guildState(c) + models := []agent.Model{ + {Name: "SPRITE", Portrait: icon.Bolt, Speed: 5, Power: 2, Cost: 1}, + {Name: "KNIGHT", Portrait: icon.Shield, Speed: 3, Power: 4, Cost: 3}, + {Name: "WIZARD", Portrait: icon.Sparkle, Speed: 2, Power: 5, Cost: 5}, + {Name: "BARD", Portrait: icon.Music, Speed: 4, Power: 3, Cost: 2}, + } + if agent.ModelSelect(c, "Models", models, &s.model, agent.ModelSelectOptions{}).Changed { + log(models[s.model].Name + " joins the party") + } +} + +func demoPromptBox(c *ui.Context, log func(string)) { + s := guildState(c) + r := agent.PromptBox(c, "Prompt", &s.prompt, agent.PromptBoxOptions{Placeholder: "Describe the quest…"}) + // Prompts can hold secrets, so the log only counts. + if r.Changed { + log(fmt.Sprintf("%d runes written", utf8.RuneCountInString(s.prompt))) + } + if r.Cast != "" { + log(fmt.Sprintf("cast %d runes", utf8.RuneCountInString(r.Cast))) + } +} + +func demoCostCoins(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := guildState(c) + ui.Row(c).Gap(k.D(8)).AlignItems(ui.End).Children(func() { + agent.CostCoins(c, "Spent", s.cents, agent.CostCoinsOptions{}) + ui.Column(c).Gap(k.D(4)).Children(func() { + if input.Button(c, "RUN THE AGENT", input.ButtonOptions{Size: input.Small}).Clicked { + s.cents += 37 + log(fmt.Sprintf("spent $%d.%02d", s.cents/100, s.cents%100)) + } + if input.Button(c, "NEW MONTH", input.ButtonOptions{Size: input.Small}).Clicked { + s.cents = 0 + log("the bill is paid") + } + }) + }) +} + +func demoCheckpoints(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := guildState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + if i := agent.Checkpoints(c, "Save points", s.saves, agent.CheckpointsOptions{}).Restored; i >= 0 { + log("restored: " + s.saves[i].Name) + } + if input.Button(c, "SAVE HERE", input.ButtonOptions{Size: input.Small}).Clicked { + s.saves = append(s.saves, agent.Checkpoint{Name: fmt.Sprintf("Save %d", len(s.saves)+1), At: k.Now()}) + log("saved") + } + }) +} diff --git a/gallery/registry.go b/gallery/registry.go index 850799b..1afae92 100644 --- a/gallery/registry.go +++ b/gallery/registry.go @@ -56,7 +56,7 @@ var Categories = []Category{ {"tabletop", "Tabletop", "桌游", icon.Crown}, } -var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries, screenEntries, lootEntries, fxEntries, screenFxEntries, chatEntries, mailEntries, mediaEntries, studioEntries, codeEntries, debugEntries, accountEntries, playerEntries, filesEntries, storageEntries, projectEntries, teamEntries, shopEntries, marketEntries) +var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries, screenEntries, lootEntries, fxEntries, screenFxEntries, chatEntries, mailEntries, mediaEntries, studioEntries, codeEntries, debugEntries, accountEntries, playerEntries, filesEntries, storageEntries, projectEntries, teamEntries, shopEntries, marketEntries, agentEntries, guildEntries) func Entries() []Entry { return entries } diff --git a/icon/icon.go b/icon/icon.go index c9834fa..61fa3f9 100644 --- a/icon/icon.go +++ b/icon/icon.go @@ -31,7 +31,7 @@ var All = []Named{ {"heart", Heart}, {"heart-empty", HeartEmpty}, {"star", Star}, {"star-empty", StarEmpty}, {"sparkle", Sparkle}, {"volume", Volume}, {"mute", Mute}, {"play", Play}, {"pause", Pause}, {"stop", Stop}, {"next", Next}, {"prev", Prev}, - {"coin", Coin}, {"gem", Gem}, {"potion", Potion}, {"sword", Sword}, + {"coin", Coin}, {"gem", Gem}, {"crystal", Crystal}, {"potion", Potion}, {"sword", Sword}, {"shield", Shield}, {"chest", Chest}, {"crown", Crown}, {"trophy", Trophy}, {"skull", Skull}, {"scroll", Scroll}, {"flame", Flame}, {"drop", Drop}, {"leaf", Leaf}, {"flower", Flower}, {"sun", Sun}, {"moon", Moon}, diff --git a/icon/items.go b/icon/items.go index 90949a5..4f5c7d2 100644 --- a/icon/items.go +++ b/icon/items.go @@ -30,6 +30,19 @@ var ( .....ee..... ............ ............`) + Crystal = pixel.MustSprite(` + .....ee..... + ....e##e.... + ...e#l##e... + ..e#l####e.. + ..e#l###se.. + ..e#l###se.. + ..e#l###se.. + ..e#l###se.. + ..e#l##sse.. + ...e#lsse... + ....e#se.... + .....ee.....`) Potion = pixel.MustSprite(` ....eeee.... ....eeee.... diff --git a/pixel/messages.go b/pixel/messages.go index 20507cd..9ba417d 100644 --- a/pixel/messages.go +++ b/pixel/messages.go @@ -60,6 +60,11 @@ var messages = map[Locale]map[string]string{ "see_you": "SEE YOU TOMORROW!", "highest": "HIGHEST: %s, %d %s", "you": "YOU", "confirm": "CONFIRM", "confirmed": "CONFIRMED", "waiting": "WAITING", "traded": "TRADE DONE!", "snacks": "SNACKS", "keypad": "KEYPAD", "sold_out": "SOLD OUT", "insert_coin": "INSERT COIN", "enjoy": "ENJOY!", "code_prompt": "CODE?", + "idle": "IDLE", "working": "WORKING", "blocked": "BLOCKED", "hp": "HP", "mp": "MP", "cast_tool": "CAST %s", + "casting": "CASTING", "thinking": "THINKING", "out_of_mana": "OUT OF MANA!", "allow_spell": "ALLOW SPELL?", + "danger": "DANGER!", "log_thought": "%s ponders: %s", "log_cast": "%s casts %s!", "log_hit": "A hit! %s", + "log_miss": "A miss! %s", "log_said": "%s: %s", "empty_crystal": "EMPTY CRYSTAL", "files_changed": "FILES CHANGED: %d", + "speed": "SPD", "power": "POW", "cost": "COST", "cast": "CAST", "restore": "RESTORE", }, Zh: { "ok": "确定", "cancel": "取消", "yes": "是", "no": "否", "close": "关闭", @@ -118,6 +123,11 @@ var messages = map[Locale]map[string]string{ "see_you": "明天见!", "highest": "最高:%s,%d %s", "you": "你", "confirm": "确认", "confirmed": "已确认", "waiting": "等待中", "traded": "交易完成!", "snacks": "零食", "keypad": "键盘", "sold_out": "售罄", "insert_coin": "请投币", "enjoy": "请享用!", "code_prompt": "编号?", + "idle": "待机", "working": "工作中", "blocked": "受阻", "hp": "HP", "mp": "MP", "cast_tool": "释放 %s", + "casting": "咏唱中", "thinking": "思考中", "out_of_mana": "魔力耗尽!", "allow_spell": "允许释放法术吗?", + "danger": "危险!", "log_thought": "%s沉思:%s", "log_cast": "%s释放了%s!", "log_hit": "命中!%s", + "log_miss": "落空!%s", "log_said": "%s:%s", "empty_crystal": "空水晶", "files_changed": "改动文件:%d", + "speed": "速度", "power": "力量", "cost": "花费", "cast": "释放", "restore": "读档", }, }