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
}