Files
sanspie 5dd9cc91bb editor: refine agent, scene controls and curves
Improve mentions and the default Design workspace, clarify scene lighting controls, and polish editor workflows for 0.12.5-dev.
2026-11-05 20:14:53 +03:00

1260 lines
37 KiB
Go

// Package agent is the editor panel that talks to the coding agent and shows its steps, proposals and questions (D343).
package agent
import (
stdcontext "context"
"path"
"strconv"
"strings"
"time"
"aego/core/ids"
"aego/core/mathx"
"aego/editor/core/agentui"
"aego/editor/core/context"
"aego/editor/core/follow"
"aego/editor/core/services"
"aego/engine/i18n"
"aego/gpu"
"aego/platform/input"
"aego/render/text"
"aego/ui/core"
"aego/ui/richtext"
"aego/ui/widgets"
"aego/ui/widgets/palette"
)
const (
ID = "agent"
Icon = "panel.agent"
// maxRows caps the rows a laid-out result shows before "+N more".
maxRows = 12
)
type Deps struct {
Device gpu.Device
Host agentui.Host
Docs services.Documents
Notices services.Notices
// Assets lists the project's asset paths for @ mentions.
Assets func() []string
Root string
Show func()
// Highlight colours code blocks in answers, the same as the documentation panel.
Highlight richtext.Highlighter
// Open follows a link of a step or an answer: doc:<id>, ref:<id>, node:<file>#<id>, asset:<path>, file:<path>:<line>.
Open func(link string)
// Now is the clock of the turn separators; nil uses the wall clock.
Now func() time.Time
// Follow leads the editor after the agent; Seconds is its clock, the frame time (11b5b).
Follow *follow.Service
Seconds func() float64
// FollowChanged saves the Follow button's state in the preferences.
FollowChanged func(on bool)
// Selection is the user's selection, which a pinned Context sends with each message.
Selection interface{ Selected() []ids.NodeID }
// BudgetsChanged saves the read limits in the preferences.
BudgetsChanged func(b agentui.Budgets)
}
type avail struct {
version string
err error
}
type Panel struct {
quickRows []context.ScriptProblem
textPick transcriptPick
settingsRect mathx.Rect
explainRect mathx.Rect
imageCache []displayImage
imageViewer imageViewer
composer widgets.Multiline
draftImages []draftImage
queue []queuedMessage
queuePaused bool
d Deps
blocks []block
old []block
events []agentui.Event
cards []agentui.Card
questions []agentui.Question
seen map[int]bool
asked map[int]bool
answered map[int]agentui.State
logged map[int]agentui.State
input string
scroll float32
content float32
bar widgets.ScrollState
// stick keeps the transcript at its end while new output comes; scrolling up turns it off until the end is reached again.
stick bool
focus bool
ver string
verErr error
checking bool
checked chan avail
path string
model string
effort string
models []agentui.ModelOption
modelResults chan modelCatalogResult
modelCancel stdcontext.CancelFunc
modelError error
modelQuery string
modelPick int
customEffort bool
providerSettings core.ID
log *agentui.Transcript
logErr bool
past []string
names []string
viewing string
pal *palette.Palette
mentionOpened uint64
mentions []mention
picked []mention
notified bool
turnAll bool
toolCalls int
toolFailures int
meta string
session string
fits [5]fit
emptyFits [5]fit
emptyHint string
emptyHintWidth float32
emptyHintFont *text.Font
emptyHintLines []string
menu core.ID
history core.ID
timeline []dot
gap float32
undoTo int
undoMenu core.ID
// meter counts the context of the conversation; the Context popup shows its layout (11b5c).
meter *agentui.Meter
layout agentui.Layout
layoutRev uint64
ctxPop core.ID
ctxOpen [agentui.CatCount]bool
ctxWant bool
ctxLabel string
ctxLabelRev uint64
itemFits map[int]*fit
pinScene bool
pinSelection bool
seed string
summarizing bool
summary strings.Builder
}
// mention is an @ reference: the input shows Label, the message sends Full.
type mention struct {
Label, Full, Icon string
}
func New(d Deps) *Panel {
return &Panel{d: d, seen: map[int]bool{}, asked: map[int]bool{}, answered: map[int]agentui.State{}, logged: map[int]agentui.State{}, checked: make(chan avail, 1), stick: true,
menu: core.IDOf("agent.menu"), history: core.IDOf("agent.history"), undoMenu: core.IDOf("agent.undoto"), ctxPop: core.IDOf("agent.context"), providerSettings: core.IDOf("agent.provider-settings"), itemFits: map[int]*fit{}}
}
func (p *Panel) ID() string {
return ID
}
func (p *Panel) Title() string {
if p.waiting() {
return i18n.Tr("editor.agent.agent", "Agent •")
}
return i18n.Tr("editor.agent.agent_2", "Agent")
}
// Missing is why the selected agent cannot run, nil when it can or while checking.
func (p *Panel) Missing() error {
return p.verErr
}
func (p *Panel) Input() string {
return p.input
}
// Recheck starts a fresh availability generation. An older probe may finish, but
// its private buffered channel can no longer change the selected provider's state.
func (p *Panel) Recheck() {
p.ver, p.verErr = "", nil
p.checking = false
p.checked = make(chan avail, 1)
}
// Ask shows the panel with the prompt in the input, not sent.
func (p *Panel) Ask(prompt string) {
p.viewing = ""
if prompt != "" {
p.input = prompt
}
p.focus = true
if p.d.Show != nil {
p.d.Show()
}
}
// Submit sends text as if typed and sent, for tests.
func (p *Panel) Submit(text string) {
p.input = text
p.send()
}
func (p *Panel) Pending() []int {
var out []int
for i := range p.cards {
if p.cards[i].State == agentui.Pending {
out = append(out, p.cards[i].ID)
}
}
return out
}
// Questions are the agent's questions waiting for an answer.
func (p *Panel) Questions() []int {
var out []int
for i := range p.questions {
if p.questions[i].State == agentui.Pending {
out = append(out, p.questions[i].ID)
}
}
return out
}
// Answer answers a waiting question as its buttons do, for tests.
func (p *Panel) Answer(id int, picks []int, text string) {
p.d.Host.Answer(id, picks, text)
}
func (p *Panel) Decide(id int, apply bool) {
p.d.Host.Decide(id, apply)
}
func (p *Panel) Close() {
p.cancelModelLoad()
p.closeImageViewer(nil)
p.clearDraftImages()
p.dropImages()
p.d.Host.Close()
if p.log != nil {
p.log.Close()
p.log = nil
}
}
func (p *Panel) waiting() bool {
for i := range p.cards {
if p.cards[i].State == agentui.Pending {
return true
}
}
for i := range p.questions {
if p.questions[i].State == agentui.Pending {
return true
}
}
return false
}
func (p *Panel) check() {
p.checking, p.verErr = true, nil
host, cmd, ch := p.d.Host, p.d.Host.Command(), p.checked
go func() {
v, err := host.Available(cmd)
ch <- avail{version: v, err: err}
}()
}
func (p *Panel) record(e agentui.Entry) {
if p.log == nil {
return
}
if err := p.log.Append(e); err != nil && !p.logErr {
p.logErr = true
p.d.Notices.Notify(context.LevelWarn, i18n.Note("editor.agent.cannot_write_the_agent_session", "cannot write the agent session log: {error}", i18n.Str("error", err.Error())))
}
}
// Tick takes what the model and the bridge produced since the last frame; it runs also while the panel is hidden.
func (p *Panel) Tick() {
select {
case a := <-p.checked:
p.ver, p.verErr, p.checking = a.version, a.err, false
default:
}
if p.ver == "" && p.verErr == nil && !p.checking {
p.check()
}
host := p.d.Host
clear(p.events) // Drop previous result/image references before shortening the reusable slice.
p.events = host.Poll(p.events[:0])
now := p.now()
for _, e := range p.events {
p.event(e)
if e.Kind == agentui.EvToolStart && p.d.Follow != nil {
p.d.Follow.Step(e.Tool, e.Args, now)
}
}
p.cards = host.Cards(p.cards[:0])
for i := range p.cards {
c := &p.cards[i]
if !p.seen[c.ID] {
p.seen[c.ID] = true
p.attach(c)
}
if c.State == agentui.Pending && p.turnAll && !c.Destructive {
p.d.Host.Decide(c.ID, true)
c.State = agentui.Approved
}
if st, ok := p.logged[c.ID]; !ok || st != c.State {
p.logged[c.ID] = c.State
p.record(agentui.Entry{Kind: "card", Card: c.ID, Text: summary(c), State: c.State.String()})
p.redraw(c.ID)
}
}
if p.d.Follow != nil {
p.d.Follow.Cards(p.cards, now)
p.d.Follow.Tick(now)
}
p.questions = host.Questions(p.questions[:0])
for i := range p.questions {
q := &p.questions[i]
if !p.asked[q.ID] {
p.asked[q.ID] = true
p.push(block{kind: blockQuestion, question: q.ID, pick: make([]bool, len(q.Options))})
}
if st, ok := p.answered[q.ID]; !ok || st != q.State {
p.answered[q.ID] = q.State
for j := range p.blocks {
if b := &p.blocks[j]; b.kind == blockQuestion && b.question == q.ID {
b.dirty()
}
}
if q.State != agentui.Pending {
p.record(agentui.Entry{Kind: "question", Card: q.ID, Text: q.Text + "\n" + q.Answer, State: q.State.String()})
}
}
}
pending := p.waiting()
switch {
case pending && !p.notified:
p.notified = true
if p.d.Show != nil {
p.d.Show()
}
p.d.Notices.Notify(context.LevelInfo, i18n.Note("editor.agent.the_agent_is_waiting_for", "The agent is waiting for you to apply or reject a change"))
case !pending:
p.notified = false
}
if !host.Busy() && !pending && !p.queuePaused && len(p.queue) > 0 {
if p.sendPrepared(p.queue[0], false) {
p.removeQueued(0)
} else {
p.queuePaused = true
}
}
}
// redraw measures the block of a card again after its state changed; a decided card folds.
func (p *Panel) redraw(card int) {
c := p.card(card)
for i := range p.blocks {
b := &p.blocks[i]
if b.kind == blockCard && b.card == card {
b.dirty()
if c != nil && c.State != agentui.Pending && c.State != agentui.Approved {
b.open = false
}
}
}
}
func (p *Panel) event(e agentui.Event) {
p.meterEvent(e)
switch e.Kind {
case agentui.EvText:
p.textEvent(e)
p.record(agentui.Entry{Kind: "text", Text: e.Text, Delta: e.Delta, MessageID: e.MessageID})
case agentui.EvToolStart:
p.toolCalls++
p.toolStart(e)
p.record(agentui.Entry{Kind: "tool", Tool: e.Tool, CallID: e.CallID, Args: e.Args})
case agentui.EvToolResult:
if e.Failed {
p.toolFailures++
}
e.ImageKeys = p.storeImages(e.Images)
p.toolResult(e)
p.record(agentui.Entry{Kind: "result", Images: e.ImageKeys, Tool: e.Tool, CallID: e.CallID, Text: e.Result, Failed: e.Failed})
case agentui.EvError:
p.push(block{kind: blockError, text: e.Text})
p.record(agentui.Entry{Kind: "error", Text: e.Text})
if len(p.queue) > 0 {
p.queuePaused = true
}
case agentui.EvTurnEnd:
p.meta = i18n.Tr("editor.agent.turn_tools", "Tool calls: {n}; errors: {f}", i18n.Int("n", int64(p.toolCalls)), i18n.Int("f", int64(p.toolFailures)))
if info := turnMeta(e.Cost, e.Millis); info != "" {
p.meta += " · " + info
}
}
if e.Kind == agentui.EvTurnEnd || e.Kind == agentui.EvError {
p.turnAll = false
}
}
// turnMeta is the small line under the input: what the last answer cost and how long it took.
func turnMeta(cost float64, millis int) string {
var parts []string
if cost > 0 {
parts = append(parts, "$"+strconv.FormatFloat(cost, 'f', 2, 64))
}
if millis > 0 {
parts = append(parts, i18n.Tr("editor.agent.seconds", "{n} s", i18n.Str("n", strconv.FormatFloat(float64(millis)/1000, 'f', 1, 64))))
}
return strings.Join(parts, " · ")
}
func (p *Panel) send() { p.sendMessage(false) }
func (p *Panel) sendMessage(explain bool) {
text := strings.TrimSpace(p.input)
if text == "" && len(p.draftImages) == 0 || p.viewing != "" {
return
}
full := text
for _, m := range p.picked {
full = strings.Replace(full, m.Label, m.Full, 1)
}
if explain {
if _, ok := p.d.Host.(agentui.SceneHost); !ok {
full = p.scenePrompt(full)
}
}
var imgs [][]byte
for _, im := range p.draftImages {
imgs = append(imgs, append([]byte(nil), im.png...))
}
msg := queuedMessage{text: text, full: full, images: imgs}
if p.d.Host.Busy() || len(p.queue) > 0 {
if explain || !p.enqueue(msg) {
return
}
} else if !p.sendPrepared(msg, explain) {
return
}
p.clearDraftImages()
p.input, p.stick, p.picked = "", true, p.picked[:0]
}
func (p *Panel) sendPrepared(msg queuedMessage, explain bool) bool {
if p.log == nil {
t, err := agentui.NewTranscript(p.d.Root, time.Now())
if err != nil {
p.d.Notices.Notify(context.LevelWarn, i18n.Note("editor.agent.cannot_start_the_agent_session", "cannot start the agent session log: {error}", i18n.Str("error", err.Error())))
} else {
p.log, p.past = t, nil
if h, ok := p.d.Host.(agentui.ProviderHost); ok {
p.record(agentui.Entry{Kind: "session", Provider: h.Provider()})
}
}
}
full, imgs := msg.full, msg.images
var sendErr error
if len(imgs) > 0 {
sender, ok := p.d.Host.(interface{ SendImages(string, [][]byte) error })
if !ok {
p.notice(i18n.Tr("editor.agent.image_unsupported", "This agent does not support image input."))
return false
}
sendErr = sender.SendImages(p.outgoing(full), imgs)
} else {
send := p.d.Host.Send
if explain {
if h, ok := p.d.Host.(agentui.SceneHost); ok {
send = h.ExplainScene
}
}
sendErr = send(p.outgoing(full))
}
if sendErr != nil {
p.notice(sendErr.Error())
return false
}
keys := p.storeImages(imgs)
now := time.Now()
if p.d.Now != nil {
now = p.d.Now()
}
if len(p.blocks) > 0 {
p.push(block{kind: blockTurn, at: now})
}
if p.session == "" {
p.session = firstLine(msg.text)
}
p.push(block{kind: blockUser, text: msg.text, at: now, images: keys})
p.record(agentui.Entry{Kind: "user", Text: full, Images: keys})
p.stick = true
p.toolCalls, p.toolFailures = 0, 0
return true
}
// sendText sends text from a button: an example, Retry or Ask Agent to Fix; while the agent works it waits in the input.
func (p *Panel) sendText(text string) {
p.input = text
if !p.d.Host.Busy() {
p.send()
}
}
func summary(c *agentui.Card) string {
lines := []string{c.Command}
for _, ch := range c.Changes {
lines = append(lines, ch.String())
}
lines = append(lines, c.Notes...)
return strings.Join(lines, "\n")
}
// NewSession ends the conversation and starts an empty transcript; the next message opens a new session log.
func (p *Panel) NewSession() {
if len(p.queue) > 0 {
p.notice(i18n.Tr("editor.agent.queue_clear_first", "Send or clear queued messages before starting a new session."))
return
}
p.clearDraftImages()
p.d.Host.Reset()
// Reset synchronously dismisses bridge work; log its final decisions and unseen cards/questions before closing the old log so an immediate Send cannot leak them into the new session.
p.cards = p.d.Host.Cards(p.cards[:0])
for i := range p.cards {
c := &p.cards[i]
p.seen[c.ID] = true
if st, ok := p.logged[c.ID]; !ok || st != c.State {
p.record(agentui.Entry{Kind: "card", Card: c.ID, Text: summary(c), State: c.State.String()})
p.logged[c.ID] = c.State
}
}
p.questions = p.d.Host.Questions(p.questions[:0])
for i := range p.questions {
q := &p.questions[i]
p.asked[q.ID] = true
if st, ok := p.answered[q.ID]; !ok || st != q.State {
if q.State != agentui.Pending {
p.record(agentui.Entry{Kind: "question", Card: q.ID, Text: q.Text + "\n" + q.Answer, State: q.State.String()})
}
p.answered[q.ID] = q.State
}
}
if p.meter != nil {
p.meter.Reset()
}
p.seed, p.summarizing = "", false
if p.log != nil {
p.log.Close()
p.log = nil
}
p.textPick = transcriptPick{}
p.blocks, p.old, p.viewing, p.session, p.meta = nil, nil, "", "", ""
p.dropImages()
p.toolCalls, p.toolFailures = 0, 0
p.past, p.scroll, p.stick = nil, 0, true
}
func (p *Panel) Draw(f *services.Frame, r mathx.Rect) {
p.explainRect = mathx.Rect{}
ui := f.UI
th := &ui.Theme
if height := p.providerRow(ui, r); height > 0 {
r.Y += height
r.H = max(0, r.H-height)
if p.verErr != nil && !p.d.Host.Running() {
ui.PushCursor(core.Cursor{X: r.X + th.Padding, Y: r.Y + th.Padding, W: max(0, r.W-2*th.Padding), RowH: th.RowHeight, Pad: th.Padding, Space: th.Spacing})
defer ui.PopCursor()
}
}
if p.verErr != nil && !p.d.Host.Running() {
p.drawMissing(ui)
return
}
bar := mathx.Rect{X: r.X, Y: r.Y, W: r.W, H: th.RowHeight + th.Padding*2}
p.header(ui, bar)
sendLabel := i18n.Tr("editor.agent.send", "Send")
if p.d.Host.Busy() || len(p.queue) > 0 {
sendLabel = i18n.Tr("editor.agent.queue_send", "Queue")
}
footerW := max(0, r.W-2*th.Padding)
field, button, stop := p.composerBoxes(ui, footerW, r.H-bar.H, sendLabel, p.d.Host.Busy())
inputH := max(field.H, button.Y+button.H) + 2*th.Padding + 12
inputH += p.queueHeight(ui)
if len(p.draftImages) > 0 {
inputH += 54
}
if len(p.picked) > 0 {
inputH += th.RowHeight + th.Spacing
}
metaH := th.RowHeight * 0.85
top := bar.Y + bar.H
body := mathx.Rect{X: r.X, Y: top, W: max(0, r.W-th.ScrollbarW-2), H: max(0, r.Y+r.H-top-inputH-metaH)}
ui.Rect(mathx.Rect{X: r.X, Y: top - 1, W: r.W, H: 1}, th.Border)
p.transcript(ui, body, mathx.Rect{X: body.X + body.W + 2, Y: body.Y, W: th.ScrollbarW, H: body.H})
p.activity(ui, mathx.Rect{X: r.X + th.Padding, Y: body.Y + body.H + 2, W: max(0, r.W-2*th.Padding), H: 10})
p.footer(ui, mathx.Rect{X: r.X + th.Padding, Y: body.Y + body.H + th.Padding + 12, W: footerW, H: inputH - th.Padding - 12 + metaH}, field, button, stop, sendLabel)
p.drawMentions(ui)
}
// status is what the agent does now, as the header says it.
func (p *Panel) status(ui *core.Context) (string, mathx.Color) {
th := &ui.Theme
switch {
case p.waiting():
return i18n.Tr("editor.agent.status_waiting", "waiting for you"), th.Accent
case p.d.Host.Busy() && p.running() != nil:
return i18n.Tr("editor.agent.status_tool", "working"), th.TextDim
case p.d.Host.Busy():
return i18n.Tr("editor.agent.status_thinking", "thinking"), th.TextDim
}
return i18n.Tr("editor.agent.status_idle", "idle"), th.TextDisabled
}
// running is the call the agent waits on, nil when none.
func (p *Panel) running() *step {
for i := len(p.blocks) - 1; i >= 0 && i >= len(p.blocks)-4; i-- {
b := &p.blocks[i]
for j := len(b.steps) - 1; j >= 0; j-- {
if !b.steps[j].done {
return &b.steps[j]
}
}
}
return nil
}
func (p *Panel) header(ui *core.Context, r mathx.Rect) {
th := &ui.Theme
host := p.d.Host
ui.Rect(r, th.Header)
r = mathx.Rect{X: r.X + th.Padding, Y: r.Y + th.Padding, W: max(0, r.W-2*th.Padding), H: th.RowHeight}
x := r.X
right := r.X + r.W
mw := r.H
if agentButton(ui, ui.ID("more"), mathx.Rect{X: right - mw, Y: r.Y, W: mw, H: r.H}, "ui.more", "", i18n.Tr("editor.agent.menu", "Session"), false) {
ui.OpenPopup(p.menu, mathx.Vec2{X: max(r.X, right-220), Y: r.Y + r.H})
}
right -= mw + 4
if f := p.d.Follow; f != nil {
fr := mathx.Rect{X: right - r.H, Y: r.Y, W: r.H, H: r.H}
tip := ""
if ui.Hit(fr) {
tip = i18n.Tr("editor.agent.follow_tip", "Follow the agent: the editor shows where it works")
if f.Paused() {
tip = i18n.Tr("editor.agent.following_paused", "Following paused while you work") + " · " + i18n.Tr("editor.agent.resume", "Resume Following")
}
if sp, _, ok := f.Model.Where(follow.Agent, p.now()); ok {
tip += "\n" + p.spotLabel(sp)
}
}
if agentButton(ui, ui.ID("follow"), fr, "panel.watch", "", tip, f.Active()) {
if f.Paused() {
f.ResumeNow()
} else {
f.SetOn(!f.On())
if p.d.FollowChanged != nil {
p.d.FollowChanged(f.On())
}
}
}
if f.Paused() {
ui.RoundRect(mathx.Rect{X: fr.X + fr.W - 6, Y: fr.Y + 2, W: 5, H: 5}, 2.5, th.Warn)
}
right -= r.H + 4
}
right = p.contextButton(ui, right, r)
auto := host.AutoApply()
aw := r.H
ar := mathx.Rect{X: right - aw, Y: r.Y, W: aw, H: r.H}
atip := ""
if ui.Hit(ar) {
atip = i18n.Tr("editor.agent.auto_apply", "Auto-apply") + ": " + i18n.Tr("editor.agent.apply_changes_without_asking_removals", "Apply changes without asking; removals, moves and deletes still wait for you")
}
if agentButton(ui, ui.ID("auto"), ar, "ui.check", "", atip, auto) {
host.SetAutoApply(!auto)
}
right -= aw + 6
status, col := p.status(ui)
statusTip := status
if !host.Busy() && !p.waiting() {
status = ""
}
sw := ui.TextSize(status).X + 10
if host.Busy() {
sw += 10
}
name := p.session
if p.viewing != "" {
name = sessionName(p.d.Root, p.viewing)
}
if name == "" {
name = i18n.Tr("editor.agent.new_session", "New session")
}
if right-x-sw-10 < 90 {
status, sw = "", 10
}
nw := max(0, right-x-sw-6)
shown := p.fits[0].get(ui, name, max(0, nw-42))
nr := mathx.Rect{X: x, Y: r.Y, W: min(nw, ui.TextSize(shown).X+42), H: r.H}
id := ui.ID("session")
if ui.Clicked(id, nr) {
p.past = nil
ui.OpenPopup(p.history, mathx.Vec2{X: nr.X, Y: r.Y + r.H})
}
if ui.Hot() == id {
ui.RoundRect(nr, th.Radius.Button, th.ButtonHover)
}
ui.PushClip(nr)
widgets.IconAt(ui, mathx.Rect{X: nr.X + 4, Y: nr.Y + (nr.H-16)/2, W: 16, H: 16}, "agent.avatar", th.Agent.Presence)
ui.Text(shown, mathx.Vec2{X: nr.X + 24, Y: ui.TextY(r)}, th.Text)
if nr.W >= 40 {
widgets.IconAt(ui, mathx.Rect{X: nr.X + nr.W - 15, Y: nr.Y + (nr.H-12)/2, W: 12, H: 12}, "ui.chevron_down", th.TextDim)
}
ui.PopClip()
if ui.Hot() == id {
widgets.Tooltip(ui, id, i18n.Tr("editor.agent.history_tip", "Past sessions")+" · "+name+" · "+statusTip)
}
sx := x + nr.W + 6
if host.Busy() {
pulse := 0.55 + 0.45*mathx.Sin(float32(ui.Time)*5)
dot := col
dot.A *= pulse
ui.RoundRect(mathx.Rect{X: sx, Y: r.Y + r.H/2 - 3, W: 6, H: 6}, 3, dot)
sx += 10
}
if status != "" {
ui.Text(status, mathx.Vec2{X: sx, Y: ui.TextY(r)}, col)
}
p.sessionMenu(ui)
p.historyMenu(ui)
p.contextPopup(ui)
}
// agentButton keeps the header actions compact, legible and usable at narrow widths.
func agentButton(ui *core.Context, id core.ID, r mathx.Rect, icon, label, tip string, active bool) bool {
if r.W <= 0 || r.H <= 0 {
return false
}
clicked := ui.Clicked(id, r)
bg := ui.WidgetColor(id)
if active {
bg = ui.Theme.ButtonActive
}
ui.Box(r, ui.Theme.Radius.Button, bg, ui.Theme.Border)
if label == "" || r.W < ui.TextSize(label).X+32 {
widgets.IconIn(ui, r, icon, ui.Theme.Text)
} else {
widgets.IconAt(ui, mathx.Rect{X: r.X + 7, Y: r.Y + (r.H-16)/2, W: 16, H: 16}, icon, ui.Theme.TextDim)
text := mathx.Rect{X: r.X + 27, Y: r.Y, W: max(0, r.W-32), H: r.H}
ui.TextIn(text, widgets.Elide(ui, label, text.W), ui.Theme.Text, core.AlignLeft)
}
widgets.Tooltip(ui, id, tip)
return clicked
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
func (p *Panel) sessionMenu(ui *core.Context) {
if !widgets.ContextMenu(ui, p.menu) {
return
}
host := p.d.Host
if widgets.MenuItemIcon(ui, i18n.Tr("editor.agent.menu_new", "New Session"), "", "agent.new", true, false) {
p.NewSession()
}
if widgets.MenuItemIcon(ui, i18n.Tr("editor.agent.menu_history", "History…"), "", "panel.history", true, false) {
p.past = nil
at := ui.Mouse()
ui.CloseAllPopups()
ui.OpenPopup(p.history, at)
}
if f := p.d.Follow; f != nil {
sp, _, ok := f.Model.Where(follow.Agent, p.now())
if widgets.MenuItemIcon(ui, i18n.Tr("editor.agent.go_to_location", "Show agent location"), "", "agent.presence", ok, false) && ok {
f.GoTo(sp)
}
}
widgets.Separator(ui)
for _, blockers := range []bool{false, true} {
if widgets.MenuItemIcon(ui, problemLabel(blockers), "", "panel.problems", !host.Busy(), false) {
p.draftProblems(blockers)
}
}
widgets.Separator(ui)
if widgets.MenuItemIcon(ui, i18n.Tr("editor.agent.menu_plan", "Plan Only"), "", "agent.plan", true, host.PlanOnly()) {
host.SetPlanOnly(!host.PlanOnly())
}
if widgets.MenuItemIcon(ui, i18n.Tr("editor.agent.stop", "Stop"), "", "play.stop", host.Busy(), false) {
p.queuePaused = len(p.queue) > 0
host.Cancel()
}
widgets.EndContextMenu(ui)
}
func (p *Panel) historyMenu(ui *core.Context) {
if !widgets.ContextMenu(ui, p.history) {
return
}
if p.past == nil {
p.past, _ = agentui.List(p.d.Root)
if p.past == nil {
p.past = []string{}
}
p.names = p.names[:0]
for _, id := range p.past {
p.names = append(p.names, sessionName(p.d.Root, id))
}
}
if widgets.MenuItem(ui, i18n.Tr("editor.agent.current_session", "Current session"), "", true, p.viewing == "") {
p.viewing, p.old, p.stick = "", nil, true
}
for i, id := range p.past {
if p.log != nil && id == p.log.ID {
continue
}
if widgets.MenuItem(ui, p.names[i], "", true, p.viewing == id) {
p.open(id)
}
}
widgets.EndContextMenu(ui)
}
// sessionName is a past session by its first message.
func sessionName(root, id string) string {
entries, err := agentui.Load(root, id)
if err == nil {
provider := ""
for _, e := range entries {
if e.Kind == "session" {
provider = providerLabel(e.Provider)
}
if e.Kind == "user" && e.Text != "" {
line := firstLine(e.Text)
if r := []rune(line); len(r) > 48 {
line = string(r[:47]) + "…"
}
if provider != "" {
return provider + " · " + line
}
return line
}
}
}
return id
}
func (p *Panel) open(id string) {
entries, err := agentui.Load(p.d.Root, id)
if err != nil {
p.d.Notices.Notify(context.LevelError, err.Error())
return
}
p.viewing, p.old, p.scroll, p.stick = id, p.blocksFrom(entries), 0, false
}
// blocksFrom rebuilds a past session: steps get their looks again from the saved arguments and results.
func (p *Panel) blocksFrom(entries []agentui.Entry) []block {
saved := p.blocks
p.blocks = nil
for _, e := range entries {
switch e.Kind {
case "user":
if len(p.blocks) > 0 {
p.push(block{kind: blockTurn, at: e.Time})
}
p.push(block{kind: blockUser, text: e.Text, at: e.Time, images: e.Images})
case "text":
p.textEvent(agentui.Event{Text: e.Text, Delta: e.Delta, MessageID: e.MessageID})
case "tool":
p.toolStart(agentui.Event{Tool: e.Tool, CallID: e.CallID, Args: e.Args})
case "result":
p.toolResult(agentui.Event{ImageKeys: e.Images, Tool: e.Tool, CallID: e.CallID, Result: e.Text, Failed: e.Failed})
case "card":
if b := p.pastCard(e.Card); b != nil {
b.text = e.Text
continue
}
for i := len(p.blocks) - 1; i >= 0; i-- {
if b := &p.blocks[i]; b.kind == blockCard && b.card == 0 {
b.card, b.text, b.open = -e.Card, e.Text, false
break
}
}
case "error":
p.push(block{kind: blockError, text: e.Text})
case "notice":
p.push(block{kind: blockNotice, text: e.Text})
}
}
out := p.blocks
p.blocks = saved
return out
}
func (p *Panel) pastCard(id int) *block {
for i := range p.blocks {
if p.blocks[i].kind == blockCard && p.blocks[i].card == -id {
return &p.blocks[i]
}
}
return nil
}
// Position the action below a narrow composer so the draft uses the whole
// dock width. Wide composers keep a single-height button beside the last row.
func (p *Panel) composerBoxes(ui *core.Context, width, height float32, label string, busy bool) (field, button, stop mathx.Rect) {
button.W = min(width, ui.TextSize(label).X+24)
button.H = ui.Theme.RowHeight
button.X = width - button.W
reserved := button.W + 6
if busy {
stop.W = min(max(0, width-button.W-6), ui.TextSize(i18n.Tr("editor.agent.stop", "Stop")).X+20)
stop.H = button.H
stop.X = max(0, button.X-stop.W-6)
reserved += stop.W + 6
}
field.W = max(0, width-reserved)
stacked := field.W < 100 || width < 300 && (len(p.draftImages) > 0 || strings.Contains(p.input, "\n") || ui.TextSize(p.input).X > field.W-10)
if stacked {
field.W = width
}
rows := max(1, min(7, int(height*0.4/max(1, ui.LineHeight()))))
field.H = p.composer.Height(ui, p.input, field.W, rows)
button.Y = field.H - button.H
if stacked {
button.Y = field.H + ui.Theme.Spacing
}
stop.Y = button.Y
return
}
func (p *Panel) footer(ui *core.Context, r, fr, br, stop mathx.Rect, label string) {
th := &ui.Theme
host := p.d.Host
y := r.Y
if len(p.queue) > 0 {
h := p.queueHeight(ui)
p.drawQueue(ui, mathx.Rect{X: r.X, Y: y, W: r.W, H: h})
y += h
}
if len(p.picked) > 0 {
x := r.X
for i := range p.picked {
m := &p.picked[i]
w := ui.TextSize(m.Label).X + 30
if x+w > r.X+r.W {
break
}
chip := mathx.Rect{X: x, Y: y, W: w, H: th.RowHeight}
ui.RoundRect(chip, th.Radius.Button, th.Agent.User)
widgets.IconAt(ui, mathx.Rect{X: x + 5, Y: y + (chip.H-14)/2, W: 14, H: 14}, m.Icon, th.TextDim)
ui.Text(m.Label, mathx.Vec2{X: x + 23, Y: ui.TextY(chip)}, th.Text)
x += w + 4
}
y += th.RowHeight + th.Spacing
}
if len(p.draftImages) > 0 {
p.draftAttachments(ui, mathx.Rect{X: r.X, Y: y, W: r.W, H: 54})
y += 54
}
fr.X, fr.Y = fr.X+r.X, fr.Y+y
br.X, br.Y = br.X+r.X, br.Y+y
stop.X, stop.Y = stop.X+r.X, stop.Y+y
locked := p.viewing != ""
busy := host.Busy()
id := ui.ID("message")
if p.focus {
ui.SetFocus(id)
p.focus = false
}
enter := !ui.Mods().Has(input.ModShift) && ui.Focus() == id && (ui.KeyPressed(input.KeyEnter) || ui.KeyPressed(input.KeyKPEnter))
if !locked {
p.composer.Draw(ui, id, fr, &p.input, func() bool { return p.pasteImage(ui) })
p.dropMissingDraftImages()
} else {
ui.Box(fr, th.Radius.Field, th.Widget, th.Border)
}
if p.input == "" && ui.Focus() != id {
hint := i18n.Tr("editor.agent.message_the_agent_to_mention", "Message the agent, @ to mention")
if locked {
hint = i18n.Tr("editor.agent.past_read_only", "A past session is read-only; pick Current session in the history to write")
}
ui.Text(p.fits[1].get(ui, hint, fr.W-8), mathx.Vec2{X: fr.X + 4, Y: ui.TextY(fr)}, th.TextDisabled)
}
if !locked && ui.Focus() == id && !ui.Primary() && string(ui.TextInput()) == "@" && p.composer.RemoveBeforeCursor(&p.input, '@') {
p.openMentions(ui)
}
p.dropMentions()
if busy && stop.W > 0 {
if widgets.ButtonRect(ui, ui.ID("stop"), stop, widgets.Elide(ui, i18n.Tr("editor.agent.stop", "Stop"), max(0, stop.W-8))) {
p.queuePaused = len(p.queue) > 0
host.Cancel()
}
}
if sent := primary(ui, ui.ID("send"), br, label, !locked && (strings.TrimSpace(p.input) != "" || len(p.draftImages) > 0)); sent || (enter && !locked) {
p.send()
p.focus = true
}
meta := p.meta
if host.PlanOnly() {
meta = i18n.Tr("editor.agent.plan_on", "Plan only: the agent describes changes and makes none")
}
if meta != "" {
mr := mathx.Rect{X: r.X, Y: max(fr.Y+fr.H, br.Y+br.H) + 2, W: r.W, H: th.RowHeight * 0.8}
ui.Text(p.fits[2].get(ui, meta, mr.W), mathx.Vec2{X: mr.X, Y: ui.TextY(mr)}, th.TextDisabled)
}
}
// primary is the accent button of a row: Send, Apply.
func primary(ui *core.Context, id core.ID, r mathx.Rect, label string, enabled bool) bool {
th := &ui.Theme
if !enabled {
ui.RoundRect(r, th.Radius.Button, th.Widget)
ui.TextIn(r, label, th.TextDisabled, core.AlignCenter)
return false
}
hit := ui.Clicked(id, r)
fill := th.Accent
if ui.Hot() == id {
fill = fill.Lerp(th.Text, 0.15)
}
ui.RoundRect(r, th.Radius.Button, fill)
ui.TextIn(r, label, th.Background, core.AlignCenter)
return hit
}
func (p *Panel) openMentions(ui *core.Context) {
if p.pal == nil {
p.pal = palette.New(core.IDOf("agent.mentions"))
p.pal.Title = i18n.Tr("editor.agent.mention", "Mention")
p.pal.Hint = i18n.Tr("editor.agent.mention_hint", "Enter insert · Esc close")
p.pal.NoFavorites = true
p.pal.CategoryIcon = func(category string) string {
switch category {
case i18n.Tr("editor.agent.mention_nodes", "Nodes"):
return "cat.node"
case i18n.Tr("editor.agent.mention_assets", "Assets"):
return "panel.assets"
}
return ""
}
}
var items []palette.Item
p.mentions = p.mentions[:0]
if p.d.Docs != nil && p.d.Docs.Doc() != nil {
d := p.d.Docs.Doc()
s := d.Scene()
root := s.Root()
for _, h := range s.PreOrder() {
if h == root || !s.IsAlive(h) {
continue
}
id := s.ID(h)
name := s.Name(h)
items = append(items, palette.Item{Key: "node:" + id.String(), Label: name, Category: i18n.Tr("editor.agent.mention_nodes", "Nodes"), Detail: path.Base(d.Path), Icon: "cat.node"})
p.mentions = append(p.mentions, mention{Label: "@" + name, Full: "@" + name + " (#" + id.String() + " in " + d.Path + ")", Icon: "cat.node"})
}
}
if p.d.Assets != nil {
for _, a := range p.d.Assets() {
icon := assetIcon(a)
dir := path.Dir(a)
if dir == "." {
dir = ""
}
items = append(items, palette.Item{Key: "asset:" + a, Label: path.Base(a), Category: i18n.Tr("editor.agent.mention_assets", "Assets"), Detail: dir, Icon: icon})
p.mentions = append(p.mentions, mention{Label: "@" + path.Base(a), Full: "@" + a, Icon: icon})
}
}
p.pal.SetItems(items)
if len(p.pal.Open) == 0 {
p.pal.Open[i18n.Tr("editor.agent.mention_nodes", "Nodes")] = true
p.pal.Open[i18n.Tr("editor.agent.mention_assets", "Assets")] = true
}
p.pal.Show(ui)
// The composer's text event belongs to the trigger, not to the search.
// Transfer focus now and draw the picker on the next frame.
ui.SetFocus(p.pal.SearchID())
p.mentionOpened = ui.Frame()
}
func assetIcon(a string) string {
switch strings.ToLower(path.Ext(a)) {
case ".aes":
return "file.script"
case ".aeanim":
return "anim.key"
case ".aectrl":
return "anim.graph"
case ".aemat", ".aeshader":
return "cat.rendering"
case ".aepreset":
return "file.preset"
case ".aedata":
return "file.data"
case ".aetable", ".csv":
return "file.table"
case ".aescene":
return "file.scene"
case ".aeprefab":
return "file.prefab"
case ".png", ".jpg", ".jpeg":
return "file.texture"
case ".wav", ".ogg":
return "file.audio"
}
return "file.unknown"
}
// dropMentions forgets the chips whose label the user deleted from the input.
func (p *Panel) dropMentions() {
n := 0
for _, m := range p.picked {
if strings.Contains(p.input, m.Label) {
p.picked[n] = m
n++
}
}
p.picked = p.picked[:n]
}
func (p *Panel) drawMentions(ui *core.Context) {
if p.pal == nil || !p.pal.Active() || p.mentionOpened == ui.Frame() {
return
}
sz := ui.Size()
w, h := min(560, sz.X-40), min(380, sz.Y-60)
res := p.pal.Draw(ui, mathx.Rect{X: (sz.X - w) / 2, Y: max(20, sz.Y*0.15), W: w, H: h})
switch {
case res.Close:
p.pal.Hide(ui)
p.focus = true
case res.Pick >= 0 && res.Pick < len(p.mentions):
m := p.mentions[res.Pick]
p.composer.Insert(&p.input, m.Label+" ")
p.picked = append(p.picked, m)
p.pal.Hide(ui)
p.focus = true
}
}
func (p *Panel) drawMissing(ui *core.Context) {
if p.Provider() == "codex" {
widgets.Label(ui, i18n.Tr("editor.agent.codex_not_available", "Codex is not available"))
widgets.LabelWrapped(ui, i18n.Tr("editor.agent.codex_install_help", "Install Codex CLI with npm install -g @openai/codex, then run codex login in a terminal. Set its executable path below if needed and check again. Codex uses your existing CLI sign-in and model settings."), true)
} else {
widgets.Label(ui, i18n.Tr("editor.agent.claude_code_is_not_available", "Claude Code is not available"))
widgets.LabelWrapped(ui, i18n.Tr("editor.agent.install_help", "The agent runs Claude Code, the claude command. Install it, sign in once by running claude in a terminal, then check again. If it is installed where the editor does not look, set its path below."), true)
}
if p.verErr != nil {
widgets.LabelWrapped(ui, p.verErr.Error(), true)
}
if p.path == "" {
p.path = p.d.Host.Command()
}
widgets.TextFieldRect(ui, ui.ID("path"), widgets.Field(ui, i18n.Tr("editor.agent.path", "Path")), &p.path, true)
if p.checking {
widgets.LabelDim(ui, i18n.Tr("editor.agent.checking", "Checking…"))
return
}
if widgets.Button(ui, i18n.Tr("editor.agent.check_again", "Check Again")) {
p.setCommand(p.path)
p.check()
}
}
// Checked reports whether the selected agent's last availability probe finished.
func (p *Panel) Checked() bool {
return !p.checking && (p.ver != "" || p.verErr != nil)
}
// Busy reports whether the model is answering.
func (p *Panel) Busy() bool {
return p.d.Host.Busy()
}
// UndoCard takes back an applied card while it is the last step, as its Undo button does.
func (p *Panel) UndoCard(id int) error {
return p.d.Host.Undo(id)
}
func (p *Panel) card(id int) *agentui.Card {
for i := range p.cards {
if p.cards[i].ID == id {
return &p.cards[i]
}
}
return nil
}
func (p *Panel) question(id int) *agentui.Question {
for i := range p.questions {
if p.questions[i].ID == id {
return &p.questions[i]
}
}
return nil
}
// applicable counts the waiting cards Apply All takes: every pending one that is not destructive.
func (p *Panel) applicable() int {
n := 0
for i := range p.cards {
if c := &p.cards[i]; c.State == agentui.Pending && !c.Destructive {
n++
}
}
return n
}
// ApplyAll applies every waiting card that is not destructive, and those that come until the answer ends.
func (p *Panel) ApplyAll() {
p.turnAll = true
for i := range p.cards {
if c := &p.cards[i]; c.State == agentui.Pending && !c.Destructive {
p.d.Host.Decide(c.ID, true)
}
}
}
// Expand opens every row and step of the transcript, for screenshots and tests.
func (p *Panel) Expand() {
for i := range p.blocks {
b := &p.blocks[i]
b.open = true
for j := range b.steps {
b.steps[j].open = b.steps[j].look.Detail.Kind != agentui.DetailNone
}
b.dirty()
}
}
// ExplainScene runs the read-only quick action against the active document.
func (p *Panel) ExplainScene() {
if p.d.Host.Busy() || p.viewing != "" {
return
}
p.input = i18n.Tr("editor.agent.example_explain", "Explain what this scene does")
p.sendMessage(true)
}
func (p *Panel) ExplainRect() mathx.Rect { return p.explainRect }
func (p *Panel) SettingsRect() mathx.Rect { return p.settingsRect }