Files
MujicaUI/chat/context_mention_menu.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

137 lines
4.2 KiB
Go

package chat
import (
"log/slog"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core"
"git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/input"
)
// ContextKind is what a piece of context is.
type ContextKind int
const (
ContextFile ContextKind = iota
ContextDoc
ContextWeb
)
func (k ContextKind) icon() string {
switch k {
case ContextFile:
return "file"
case ContextDoc:
return "file-text"
case ContextWeb:
return "globe"
}
panic("mujica: unknown ContextKind")
}
func (k ContextKind) group() string {
return [...]string{"context_mention_menu.file", "context_mention_menu.doc", "context_mention_menu.web"}[k]
}
// ContextItem describes a file, document or webpage mention.
type ContextItem struct {
// ID is the item's stable identity; required.
ID string
Label string
Detail string
Kind ContextKind
}
// ContextMentionOptions configure a ContextMentionMenu.
type ContextMentionOptions struct {
// Loading indicates caller-owned asynchronous fetching.
Loading bool
}
// ContextMentionResult carries the popup, query and chosen item.
type ContextMentionResult struct {
Element ui.Element
query string
queryChanged bool
chosen ContextItem
ok bool
}
// Query returns mention text; QueryChanged requests fresh results.
func (r ContextMentionResult) Query() string { return r.query }
func (r ContextMentionResult) QueryChanged() bool { return r.queryChanged }
// Chosen returns the selected item and consumes its trigger.
func (r ContextMentionResult) Chosen() (ContextItem, bool) { return r.chosen, r.ok }
// ContextMentionMenu groups results for the active mention query.
// The caller filters results synchronously or asynchronously.
func ContextMentionMenu(c *ui.Context, composer input.InputResult, value *string, items []ContextItem, opts ContextMentionOptions) ContextMentionResult {
if !composer.Element.Valid() || !composer.Input.Valid() || value == nil {
panic("mujica: ContextMentionMenu needs composer and value")
}
data := ui.Local(composer.Element, "mention-data", func() mentionMenuData { return mentionMenuData{} })
locale := core.CurrentLocale(c)
if !data.built || locale != data.locale || !slices.Equal(data.items, items) {
data.built, data.locale = true, locale
data.items = append(data.items[:0], items...)
data.rows, data.order = data.rows[:0], data.order[:0]
for kind := ContextFile; kind <= ContextWeb; kind++ {
for i, it := range items {
if it.ID == "" || it.Label == "" {
panic("mujica: ContextItem needs ID and Label")
}
if it.Kind == kind {
data.rows = append(data.rows, slashMenuRow{label: it.Label, detail: it.Detail, icon: kind.icon(), group: core.Msg(c, kind.group())})
data.order = append(data.order, i)
}
}
}
if len(data.order) != len(items) {
panic("mujica: unknown ContextKind")
}
}
rows, order := data.rows, data.order
st := ui.Local(composer.Element, slashMenuKey{"mention"}, func() slashMenuState { return slashMenuState{} })
at := contextMentionAt(*value)
var r ContextMentionResult
if at >= 0 {
r.query = (*value)[at+1:]
}
// Opening or changing queries requests caller-owned results.
r.queryChanged = at >= 0 && !composer.Element.IsDisabled() && composer.Input.Focused() && (r.query != st.query || !st.open && st.dismissed == nil)
empty := core.Msg(c, "context_mention_menu.empty")
if opts.Loading {
empty = core.Msg(c, "context_mention_menu.loading")
}
i, _, panel := slashMenuShow(c, composer, st, r.query, at >= 0, rows, slashMenuOptions{empty: empty, label: core.Msg(c, "context_mention_menu.label"), loading: opts.Loading})
r.Element = panel
if i >= 0 {
r.chosen, r.ok = items[order[i]], true
*value = (*value)[:at]
slog.Debug("mujica: context_mention_menu choose", "id", r.chosen.ID)
}
return r
}
// contextMentionAt returns where the trailing "@query" starts, or -1.
func contextMentionAt(s string) int {
at := strings.LastIndexByte(s, '@')
if at < 0 || strings.ContainsAny(s[at+1:], " \t\n") || at > 0 && !strings.ContainsRune(" \t\n", rune(s[at-1])) {
return -1
}
return at
}
type mentionMenuData struct {
built bool
locale core.Locale
items []ContextItem
rows []slashMenuRow
order []int
}