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:
29 files changed
+2489
-2
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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},
|
||||
|
||||
@@ -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....
|
||||
|
||||
@@ -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": "读档",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user