Add agent components 451-465 and icon.Crystal

Fifteen components that dress an AI agent's work as an RPG party:
AgentCard, ToolCall, PlanQuest, ThinkingOrb, TokenMana, ApprovalPrompt,
AgentLog, SubAgentParty, MemoryCrystals, AgentStatus, DiffScroll,
ModelSelect, PromptBox, CostCoins and Checkpoints.

The context window is HP and the token budget MP; tool calls are spells
that CAST, glow, then land OK or FAILED; the transcript reads as a battle
log whose newest line types itself out; models line up like fighters.
Busy signs keep moving under Reduce Motion, decoration stops. Commands
and prompts never reach the log, and a veiled PromptBox casts nothing.

icon.Crystal is the new 12x12 save and memory crystal.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-12 12:03:44 -04:00
1 parent 5605d785de
commit d60d53ef90
29 files changed
+2489 -2

No files matched your search

+72
View File
@@ -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
}
+54
View File
@@ -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)
})
}
+102
View File
@@ -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
}
}
})
}
+86
View File
@@ -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)
}
+88
View File
@@ -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)
})
}
+90
View File
@@ -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
}
+198
View File
@@ -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")
}
+75
View File
@@ -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)
}
+121
View File
@@ -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)
}
+93
View File
@@ -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{})
})
}
+90
View File
@@ -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
}
+125
View File
@@ -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
}
+183
View File
@@ -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)
}
}
}
+86
View File
@@ -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)
}
})
}
+49
View File
@@ -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
}
+102
View File
@@ -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) })
}
}
+87
View File
@@ -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)
}
})
}
+67
View File
@@ -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
}
+45
View File
@@ -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)
}
})
}
+50
View File
@@ -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)
}
}
+83
View File
@@ -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)
}
})
}
+135
View File
@@ -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
}
+43
View File
@@ -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])
}
}
}
+213
View File
@@ -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)
}
}
+127
View File
@@ -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")
}
})
}
+1 -1
View File
@@ -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 }
+1 -1
View File
@@ -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},
+13
View File
@@ -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....
+10
View File
@@ -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": "读档",
},
}