diff --git a/attachment_chip.go b/attachment_chip.go
index 2aad33b..87cec24 100644
--- a/attachment_chip.go
+++ b/attachment_chip.go
@@ -11,11 +11,11 @@ import (
"mujicaui/theme"
)
-// Attachment is a file attached to a prompt; the caller uploads it.
+// Attachment describes a caller-uploaded file.
type Attachment struct {
// Name is required.
Name string
- // Type is the MIME type; empty picks the icon from the name.
+ // Type supplies MIME metadata; empty uses the filename.
Type string
Size int64
// Uploading shows Progress, 0 to 1.
@@ -25,7 +25,7 @@ type Attachment struct {
Err error
}
-// AttachmentChipResult is a chip's element and the user's request this frame.
+// AttachmentChipResult carries the chip and requested action.
type AttachmentChipResult struct {
Element *ui.Element
removed, retried bool
@@ -37,7 +37,7 @@ func (r AttachmentChipResult) Removed() bool { return r.removed }
// Retried reports a press of retry on a failed upload.
func (r AttachmentChipResult) Retried() bool { return r.retried }
-// AttachmentChip shows an attachment's type, name, size, upload progress or failure, and remove.
+// AttachmentChip displays upload status, retry and removal controls.
func AttachmentChip(c *ui.Context, a Attachment) AttachmentChipResult {
if a.Name == "" || a.Size < 0 {
panic("mujica: Attachment needs a Name and a non-negative Size")
diff --git a/attachment_chip_test.go b/attachment_chip_test.go
index 3b9e1bd..9835d81 100644
--- a/attachment_chip_test.go
+++ b/attachment_chip_test.go
@@ -60,3 +60,33 @@ func TestAttachmentChipSnapshot(t *testing.T) {
})
})
}
+
+func TestAttachmentChipKeyboardAndDisabled(t *testing.T) {
+ off, removed, retried := false, 0, 0
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ ui.Row(c).Disabled(off).Children(func() {
+ r := AttachmentChip(c, Attachment{Name: "guests.csv", Size: 0, Err: errors.New("upload failed")})
+ if r.Removed() {
+ removed++
+ }
+ if r.Retried() {
+ retried++
+ }
+ })
+ }, 400, 160)
+ tt.Key(0, ui.KeyTab)
+ tt.Key(0, ui.KeyEnter)
+ tt.Key(0, ui.KeyTab)
+ tt.Key(0, ui.KeySpace)
+ if retried != 1 || removed != 1 {
+ t.Fatalf("retry %d remove %d", retried, removed)
+ }
+ off = true
+ tt.Frame()
+ tt.Key(0, ui.KeyEnter)
+ _ = tt.Click("Retry upload of guests.csv")
+ if retried != 1 || removed != 1 {
+ t.Fatal("disabled attachment handled input")
+ }
+}
diff --git a/citation_badge.go b/citation_badge.go
index e24e90b..cab90fb 100644
--- a/citation_badge.go
+++ b/citation_badge.go
@@ -20,20 +20,22 @@ type Source struct {
Published time.Time
}
-// CitationState links citations, source cards and the sources panel; the caller holds it.
+// CitationState links citations and sources through caller-owned state.
type CitationState struct {
- // Active is the 1-based number of the source the user chose; 0 for none.
+ // Active selects a 1-based source; zero clears selection.
Active int
hover int
owner uint64
+ seen time.Time
+ locate uint64
}
// lit reports whether source n is chosen or hovered anywhere.
func (s *CitationState) lit(n int) bool { return n > 0 && (s.Active == n || s.hover == n) }
-// track records that e, showing source n, is or is no longer under the pointer.
+// track refreshes pointer ownership for the current frame.
func (s *CitationState) track(c *ui.Context, e *ui.Element, n int) {
- switch over := e.Hovered(); {
+ switch over := e.Hovered() && !e.IsDisabled(); {
case over && s.hover != n:
s.hover, s.owner = n, e.ID()
c.Invalidate()
@@ -41,9 +43,19 @@ func (s *CitationState) track(c *ui.Context, e *ui.Element, n int) {
s.hover, s.owner = 0, 0
c.Invalidate()
}
+ if s.owner == e.ID() {
+ s.seen = c.Now()
+ }
}
-// CitationBadge is an inline source number; hovering previews the source, clicking makes it Active.
+func (s *CitationState) expire(c *ui.Context, frame time.Time) {
+ if s.hover != 0 && s.seen != frame {
+ s.hover, s.owner = 0, 0
+ c.Invalidate()
+ }
+}
+
+// CitationBadge previews and locates a numbered source.
func CitationBadge(c *ui.Context, n int, src Source, st *CitationState) ChoiceResult {
if n < 1 || st == nil || src.Title == "" || src.URL == "" {
panic("mujica: CitationBadge needs n >= 1, a state and a Source with Title and URL")
@@ -51,7 +63,7 @@ func CitationBadge(c *ui.Context, n int, src Source, st *CitationState) ChoiceRe
k := Tokens(c)
label := fmt.Sprintf(msg(c, "citation_badge.label"), n, src.Title)
size := FontSize(c, theme.CaptionSize-1)
- b := ui.ButtonBase(c).MinWidth(18).Height(18).Padding(0, 4).Radius(theme.ControlRadius).Label(label).Transition(colorFade)
+ b := ui.ButtonBase(c).MinWidth(theme.MinHitSize).MinHeight(theme.MinHitSize).Padding(0, 4).Radius(theme.ControlRadius).Label(label).Transition(colorFade)
internal.Ring(b, k.Focus, theme.ControlRadius)
st.track(c, b, n)
if st.lit(n) {
@@ -59,25 +71,33 @@ func CitationBadge(c *ui.Context, n int, src Source, st *CitationState) ChoiceRe
} else {
b.Background(k.SurfaceHover).Border(theme.BorderWidth, k.Border).TextColor(k.TextMuted)
}
+ frame := c.Now()
+ b.Draw(func(p *ui.Painter, r ui.Rect) {
+ st.expire(c, frame)
+ if st.lit(n) {
+ p.Fill(ui.Rect{X: r.X + 4, Y: r.Y + r.H - 4, W: r.W - 8, H: 2}, k.AccentText, 0)
+ }
+ })
b.Children(func() { ui.Text(c, fmt.Sprint(n)).FontSize(size).FontFeatures("tnum").FontWeight(600).SingleLine() })
r := ChoiceResult{Element: b}
- if b.Clicked() && !b.IsDisabled() && st.Active != n {
+ if b.Clicked() && !b.IsDisabled() {
st.Active, r.changed = n, true
+ st.locate++
slog.Debug("mujica: citation_badge activate", "n", n)
}
citationPreview(c, b, n, src)
return r
}
-// citationPreviewDelay is how long hover or focus rests before the preview shows.
+// citationPreviewDelay sets the hover and focus wait.
const citationPreviewDelay = 500 * time.Millisecond
-// citationPreview shows the source by badge b once hover or keyboard focus rests on it.
-// It lets the pointer through, so the badge stays clickable under it; Escape hides it.
+// citationPreview displays a source after resting hover or focus.
+// Pass-through preserves badge clicks; Escape dismisses the preview.
func citationPreview(c *ui.Context, b *ui.Element, n int, src Source) {
st := ui.Local(b, "mujica.citation", func() tooltipState { return tooltipState{} })
size := ui.Local(b, "mujica.citation.size", func() ui.Rect { return ui.Rect{} })
- if !b.Hovered() && !b.FocusVisible() {
+ if b.IsDisabled() || !b.Hovered() && !b.FocusVisible() {
*st = tooltipState{}
return
}
@@ -96,13 +116,14 @@ func citationPreview(c *ui.Context, b *ui.Element, n int, src Source) {
return
}
u := Density(c).Unit()
- w := float32(300)
- x, y := overlayPlace(c, b.Bounds(), w, size.H, 6)
+ ww, wh := c.Size()
+ w, h := min(float32(300), max(0, ww-8)), max(0, wh-8)
+ x, y := overlayPlace(c, b.Bounds(), w, min(size.H, h), 6)
ui.Overlay(c, func() {
- panel := ui.Column(c).Absolute().Left(x).Top(y).Width(w).Padding(u * 3).PassThrough().Role(ui.RoleTooltip)
+ panel := ui.Column(c).Absolute().Left(x).Top(y).Width(w).MaxHeight(h).Padding(u * 3).PassThrough().Role(ui.RoleTooltip).Label(msg(c, "citation_badge.preview"))
overlayStyle(c, panel, overlayAnchored(c, panel, *size))
*size = panel.Bounds()
- panel.Children(func() { sourceSummary(c, n, src) })
+ panel.Children(func() { ui.Scroll(c).Shrink(1).Children(func() { sourceSummary(c, n, src) }) })
})
}
diff --git a/citation_badge_test.go b/citation_badge_test.go
index 52afd4a..90d83be 100644
--- a/citation_badge_test.go
+++ b/citation_badge_test.go
@@ -14,7 +14,7 @@ var sourcesSample = []Source{
{Title: "Court etiquette", URL: "https://example.net/etiquette", Site: "example.net"},
}
-// citationApp is an answer with badges beside a sources panel, sharing one state.
+// citationApp shares state between inline citations and sources.
type citationApp struct {
st CitationState
opened []int
@@ -97,3 +97,72 @@ func TestCitationBadgeSnapshot(t *testing.T) {
})
})
}
+
+func TestCitationBadgeHitAreaAndDisabledPreview(t *testing.T) {
+ var st CitationState
+ off := false
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ ui.Row(c).Padding(16).Disabled(off).Children(func() {
+ CitationBadge(c, 1, sourcesSample[0], &st)
+ })
+ }, 240, 160)
+ b, _ := tt.Find("Source 1: The Palace of Versailles")
+ if b.W < 28 || b.H < 28 {
+ t.Errorf("hit area %v, want at least 28x28", b)
+ }
+ tt.Key(0, ui.KeyTab)
+ time.Sleep(550 * time.Millisecond)
+ tt.Frame()
+ off = true
+ tt.Frame()
+ tt.Key(0, ui.KeyEnter)
+ if tt.HasText("Seat of the court from 1682.") || st.Active != 0 {
+ t.Errorf("disabled preview or activation: active %d texts %q", st.Active, tt.Texts())
+ }
+}
+
+func TestCitationBadgePreviewFitsNarrowWindow(t *testing.T) {
+ var st CitationState
+ var preview ui.Rect
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ src := sourcesSample[0]
+ ui.Row(c).Padding(8).Children(func() { CitationBadge(c, 1, src, &st) })
+ }, 180, 120)
+ tt.Key(0, ui.KeyTab)
+ time.Sleep(550 * time.Millisecond)
+ tt.Frame()
+ preview, ok := tt.Find("Source preview")
+ if !ok || preview.X < 0 || preview.X+preview.W > 180 || preview.Y+preview.H > 120 {
+ t.Fatalf("preview content outside window: %v found %v", preview, ok)
+ }
+}
+
+func TestCitationBadgeRemovedOwnerClearsHover(t *testing.T) {
+ show := true
+ st := CitationState{}
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ ui.Column(c).Children(func() {
+ ui.Row(c).Key("badge").Height(36).Children(func() {
+ if show {
+ CitationBadge(c, 1, sourcesSample[0], &st)
+ }
+ })
+ ui.Row(c).Key("card").Width(300).Children(func() { SourceCard(c, 1, sourcesSample[0], &st) })
+ })
+ }, 400, 300)
+ b, _ := tt.Find("Source 1: The Palace of Versailles")
+ tt.Move(b.X+b.W/2, b.Y+b.H/2)
+ tt.Frame()
+ if st.hover != 1 {
+ t.Fatal("hover did not activate")
+ }
+ show = false
+ tt.Move(390, 290)
+ tt.Frame()
+ if st.hover != 0 {
+ t.Fatalf("removed owner retained hover %d", st.hover)
+ }
+}
diff --git a/context_chips.go b/context_chips.go
index fa0a11f..fc9d183 100644
--- a/context_chips.go
+++ b/context_chips.go
@@ -13,11 +13,11 @@ import (
// ContextChipsOptions configure ContextChips.
type ContextChipsOptions struct {
- // Max is how many chips show before the rest fold under "+N"; zero means 4.
+ // Max sets visible chips before folding; zero means four.
Max int
}
-// RemoveResult is a list's element and the ID the user removed this frame.
+// RemoveResult carries the element and removed item ID.
type RemoveResult struct {
Element *ui.Element
removed string
@@ -26,7 +26,7 @@ type RemoveResult struct {
// Removed returns the ID whose remove button was pressed.
func (r RemoveResult) Removed() (string, bool) { return r.removed, r.removed != "" }
-// ContextChips lists attached context as chips with remove buttons; past Max the rest fold.
+// ContextChips displays removable context with folded overflow.
func ContextChips(c *ui.Context, items []ContextItem, opts ContextChipsOptions) RemoveResult {
if opts.Max < 0 {
panic("mujica: ContextChips Max must not be negative")
@@ -47,7 +47,7 @@ func ContextChips(c *ui.Context, items []ContextItem, opts ContextChipsOptions)
shown = min(shown, limit)
}
for _, it := range items[:shown] {
- ui.Row(c).Key(it.ID).Gap(u).AlignItems(ui.Center).Padding(0, 0, 0, u*2).MinHeight(theme.MinHitSize).
+ ui.Row(c).Key(it.ID).Gap(u).AlignItems(ui.Center).Padding(0, 0, 0, u*2).MinHeight(Density(c).ControlHeight()).
Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).Role(ui.RoleListItem).
Children(func() {
ui.Icon(c, icons.Must(it.Kind.icon())).Size(14, 14).Shrink(0).TextColor(k.AccentText)
@@ -65,7 +65,7 @@ func ContextChips(c *ui.Context, items []ContextItem, opts ContextChipsOptions)
if *expanded {
label = msg(c, "context_chips.less")
}
- b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, u*2).Radius(theme.ControlRadius).Label(label).TextColor(k.AccentText)
+ b := ui.ButtonBase(c).MinHeight(Density(c).ControlHeight()).Padding(0, u*2).Radius(theme.ControlRadius).Label(label).TextColor(k.AccentText)
internal.Ring(b, k.Focus, theme.ControlRadius)
if b.Hovered() {
b.Background(k.SurfaceHover)
diff --git a/context_chips_test.go b/context_chips_test.go
index de476f6..4542f5d 100644
--- a/context_chips_test.go
+++ b/context_chips_test.go
@@ -58,3 +58,23 @@ func TestContextChipsSnapshot(t *testing.T) {
ui.Column(c).Padding(16).Children(func() { ContextChips(c, mentionSample, ContextChipsOptions{Max: 3}) })
})
}
+
+func TestContextChipsKeyboardAndEmpty(t *testing.T) {
+ items, removed := mentionSample, ""
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ if id, ok := ContextChips(c, items, ContextChipsOptions{Max: 1}).Removed(); ok {
+ removed = id
+ }
+ }, 400, 160)
+ tt.Key(0, ui.KeyTab)
+ tt.Key(0, ui.KeyEnter)
+ if removed != "w1" {
+ t.Fatalf("removed %q, want w1", removed)
+ }
+ items = nil
+ tt.Frame()
+ if tt.HasText("+3 more") || tt.HasText("Versailles") {
+ t.Fatal("empty chips retained content")
+ }
+}
diff --git a/context_mention_menu.go b/context_mention_menu.go
index 2f4b689..74518a7 100644
--- a/context_mention_menu.go
+++ b/context_mention_menu.go
@@ -2,6 +2,7 @@ package mujica
import (
"log/slog"
+ "slices"
"strings"
"github.com/egoist/mygo/ui"
@@ -32,7 +33,7 @@ func (k ContextKind) group() string {
return [...]string{"context_mention_menu.file", "context_mention_menu.doc", "context_mention_menu.web"}[k]
}
-// ContextItem is a file, document or web page the user can mention.
+// ContextItem describes a file, document or webpage mention.
type ContextItem struct {
// ID is the item's stable identity; required.
ID string
@@ -43,11 +44,11 @@ type ContextItem struct {
// ContextMentionOptions configure a ContextMentionMenu.
type ContextMentionOptions struct {
- // Loading shows that the caller is fetching items for the query.
+ // Loading indicates caller-owned asynchronous fetching.
Loading bool
}
-// ContextMentionResult is the menu's popup, nil while closed, its query and the item chosen this frame.
+// ContextMentionResult carries the popup, query and chosen item.
type ContextMentionResult struct {
Element *ui.Element
query string
@@ -56,46 +57,56 @@ type ContextMentionResult struct {
ok bool
}
-// Query returns the text after "@"; QueryChanged reports a new one to fetch items for.
+// 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 item picked by Enter, Tab or a click; its "@query" was taken out of the text.
+// Chosen returns the selected item and consumes its trigger.
func (r ContextMentionResult) Chosen() (ContextItem, bool) { return r.chosen, r.ok }
-// ContextMentionMenu pops up items grouped by kind while the composer's text ends in "@query".
-// The caller filters items for Query, possibly asynchronously, and attaches the chosen one.
+// ContextMentionMenu groups results for the active mention query.
+// The caller filters results synchronously or asynchronously.
func ContextMentionMenu(c *ui.Context, composer InputResult, value *string, items []ContextItem, opts ContextMentionOptions) ContextMentionResult {
- var rows []slashMenuRow
- var order []int
- 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 {
- rows = append(rows, slashMenuRow{label: it.Label, detail: it.Detail, icon: kind.icon(), group: msg(c, kind.group())})
- order = append(order, i)
+ if composer.Element == nil || composer.Input == nil || value == nil {
+ panic("mujica: ContextMentionMenu needs composer and value")
+ }
+ data := ui.Local(composer.Element, "mention-data", func() mentionMenuData { return mentionMenuData{} })
+ locale := current(c).locale
+ 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: msg(c, kind.group())})
+ data.order = append(data.order, i)
+ }
}
}
+ if len(data.order) != len(items) {
+ panic("mujica: unknown ContextKind")
+ }
}
- if len(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:]
}
- // A new query, or the menu opening, asks the caller for items.
- r.queryChanged = at >= 0 && (r.query != st.query || !st.open && st.dismissed == nil)
+ // 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 := msg(c, "context_mention_menu.empty")
if opts.Loading {
empty = msg(c, "context_mention_menu.loading")
}
- i, _ := slashMenuShow(c, composer, st, r.query, at >= 0, rows, slashMenuOptions{empty: empty, label: msg(c, "context_mention_menu.label"), loading: opts.Loading})
- r.Element = st.panel
+ i, _, panel := slashMenuShow(c, composer, st, r.query, at >= 0, rows, slashMenuOptions{empty: empty, label: 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]
@@ -112,3 +123,11 @@ func contextMentionAt(s string) int {
}
return at
}
+
+type mentionMenuData struct {
+ built bool
+ locale Locale
+ items []ContextItem
+ rows []slashMenuRow
+ order []int
+}
diff --git a/context_mention_menu_test.go b/context_mention_menu_test.go
index 081eb4e..703c658 100644
--- a/context_mention_menu_test.go
+++ b/context_mention_menu_test.go
@@ -14,8 +14,8 @@ var mentionSample = []ContextItem{
{ID: "f2", Label: "seating.xlsx", Detail: "~/ball", Kind: ContextFile},
}
-// mentionMenuTester filters items as an async source would, one frame late.
-type mentionMenuTester struct {
+// contextMentionTester supplies filtered results one frame later.
+type contextMentionTester struct {
text string
items []ContextItem
loading bool
@@ -23,7 +23,7 @@ type mentionMenuTester struct {
queries []string
}
-func (m *mentionMenuTester) view(c *ui.Context) {
+func (m *contextMentionTester) view(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Fill().Justify(ui.End).Padding(16).Children(func() {
p := PromptComposer(c, &m.text, PromptComposerOptions{Label: "Prompt"})
@@ -44,7 +44,7 @@ func (m *mentionMenuTester) view(c *ui.Context) {
}
func TestContextMentionMenuGroupsAndChooses(t *testing.T) {
- m := &mentionMenuTester{}
+ m := &contextMentionTester{}
tt := ui.NewTester(m.view, 600, 500)
_ = tt.Click("Prompt")
tt.Type("Read @")
@@ -68,7 +68,7 @@ func TestContextMentionMenuGroupsAndChooses(t *testing.T) {
}
func TestContextMentionMenuOnlyAfterSpaceAndLoading(t *testing.T) {
- m := &mentionMenuTester{loading: true}
+ m := &contextMentionTester{loading: true}
tt := ui.NewTester(m.view, 600, 500)
_ = tt.Click("Prompt")
tt.Type("mail@host")
@@ -93,16 +93,46 @@ func TestContextMentionMenuBadItem(t *testing.T) {
t.Fatal("no panic for an item without ID")
}
}()
- m := &mentionMenuTester{items: []ContextItem{{Label: "x"}}}
+ m := &contextMentionTester{items: []ContextItem{{Label: "x"}}}
ui.NewTester(m.view, 300, 300)
}
func TestContextMentionMenuSnapshot(t *testing.T) {
snapshotAfter(t, "context_mention_menu", 560, 420, func(c *ui.Context) {
- (*ui.Local(c.Root(), "m", func() *mentionMenuTester { return &mentionMenuTester{items: mentionSample} })).view(c)
+ (*ui.Local(c.Root(), "m", func() *contextMentionTester { return &contextMentionTester{items: mentionSample} })).view(c)
}, func(tt *ui.Tester) {
_ = tt.Click("Prompt")
tt.Type("Compare @")
tt.Frame()
})
}
+
+func TestContextMentionMenuAsyncFirstChoiceAndDisabledQuery(t *testing.T) {
+ text, off := "@", false
+ var items []ContextItem
+ picked, queries := "", 0
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ p := PromptComposer(c, &text, PromptComposerOptions{Label: "Prompt", Disabled: off})
+ r := ContextMentionMenu(c, p, &text, items, ContextMentionOptions{Loading: items == nil})
+ if r.QueryChanged() {
+ queries++
+ }
+ if it, ok := r.Chosen(); ok {
+ picked = it.ID
+ }
+ }, 400, 300)
+ _ = tt.Click("Prompt")
+ items = []ContextItem{{ID: "first", Label: "First", Kind: ContextDoc}}
+ tt.Frame()
+ tt.Key(0, ui.KeyEnter)
+ if picked != "first" || text != "" {
+ t.Errorf("async first choice: %q text %q", picked, text)
+ }
+ off, text = true, "@other"
+ before := queries
+ tt.Frame()
+ if queries != before {
+ t.Errorf("disabled menu issued %d queries", queries-before)
+ }
+}
diff --git a/context_window_meter.go b/context_window_meter.go
index 068fc4e..3e56a28 100644
--- a/context_window_meter.go
+++ b/context_window_meter.go
@@ -11,7 +11,7 @@ import (
"mujicaui/theme"
)
-// ContextSegment is one part of the context window, as system, history or attachments.
+// ContextSegment describes one part of the context window.
type ContextSegment struct {
Label string
Tokens int
@@ -19,12 +19,12 @@ type ContextSegment struct {
// ContextWindowMeterOptions configure a ContextWindowMeter.
type ContextWindowMeterOptions struct {
- // Warn is the used share that shows the warning; zero means 0.8.
+ // Warn sets the warning threshold; zero means 0.8.
Warn float64
}
-// ContextWindowMeter shows how the context window is used, segment by segment, and warns past a threshold.
-// Each segment has a palette color and a legend marker shape, so it never relies on color alone.
+// ContextWindowMeter displays segmented usage and threshold warnings.
+// Palette colors and shaped legend markers identify segments.
func ContextWindowMeter(c *ui.Context, segments []ContextSegment, limit int, opts ContextWindowMeterOptions) *ui.Element {
if limit <= 0 || opts.Warn < 0 || opts.Warn > 1 {
panic("mujica: ContextWindowMeter needs limit > 0 and 0 <= Warn <= 1")
diff --git a/docs/components/221-prompt-composer.md b/docs/components/221-prompt-composer.md
new file mode 100644
index 0000000..935fb98
--- /dev/null
+++ b/docs/components/221-prompt-composer.md
@@ -0,0 +1,40 @@
+# 221 PromptComposer 提示编辑器
+
+## 用途
+
+多行输入;Enter 发送、Shift+Enter 换行可配置;输入法组合期间不发送;附件与工具插槽。
+
+## 最小示例
+
+```go
+var prompt string
+view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ r := mujica.PromptComposer(c, &prompt, mujica.PromptComposerOptions{Placeholder: "Ask the herald"})
+ if r.Submitted() {
+ fmt.Println("send", prompt)
+ prompt = ""
+ }
+}
+ui.Render(view, 400, 300, 1)
+```
+
+## 参数
+
+`value` 为调用方持有的文本指针。`Placeholder`、`Label` 配置占位与名称;`CmdEnterSends=false` 使用 Enter 发送;`Composing=false`,调用方在组合输入时设为 true;`MaxHeight=0` 使用 200 DIP。`Attachments`、`Tools`、`Actions` 为内容插槽;`Disabled=false`。
+
+## 状态
+
+默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
+
+## 事件
+
+InputResult.Changed() 表示文本编辑;Submitted() 表示发送请求;Input 暴露编辑器。
+
+## 键盘操作
+
+Enter 发送,Shift+Enter 换行;CmdEnterSends 下使用 Command/Ctrl+Enter;组合保护期间 Enter 留给输入法。
+
+## 限制
+
+MyGo v0.2.10 的 ui/input.go:688–708 将组合事件直接送到私有编辑器,公开 HandleInput 无法观察组合状态。自动检测组合输入仍需上游 API;调用方须设置 Composing。
diff --git a/docs/components/222-attachment-chip.md b/docs/components/222-attachment-chip.md
new file mode 100644
index 0000000..0d08e1f
--- /dev/null
+++ b/docs/components/222-attachment-chip.md
@@ -0,0 +1,38 @@
+# 222 AttachmentChip 附件标签
+
+## 用途
+
+附件名、类型、大小、上传进度、移除;失败重试。
+
+## 最小示例
+
+```go
+view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ r := mujica.AttachmentChip(c, mujica.Attachment{Name: "guests.csv", Size: 4096, Uploading: true, Progress: 0.4})
+ if r.Removed() {
+ fmt.Println("cancel the upload")
+ }
+}
+ui.Render(view, 400, 300, 1)
+```
+
+## 参数
+
+`Attachment.Name` 必填;`Type` 为 MIME 类型,空值按扩展名选择图标;`Size` 为非负字节数;`Uploading` 默认 false,`Progress` 为 0–1;`Err` 表示上传失败。
+
+## 状态
+
+默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
+
+## 事件
+
+Removed() 表示移除;Retried() 表示重试。
+
+## 键盘操作
+
+Tab 到移除与重试;Enter、Space 激活。
+
+## 限制
+
+上传由调用方执行,组件只展示进度与结果。
diff --git a/docs/components/223-paste-image-preview.md b/docs/components/223-paste-image-preview.md
new file mode 100644
index 0000000..7a8568a
--- /dev/null
+++ b/docs/components/223-paste-image-preview.md
@@ -0,0 +1,39 @@
+# 223 PasteImagePreview 图片预览
+
+## 用途
+
+粘贴或拖入图片的预览缩略图;移除。
+
+## 最小示例
+
+```go
+pic := ui.MustParseSVG([]byte(``))
+images := []mujica.PastedImage{{ID: "p1", Source: pic, Name: "screenshot"}}
+view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ if id, ok := mujica.PasteImagePreview(c, images).Removed(); ok {
+ fmt.Println("remove", id)
+ }
+}
+ui.Render(view, 400, 300, 1)
+```
+
+## 参数
+
+`images` 为调用方解码的缩略图列表。每项 `ID` 为唯一稳定标识,`Source` 为必填图片,`Name` 为操作名称;缩略图边长为 72 DIP。
+
+## 状态
+
+默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
+
+## 事件
+
+Removed() 返回待移除的稳定 ID 与事件标志。
+
+## 键盘操作
+
+Tab 到移除按钮;Enter、Space 移除。
+
+## 限制
+
+粘贴和拖入的图片解码、获取及去重由调用方完成。MyGo ui/headless.go:377–380 仅提供文本剪贴板;ui/drop.go:11 返回文件路径,原生图片剪贴板接口仍需上游扩展。
diff --git a/docs/components/224-drag-drop-overlay.md b/docs/components/224-drag-drop-overlay.md
new file mode 100644
index 0000000..4010a02
--- /dev/null
+++ b/docs/components/224-drag-drop-overlay.md
@@ -0,0 +1,41 @@
+# 224 DragDropOverlay 拖放遮罩
+
+## 用途
+
+拖入文件时的全区域遮罩与提示;类型检查回调。
+
+## 最小示例
+
+```go
+view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ images := func(path string) bool { return strings.HasSuffix(path, ".png") }
+ r := mujica.DragDropOverlay(c, mujica.DragDropOverlayOptions{Accept: images}, func() {
+ ui.Text(c, "Drag images into the chat")
+ })
+ for _, path := range r.Accepted() {
+ fmt.Println("attach", path)
+ }
+}
+ui.Render(view, 400, 300, 1)
+```
+
+## 参数
+
+`content` 必填;`Hint` 默认“松开以添加文件”;`Accept` 接收路径并返回类型检查结果,nil 接受全部。
+
+## 状态
+
+默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
+
+## 事件
+
+Accepted() 与 Rejected() 返回本帧接受和拒绝的文件路径。
+
+## 键盘操作
+
+内容中的操作按其自身键盘规则访问;文件拖入由操作系统驱动。
+
+## 限制
+
+读取文件与上传由调用方完成。MyGo ui/headless.go:93–163 的 Tester 通过平台事件驱动,公开接口未提供外部文件拖放注入;类型检查与遮罩分别测试。
diff --git a/docs/components/225-voice-input-button.md b/docs/components/225-voice-input-button.md
new file mode 100644
index 0000000..22f3541
--- /dev/null
+++ b/docs/components/225-voice-input-button.md
@@ -0,0 +1,38 @@
+# 225 VoiceInputButton 语音输入按钮
+
+## 用途
+
+录音开始与停止;录音时长;权限失败提示;录音由调用方实现。
+
+## 最小示例
+
+```go
+var rec mujica.VoiceInputOptions
+view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ if mujica.VoiceInputButton(c, rec).Changed() {
+ rec = mujica.VoiceInputOptions{Recording: !rec.Recording, Started: time.Now()}
+ }
+}
+ui.Render(view, 400, 300, 1)
+```
+
+## 参数
+
+`Recording=false`;录音时 `Started` 必填;`Denied=false`,表示权限失败;`Disabled=false`。
+
+## 状态
+
+默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
+
+## 事件
+
+ChoiceResult.Changed() 表示开始、停止或重试录音请求。
+
+## 键盘操作
+
+Tab 到按钮;Enter、Space 开始或停止。
+
+## 限制
+
+录音与权限请求由调用方实现。
diff --git a/docs/components/226-voice-waveform.md b/docs/components/226-voice-waveform.md
new file mode 100644
index 0000000..2d6c7e0
--- /dev/null
+++ b/docs/components/226-voice-waveform.md
@@ -0,0 +1,36 @@
+# 226 VoiceWaveform 输入波形
+
+## 用途
+
+输入电平波形;静音提示。
+
+## 最小示例
+
+```go
+levels := []float32{0.2, 0.6, 0.9, 0.4, 0.1}
+view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ mujica.VoiceWaveform(c, levels, mujica.VoiceWaveformOptions{})
+}
+ui.Render(view, 400, 300, 1)
+```
+
+## 参数
+
+`levels` 为 0–1 的电平样本,最新样本放在末尾。`Silence=0` 使用 0.05;`SilentFor=0` 使用 20 个样本;`Width=0` 使用 200 DIP。
+
+## 状态
+
+展示由输入数据驱动;边界值与警告状态带文字说明。
+
+## 事件
+
+展示组件,样本变化由调用方更新。
+
+## 键盘操作
+
+展示组件,沿用外层 Tab 顺序。
+
+## 限制
+
+电平采集由调用方提供;只绘制适合宽度的最新样本。
diff --git a/docs/components/227-slash-command-menu.md b/docs/components/227-slash-command-menu.md
new file mode 100644
index 0000000..12d060c
--- /dev/null
+++ b/docs/components/227-slash-command-menu.md
@@ -0,0 +1,40 @@
+# 227 SlashCommandMenu 斜杠命令菜单
+
+## 用途
+
+输入“/”弹出命令;筛选;方向键选择;Tab 补全。
+
+## 最小示例
+
+```go
+var prompt string
+commands := []mujica.SlashCommand{{Name: "summarize", Description: "Summarize the thread"}}
+view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ composer := mujica.PromptComposer(c, &prompt, mujica.PromptComposerOptions{})
+ if cmd, ok := mujica.SlashCommandMenu(c, composer, &prompt, commands).Chosen(); ok {
+ fmt.Println("run", cmd.Name)
+ }
+}
+ui.Render(view, 400, 300, 1)
+```
+
+## 参数
+
+`composer` 为本帧构建的 PromptComposer 结果;`value` 为同一文本指针;`commands` 每项 `Name` 为必填单词,`Description` 为调用方说明。
+
+## 状态
+
+默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
+
+## 事件
+
+Chosen() 返回选中的命令;Tab 补全文本,Enter 选择命令。
+
+## 键盘操作
+
+编辑器内 Up/Down 选择,Enter 选中,Tab 补全,Escape 关闭。
+
+## 限制
+
+菜单按可见行虚拟化;筛选在查询或命令变化时更新。
diff --git a/docs/components/228-context-mention-menu.md b/docs/components/228-context-mention-menu.md
new file mode 100644
index 0000000..223edb0
--- /dev/null
+++ b/docs/components/228-context-mention-menu.md
@@ -0,0 +1,44 @@
+# 228 ContextMentionMenu 上下文提及菜单
+
+## 用途
+
+输入“@”选择文件、文档、网页等上下文;分组;异步结果。
+
+## 最小示例
+
+```go
+var prompt string
+var found []mujica.ContextItem
+view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ composer := mujica.PromptComposer(c, &prompt, mujica.PromptComposerOptions{})
+ r := mujica.ContextMentionMenu(c, composer, &prompt, found, mujica.ContextMentionOptions{})
+ if r.QueryChanged() {
+ found = []mujica.ContextItem{{ID: "f1", Label: r.Query() + ".md", Kind: mujica.ContextFile}}
+ }
+ if item, ok := r.Chosen(); ok {
+ fmt.Println("attach", item.ID)
+ }
+}
+ui.Render(view, 400, 300, 1)
+```
+
+## 参数
+
+`composer` 与 `value` 关联当前编辑器;`items` 每项 `ID`、`Label` 必填,`Detail` 可选,`Kind` 为 ContextFile、ContextDoc 或 ContextWeb;`Loading=false`。
+
+## 状态
+
+默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
+
+## 事件
+
+QueryChanged() 请求新查询;Query() 返回查询文本;Chosen() 返回上下文并移除尾部提及文字。
+
+## 键盘操作
+
+编辑器内 Up/Down 选择,Enter 或 Tab 附加,Escape 关闭。
+
+## 限制
+
+异步结果由调用方提供并调用 c.Invalidate(),调用方负责淘汰过期请求。
diff --git a/docs/components/229-context-chips.md b/docs/components/229-context-chips.md
new file mode 100644
index 0000000..b2e4e74
--- /dev/null
+++ b/docs/components/229-context-chips.md
@@ -0,0 +1,38 @@
+# 229 ContextChips 上下文标签
+
+## 用途
+
+已附加上下文列表;移除;溢出折叠。
+
+## 最小示例
+
+```go
+items := []mujica.ContextItem{{ID: "f1", Label: "guests.csv", Kind: mujica.ContextFile}}
+view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ if id, ok := mujica.ContextChips(c, items, mujica.ContextChipsOptions{}).Removed(); ok {
+ fmt.Println("detach", id)
+ }
+}
+ui.Render(view, 400, 300, 1)
+```
+
+## 参数
+
+`items` 为已附加上下文,`ID`、`Label` 必填,`Kind` 指定图标;`Max=0` 默认显示 4 项,超出时可展开。
+
+## 状态
+
+默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
+
+## 事件
+
+Removed() 返回待移除 ID;展开与折叠保留在组件状态。
+
+## 键盘操作
+
+Tab 到移除与展开;Enter、Space 激活。
+
+## 限制
+
+上下文文件内容由调用方管理。
diff --git a/docs/components/230-model-selector.md b/docs/components/230-model-selector.md
new file mode 100644
index 0000000..0d3206b
--- /dev/null
+++ b/docs/components/230-model-selector.md
@@ -0,0 +1,39 @@
+# 230 ModelSelector 模型选择器
+
+## 用途
+
+模型列表;能力标签与上下文长度;不可用项说明。
+
+## 最小示例
+
+```go
+model := "herald"
+models := []mujica.ModelOption{{ID: "herald", Name: "Herald 2", Capabilities: []string{"Vision"}, ContextTokens: 200000}}
+view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ if mujica.ModelSelector(c, &model, models, mujica.ModelSelectorOptions{}).Changed() {
+ fmt.Println("model", model)
+ }
+}
+ui.Render(view, 400, 300, 1)
+```
+
+## 参数
+
+`value` 为模型 ID 指针;`models` 每项 `ID`、`Name` 必填,`Capabilities` 为能力标签,`ContextTokens=0` 隐藏容量;`Unavailable` 提供不可用原因。`Label` 默认“模型”,`Disabled=false`。
+
+## 状态
+
+默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
+
+## 事件
+
+ChoiceResult.Changed() 表示模型 ID 已改变。
+
+## 键盘操作
+
+Tab 到选择器;Up/Down、Home/End 选择,Enter/Space 确认,Escape 关闭。
+
+## 限制
+
+模型服务与可用性由调用方提供。
diff --git a/docs/components/231-mode-selector.md b/docs/components/231-mode-selector.md
new file mode 100644
index 0000000..2fe54da
--- /dev/null
+++ b/docs/components/231-mode-selector.md
@@ -0,0 +1,38 @@
+# 231 ModeSelector 模式选择器
+
+## 用途
+
+对话、搜索、深度研究、Agent 模式切换。
+
+## 最小示例
+
+```go
+mode := mujica.ModeChat
+view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ if mujica.ModeSelector(c, &mode, mujica.ModeSelectorOptions{}).Changed() {
+ fmt.Println("mode", mode)
+ }
+}
+ui.Render(view, 400, 300, 1)
+```
+
+## 参数
+
+`mode` 指向 ModeChat、ModeSearch、ModeResearch 或 ModeAgent;`IconsOnly=false` 保留文字;`Disabled=false`。
+
+## 状态
+
+默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
+
+## 事件
+
+ChoiceResult.Changed() 表示模式已改变。
+
+## 键盘操作
+
+Tab 进入模式组;Left/Right 切换模式。
+
+## 限制
+
+模式执行逻辑由调用方实现。
diff --git a/docs/components/232-tool-toggle-menu.md b/docs/components/232-tool-toggle-menu.md
new file mode 100644
index 0000000..a28700f
--- /dev/null
+++ b/docs/components/232-tool-toggle-menu.md
@@ -0,0 +1,39 @@
+# 232 ToolToggleMenu 工具开关菜单
+
+## 用途
+
+联网、代码执行等工具开关;需授权项标记。
+
+## 最小示例
+
+```go
+web := true
+view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ tools := []mujica.ToolItem{{ID: "web", Label: "Web search", On: &web}}
+ if id, ok := mujica.ToolToggleMenu(c, tools).Changed(); ok {
+ fmt.Println("toggled", id)
+ }
+}
+ui.Render(view, 400, 300, 1)
+```
+
+## 参数
+
+`tools` 每项 `ID`、`On` 必填;`Label`、`Description` 由调用方提供;`NeedsAuth=false`;需要授权时由 `Authorized` 控制开关可用性。
+
+## 状态
+
+默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
+
+## 事件
+
+Changed() 返回切换工具 ID;Authorize() 返回授权请求 ID。
+
+## 键盘操作
+
+Tab 到菜单按钮;Enter/Space 打开;Tab 到开关与授权;Escape 关闭。
+
+## 限制
+
+授权与工具执行由调用方完成。
diff --git a/docs/components/233-parameter-panel.md b/docs/components/233-parameter-panel.md
new file mode 100644
index 0000000..c235f26
--- /dev/null
+++ b/docs/components/233-parameter-panel.md
@@ -0,0 +1,37 @@
+# 233 ParameterPanel 参数面板
+
+## 用途
+
+温度、Top-p、最大长度等参数;范围与默认值;重置。
+
+## 最小示例
+
+```go
+temperature := 1.0
+view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ params := []mujica.ModelParam{{Label: "Temperature", Value: &temperature, Min: 0, Max: 2, Step: 0.1, Default: 1}}
+ mujica.ParameterPanel(c, params)
+}
+ui.Render(view, 400, 300, 1)
+```
+
+## 参数
+
+`params` 每项 `Label`、`Value` 必填;`Min < Max`、`Step > 0`;`Default` 与当前值须为有限范围内数值,且位于 Min 起算的 Step 网格;范围与默认值显示在滑轨下。
+
+## 状态
+
+默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
+
+## 事件
+
+ChoiceResult.Changed() 表示参数或默认值重置改变了值。
+
+## 键盘操作
+
+Tab 到滑块与重置;方向键按 Step 调整,Home/End 到范围端点;Enter/Space 重置。
+
+## 限制
+
+参数校验失败 panic,数字步进复用 Slider 与 NumberInput 的步长网格。
diff --git a/docs/components/234-token-counter.md b/docs/components/234-token-counter.md
new file mode 100644
index 0000000..aa0db0f
--- /dev/null
+++ b/docs/components/234-token-counter.md
@@ -0,0 +1,35 @@
+# 234 TokenCounter Token计数
+
+## 用途
+
+当前输入 token 数与上限;估算值由调用方提供。
+
+## 最小示例
+
+```go
+view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ mujica.TokenCounter(c, 1234, 8000)
+}
+ui.Render(view, 400, 300, 1)
+```
+
+## 参数
+
+`used` 为调用方估算的非负 token 数;`limit` 为正数;达到 90% 时警告,超过上限时显示超额。
+
+## 状态
+
+展示由输入数据驱动;边界值与警告状态带文字说明。
+
+## 事件
+
+展示组件,调用方更新估算值。
+
+## 键盘操作
+
+展示组件,沿用外层 Tab 顺序。
+
+## 限制
+
+token 分词与模型计算由调用方完成。
diff --git a/docs/components/235-context-window-meter.md b/docs/components/235-context-window-meter.md
new file mode 100644
index 0000000..b8d31bb
--- /dev/null
+++ b/docs/components/235-context-window-meter.md
@@ -0,0 +1,36 @@
+# 235 ContextWindowMeter 上下文容量
+
+## 用途
+
+上下文使用率条;分段(系统、历史、附件);阈值警告。
+
+## 最小示例
+
+```go
+segments := []mujica.ContextSegment{{Label: "System", Tokens: 1200}, {Label: "History", Tokens: 58000}}
+view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ mujica.ContextWindowMeter(c, segments, 128000, mujica.ContextWindowMeterOptions{})
+}
+ui.Render(view, 400, 300, 1)
+```
+
+## 参数
+
+`segments` 每项 `Label` 必填、`Tokens` 非负;`limit` 为正数;`Warn=0` 默认阈值 0.8,允许配置 0–1。
+
+## 状态
+
+展示由输入数据驱动;边界值与警告状态带文字说明。
+
+## 事件
+
+展示组件,调用方更新各段用量。
+
+## 键盘操作
+
+展示组件,沿用外层 Tab 顺序。
+
+## 限制
+
+上下文数据由调用方统计,分段同时提供文字与图形标记。
diff --git a/docs/components/236-send-button.md b/docs/components/236-send-button.md
new file mode 100644
index 0000000..d80d5af
--- /dev/null
+++ b/docs/components/236-send-button.md
@@ -0,0 +1,39 @@
+# 236 SendButton 发送按钮
+
+## 用途
+
+发送与停止两态;禁用条件;快捷键提示。
+
+## 最小示例
+
+```go
+streaming := false
+view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ r := mujica.SendButton(c, mujica.SendButtonOptions{Streaming: streaming, Shortcut: "Enter"})
+ if r.Sent() || r.Stopped() {
+ streaming = r.Sent()
+ }
+}
+ui.Render(view, 400, 300, 1)
+```
+
+## 参数
+
+`Streaming=false` 显示发送;true 显示停止;`Disabled=false`,禁用发送时停止仍可用;`Shortcut` 仅展示在提示中。
+
+## 状态
+
+默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
+
+## 事件
+
+Sent() 表示发送请求;Stopped() 表示停止请求。
+
+## 键盘操作
+
+Tab 到按钮;Enter、Space 激活;快捷键提示由调用方绑定。
+
+## 限制
+
+生成和停止逻辑由调用方实现。
diff --git a/docs/components/237-citation-badge.md b/docs/components/237-citation-badge.md
new file mode 100644
index 0000000..cb4e3f4
--- /dev/null
+++ b/docs/components/237-citation-badge.md
@@ -0,0 +1,39 @@
+# 237 CitationBadge 引用编号
+
+## 用途
+
+行内引用编号;悬停预览来源;点击定位来源。
+
+## 最小示例
+
+```go
+var cites mujica.CitationState
+src := mujica.Source{Title: "The Palace of Versailles", URL: "https://example.com/versailles"}
+view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ if mujica.CitationBadge(c, 1, src, &cites).Changed() {
+ fmt.Println("show source", cites.Active)
+ }
+}
+ui.Render(view, 400, 300, 1)
+```
+
+## 参数
+
+`n` 为从 1 开始的引用编号;`src.Title`、`src.URL` 必填;`st` 为与来源卡片共享的 CitationState;`Active=0` 表示尚未选中。
+
+## 状态
+
+默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
+
+## 事件
+
+每次点击或键盘激活触发 ChoiceResult.Changed() 并定位来源;重复激活同一引用也会重新定位。
+
+## 键盘操作
+
+Tab 到引用;Enter、Space 定位;悬停或键盘焦点 500 毫秒后预览;Escape 关闭预览。
+
+## 限制
+
+编号按调用方引用顺序映射;预览内容来自调用方。
diff --git a/docs/components/238-source-card.md b/docs/components/238-source-card.md
new file mode 100644
index 0000000..374d589
--- /dev/null
+++ b/docs/components/238-source-card.md
@@ -0,0 +1,39 @@
+# 238 SourceCard 来源卡片
+
+## 用途
+
+来源标题、站点、摘要、时间;打开回调。
+
+## 最小示例
+
+```go
+var cites mujica.CitationState
+src := mujica.Source{Title: "Hall of Mirrors", URL: "https://example.org/hall", Snippet: "Seventeen arches."}
+view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ if mujica.SourceCard(c, 1, src, &cites).Clicked() {
+ fmt.Println("open", src.URL)
+ }
+}
+ui.Render(view, 400, 300, 1)
+```
+
+## 参数
+
+`n` 为引用编号;`src.Title`、`src.URL` 必填,`Site`、`Snippet`、`Published` 可选;`st` 与正文引用共享。
+
+## 状态
+
+默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
+
+## 事件
+
+Element.Clicked() 表示打开来源请求。
+
+## 键盘操作
+
+Tab 到卡片;Enter、Space 请求打开。
+
+## 限制
+
+打开 URL 由调用方处理;组件返回事件。
diff --git a/docs/components/239-sources-panel.md b/docs/components/239-sources-panel.md
new file mode 100644
index 0000000..ca911d8
--- /dev/null
+++ b/docs/components/239-sources-panel.md
@@ -0,0 +1,39 @@
+# 239 SourcesPanel 来源面板
+
+## 用途
+
+来源列表;按引用顺序;与正文引用联动高亮。
+
+## 最小示例
+
+```go
+var cites mujica.CitationState
+sources := []mujica.Source{{Title: "Hall of Mirrors", URL: "https://example.org/hall"}}
+view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ if n, ok := mujica.SourcesPanel(c, sources, &cites).Opened(); ok {
+ fmt.Println("open", sources[n-1].URL)
+ }
+}
+ui.Render(view, 400, 300, 1)
+```
+
+## 参数
+
+`sources` 按引用顺序提供;`st.Active` 为 0 或列表内的引用编号;外层容器提供高度。
+
+## 状态
+
+默认、悬停、键盘焦点与祖先禁用;业务状态由调用方提供。
+
+## 事件
+
+Opened() 返回从 1 开始的来源编号。
+
+## 键盘操作
+
+Tab 到可见来源;Enter、Space 请求打开;滚轮滚动;Active 变化定位来源。
+
+## 限制
+
+使用虚拟列表,只构建可见来源;调用方保证引用编号和来源顺序一致。
diff --git a/docs/components/240-search-progress.md b/docs/components/240-search-progress.md
new file mode 100644
index 0000000..f171062
--- /dev/null
+++ b/docs/components/240-search-progress.md
@@ -0,0 +1,36 @@
+# 240 SearchProgress 搜索进度
+
+## 用途
+
+搜索中关键词与已读来源数;阶段列表;完成后折叠。
+
+## 最小示例
+
+```go
+view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ stages := []mujica.SearchStage{{Label: "Searching the web", Done: true}, {Label: "Reading sources"}}
+ mujica.SearchProgress(c, mujica.SearchProgressOptions{Query: "Versailles", Read: 3, Total: 8, Stages: stages})
+}
+ui.Render(view, 400, 300, 1)
+```
+
+## 参数
+
+`Query` 必填;`Read`、`Total` 满足 0 ≤ Read ≤ Total;`Stages` 每项提供 `Label` 与 `Done`;`Done=false`,完成时自动折叠一次。
+
+## 状态
+
+搜索中展开,完成后自动折叠;Done 从 true 切换为 false 时展开下一次搜索;支持键盘焦点与祖先禁用。
+
+## 事件
+
+ChoiceResult.Changed() 表示用户展开或折叠。
+
+## 键盘操作
+
+Tab 到折叠标题;Enter、Space 展开或收起。
+
+## 限制
+
+网络搜索由调用方执行,阶段与来源数量由调用方更新。
diff --git a/drag_drop_overlay.go b/drag_drop_overlay.go
index c4cbf45..1c00391 100644
--- a/drag_drop_overlay.go
+++ b/drag_drop_overlay.go
@@ -21,18 +21,18 @@ type DragDropOverlayOptions struct {
Accept func(path string) bool
}
-// DropResult is the overlay's element and the files dropped this frame.
+// DropResult carries the overlay and dropped files.
type DropResult struct {
Element *ui.Element
accepted []string
rejected []string
}
-// Accepted returns the dropped paths Accept took; Rejected those it refused.
+// Accepted returns accepted paths; Rejected returns refused paths.
func (r DropResult) Accepted() []string { return r.accepted }
func (r DropResult) Rejected() []string { return r.rejected }
-// DragDropOverlay wraps content; while files from other apps are dragged over it, a full mask shows the hint.
+// DragDropOverlay covers content during external file dragging.
func DragDropOverlay(c *ui.Context, opts DragDropOverlayOptions, content func()) DropResult {
if content == nil {
panic("mujica: DragDropOverlay needs content")
diff --git a/drag_drop_overlay_test.go b/drag_drop_overlay_test.go
index 9ed981a..19fb1e7 100644
--- a/drag_drop_overlay_test.go
+++ b/drag_drop_overlay_test.go
@@ -42,7 +42,7 @@ func TestDragDropOverlayNeedsContent(t *testing.T) {
}, 100, 100)
}
-// TestDragDropOverlaySnapshot draws the mask shown while files are over the area.
+// TestDragDropOverlaySnapshot captures the active drop mask.
func TestDragDropOverlaySnapshot(t *testing.T) {
snapshot(t, "drag_drop_overlay", 420, 220, func(c *ui.Context) {
ui.Box(c).Fill().Padding(12).Children(func() {
diff --git a/example_chatinput_test.go b/example_chatinput_test.go
new file mode 100644
index 0000000..36e691c
--- /dev/null
+++ b/example_chatinput_test.go
@@ -0,0 +1,241 @@
+package mujica_test
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/egoist/mygo/ui"
+
+ mujica "mujicaui"
+)
+
+func ExamplePromptComposer() {
+ var prompt string
+ view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ r := mujica.PromptComposer(c, &prompt, mujica.PromptComposerOptions{Placeholder: "Ask the herald"})
+ if r.Submitted() {
+ fmt.Println("send", prompt)
+ prompt = ""
+ }
+ }
+ ui.Render(view, 400, 300, 1)
+}
+
+func ExampleAttachmentChip() {
+ view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ r := mujica.AttachmentChip(c, mujica.Attachment{Name: "guests.csv", Size: 4096, Uploading: true, Progress: 0.4})
+ if r.Removed() {
+ fmt.Println("cancel the upload")
+ }
+ }
+ ui.Render(view, 400, 300, 1)
+}
+
+func ExamplePasteImagePreview() {
+ pic := ui.MustParseSVG([]byte(``))
+ images := []mujica.PastedImage{{ID: "p1", Source: pic, Name: "screenshot"}}
+ view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ if id, ok := mujica.PasteImagePreview(c, images).Removed(); ok {
+ fmt.Println("remove", id)
+ }
+ }
+ ui.Render(view, 400, 300, 1)
+}
+
+func ExampleDragDropOverlay() {
+ view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ images := func(path string) bool { return strings.HasSuffix(path, ".png") }
+ r := mujica.DragDropOverlay(c, mujica.DragDropOverlayOptions{Accept: images}, func() {
+ ui.Text(c, "Drag images into the chat")
+ })
+ for _, path := range r.Accepted() {
+ fmt.Println("attach", path)
+ }
+ }
+ ui.Render(view, 400, 300, 1)
+}
+
+func ExampleVoiceInputButton() {
+ var rec mujica.VoiceInputOptions
+ view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ if mujica.VoiceInputButton(c, rec).Changed() {
+ rec = mujica.VoiceInputOptions{Recording: !rec.Recording, Started: time.Now()}
+ }
+ }
+ ui.Render(view, 400, 300, 1)
+}
+
+func ExampleVoiceWaveform() {
+ levels := []float32{0.2, 0.6, 0.9, 0.4, 0.1}
+ view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ mujica.VoiceWaveform(c, levels, mujica.VoiceWaveformOptions{})
+ }
+ ui.Render(view, 400, 300, 1)
+}
+
+func ExampleSlashCommandMenu() {
+ var prompt string
+ commands := []mujica.SlashCommand{{Name: "summarize", Description: "Summarize the thread"}}
+ view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ composer := mujica.PromptComposer(c, &prompt, mujica.PromptComposerOptions{})
+ if cmd, ok := mujica.SlashCommandMenu(c, composer, &prompt, commands).Chosen(); ok {
+ fmt.Println("run", cmd.Name)
+ }
+ }
+ ui.Render(view, 400, 300, 1)
+}
+
+func ExampleContextMentionMenu() {
+ var prompt string
+ var found []mujica.ContextItem
+ view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ composer := mujica.PromptComposer(c, &prompt, mujica.PromptComposerOptions{})
+ r := mujica.ContextMentionMenu(c, composer, &prompt, found, mujica.ContextMentionOptions{})
+ if r.QueryChanged() {
+ found = []mujica.ContextItem{{ID: "f1", Label: r.Query() + ".md", Kind: mujica.ContextFile}}
+ }
+ if item, ok := r.Chosen(); ok {
+ fmt.Println("attach", item.ID)
+ }
+ }
+ ui.Render(view, 400, 300, 1)
+}
+
+func ExampleContextChips() {
+ items := []mujica.ContextItem{{ID: "f1", Label: "guests.csv", Kind: mujica.ContextFile}}
+ view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ if id, ok := mujica.ContextChips(c, items, mujica.ContextChipsOptions{}).Removed(); ok {
+ fmt.Println("detach", id)
+ }
+ }
+ ui.Render(view, 400, 300, 1)
+}
+
+func ExampleModelSelector() {
+ model := "herald"
+ models := []mujica.ModelOption{{ID: "herald", Name: "Herald 2", Capabilities: []string{"Vision"}, ContextTokens: 200000}}
+ view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ if mujica.ModelSelector(c, &model, models, mujica.ModelSelectorOptions{}).Changed() {
+ fmt.Println("model", model)
+ }
+ }
+ ui.Render(view, 400, 300, 1)
+}
+
+func ExampleModeSelector() {
+ mode := mujica.ModeChat
+ view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ if mujica.ModeSelector(c, &mode, mujica.ModeSelectorOptions{}).Changed() {
+ fmt.Println("mode", mode)
+ }
+ }
+ ui.Render(view, 400, 300, 1)
+}
+
+func ExampleToolToggleMenu() {
+ web := true
+ view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ tools := []mujica.ToolItem{{ID: "web", Label: "Web search", On: &web}}
+ if id, ok := mujica.ToolToggleMenu(c, tools).Changed(); ok {
+ fmt.Println("toggled", id)
+ }
+ }
+ ui.Render(view, 400, 300, 1)
+}
+
+func ExampleParameterPanel() {
+ temperature := 1.0
+ view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ params := []mujica.ModelParam{{Label: "Temperature", Value: &temperature, Min: 0, Max: 2, Step: 0.1, Default: 1}}
+ mujica.ParameterPanel(c, params)
+ }
+ ui.Render(view, 400, 300, 1)
+}
+
+func ExampleTokenCounter() {
+ view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ mujica.TokenCounter(c, 1234, 8000)
+ }
+ ui.Render(view, 400, 300, 1)
+}
+
+func ExampleContextWindowMeter() {
+ segments := []mujica.ContextSegment{{Label: "System", Tokens: 1200}, {Label: "History", Tokens: 58000}}
+ view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ mujica.ContextWindowMeter(c, segments, 128000, mujica.ContextWindowMeterOptions{})
+ }
+ ui.Render(view, 400, 300, 1)
+}
+
+func ExampleSendButton() {
+ streaming := false
+ view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ r := mujica.SendButton(c, mujica.SendButtonOptions{Streaming: streaming, Shortcut: "Enter"})
+ if r.Sent() || r.Stopped() {
+ streaming = r.Sent()
+ }
+ }
+ ui.Render(view, 400, 300, 1)
+}
+
+func ExampleCitationBadge() {
+ var cites mujica.CitationState
+ src := mujica.Source{Title: "The Palace of Versailles", URL: "https://example.com/versailles"}
+ view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ if mujica.CitationBadge(c, 1, src, &cites).Changed() {
+ fmt.Println("show source", cites.Active)
+ }
+ }
+ ui.Render(view, 400, 300, 1)
+}
+
+func ExampleSourceCard() {
+ var cites mujica.CitationState
+ src := mujica.Source{Title: "Hall of Mirrors", URL: "https://example.org/hall", Snippet: "Seventeen arches."}
+ view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ if mujica.SourceCard(c, 1, src, &cites).Clicked() {
+ fmt.Println("open", src.URL)
+ }
+ }
+ ui.Render(view, 400, 300, 1)
+}
+
+func ExampleSourcesPanel() {
+ var cites mujica.CitationState
+ sources := []mujica.Source{{Title: "Hall of Mirrors", URL: "https://example.org/hall"}}
+ view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ if n, ok := mujica.SourcesPanel(c, sources, &cites).Opened(); ok {
+ fmt.Println("open", sources[n-1].URL)
+ }
+ }
+ ui.Render(view, 400, 300, 1)
+}
+
+func ExampleSearchProgress() {
+ view := func(c *ui.Context) {
+ mujica.Use(c, mujica.Settings{})
+ stages := []mujica.SearchStage{{Label: "Searching the web", Done: true}, {Label: "Reading sources"}}
+ mujica.SearchProgress(c, mujica.SearchProgressOptions{Query: "Versailles", Read: 3, Total: 8, Stages: stages})
+ }
+ ui.Render(view, 400, 300, 1)
+}
diff --git a/examples/gallery/pages_chatinput.go b/examples/gallery/pages_chatinput.go
index b4bd050..594ac10 100644
--- a/examples/gallery/pages_chatinput.go
+++ b/examples/gallery/pages_chatinput.go
@@ -1,3 +1,66 @@
package main
-var chatinputPages = []page{}
+const chatinputCategory = "Chat Input"
+
+var chatinputPages = []page{
+ {ID: "221", Name: "PromptComposer", ZhName: "提示编辑器", Category: chatinputCategory, Demo: chatinputDemoPromptComposer,
+ Keys: "Enter 发送,Shift+Enter 换行;CmdEnterSends 下使用 Command/Ctrl+Enter;组合保护期间 Enter 留给输入法。",
+ Code: "var prompt string\nview := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tr := mujica.PromptComposer(c, &prompt, mujica.PromptComposerOptions{Placeholder: \"Ask the herald\"})\n\tif r.Submitted() {\n\t\tfmt.Println(\"send\", prompt)\n\t\tprompt = \"\"\n\t}\n}\nui.Render(view, 400, 300, 1)"},
+ {ID: "222", Name: "AttachmentChip", ZhName: "附件标签", Category: chatinputCategory, Demo: chatinputDemoAttachmentChip,
+ Keys: "Tab 到移除与重试;Enter、Space 激活。",
+ Code: "view := func(c *ui.Context) {\n\tmujica.Use(c, mujica.Settings{})\n\tr := mujica.AttachmentChip(c, mujica.Attachment{Name: \"guests.csv\", Size: 4096, Uploading: true, Progress: 0.4})\n\tif r.Removed() {\n\t\tfmt.Println(\"cancel the upload\")\n\t}\n}\nui.Render(view, 400, 300, 1)"},
+ {ID: "223", Name: "PasteImagePreview", ZhName: "图片预览", Category: chatinputCategory, Demo: chatinputDemoPasteImagePreview,
+ Keys: "Tab 到移除按钮;Enter、Space 移除。",
+ Code: "pic := ui.MustParseSVG([]byte(`