Files
MujicaUI/agent/human_input_request.go
T
ZacharyZhang-NYandClaude Opus 5.5 bcce34cda1 Upgrade MyGo to v0.5.5
MyGo v0.5.5 returns ui.Element as a checked value handle instead of a
pointer. Migrated with `mygo migrate-ui`, then fixed what the codemod
leaves to review:

- Element nil checks become .Valid(); absent elements are ui.Element{}.
- Input handlers and paint callbacks run outside the build pass, where
  elements expire and Context methods are inert: read IsDisabled into a
  local during the build, keep a ui.Handle for Focus, use c.Services()
  for clipboard access, create ui.Local state during the build.
- Add core.AutoFocus: v0.5.5 records an overlay's opener after its
  content builds, so same-frame autofocus lost focus restore on close.
  It focuses one frame later; cascader rows defer their focus likewise.
- core.OverlayLayer identifies build passes by the root element, not
  c.Now(), which repeats across passes and under the Tester clock.
- Context menus survive the opening press counted as PressedOutside;
  split button tooltip no longer reorders its open menu; disabled
  menubar rows pass presses to the panel; Form defers blur-started
  async checks so the growing message no longer moves Submit away
  from the release; Combobox drops its disabled copy now that MyGo
  blocks typing under a disabled wrapper (canary flipped to a guard).
- The Tester clock is virtual: tests use Advance/Now instead of
  time.Sleep/time.Now and read element state inside the view. The web
  mirror advances the clock to wall time before each frame and input.
- AGENTS.md and docs describe the value Element API and the new pin.

Snapshot goldens are darwin-only and were not re-verified here.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 01:37:54 -04:00

129 lines
4.2 KiB
Go

package agent
import (
"fmt"
"log/slog"
"slices"
"strings"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/icons"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme"
)
// HumanInputRequestOptions configure a HumanInputRequest. Question is required.
type HumanInputRequestOptions struct {
Question string
// Options are suggested answers; AllowFree adds a free one.
Options []string
AllowFree bool
// Deadline is when the agent stops waiting; zero waits forever.
Deadline time.Time
Disabled bool
}
// HumanInputResult reports this frame's events.
type HumanInputResult struct {
Element ui.Element
Submitted bool
// TimedOut reports the frame the deadline passed.
TimedOut bool
}
type humanInputState struct {
draft string
expired bool
}
// HumanInputRequest asks the user a question before a deadline.
func HumanInputRequest(c *ui.Context, answer *string, opts HumanInputRequestOptions) HumanInputResult {
if answer == nil || opts.Question == "" {
panic("mujica: HumanInputRequest needs an answer and a Question")
}
if len(opts.Options) == 0 && !opts.AllowFree {
panic("mujica: HumanInputRequest needs Options or AllowFree")
}
k := core.Tokens(c)
u := core.Density(c).Unit()
var r HumanInputResult
r.Element = AgentCard(c).Border(theme.BorderWidth, k.Info).Role(ui.RoleGroup).Label(opts.Question)
st := ui.Local(r.Element, "mujica.humaninput", func() humanInputState { return humanInputState{} })
left := time.Duration(0)
if !opts.Deadline.IsZero() {
left = opts.Deadline.Sub(c.Now())
if left <= 0 && !st.expired {
st.expired, r.TimedOut = true, true
}
}
r.Element.Disabled(opts.Disabled || st.expired)
locked := r.Element.IsDisabled()
r.Element.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Children(func() {
ui.Icon(c, icons.Must("user")).Size(18, 18).TextColor(k.Info).Shrink(0).Margin(2, 0, 0, 0)
ui.Text(c, opts.Question).FontWeight(600).Grow(1).Shrink(1)
})
if len(opts.Options) > 0 {
items := make([]input.RadioItem[string], len(opts.Options))
for i, o := range opts.Options {
items[i] = input.RadioItem[string]{Value: o, Label: o}
}
chosen := *answer
if input.RadioGroup(c, &chosen, items, input.RadioGroupOptions{Label: opts.Question, Disabled: locked}).Changed() && !locked {
*answer, st.draft = chosen, ""
}
}
submit := false
if opts.AllowFree {
in := input.TextInput(c, &st.draft, input.TextInputOptions{Placeholder: core.Msg(c, "human_input_request.free"),
Label: core.Msg(c, "human_input_request.free"), Disabled: locked})
if in.Changed() && !locked {
*answer = st.draft
}
submit = in.Submitted()
}
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
humanInputClock(c, opts.Deadline, left, st.expired)
blank := strings.TrimSpace(*answer) == ""
if input.Button(c, core.Msg(c, "human_input_request.send"), input.ButtonOptions{Disabled: locked || blank}).Clicked() {
submit = true
}
r.Submitted = submit && !locked && !blank
})
})
if len(opts.Options) > 0 && !opts.AllowFree && *answer != "" && !slices.Contains(opts.Options, *answer) {
panic(fmt.Sprintf("mujica: HumanInputRequest answer %q is not an option", *answer))
}
if r.Submitted || r.TimedOut {
slog.Debug("mujica: human_input_request event", "submitted", r.Submitted, "timed_out", r.TimedOut)
}
return r
}
// humanInputClock shows the time left, or that it ran out.
func humanInputClock(c *ui.Context, deadline time.Time, left time.Duration, expired bool) {
k := core.Tokens(c)
text, tone := "", k.TextMuted
switch {
case expired:
text, tone = core.Msg(c, "human_input_request.timeout"), k.Warning
case !deadline.IsZero():
text = fmt.Sprintf(core.Msg(c, "human_input_request.left"), AgentDuration(left.Truncate(time.Second)))
wait := left % time.Second
if wait == 0 {
wait = time.Second
}
c.After(wait)
}
ui.Row(c).Grow(1).Gap(core.Density(c).Unit()).AlignItems(ui.Center).Role(ui.RoleStatus).Label(text).Children(func() {
if text == "" {
return
}
ui.Icon(c, icons.Must("clock")).Size(14, 14).TextColor(tone)
ui.Text(c, text).TextColor(tone).FontSize(core.FontSize(c, theme.CaptionSize+1))
})
}