diff --git a/attachment_chip.go b/attachment_chip.go
new file mode 100644
index 0000000..1fc9ff5
--- /dev/null
+++ b/attachment_chip.go
@@ -0,0 +1,90 @@
+package mujica
+
+import (
+ "fmt"
+ "log/slog"
+ "math"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/icons"
+ "mujicaui/theme"
+)
+
+// Attachment is a file attached to a prompt; the caller uploads it.
+type Attachment struct {
+ // Name is required.
+ Name string
+ // Type is the MIME type; empty picks the icon from the name.
+ Type string
+ Size int64
+ // Uploading shows Progress, 0 to 1.
+ Uploading bool
+ Progress float64
+ // Err shows a failed upload with retry.
+ Err error
+}
+
+// AttachmentChipResult is a chip's element and the user's request this frame.
+type AttachmentChipResult struct {
+ Element *ui.Element
+ removed, retried bool
+}
+
+// Removed reports a press of the remove button.
+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.
+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")
+ }
+ if a.Uploading && !(a.Progress >= 0 && a.Progress <= 1) {
+ panic("mujica: Attachment Progress must be from 0 to 1")
+ }
+ k := Tokens(c)
+ u := Density(c).Unit()
+ var r AttachmentChipResult
+ border := k.Border
+ if a.Err != nil {
+ border = k.Danger
+ }
+ r.Element = ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Padding(u, u, u, u*2).Width(220).
+ Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, border).Role(ui.RoleGroup).Label(a.Name)
+ disabled := r.Element.IsDisabled()
+ r.Element.Children(func() {
+ icon, color := fileMessageIcon(a.Name, a.Type), k.AccentText
+ if a.Err != nil {
+ icon, color = "circle-alert", k.Danger
+ }
+ ui.Icon(c, icons.Must(icon)).Size(20, 20).Shrink(0).TextColor(color)
+ ui.Column(c).Grow(1).MinWidth(0).Gap(u / 2).Children(func() {
+ ui.Text(c, a.Name).SingleLine().FontSize(FontSize(c, theme.CaptionSize)).FontWeight(600)
+ small := FontSize(c, theme.CaptionSize)
+ switch {
+ case a.Err != nil:
+ ui.Text(c, msg(c, "attachment_chip.failed")).SingleLine().FontSize(small).TextColor(k.Danger)
+ case a.Uploading:
+ pct := int(math.Round(a.Progress * 100))
+ ui.Text(c, fmt.Sprintf(msg(c, "attachment_chip.uploading"), pct)).SingleLine().FontSize(small).TextColor(k.TextMuted).FontFeatures("tnum")
+ frac := float32(a.Progress)
+ ui.Box(c).Height(3).Radius(1.5).Background(k.Border).Role(ui.RoleProgress).Label(fmt.Sprintf(msg(c, "attachment_chip.uploading"), pct)).
+ Draw(func(p *ui.Painter, b ui.Rect) { p.Fill(ui.Rect{X: b.X, Y: b.Y, W: b.W * frac, H: b.H}, k.AccentText, 1.5) })
+ default:
+ ui.Text(c, fileMessageSize(c, a.Size)).SingleLine().FontSize(small).TextColor(k.TextMuted)
+ }
+ })
+ if a.Err != nil && iconAction(c, icons.Must("refresh-cw"), fmt.Sprintf(msg(c, "attachment_chip.retry"), a.Name)).Clicked() && !disabled {
+ r.retried = true
+ slog.Debug("mujica: attachment_chip retry")
+ }
+ if iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "attachment_chip.remove"), a.Name)).Clicked() && !disabled {
+ r.removed = true
+ slog.Debug("mujica: attachment_chip remove")
+ }
+ })
+ return r
+}
diff --git a/attachment_chip_test.go b/attachment_chip_test.go
new file mode 100644
index 0000000..3b9e1bd
--- /dev/null
+++ b/attachment_chip_test.go
@@ -0,0 +1,62 @@
+package mujica
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/egoist/mygo/ui"
+)
+
+func TestAttachmentChipStatesAndActions(t *testing.T) {
+ a := Attachment{Name: "guests.csv", Size: 4096, Uploading: true, Progress: 0.4}
+ removed, retried := 0, 0
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ r := AttachmentChip(c, a)
+ if r.Removed() {
+ removed++
+ }
+ if r.Retried() {
+ retried++
+ }
+ }, 400, 120)
+ if !tt.HasText("Uploading 40%") {
+ t.Fatalf("progress: %q", tt.Texts())
+ }
+ if _, ok := tt.Find("Retry upload of guests.csv"); ok {
+ t.Fatal("retry shown while uploading")
+ }
+ a.Uploading, a.Err = false, errors.New("413")
+ tt.Frame()
+ _ = tt.Click("Retry upload of guests.csv")
+ _ = tt.Click("Remove guests.csv")
+ if retried != 1 || removed != 1 || !tt.HasText("Upload failed") {
+ t.Fatalf("retried %d removed %d texts %q", retried, removed, tt.Texts())
+ }
+ a.Err = nil
+ tt.Frame()
+ if !tt.HasText("4.0 KB") {
+ t.Fatalf("size: %q", tt.Texts())
+ }
+}
+
+func TestAttachmentChipBadProgress(t *testing.T) {
+ defer func() {
+ if recover() == nil {
+ t.Fatal("no panic for progress 2")
+ }
+ }()
+ ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ AttachmentChip(c, Attachment{Name: "a", Uploading: true, Progress: 2})
+ }, 100, 100)
+}
+
+func TestAttachmentChipSnapshot(t *testing.T) {
+ snapshot(t, "attachment_chip", 520, 80, func(c *ui.Context) {
+ ui.Row(c).Padding(12).Gap(8).Children(func() {
+ AttachmentChip(c, Attachment{Name: "guests.csv", Size: 4096})
+ AttachmentChip(c, Attachment{Name: "portrait.png", Size: 812000, Uploading: true, Progress: 0.62})
+ })
+ })
+}
diff --git a/citation_badge.go b/citation_badge.go
new file mode 100644
index 0000000..83e9c88
--- /dev/null
+++ b/citation_badge.go
@@ -0,0 +1,131 @@
+package mujica
+
+import (
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/internal"
+ "mujicaui/theme"
+)
+
+// Source is a document an answer cites.
+type Source struct {
+ // Title and URL are required.
+ Title, URL string
+ Site string
+ Snippet string
+ Published time.Time
+}
+
+// CitationState links citations, source cards and the sources panel; the caller holds it.
+type CitationState struct {
+ // Active is the 1-based number of the source the user chose; 0 for none.
+ Active int
+ hover int
+ owner 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.
+func (s *CitationState) track(c *ui.Context, e *ui.Element, n int) {
+ switch over := e.Hovered(); {
+ case over && s.hover != n:
+ s.hover, s.owner = n, e.ID()
+ c.Invalidate()
+ case !over && s.owner == e.ID() && s.hover != 0:
+ s.hover, s.owner = 0, 0
+ c.Invalidate()
+ }
+}
+
+// CitationBadge is an inline source number; hovering previews the source, clicking makes it Active.
+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")
+ }
+ 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)
+ internal.Ring(b, k.Focus, theme.ControlRadius)
+ st.track(c, b, n)
+ if st.lit(n) {
+ b.Background(k.Selection).Border(theme.BorderWidth, k.AccentText).TextColor(k.AccentText)
+ } else {
+ b.Background(k.SurfaceHover).Border(theme.BorderWidth, k.Border).TextColor(k.TextMuted)
+ }
+ 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 {
+ st.Active, r.changed = n, true
+ 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.
+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.
+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() {
+ *st = tooltipState{}
+ return
+ }
+ if st.dismissed {
+ return
+ }
+ if st.since.IsZero() {
+ st.since = c.Now()
+ }
+ if wait := citationPreviewDelay - c.Now().Sub(st.since); wait > 0 {
+ c.After(wait)
+ return
+ }
+ if overlayEscape(c, st) {
+ st.dismissed = true
+ return
+ }
+ u := Density(c).Unit()
+ w := float32(300)
+ x, y := overlayPlace(c, b.Bounds(), w, size.H, 6)
+ ui.Overlay(c, func() {
+ panel := ui.Column(c).Absolute().Left(x).Top(y).Width(w).Padding(u * 3).PassThrough().Role(ui.RoleTooltip)
+ overlayStyle(c, panel, overlayAnchored(c, panel, *size))
+ *size = panel.Bounds()
+ panel.Children(func() { sourceSummary(c, n, src) })
+ })
+}
+
+// sourceSummary shows a source's number, site, title, time and snippet.
+func sourceSummary(c *ui.Context, n int, src Source) {
+ k := Tokens(c)
+ u := Density(c).Unit()
+ small := FontSize(c, theme.CaptionSize)
+ ui.Column(c).Gap(u).MinWidth(0).Grow(1).Children(func() {
+ ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
+ ui.Text(c, fmt.Sprintf("[%d]", n)).FontSize(small).FontFeatures("tnum").TextColor(k.AccentText).SingleLine()
+ site := src.Site
+ if site == "" {
+ site = src.URL
+ }
+ ui.Text(c, site).FontSize(small).TextColor(k.TextMuted).SingleLine().Shrink(1).MinWidth(0)
+ if !src.Published.IsZero() {
+ ui.Text(c, src.Published.Format("2006-01-02")).FontSize(small).TextColor(k.TextMuted).FontFeatures("tnum").SingleLine()
+ }
+ })
+ ui.Text(c, src.Title).FontWeight(600).MaxLines(2)
+ if src.Snippet != "" {
+ ui.Text(c, src.Snippet).FontSize(small).TextColor(k.TextMuted).MaxLines(3)
+ }
+ })
+}
diff --git a/citation_badge_test.go b/citation_badge_test.go
new file mode 100644
index 0000000..52afd4a
--- /dev/null
+++ b/citation_badge_test.go
@@ -0,0 +1,99 @@
+package mujica
+
+import (
+ "testing"
+ "time"
+
+ "github.com/egoist/mygo/ui"
+)
+
+var sourcesSample = []Source{
+ {Title: "The Palace of Versailles", URL: "https://example.com/versailles", Site: "example.com",
+ Snippet: "Seat of the court from 1682.", Published: time.Date(2025, 5, 4, 0, 0, 0, 0, time.UTC)},
+ {Title: "Hall of Mirrors", URL: "https://example.org/hall", Snippet: "Seventeen arches of mirrors face the garden."},
+ {Title: "Court etiquette", URL: "https://example.net/etiquette", Site: "example.net"},
+}
+
+// citationApp is an answer with badges beside a sources panel, sharing one state.
+type citationApp struct {
+ st CitationState
+ opened []int
+}
+
+func (a *citationApp) view(c *ui.Context) {
+ Use(c, Settings{})
+ ui.Row(c).Fill().Padding(16).Gap(16).AlignItems(ui.Start).Children(func() {
+ ui.Row(c).Gap(4).Children(func() {
+ ui.Text(c, "The court moved in 1682")
+ CitationBadge(c, 1, sourcesSample[0], &a.st)
+ CitationBadge(c, 3, sourcesSample[2], &a.st)
+ })
+ ui.Column(c).Width(300).Height(400).Children(func() {
+ if n, ok := SourcesPanel(c, sourcesSample, &a.st).Opened(); ok {
+ a.opened = append(a.opened, n)
+ }
+ })
+ })
+}
+
+func TestCitationBadgeActivatesAndPreviews(t *testing.T) {
+ a := &citationApp{}
+ tt := ui.NewTester(a.view, 900, 500)
+ r, ok := tt.Find("Source 3: Court etiquette")
+ if !ok {
+ t.Fatal("badge not named")
+ }
+ tt.Move(r.X+r.W/2, r.Y+r.H/2)
+ tt.Frame()
+ if a.st.hover != 3 {
+ t.Fatalf("hover %d, want 3", a.st.hover)
+ }
+ time.Sleep(600 * time.Millisecond)
+ tt.Frame()
+ if n := len(tt.Texts()); n == 0 || !tt.HasText("example.net") {
+ t.Fatalf("no preview card: %q", tt.Texts())
+ }
+ tt.ClickAt(r.X+r.W/2, r.Y+r.H/2)
+ if a.st.Active != 3 {
+ t.Fatalf("active %d", a.st.Active)
+ }
+ tt.Move(5, 480)
+ tt.Frame()
+ if a.st.hover != 0 {
+ t.Fatalf("hover %d after leaving", a.st.hover)
+ }
+}
+
+func TestCitationBadgeKeyboard(t *testing.T) {
+ a := &citationApp{}
+ tt := ui.NewTester(a.view, 900, 500)
+ tt.Key(0, ui.KeyTab)
+ tt.Key(0, ui.KeyEnter)
+ if a.st.Active != 1 {
+ t.Fatalf("Enter on the first badge: active %d", a.st.Active)
+ }
+}
+
+func TestCitationBadgeBadNumber(t *testing.T) {
+ defer func() {
+ if recover() == nil {
+ t.Fatal("no panic for number 0")
+ }
+ }()
+ var st CitationState
+ ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ CitationBadge(c, 0, sourcesSample[0], &st)
+ }, 100, 100)
+}
+
+func TestCitationBadgeSnapshot(t *testing.T) {
+ snapshot(t, "citation_badge", 360, 60, func(c *ui.Context) {
+ st := ui.Local(c.Root(), "st", func() CitationState { return CitationState{Active: 2} })
+ ui.Row(c).Padding(16).Gap(4).Children(func() {
+ ui.Text(c, "Seventeen arches")
+ CitationBadge(c, 1, sourcesSample[0], st)
+ CitationBadge(c, 2, sourcesSample[1], st)
+ })
+ })
+}
diff --git a/context_chips.go b/context_chips.go
new file mode 100644
index 0000000..fa0a11f
--- /dev/null
+++ b/context_chips.go
@@ -0,0 +1,80 @@
+package mujica
+
+import (
+ "fmt"
+ "log/slog"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/icons"
+ "mujicaui/internal"
+ "mujicaui/theme"
+)
+
+// ContextChipsOptions configure ContextChips.
+type ContextChipsOptions struct {
+ // Max is how many chips show before the rest fold under "+N"; zero means 4.
+ Max int
+}
+
+// RemoveResult is a list's element and the ID the user removed this frame.
+type RemoveResult struct {
+ Element *ui.Element
+ removed string
+}
+
+// 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.
+func ContextChips(c *ui.Context, items []ContextItem, opts ContextChipsOptions) RemoveResult {
+ if opts.Max < 0 {
+ panic("mujica: ContextChips Max must not be negative")
+ }
+ limit := opts.Max
+ if limit == 0 {
+ limit = 4
+ }
+ k := Tokens(c)
+ u := Density(c).Unit()
+ var r RemoveResult
+ r.Element = ui.Row(c).Gap(u * 2).Wrap().AlignItems(ui.Center).Role(ui.RoleList).Label(msg(c, "context_chips.label"))
+ expanded := ui.Local(r.Element, "expanded", func() bool { return false })
+ disabled := r.Element.IsDisabled()
+ r.Element.Children(func() {
+ shown := len(items)
+ if !*expanded {
+ 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).
+ 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)
+ ui.Text(c, it.Label).SingleLine().MaxWidth(180).FontSize(FontSize(c, theme.CaptionSize))
+ if iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "context_chips.remove"), it.Label)).Clicked() && !disabled {
+ r.removed = it.ID
+ slog.Debug("mujica: context_chips remove", "id", it.ID)
+ }
+ })
+ }
+ if len(items) <= limit {
+ return
+ }
+ label := fmt.Sprintf(msg(c, "context_chips.more"), len(items)-limit)
+ 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)
+ internal.Ring(b, k.Focus, theme.ControlRadius)
+ if b.Hovered() {
+ b.Background(k.SurfaceHover)
+ }
+ b.Children(func() { ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize)).SingleLine() })
+ if b.Clicked() && !disabled {
+ *expanded = !*expanded
+ slog.Debug("mujica: context_chips fold", "expanded", *expanded)
+ }
+ })
+ return r
+}
diff --git a/context_chips_test.go b/context_chips_test.go
new file mode 100644
index 0000000..de476f6
--- /dev/null
+++ b/context_chips_test.go
@@ -0,0 +1,60 @@
+package mujica
+
+import (
+ "testing"
+
+ "github.com/egoist/mygo/ui"
+)
+
+func TestContextChipsRemoveAndFold(t *testing.T) {
+ items := append([]ContextItem{}, mentionSample...)
+ items = append(items, ContextItem{ID: "w2", Label: "Hall of Mirrors", Kind: ContextWeb})
+ off := false
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ ui.Column(c).Disabled(off).Children(func() {
+ if id, ok := ContextChips(c, items, ContextChipsOptions{Max: 3}).Removed(); ok {
+ for i, it := range items {
+ if it.ID == id {
+ items = append(items[:i], items[i+1:]...)
+ break
+ }
+ }
+ }
+ })
+ }, 600, 200)
+ if tt.HasText("seating.xlsx") || !tt.HasText("+2 more") {
+ t.Fatalf("fold: %q", tt.Texts())
+ }
+ _ = tt.Click("+2 more")
+ if !tt.HasText("Hall of Mirrors") || !tt.HasText("Show less") {
+ t.Fatalf("expand: %q", tt.Texts())
+ }
+ _ = tt.Click("Remove guests.csv")
+ if len(items) != 4 || tt.HasText("guests.csv") {
+ t.Fatalf("remove left %d items", len(items))
+ }
+ off = true
+ _ = tt.Click("Remove Protocol")
+ if len(items) != 4 {
+ t.Fatal("disabled chips removed an item")
+ }
+}
+
+func TestContextChipsNegativeMax(t *testing.T) {
+ defer func() {
+ if recover() == nil {
+ t.Fatal("no panic for a negative Max")
+ }
+ }()
+ ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ ContextChips(c, nil, ContextChipsOptions{Max: -1})
+ }, 100, 100)
+}
+
+func TestContextChipsSnapshot(t *testing.T) {
+ snapshot(t, "context_chips", 560, 90, func(c *ui.Context) {
+ ui.Column(c).Padding(16).Children(func() { ContextChips(c, mentionSample, ContextChipsOptions{Max: 3}) })
+ })
+}
diff --git a/context_mention_menu.go b/context_mention_menu.go
new file mode 100644
index 0000000..2f4b689
--- /dev/null
+++ b/context_mention_menu.go
@@ -0,0 +1,114 @@
+package mujica
+
+import (
+ "log/slog"
+ "strings"
+
+ "github.com/egoist/mygo/ui"
+)
+
+// 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 is a file, document or web page the user can 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 shows that the caller is fetching items for the query.
+ Loading bool
+}
+
+// ContextMentionResult is the menu's popup, nil while closed, its query and the item chosen this frame.
+type ContextMentionResult struct {
+ Element *ui.Element
+ query string
+ queryChanged bool
+ chosen ContextItem
+ ok bool
+}
+
+// Query returns the text after "@"; QueryChanged reports a new one to fetch items for.
+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.
+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.
+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 len(order) != len(items) {
+ panic("mujica: unknown ContextKind")
+ }
+ 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)
+ 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
+ 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
+}
diff --git a/context_mention_menu_test.go b/context_mention_menu_test.go
new file mode 100644
index 0000000..4640122
--- /dev/null
+++ b/context_mention_menu_test.go
@@ -0,0 +1,108 @@
+package mujica
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/egoist/mygo/ui"
+)
+
+var mentionSample = []ContextItem{
+ {ID: "w1", Label: "Versailles — Wikipedia", Detail: "en.wikipedia.org", Kind: ContextWeb},
+ {ID: "f1", Label: "guests.csv", Detail: "~/ball", Kind: ContextFile},
+ {ID: "d1", Label: "Protocol", Detail: "Notion", Kind: ContextDoc},
+ {ID: "f2", Label: "seating.xlsx", Detail: "~/ball", Kind: ContextFile},
+}
+
+// mentionTester filters items as an async source would, one frame late.
+type mentionTester struct {
+ text string
+ items []ContextItem
+ loading bool
+ chosen []string
+ queries []string
+}
+
+func (m *mentionTester) 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"})
+ r := ContextMentionMenu(c, p, &m.text, m.items, ContextMentionOptions{Loading: m.loading})
+ if r.QueryChanged() {
+ m.queries = append(m.queries, r.Query())
+ m.items = nil
+ for _, it := range mentionSample {
+ if strings.Contains(strings.ToLower(it.Label), strings.ToLower(r.Query())) {
+ m.items = append(m.items, it)
+ }
+ }
+ }
+ if it, ok := r.Chosen(); ok {
+ m.chosen = append(m.chosen, it.ID)
+ }
+ })
+}
+
+func TestContextMentionMenuGroupsAndChooses(t *testing.T) {
+ m := &mentionTester{}
+ tt := ui.NewTester(m.view, 600, 500)
+ _ = tt.Click("Prompt")
+ tt.Type("Read @")
+ tt.Frame()
+ files, _ := tt.Find("Files")
+ docs, _ := tt.Find("Documents")
+ web, ok := tt.Find("Web pages")
+ if !ok || !(files.Y < docs.Y && docs.Y < web.Y) {
+ t.Fatalf("groups not in file, doc, web order: %q", tt.Texts())
+ }
+ tt.Type("s")
+ tt.Frame()
+ if len(m.queries) < 2 || m.queries[len(m.queries)-1] != "s" {
+ t.Fatalf("queries %q", m.queries)
+ }
+ tt.Key(0, ui.KeyDown)
+ tt.Key(0, ui.KeyEnter)
+ if len(m.chosen) != 1 || m.text != "Read " {
+ t.Fatalf("chosen %v text %q", m.chosen, m.text)
+ }
+}
+
+func TestContextMentionMenuOnlyAfterSpaceAndLoading(t *testing.T) {
+ m := &mentionTester{loading: true}
+ tt := ui.NewTester(m.view, 600, 500)
+ _ = tt.Click("Prompt")
+ tt.Type("mail@host")
+ if tt.HasText("Files") || len(m.queries) != 0 {
+ t.Fatal("an @ inside a word opened the menu")
+ }
+ tt.Type(" @x")
+ m.items = nil
+ tt.Frame()
+ if !tt.HasText("Searching…") {
+ t.Fatalf("loading state missing: %q", tt.Texts())
+ }
+ tt.Key(0, ui.KeyEnter)
+ if len(m.chosen) != 0 {
+ t.Fatal("Enter chose from an empty list")
+ }
+}
+
+func TestContextMentionMenuBadItem(t *testing.T) {
+ defer func() {
+ if recover() == nil {
+ t.Fatal("no panic for an item without ID")
+ }
+ }()
+ m := &mentionTester{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() *mentionTester { return &mentionTester{items: mentionSample} })).view(c)
+ }, func(tt *ui.Tester) {
+ _ = tt.Click("Prompt")
+ tt.Type("Compare @")
+ tt.Frame()
+ })
+}
diff --git a/context_window_meter.go b/context_window_meter.go
new file mode 100644
index 0000000..068fc4e
--- /dev/null
+++ b/context_window_meter.go
@@ -0,0 +1,95 @@
+package mujica
+
+import (
+ "fmt"
+ "math"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/icons"
+ "mujicaui/internal"
+ "mujicaui/theme"
+)
+
+// ContextSegment is one part of the context window, as system, history or attachments.
+type ContextSegment struct {
+ Label string
+ Tokens int
+}
+
+// ContextWindowMeterOptions configure a ContextWindowMeter.
+type ContextWindowMeterOptions struct {
+ // Warn is the used share that shows the warning; 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.
+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")
+ }
+ used := 0
+ for _, s := range segments {
+ if s.Label == "" || s.Tokens < 0 {
+ panic("mujica: ContextSegment needs a Label and non-negative Tokens")
+ }
+ used += s.Tokens
+ }
+ warn := opts.Warn
+ if warn == 0 {
+ warn = 0.8
+ }
+ k := Tokens(c)
+ u := Density(c).Unit()
+ small := FontSize(c, theme.CaptionSize)
+ pal := internal.Palette(k)
+ share := float64(used) / float64(limit)
+ pct := int(math.Round(share * 100))
+ summary := fmt.Sprintf(msg(c, "context_window_meter.used"), tokenThousands(used), tokenThousands(limit), pct)
+ col := ui.Column(c).Gap(u * 2).MinWidth(200).Role(ui.RoleMeter).Label(msg(c, "context_window_meter.label")).Description(summary)
+ col.Children(func() {
+ ui.Row(c).Gap(u * 2).Children(func() {
+ ui.Text(c, msg(c, "context_window_meter.label")).FontWeight(600).Grow(1).SingleLine()
+ ui.Text(c, summary).FontSize(small).FontFeatures("tnum").TextColor(k.TextMuted).SingleLine()
+ })
+ ui.Box(c).Height(10).Radius(theme.ControlRadius).Background(k.Border).Clip().Draw(func(p *ui.Painter, r ui.Rect) {
+ x := r.X
+ for i, s := range segments {
+ w := r.W * float32(min(float64(s.Tokens)/float64(limit), 1))
+ w = min(w, r.X+r.W-x)
+ p.Fill(ui.Rect{X: x, Y: r.Y, W: w, H: r.H}, pal[i%len(pal)], 0)
+ x += w
+ // A surface gap parts neighbouring segments.
+ if i < len(segments)-1 && w > 0 {
+ p.Fill(ui.Rect{X: internal.Snap(p, x-1), Y: r.Y, W: 1, H: r.H}, k.Surface, 0)
+ }
+ }
+ mark := r.X + r.W*float32(warn)
+ p.Fill(ui.Rect{X: internal.Snap(p, mark), Y: r.Y, W: 1, H: r.H}, k.Text, 0)
+ })
+ ui.Row(c).Gap(u * 4).Wrap().Children(func() {
+ for i, s := range segments {
+ ui.Row(c).Gap(u).AlignItems(ui.Center).Children(func() {
+ ui.Box(c).Size(10, 10).Draw(func(p *ui.Painter, r ui.Rect) {
+ internal.Marker(p, i, r.X+r.W/2, r.Y+r.H/2, 9, pal[i%len(pal)])
+ })
+ ui.Text(c, s.Label+" "+tokenThousands(s.Tokens)).FontSize(small).FontFeatures("tnum").SingleLine()
+ })
+ }
+ ui.Text(c, msg(c, "context_window_meter.free")+" "+tokenThousands(max(limit-used, 0))).FontSize(small).
+ FontFeatures("tnum").TextColor(k.TextMuted).SingleLine()
+ })
+ if share >= warn {
+ color := k.Warning
+ if used > limit {
+ color = k.Danger
+ }
+ ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(color).Role(ui.RoleStatus).Children(func() {
+ ui.Icon(c, icons.Must("triangle-alert")).Size(14, 14)
+ ui.Text(c, fmt.Sprintf(msg(c, "context_window_meter.warn"), pct)).FontSize(small)
+ })
+ }
+ })
+ return col
+}
diff --git a/context_window_meter_test.go b/context_window_meter_test.go
new file mode 100644
index 0000000..f961478
--- /dev/null
+++ b/context_window_meter_test.go
@@ -0,0 +1,63 @@
+package mujica
+
+import (
+ "testing"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/internal"
+ "mujicaui/theme"
+)
+
+var contextSegmentsSample = []ContextSegment{{Label: "System", Tokens: 1200}, {Label: "History", Tokens: 58000}, {Label: "Attachments", Tokens: 24000}}
+
+func TestContextWindowMeterLegendAndWarning(t *testing.T) {
+ segs := contextSegmentsSample
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ ContextWindowMeter(c, segs, 128000, ContextWindowMeterOptions{})
+ }, 600, 200)
+ for _, s := range []string{"System 1,200", "History 58,000", "Attachments 24,000", "Free 44,800", "83,200 of 128,000 tokens (65%)"} {
+ if !tt.HasText(s) {
+ t.Errorf("missing %q in %q", s, tt.Texts())
+ }
+ }
+ if tt.HasText("Context is") {
+ t.Fatal("warning below the threshold")
+ }
+ segs = append(segs, ContextSegment{Label: "Draft", Tokens: 30000})
+ tt.Frame()
+ if !tt.HasText("Context is 88% full") {
+ t.Fatalf("warning missing: %q", tt.Texts())
+ }
+}
+
+func TestContextWindowMeterPaletteContrast(t *testing.T) {
+ for _, k := range []theme.Tokens{theme.Light(), theme.Dark()} {
+ for i, col := range internal.Palette(k) {
+ if r := theme.Contrast(col, k.Surface); r < 3 {
+ t.Errorf("dark %v series %d contrast %.2f", k.Dark, i, r)
+ }
+ }
+ }
+}
+
+func TestContextWindowMeterBadOptions(t *testing.T) {
+ defer func() {
+ if recover() == nil {
+ t.Fatal("no panic for a zero limit")
+ }
+ }()
+ ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ ContextWindowMeter(c, nil, 0, ContextWindowMeterOptions{})
+ }, 100, 100)
+}
+
+func TestContextWindowMeterSnapshot(t *testing.T) {
+ snapshot(t, "context_window_meter", 520, 140, func(c *ui.Context) {
+ ui.Column(c).Padding(16).Children(func() {
+ ContextWindowMeter(c, append(contextSegmentsSample, ContextSegment{Label: "Draft", Tokens: 30000}), 128000, ContextWindowMeterOptions{})
+ })
+ })
+}
diff --git a/drag_drop_overlay.go b/drag_drop_overlay.go
new file mode 100644
index 0000000..c4cbf45
--- /dev/null
+++ b/drag_drop_overlay.go
@@ -0,0 +1,100 @@
+package mujica
+
+import (
+ "fmt"
+ "log/slog"
+ "path/filepath"
+ "strings"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/icons"
+ "mujicaui/internal"
+ "mujicaui/theme"
+)
+
+// DragDropOverlayOptions configure a DragDropOverlay.
+type DragDropOverlayOptions struct {
+ // Hint replaces the built-in "Drop files to attach".
+ Hint string
+ // Accept checks each dropped path; nil accepts all.
+ Accept func(path string) bool
+}
+
+// DropResult is the overlay's element and the files dropped this frame.
+type DropResult struct {
+ Element *ui.Element
+ accepted []string
+ rejected []string
+}
+
+// Accepted returns the dropped paths Accept took; Rejected those it refused.
+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.
+func DragDropOverlay(c *ui.Context, opts DragDropOverlayOptions, content func()) DropResult {
+ if content == nil {
+ panic("mujica: DragDropOverlay needs content")
+ }
+ k := Tokens(c)
+ u := Density(c).Unit()
+ var r DropResult
+ r.Element = ui.Box(c).MinHeight(0).MinWidth(0)
+ disabled := r.Element.IsDisabled()
+ rejected := ui.Local(r.Element, "rejected", func() []string { return nil })
+ over := !disabled && r.Element.FileDragOver()
+ if files := r.Element.DroppedFiles(); len(files) > 0 && !disabled {
+ r.accepted, r.rejected = dragDropSplit(files, opts.Accept)
+ *rejected = r.rejected
+ slog.Debug("mujica: drag_drop_overlay drop", "accepted", len(r.accepted), "rejected", len(r.rejected))
+ }
+ if over {
+ *rejected = nil
+ }
+ hint := opts.Hint
+ if hint == "" {
+ hint = msg(c, "drag_drop_overlay.hint")
+ }
+ r.Element.Children(func() {
+ content()
+ if len(*rejected) > 0 && !over {
+ names := make([]string, len(*rejected))
+ for i, p := range *rejected {
+ names[i] = filepath.Base(p)
+ }
+ ui.Text(c, fmt.Sprintf(msg(c, "drag_drop_overlay.rejected"), strings.Join(names, ", "))).
+ TextColor(k.Danger).FontSize(FontSize(c, theme.CaptionSize)).Role(ui.RoleStatus).Padding(u, u*2)
+ }
+ if over {
+ dragDropMask(c, hint)
+ }
+ })
+ return r
+}
+
+// dragDropMask covers its parent with the drop hint.
+func dragDropMask(c *ui.Context, hint string) {
+ k := Tokens(c)
+ u := Density(c).Unit()
+ mask := ui.Column(c).Absolute().Left(0).Top(0).Right(0).Bottom(0).Center().Gap(u * 2).
+ Background(k.Background.Alpha(0.92)).Radius(theme.CardRadius).PassThrough().Role(ui.RoleStatus).Label(hint)
+ mask.Opacity(overlayEnter(c, mask))
+ mask.Draw(func(p *ui.Painter, b ui.Rect) { internal.Frame(p, b, u*2, 5, k.Ornament) })
+ mask.Children(func() {
+ ui.Icon(c, icons.Must("upload")).Size(24, 24).TextColor(k.AccentText)
+ ui.Text(c, hint).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text)
+ })
+}
+
+// dragDropSplit sorts dropped paths by accept, nil accepting all.
+func dragDropSplit(files []string, accept func(string) bool) (ok, no []string) {
+ for _, f := range files {
+ if accept == nil || accept(f) {
+ ok = append(ok, f)
+ } else {
+ no = append(no, f)
+ }
+ }
+ return ok, no
+}
diff --git a/drag_drop_overlay_test.go b/drag_drop_overlay_test.go
new file mode 100644
index 0000000..9ed981a
--- /dev/null
+++ b/drag_drop_overlay_test.go
@@ -0,0 +1,53 @@
+package mujica
+
+import (
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/egoist/mygo/ui"
+)
+
+func TestDragDropOverlayIdleShowsContentOnly(t *testing.T) {
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ DragDropOverlay(c, DragDropOverlayOptions{}, func() { ui.Text(c, "Chat") })
+ }, 300, 200)
+ if !tt.HasText("Chat") || tt.HasText("Drop files to attach") {
+ t.Fatalf("idle overlay: %q", tt.Texts())
+ }
+}
+
+func TestDragDropOverlaySplitsByAccept(t *testing.T) {
+ images := func(p string) bool { return strings.HasSuffix(p, ".png") }
+ ok, no := dragDropSplit([]string{"/a.png", "/b.exe", "/c.png"}, images)
+ if !slices.Equal(ok, []string{"/a.png", "/c.png"}) || !slices.Equal(no, []string{"/b.exe"}) {
+ t.Fatalf("ok %v no %v", ok, no)
+ }
+ ok, no = dragDropSplit([]string{"/b.exe"}, nil)
+ if len(ok) != 1 || no != nil {
+ t.Fatalf("nil accept: ok %v no %v", ok, no)
+ }
+}
+
+func TestDragDropOverlayNeedsContent(t *testing.T) {
+ defer func() {
+ if recover() == nil {
+ t.Fatal("no panic without content")
+ }
+ }()
+ ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ DragDropOverlay(c, DragDropOverlayOptions{}, nil)
+ }, 100, 100)
+}
+
+// TestDragDropOverlaySnapshot draws the mask shown while files are over the area.
+func TestDragDropOverlaySnapshot(t *testing.T) {
+ snapshot(t, "drag_drop_overlay", 420, 220, func(c *ui.Context) {
+ ui.Box(c).Fill().Padding(12).Children(func() {
+ ui.Text(c, "A conversation sits under the mask.")
+ dragDropMask(c, msg(c, "drag_drop_overlay.hint"))
+ })
+ })
+}
diff --git a/icons/svg/at-sign.svg b/icons/svg/at-sign.svg
new file mode 100644
index 0000000..215e899
--- /dev/null
+++ b/icons/svg/at-sign.svg
@@ -0,0 +1,15 @@
+
diff --git a/icons/svg/bot.svg b/icons/svg/bot.svg
new file mode 100644
index 0000000..fd35df9
--- /dev/null
+++ b/icons/svg/bot.svg
@@ -0,0 +1,19 @@
+
diff --git a/icons/svg/globe.svg b/icons/svg/globe.svg
new file mode 100644
index 0000000..736ac9b
--- /dev/null
+++ b/icons/svg/globe.svg
@@ -0,0 +1,16 @@
+
diff --git a/icons/svg/key-round.svg b/icons/svg/key-round.svg
new file mode 100644
index 0000000..7e73cc9
--- /dev/null
+++ b/icons/svg/key-round.svg
@@ -0,0 +1,15 @@
+
diff --git a/icons/svg/mic-off.svg b/icons/svg/mic-off.svg
new file mode 100644
index 0000000..2c76f1b
--- /dev/null
+++ b/icons/svg/mic-off.svg
@@ -0,0 +1,19 @@
+
diff --git a/icons/svg/mic.svg b/icons/svg/mic.svg
new file mode 100644
index 0000000..0fa775a
--- /dev/null
+++ b/icons/svg/mic.svg
@@ -0,0 +1,16 @@
+
diff --git a/icons/svg/paperclip.svg b/icons/svg/paperclip.svg
new file mode 100644
index 0000000..9f77f61
--- /dev/null
+++ b/icons/svg/paperclip.svg
@@ -0,0 +1,14 @@
+
diff --git a/icons/svg/shield-check.svg b/icons/svg/shield-check.svg
new file mode 100644
index 0000000..f7002a9
--- /dev/null
+++ b/icons/svg/shield-check.svg
@@ -0,0 +1,15 @@
+
diff --git a/icons/svg/sliders-horizontal.svg b/icons/svg/sliders-horizontal.svg
new file mode 100644
index 0000000..d0323e8
--- /dev/null
+++ b/icons/svg/sliders-horizontal.svg
@@ -0,0 +1,22 @@
+
diff --git a/icons/svg/square.svg b/icons/svg/square.svg
new file mode 100644
index 0000000..0addb4f
--- /dev/null
+++ b/icons/svg/square.svg
@@ -0,0 +1,14 @@
+
diff --git a/icons/svg/telescope.svg b/icons/svg/telescope.svg
new file mode 100644
index 0000000..38cf16b
--- /dev/null
+++ b/icons/svg/telescope.svg
@@ -0,0 +1,20 @@
+
diff --git a/icons/svg/terminal.svg b/icons/svg/terminal.svg
new file mode 100644
index 0000000..63f090f
--- /dev/null
+++ b/icons/svg/terminal.svg
@@ -0,0 +1,15 @@
+
diff --git a/messages_chatinput.go b/messages_chatinput.go
new file mode 100644
index 0000000..3a9744f
--- /dev/null
+++ b/messages_chatinput.go
@@ -0,0 +1,64 @@
+package mujica
+
+func init() {
+ addMessages(map[string][2]string{
+ "prompt_composer.label": {"Message", "消息"},
+ "slash_command_menu.label": {"Commands", "命令"},
+ "slash_command_menu.empty": {"No matching commands", "没有匹配的命令"},
+ "context_mention_menu.label": {"Add context", "添加上下文"},
+ "context_mention_menu.empty": {"No matches", "无匹配"},
+ "context_mention_menu.loading": {"Searching…", "搜索中…"},
+ "context_mention_menu.file": {"Files", "文件"},
+ "context_mention_menu.doc": {"Documents", "文档"},
+ "context_mention_menu.web": {"Web pages", "网页"},
+ "attachment_chip.remove": {"Remove %s", "移除 %s"},
+ "attachment_chip.retry": {"Retry upload of %s", "重试上传 %s"},
+ "attachment_chip.uploading": {"Uploading %d%%", "上传中 %d%%"},
+ "attachment_chip.failed": {"Upload failed", "上传失败"},
+ "paste_image_preview.remove": {"Remove image %s", "移除图片 %s"},
+ "paste_image_preview.label": {"Pasted images", "粘贴的图片"},
+ "drag_drop_overlay.hint": {"Drop files to attach", "松开以添加文件"},
+ "drag_drop_overlay.rejected": {"Not accepted: %s", "不支持:%s"},
+ "voice_input_button.start": {"Start voice input", "开始语音输入"},
+ "voice_input_button.stop": {"Stop recording, %s", "停止录音,%s"},
+ "voice_input_button.denied": {"Microphone access denied", "麦克风权限被拒绝"},
+ "voice_waveform.label": {"Input level", "输入电平"},
+ "voice_waveform.silent": {"No sound detected", "未检测到声音"},
+ "context_chips.label": {"Attached context", "已附加的上下文"},
+ "context_chips.remove": {"Remove %s", "移除 %s"},
+ "context_chips.more": {"+%d more", "另有 %d 项"},
+ "context_chips.less": {"Show less", "收起"},
+ "model_selector.label": {"Model", "模型"},
+ "model_selector.context": {"%s context", "%s 上下文"},
+ "model_selector.placeholder": {"Choose a model", "选择模型"},
+ "mode_selector.label": {"Mode", "模式"},
+ "mode_selector.chat": {"Chat", "对话"},
+ "mode_selector.search": {"Search", "搜索"},
+ "mode_selector.research": {"Deep research", "深度研究"},
+ "mode_selector.agent": {"Agent", "智能体"},
+ "tool_toggle_menu.label": {"Tools", "工具"},
+ "tool_toggle_menu.count": {"Tools, %d on", "工具,已开 %d 个"},
+ "tool_toggle_menu.needs_auth": {"Needs authorization", "需要授权"},
+ "tool_toggle_menu.authorize_short": {"Authorize", "授权"},
+ "tool_toggle_menu.authorize": {"Authorize %s", "授权 %s"},
+ "parameter_panel.label": {"Parameters", "参数"},
+ "parameter_panel.reset": {"Reset %s", "重置 %s"},
+ "parameter_panel.reset_all": {"Reset all", "全部重置"},
+ "parameter_panel.default": {"Default %s", "默认 %s"},
+ "token_counter.label": {"About %s of %s tokens", "约 %s / %s 个 token"},
+ "token_counter.over": {"%s over the limit", "超出 %s"},
+ "context_window_meter.label": {"Context window", "上下文窗口"},
+ "context_window_meter.used": {"%s of %s tokens (%d%%)", "%s / %s token(%d%%)"},
+ "context_window_meter.warn": {"Context is %d%% full", "上下文已用 %d%%"},
+ "context_window_meter.free": {"Free", "剩余"},
+ "send_button.send": {"Send", "发送"},
+ "send_button.stop": {"Stop generating", "停止生成"},
+ "citation_badge.label": {"Source %d: %s", "来源 %d:%s"},
+ "source_card.open": {"Open %s", "打开 %s"},
+ "sources_panel.label": {"Sources", "来源"},
+ "sources_panel.empty": {"No sources", "没有来源"},
+ "search_progress.searching": {"Searching for “%s”", "正在搜索“%s”"},
+ "search_progress.read": {"Read %d of %d sources", "已读 %d / %d 个来源"},
+ "search_progress.done": {"Searched “%s”, read %d sources", "已搜索“%s”,读了 %d 个来源"},
+ })
+}
diff --git a/mode_selector.go b/mode_selector.go
new file mode 100644
index 0000000..953667a
--- /dev/null
+++ b/mode_selector.go
@@ -0,0 +1,53 @@
+package mujica
+
+import (
+ "log/slog"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/icons"
+)
+
+// ChatMode is how the assistant answers.
+type ChatMode int
+
+const (
+ ModeChat ChatMode = iota
+ ModeSearch
+ ModeResearch
+ ModeAgent
+)
+
+// chatModes names each mode's copy key and icon, in order.
+var chatModes = [...][2]string{
+ {"mode_selector.chat", "message-square"},
+ {"mode_selector.search", "globe"},
+ {"mode_selector.research", "telescope"},
+ {"mode_selector.agent", "bot"},
+}
+
+// ModeSelectorOptions configure a ModeSelector.
+type ModeSelectorOptions struct {
+ // IconsOnly hides the labels, which then name the icons.
+ IconsOnly bool
+ Disabled bool
+}
+
+// ModeSelector switches among chat, search, deep research and agent modes; arrows move the choice.
+func ModeSelector(c *ui.Context, mode *ChatMode, opts ModeSelectorOptions) ChoiceResult {
+ if *mode < ModeChat || *mode > ModeAgent {
+ panic("mujica: unknown ChatMode")
+ }
+ segs := make([]Segment, len(chatModes))
+ for i, m := range chatModes {
+ segs[i] = Segment{Label: msg(c, m[0]), Icon: icons.Must(m[1]), HideLabel: opts.IconsOnly}
+ }
+ i := int(*mode)
+ e := SegmentedControl(c, &i, segs, SegmentedControlOptions{Label: msg(c, "mode_selector.label"), Disabled: opts.Disabled})
+ r := ChoiceResult{Element: e, changed: ChatMode(i) != *mode}
+ if r.changed {
+ *mode = ChatMode(i)
+ slog.Debug("mujica: mode_selector changed", "mode", i)
+ }
+ return r
+}
diff --git a/mode_selector_test.go b/mode_selector_test.go
new file mode 100644
index 0000000..9464ec0
--- /dev/null
+++ b/mode_selector_test.go
@@ -0,0 +1,62 @@
+package mujica
+
+import (
+ "testing"
+
+ "github.com/egoist/mygo/ui"
+)
+
+func TestModeSelectorClickAndArrows(t *testing.T) {
+ mode, n, off := ModeChat, 0, false
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ if ModeSelector(c, &mode, ModeSelectorOptions{Disabled: off}).Changed() {
+ n++
+ }
+ }, 600, 100)
+ _ = tt.Click("Deep research")
+ if mode != ModeResearch || n != 1 {
+ t.Fatalf("click: mode %d changes %d", mode, n)
+ }
+ tt.Key(0, ui.KeyTab)
+ tt.Key(0, ui.KeyRight)
+ if mode != ModeAgent {
+ t.Fatalf("Right: mode %d", mode)
+ }
+ off = true
+ _ = tt.Click("Chat")
+ if mode != ModeAgent {
+ t.Fatal("disabled selector changed")
+ }
+}
+
+func TestModeSelectorIconsOnlyAndBadMode(t *testing.T) {
+ mode := ModeSearch
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ ModeSelector(c, &mode, ModeSelectorOptions{IconsOnly: true})
+ }, 300, 100)
+ if _, ok := tt.Find("Search"); !ok {
+ t.Fatal("icon segments unnamed")
+ }
+ defer func() {
+ if recover() == nil {
+ t.Fatal("no panic for an unknown mode")
+ }
+ }()
+ bad := ChatMode(9)
+ ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ ModeSelector(c, &bad, ModeSelectorOptions{})
+ }, 100, 100)
+}
+
+func TestModeSelectorSnapshot(t *testing.T) {
+ mode := ModeResearch
+ snapshot(t, "mode_selector", 520, 120, func(c *ui.Context) {
+ ui.Column(c).Padding(16).Gap(12).AlignItems(ui.Start).Children(func() {
+ ModeSelector(c, &mode, ModeSelectorOptions{})
+ ModeSelector(c, &mode, ModeSelectorOptions{IconsOnly: true})
+ })
+ })
+}
diff --git a/model_selector.go b/model_selector.go
new file mode 100644
index 0000000..2f53b9e
--- /dev/null
+++ b/model_selector.go
@@ -0,0 +1,150 @@
+package mujica
+
+import (
+ "fmt"
+ "log/slog"
+ "strconv"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/theme"
+)
+
+// ModelOption is one model a ModelSelector offers.
+type ModelOption struct {
+ // ID is the value chosen; required.
+ ID string
+ Name string
+ // Capabilities are short tags, as "Vision" or "Tools".
+ Capabilities []string
+ // ContextTokens is the context length; zero hides it.
+ ContextTokens int
+ // Unavailable explains why the model cannot be chosen; empty means available.
+ Unavailable string
+}
+
+// ModelSelectorOptions configure a ModelSelector.
+type ModelSelectorOptions struct {
+ Disabled bool
+ // Label names the selector; empty uses the built-in "Model".
+ Label string
+}
+
+// ModelSelector chooses a model ID into *value from a list showing capabilities, context length and why some are unavailable.
+func ModelSelector(c *ui.Context, value *string, models []ModelOption, opts ModelSelectorOptions) ChoiceResult {
+ for _, m := range models {
+ if m.ID == "" || m.Name == "" || m.ContextTokens < 0 {
+ panic("mujica: ModelOption needs ID, Name and a non-negative ContextTokens")
+ }
+ }
+ k := Tokens(c)
+ u := Density(c).Unit()
+ old := *value
+ pick := old
+ var s *ui.SelectParts[string]
+ ui.Row(c).Shrink(0).Disabled(opts.Disabled).Children(func() { s = ui.SelectBase(c, &pick) })
+ tr := s.Trigger
+ label := opts.Label
+ if label == "" {
+ label = msg(c, "model_selector.label")
+ }
+ tr.Label(label)
+ cur, has := modelSelectorFind(models, *value)
+ selectField(c, tr, s.Open(), func() {
+ if has {
+ ui.Text(c, cur.Name).SingleLine().FontWeight(600).Grow(1).Shrink(1)
+ } else {
+ ui.Text(c, msg(c, "model_selector.placeholder")).SingleLine().TextColor(k.TextMuted).Grow(1).Shrink(1)
+ }
+ })
+ if opts.Disabled {
+ return ChoiceResult{Element: tr}
+ }
+ s.Popup(func(panel *ui.Element) {
+ selectPanel(c, panel)
+ panel.MinWidth(320)
+ ui.Scroll(c).MaxHeight(360).Children(func() {
+ for _, m := range models {
+ var row *ui.Element
+ if m.Unavailable != "" {
+ row = ui.Row(c).Disabled(true).Description(m.Unavailable)
+ } else {
+ row = s.Item(m.ID)
+ }
+ hi := m.Unavailable == "" && row.Highlighted()
+ chosen := m.ID == *value
+ row.Padding(u*2, u*3, u*2, u*2).Gap(u * 2).AlignItems(ui.Start).Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(m.Name)
+ switch {
+ case chosen:
+ row.Background(k.Selection)
+ case hi:
+ row.Background(k.SurfaceHover)
+ }
+ if hi {
+ row.Draw(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 2, H: r.H - 8}, k.AccentText, 1) })
+ }
+ row.Children(func() {
+ selectCheck(c, chosen)
+ modelSelectorRow(c, m)
+ })
+ }
+ })
+ })
+ if pick != *value && !tr.IsDisabled() {
+ if m, ok := modelSelectorFind(models, pick); ok && m.Unavailable == "" {
+ *value = pick
+ }
+ }
+ changed := *value != old
+ if changed {
+ slog.Debug("mujica: model_selector changed", "model", *value)
+ }
+ return ChoiceResult{Element: tr, changed: changed}
+}
+
+// modelSelectorRow shows a model's name, tags, context and unavailability.
+func modelSelectorRow(c *ui.Context, m ModelOption) {
+ k := Tokens(c)
+ u := Density(c).Unit()
+ small := FontSize(c, theme.CaptionSize)
+ ui.Column(c).Gap(u).Grow(1).MinWidth(0).Children(func() {
+ ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
+ ui.Text(c, m.Name).FontWeight(600).SingleLine()
+ if m.ContextTokens > 0 {
+ ui.Text(c, fmt.Sprintf(msg(c, "model_selector.context"), modelSelectorTokens(m.ContextTokens))).
+ FontSize(small).TextColor(k.TextMuted).FontFeatures("tnum").SingleLine()
+ }
+ })
+ if len(m.Capabilities) > 0 {
+ ui.Row(c).Gap(u).Wrap().Children(func() {
+ for _, cap := range m.Capabilities {
+ ui.Text(c, cap).FontSize(small).Padding(0, u*1.5).Radius(theme.ControlRadius).
+ Border(theme.BorderWidth, k.Border).TextColor(k.AccentText).SingleLine()
+ }
+ })
+ }
+ if m.Unavailable != "" {
+ ui.Text(c, m.Unavailable).FontSize(small).TextColor(k.TextMuted).Italic()
+ }
+ })
+}
+
+func modelSelectorFind(models []ModelOption, id string) (ModelOption, bool) {
+ for _, m := range models {
+ if m.ID == id {
+ return m, true
+ }
+ }
+ return ModelOption{}, false
+}
+
+// modelSelectorTokens writes a token count as 8K, 200K or 1M.
+func modelSelectorTokens(n int) string {
+ switch {
+ case n >= 1_000_000 && n%100_000 == 0:
+ return strconv.FormatFloat(float64(n)/1e6, 'f', -1, 64) + "M"
+ case n >= 1000:
+ return strconv.Itoa(n/1000) + "K"
+ }
+ return strconv.Itoa(n)
+}
diff --git a/model_selector_test.go b/model_selector_test.go
new file mode 100644
index 0000000..99f876a
--- /dev/null
+++ b/model_selector_test.go
@@ -0,0 +1,72 @@
+package mujica
+
+import (
+ "testing"
+
+ "github.com/egoist/mygo/ui"
+)
+
+var modelsSample = []ModelOption{
+ {ID: "herald", Name: "Herald 2", Capabilities: []string{"Vision", "Tools"}, ContextTokens: 200000},
+ {ID: "page", Name: "Page Mini", Capabilities: []string{"Fast"}, ContextTokens: 32000},
+ {ID: "oracle", Name: "Oracle", ContextTokens: 1000000, Unavailable: "Needs the Court plan"},
+}
+
+func modelTester(v *string, changes *int, off *bool) *ui.Tester {
+ return ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ ui.Column(c).Padding(16).Children(func() {
+ if ModelSelector(c, v, modelsSample, ModelSelectorOptions{Disabled: *off}).Changed() {
+ *changes++
+ }
+ })
+ }, 500, 500)
+}
+
+func TestModelSelectorShowsDetailsAndChooses(t *testing.T) {
+ v, n, off := "herald", 0, false
+ tt := modelTester(&v, &n, &off)
+ _ = tt.Click("Model")
+ for _, s := range []string{"200K context", "Vision", "1M context", "Needs the Court plan", "32K context"} {
+ if !tt.HasText(s) {
+ t.Errorf("missing %q in %q", s, tt.Texts())
+ }
+ }
+ _ = tt.Click("Oracle")
+ if v != "herald" || n != 0 {
+ t.Fatalf("unavailable model chosen: %q", v)
+ }
+ tt.Key(0, ui.KeyEscape)
+ tt.Key(0, ui.KeyTab)
+ tt.Key(0, ui.KeyDown)
+ tt.Key(0, ui.KeyDown)
+ tt.Key(0, ui.KeyDown)
+ tt.Key(0, ui.KeyEnter)
+ if v != "page" || n != 1 {
+ t.Fatalf("keyboard choice %q changes %d (Down stops before the unavailable model)", v, n)
+ }
+}
+
+func TestModelSelectorDisabled(t *testing.T) {
+ v, n, off := "herald", 0, true
+ tt := modelTester(&v, &n, &off)
+ _ = tt.Click("Model")
+ if tt.HasText("Page Mini") {
+ t.Fatal("disabled selector opened")
+ }
+}
+
+func TestModelSelectorTokens(t *testing.T) {
+ for n, want := range map[int]string{512: "512", 8000: "8K", 200000: "200K", 1000000: "1M", 1500000: "1.5M"} {
+ if got := modelSelectorTokens(n); got != want {
+ t.Errorf("%d: %q, want %q", n, got, want)
+ }
+ }
+}
+
+func TestModelSelectorSnapshot(t *testing.T) {
+ snapshotAfter(t, "model_selector", 440, 330, func(c *ui.Context) {
+ v := ui.Local(c.Root(), "v", func() string { return "herald" })
+ ui.Column(c).Padding(16).Children(func() { ModelSelector(c, v, modelsSample, ModelSelectorOptions{}) })
+ }, func(tt *ui.Tester) { _ = tt.Click("Model") })
+}
diff --git a/parameter_panel.go b/parameter_panel.go
new file mode 100644
index 0000000..044f68a
--- /dev/null
+++ b/parameter_panel.go
@@ -0,0 +1,76 @@
+package mujica
+
+import (
+ "fmt"
+ "log/slog"
+ "strconv"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/icons"
+ "mujicaui/theme"
+)
+
+// ModelParam is one generation parameter, as temperature or top-p.
+type ModelParam struct {
+ // Label and Value are required.
+ Label string
+ Value *float64
+ // Min, Max and Step bound the value; Default lies within them.
+ Min, Max, Step, Default float64
+}
+
+// ParameterPanel adjusts generation parameters with sliders; each shows its range and resets to its default.
+func ParameterPanel(c *ui.Context, params []ModelParam) ChoiceResult {
+ for _, p := range params {
+ if p.Label == "" || p.Value == nil || !(p.Min < p.Max) || p.Step <= 0 || p.Default < p.Min || p.Default > p.Max {
+ panic("mujica: ModelParam needs Label, Value, Min < Max, Step > 0 and Default in range")
+ }
+ }
+ k := Tokens(c)
+ u := Density(c).Unit()
+ small := FontSize(c, theme.CaptionSize)
+ var r ChoiceResult
+ r.Element = ui.Column(c).Gap(u * 4).MinWidth(240).Role(ui.RoleGroup).Label(msg(c, "parameter_panel.label"))
+ disabled := r.Element.IsDisabled()
+ set := func(p ModelParam, v float64) {
+ if *p.Value != v && !disabled {
+ *p.Value, r.changed = v, true
+ slog.Debug("mujica: parameter_panel set", "param", p.Label, "value", v)
+ }
+ }
+ r.Element.Children(func() {
+ for _, p := range params {
+ format := func(v float64) string { return strconv.FormatFloat(v, 'f', sliderDecimals(p.Min, p.Step, p.Default), 64) }
+ ui.Column(c).Key(p.Label).Gap(u).Children(func() {
+ ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
+ ui.Text(c, p.Label).FontWeight(600).Grow(1).SingleLine()
+ ui.Text(c, format(*p.Value)).FontFeatures("tnum").TextColor(k.AccentText).SingleLine()
+ ui.Row(c).Disabled(*p.Value == p.Default).Children(func() {
+ if iconAction(c, icons.Must("rotate-ccw"), fmt.Sprintf(msg(c, "parameter_panel.reset"), p.Label)).
+ Tooltip(fmt.Sprintf(msg(c, "parameter_panel.default"), format(p.Default))).Clicked() {
+ set(p, p.Default)
+ }
+ })
+ })
+ v := *p.Value
+ if Slider(c, &v, SliderOptions{Min: p.Min, Max: p.Max, Step: p.Step, Label: p.Label, Disabled: disabled}).Changed() {
+ set(p, v)
+ }
+ ui.Row(c).Children(func() {
+ ui.Text(c, format(p.Min)).FontSize(small).TextColor(k.TextMuted).Grow(1)
+ ui.Text(c, fmt.Sprintf(msg(c, "parameter_panel.default"), format(p.Default))).FontSize(small).TextColor(k.TextMuted)
+ ui.Text(c, format(p.Max)).FontSize(small).TextColor(k.TextMuted).Grow(1).TextAlign(ui.End)
+ })
+ })
+ }
+ ui.Row(c).Justify(ui.End).Children(func() {
+ if Button(c, msg(c, "parameter_panel.reset_all"), ButtonOptions{Variant: Ghost, Icon: icons.Must("rotate-ccw")}).Clicked() {
+ for _, p := range params {
+ set(p, p.Default)
+ }
+ }
+ })
+ })
+ return r
+}
diff --git a/parameter_panel_test.go b/parameter_panel_test.go
new file mode 100644
index 0000000..928a4d4
--- /dev/null
+++ b/parameter_panel_test.go
@@ -0,0 +1,69 @@
+package mujica
+
+import (
+ "testing"
+
+ "github.com/egoist/mygo/ui"
+)
+
+func paramSample(temp, topP *float64) []ModelParam {
+ return []ModelParam{
+ {Label: "Temperature", Value: temp, Min: 0, Max: 2, Step: 0.1, Default: 1},
+ {Label: "Top-p", Value: topP, Min: 0, Max: 1, Step: 0.05, Default: 1},
+ }
+}
+
+func TestParameterPanelAdjustAndReset(t *testing.T) {
+ temp, topP, n, off := 1.0, 0.9, 0, false
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ ui.Column(c).Padding(16).Disabled(off).Children(func() {
+ if ParameterPanel(c, paramSample(&temp, &topP)).Changed() {
+ n++
+ }
+ })
+ }, 500, 400)
+ for _, s := range []string{"1.0", "0.90", "Default 1.0", "2.0"} {
+ if !tt.HasText(s) {
+ t.Errorf("missing %q in %q", s, tt.Texts())
+ }
+ }
+ tt.Key(0, ui.KeyTab)
+ tt.Key(0, ui.KeyRight)
+ if temp != 1.1 || n != 1 {
+ t.Fatalf("Right: temp %v changes %d", temp, n)
+ }
+ _ = tt.Click("Reset Temperature")
+ if temp != 1 {
+ t.Fatalf("reset temp %v", temp)
+ }
+ _ = tt.Click("Reset all")
+ if topP != 1 || n != 3 {
+ t.Fatalf("reset all: topP %v changes %d", topP, n)
+ }
+ off = true
+ tt.Key(0, ui.KeyRight)
+ if temp != 1 {
+ t.Fatal("disabled panel changed")
+ }
+}
+
+func TestParameterPanelBadParam(t *testing.T) {
+ v := 0.0
+ defer func() {
+ if recover() == nil {
+ t.Fatal("no panic for a default outside the range")
+ }
+ }()
+ ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ ParameterPanel(c, []ModelParam{{Label: "x", Value: &v, Min: 0, Max: 1, Step: 0.1, Default: 3}})
+ }, 100, 100)
+}
+
+func TestParameterPanelSnapshot(t *testing.T) {
+ temp, topP := 0.7, 1.0
+ snapshot(t, "parameter_panel", 420, 260, func(c *ui.Context) {
+ ui.Column(c).Padding(16).Children(func() { ParameterPanel(c, paramSample(&temp, &topP)) })
+ })
+}
diff --git a/paste_image_preview.go b/paste_image_preview.go
new file mode 100644
index 0000000..0ef07e2
--- /dev/null
+++ b/paste_image_preview.go
@@ -0,0 +1,56 @@
+package mujica
+
+import (
+ "fmt"
+ "log/slog"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/icons"
+ "mujicaui/theme"
+)
+
+// PastedImage is an image pasted or dropped into a prompt; the caller decodes it.
+type PastedImage struct {
+ // ID is the image's stable identity; required.
+ ID string
+ Source ui.ImageSource
+ // Name describes it for assistive technology and the remove button.
+ Name string
+}
+
+// pasteImageSize is the thumbnail's edge in DIPs.
+const pasteImageSize float32 = 72
+
+// PasteImagePreview shows pasted or dropped images as thumbnails, each with a remove button.
+func PasteImagePreview(c *ui.Context, images []PastedImage) RemoveResult {
+ k := Tokens(c)
+ u := Density(c).Unit()
+ var r RemoveResult
+ r.Element = ui.Row(c).Gap(u * 2).Wrap().Role(ui.RoleList).Label(msg(c, "paste_image_preview.label"))
+ disabled := r.Element.IsDisabled()
+ r.Element.Children(func() {
+ for _, im := range images {
+ if im.ID == "" || im.Source == nil {
+ panic("mujica: PastedImage needs ID and Source")
+ }
+ ui.Box(c).Key(im.ID).Size(pasteImageSize, pasteImageSize).Role(ui.RoleListItem).Children(func() {
+ ui.Image(c, im.Source).Fit(ui.Cover).Size(pasteImageSize, pasteImageSize).Radius(theme.ControlRadius).
+ Border(theme.BorderWidth, k.Border).Label(im.Name)
+ x := iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "paste_image_preview.remove"), im.Name)).
+ Absolute().Top(-u * 3).Right(-u * 3).Radius(theme.MinHitSize / 2)
+ // A 20 DIP disc inside the 28 DIP hit area.
+ x.Draw(func(p *ui.Painter, r ui.Rect) {
+ d := ui.Rect{X: r.X + 4, Y: r.Y + 4, W: r.W - 8, H: r.H - 8}
+ p.Fill(d, k.Surface, d.W/2)
+ p.Stroke(d, k.ControlBorder, d.W/2, theme.BorderWidth)
+ })
+ if x.Clicked() && !disabled {
+ r.removed = im.ID
+ slog.Debug("mujica: paste_image_preview remove", "id", im.ID)
+ }
+ })
+ }
+ })
+ return r
+}
diff --git a/paste_image_preview_test.go b/paste_image_preview_test.go
new file mode 100644
index 0000000..d58d9fd
--- /dev/null
+++ b/paste_image_preview_test.go
@@ -0,0 +1,61 @@
+package mujica
+
+import (
+ "testing"
+
+ "github.com/egoist/mygo/ui"
+)
+
+func pasteImages() []PastedImage {
+ return []PastedImage{
+ {ID: "a", Source: imageGridPicture("#7D2034"), Name: "screenshot 1"},
+ {ID: "b", Source: imageGridPicture("#355E80"), Name: "screenshot 2"},
+ }
+}
+
+func TestPasteImagePreviewRemoves(t *testing.T) {
+ imgs, off := pasteImages(), false
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ ui.Column(c).Padding(16).Disabled(off).Children(func() {
+ if id, ok := PasteImagePreview(c, imgs).Removed(); ok {
+ for i, im := range imgs {
+ if im.ID == id {
+ imgs = append(imgs[:i], imgs[i+1:]...)
+ break
+ }
+ }
+ }
+ })
+ }, 400, 150)
+ if _, ok := tt.Find("screenshot 2"); !ok {
+ t.Fatal("thumbnail not named")
+ }
+ _ = tt.Click("Remove image screenshot 1")
+ if len(imgs) != 1 || imgs[0].ID != "b" {
+ t.Fatalf("left %v", imgs)
+ }
+ off = true
+ _ = tt.Click("Remove image screenshot 2")
+ if len(imgs) != 1 {
+ t.Fatal("disabled preview removed an image")
+ }
+}
+
+func TestPasteImagePreviewNeedsSource(t *testing.T) {
+ defer func() {
+ if recover() == nil {
+ t.Fatal("no panic without a source")
+ }
+ }()
+ ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ PasteImagePreview(c, []PastedImage{{ID: "x"}})
+ }, 100, 100)
+}
+
+func TestPasteImagePreviewSnapshot(t *testing.T) {
+ snapshot(t, "paste_image_preview", 240, 110, func(c *ui.Context) {
+ ui.Column(c).Padding(20).Children(func() { PasteImagePreview(c, pasteImages()) })
+ })
+}
diff --git a/prompt_composer.go b/prompt_composer.go
new file mode 100644
index 0000000..7221b67
--- /dev/null
+++ b/prompt_composer.go
@@ -0,0 +1,99 @@
+package mujica
+
+import (
+ "log/slog"
+ "strings"
+
+ "github.com/egoist/mygo/ui"
+)
+
+// PromptComposerOptions configure a PromptComposer.
+type PromptComposerOptions struct {
+ Placeholder string
+ // Label names the field; empty uses the built-in "Message".
+ Label string
+ // CmdEnterSends sends with Cmd+Enter (Ctrl+Enter) and makes Enter a newline.
+ CmdEnterSends bool
+ // MaxHeight caps the field in DIPs before it scrolls; zero means 200.
+ MaxHeight float32
+ // Attachments builds the row above the field, as AttachmentChips.
+ Attachments func()
+ // Tools builds the left of the bar below the field; Actions its right, as a SendButton.
+ Tools, Actions func()
+ Disabled bool
+}
+
+// promptHooks are key handlers menus over a composer add each frame.
+type promptHooks struct{ list []func(ui.InputEvent) bool }
+
+type promptHooksKey struct{}
+
+// PromptComposer is a multi-line prompt: Enter sends and Shift+Enter breaks the line, or Cmd+Enter sends.
+// Submitted never fires for blank text.
+func PromptComposer(c *ui.Context, value *string, opts PromptComposerOptions) InputResult {
+ if value == nil {
+ panic("mujica: PromptComposer needs a value")
+ }
+ if opts.MaxHeight < 0 {
+ panic("mujica: PromptComposer MaxHeight must not be negative")
+ }
+ u := Density(c).Unit()
+ maxH := opts.MaxHeight
+ if maxH == 0 {
+ maxH = 200
+ }
+ label := opts.Label
+ if label == "" {
+ label = msg(c, "prompt_composer.label")
+ }
+ var r InputResult
+ r.Element = ui.Column(c).Gap(u * 2).MinWidth(0).Disabled(opts.Disabled)
+ hooks := ui.Local(r.Element, promptHooksKey{}, func() *promptHooks { return &promptHooks{} })
+ h := *hooks
+ h.list = h.list[:0]
+ r.Element.Children(func() {
+ if opts.Attachments != nil {
+ ui.Row(c).Gap(u * 2).Wrap().Children(opts.Attachments)
+ }
+ ta := TextAreaOptions{Label: label, Placeholder: opts.Placeholder, MaxHeight: maxH,
+ keys: func(ev ui.InputEvent) bool {
+ for _, f := range h.list {
+ if f(ev) {
+ return true
+ }
+ }
+ return false
+ }}
+ if !opts.CmdEnterSends {
+ ta.SubmitKey = ui.KeyEnter
+ }
+ area := TextArea(c, value, ta)
+ r.Input, r.changed = area.Input, area.Changed()
+ r.submitted = area.Submitted() && strings.TrimSpace(*value) != ""
+ if opts.Tools != nil || opts.Actions != nil {
+ ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
+ ui.Row(c).Gap(u).AlignItems(ui.Center).Grow(1).Children(func() {
+ if opts.Tools != nil {
+ opts.Tools()
+ }
+ })
+ if opts.Actions != nil {
+ opts.Actions()
+ }
+ })
+ }
+ })
+ if r.submitted {
+ slog.Debug("mujica: prompt_composer submit", "chars", len(*value))
+ }
+ return r
+}
+
+// promptComposerHook adds a key handler that runs before the composer's editor this frame.
+func promptComposerHook(composer InputResult, f func(ui.InputEvent) bool) {
+ if composer.Element == nil {
+ panic("mujica: menus need a built PromptComposer")
+ }
+ h := *ui.Local(composer.Element, promptHooksKey{}, func() *promptHooks { return &promptHooks{} })
+ h.list = append(h.list, f)
+}
diff --git a/prompt_composer_test.go b/prompt_composer_test.go
new file mode 100644
index 0000000..abf9d04
--- /dev/null
+++ b/prompt_composer_test.go
@@ -0,0 +1,85 @@
+package mujica
+
+import (
+ "testing"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/icons"
+)
+
+func promptTester(text *string, sends *int, opts PromptComposerOptions) *ui.Tester {
+ return ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ if PromptComposer(c, text, opts).Submitted() {
+ *sends++
+ }
+ }, 500, 400)
+}
+
+func TestPromptComposerEnterSendsShiftEnterBreaks(t *testing.T) {
+ text, sends := "", 0
+ tt := promptTester(&text, &sends, PromptComposerOptions{Label: "Prompt"})
+ _ = tt.Click("Prompt")
+ tt.Key(0, ui.KeyEnter)
+ if sends != 0 {
+ t.Fatal("blank prompt sent")
+ }
+ tt.Type("Bonjour")
+ tt.Key(ui.Shift, ui.KeyEnter)
+ tt.Type("Madame")
+ tt.Key(0, ui.KeyEnter)
+ if sends != 1 || text != "Bonjour\nMadame" {
+ t.Fatalf("sends %d text %q", sends, text)
+ }
+}
+
+func TestPromptComposerCmdEnterMode(t *testing.T) {
+ text, sends := "", 0
+ tt := promptTester(&text, &sends, PromptComposerOptions{Label: "Prompt", CmdEnterSends: true})
+ _ = tt.Click("Prompt")
+ tt.Type("a")
+ tt.Key(0, ui.KeyEnter)
+ tt.Type("b")
+ tt.Key(ui.Cmd, ui.KeyEnter)
+ if sends != 1 || text != "a\nb" {
+ t.Fatalf("sends %d text %q", sends, text)
+ }
+}
+
+func TestPromptComposerSlotsAndDisabled(t *testing.T) {
+ text, sends := "hello", 0
+ tt := promptTester(&text, &sends, PromptComposerOptions{Label: "Prompt", Disabled: true})
+ _ = tt.Click("Prompt")
+ tt.Type("x")
+ tt.Key(0, ui.KeyEnter)
+ if sends != 0 || text != "hello" {
+ t.Fatalf("disabled composer: sends %d text %q", sends, text)
+ }
+ tt = ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ PromptComposer(c, &text, PromptComposerOptions{
+ Attachments: func() { ui.Text(c, "report.pdf") },
+ Tools: func() { ui.Text(c, "Web search") },
+ Actions: func() { ui.Text(c, "Send it") },
+ })
+ }, 500, 400)
+ for _, s := range []string{"report.pdf", "Web search", "Send it", "Message"} {
+ if _, ok := tt.Find(s); !ok {
+ t.Errorf("missing %q", s)
+ }
+ }
+}
+
+func TestPromptComposerSnapshot(t *testing.T) {
+ text := "Draft the invitation for the spring ball,\nin the style of the court."
+ snapshot(t, "prompt_composer", 520, 220, func(c *ui.Context) {
+ ui.Column(c).Padding(16).Children(func() {
+ PromptComposer(c, &text, PromptComposerOptions{
+ Attachments: func() { AttachmentChip(c, Attachment{Name: "guests.csv", Size: 4096}) },
+ Tools: func() { iconAction(c, icons.Must("paperclip"), "Attach") },
+ Actions: func() { SendButton(c, SendButtonOptions{Shortcut: "Enter"}) },
+ })
+ })
+ })
+}
diff --git a/search_progress.go b/search_progress.go
new file mode 100644
index 0000000..e4569a6
--- /dev/null
+++ b/search_progress.go
@@ -0,0 +1,86 @@
+package mujica
+
+import (
+ "fmt"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/icons"
+ "mujicaui/theme"
+)
+
+// SearchStage is one step of a web search, as "Planning" or "Reading".
+type SearchStage struct {
+ Label string
+ Done bool
+}
+
+// SearchProgressOptions describe the caller's search.
+type SearchProgressOptions struct {
+ // Query is the keyword searched; required.
+ Query string
+ // Read and Total count the sources read so far and found.
+ Read, Total int
+ Stages []SearchStage
+ // Done folds the block to a summary, which the user can open again.
+ Done bool
+}
+
+// searchProgressState is the fold, open while searching until the search ends.
+type searchProgressState struct {
+ open, done bool
+}
+
+// SearchProgress shows a search's keyword, sources read and stages; once Done it folds to one line.
+func SearchProgress(c *ui.Context, opts SearchProgressOptions) ChoiceResult {
+ if opts.Query == "" || opts.Read < 0 || opts.Total < 0 || opts.Read > opts.Total {
+ panic("mujica: SearchProgress needs a Query and 0 <= Read <= Total")
+ }
+ r := ChoiceResult{Element: ui.Column(c).MinWidth(0)}
+ st := ui.Local(r.Element, "search", func() searchProgressState { return searchProgressState{open: true} })
+ if opts.Done && !st.done {
+ // Finishing folds it once; the user may open it again.
+ st.open = false
+ }
+ st.done = opts.Done
+ title := fmt.Sprintf(msg(c, "search_progress.searching"), opts.Query)
+ if opts.Done {
+ title = fmt.Sprintf(msg(c, "search_progress.done"), opts.Query, opts.Read)
+ }
+ r.Element.Children(func() { r.changed = searchProgressBody(c, st, title, opts) })
+ return r
+}
+
+// searchProgressBody is the collapsible header and the stages.
+func searchProgressBody(c *ui.Context, st *searchProgressState, title string, opts SearchProgressOptions) bool {
+ k := Tokens(c)
+ u := Density(c).Unit()
+ small := FontSize(c, theme.CaptionSize)
+ return Collapsible(c, &st.open, CollapsibleOptions{Title: title, Trigger: func() {
+ ui.Row(c).Gap(u * 2).AlignItems(ui.Center).TextColor(k.TextMuted).Children(func() {
+ if opts.Done {
+ ui.Icon(c, icons.Must("search")).Size(14, 14)
+ } else {
+ Spinner(c, SpinnerOptions{Size: 14, Label: title})
+ }
+ ui.Text(c, title).FontSize(small).SingleLine()
+ })
+ }}, func() {
+ ui.Text(c, fmt.Sprintf(msg(c, "search_progress.read"), opts.Read, opts.Total)).FontSize(small).FontFeatures("tnum").TextColor(k.TextMuted)
+ ui.Column(c).Gap(u).Role(ui.RoleList).Children(func() {
+ for _, s := range opts.Stages {
+ ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Role(ui.RoleListItem).Children(func() {
+ if s.Done {
+ ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(k.Success)
+ } else {
+ ui.Icon(c, icons.Must("circle")).Size(14, 14).TextColor(k.TextMuted)
+ }
+ t := ui.Text(c, s.Label).FontSize(small)
+ if !s.Done {
+ t.TextColor(k.TextMuted)
+ }
+ })
+ }
+ })
+ }).Changed()
+}
diff --git a/search_progress_test.go b/search_progress_test.go
new file mode 100644
index 0000000..d303d42
--- /dev/null
+++ b/search_progress_test.go
@@ -0,0 +1,60 @@
+package mujica
+
+import (
+ "testing"
+
+ "github.com/egoist/mygo/ui"
+)
+
+func searchStages(done int) []SearchStage {
+ labels := []string{"Planning queries", "Searching the web", "Reading sources", "Writing the answer"}
+ out := make([]SearchStage, len(labels))
+ for i, l := range labels {
+ out[i] = SearchStage{Label: l, Done: i < done}
+ }
+ return out
+}
+
+func TestSearchProgressFoldsWhenDone(t *testing.T) {
+ opts := SearchProgressOptions{Query: "Versailles 1682", Read: 3, Total: 8, Stages: searchStages(2)}
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ SearchProgress(c, opts)
+ }, 500, 300)
+ tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
+ for _, s := range []string{"Searching for “Versailles 1682”", "Read 3 of 8 sources", "Reading sources"} {
+ if !tt.HasText(s) {
+ t.Errorf("missing %q in %q", s, tt.Texts())
+ }
+ }
+ opts.Done, opts.Read, opts.Stages = true, 8, searchStages(4)
+ tt.Frame()
+ if !tt.HasText("Searched “Versailles 1682”, read 8 sources") || tt.HasText("Reading sources") {
+ t.Fatalf("done: %q", tt.Texts())
+ }
+ _ = tt.Click("Searched “Versailles 1682”, read 8 sources")
+ if !tt.HasText("Reading sources") {
+ t.Fatal("finished search does not open again")
+ }
+}
+
+func TestSearchProgressBadCounts(t *testing.T) {
+ defer func() {
+ if recover() == nil {
+ t.Fatal("no panic for Read above Total")
+ }
+ }()
+ ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ SearchProgress(c, SearchProgressOptions{Query: "q", Read: 3, Total: 1})
+ }, 100, 100)
+}
+
+func TestSearchProgressSnapshot(t *testing.T) {
+ snapshot(t, "search_progress", 440, 230, func(c *ui.Context) {
+ ui.Column(c).Padding(16).Gap(12).Children(func() {
+ SearchProgress(c, SearchProgressOptions{Query: "Versailles 1682", Read: 3, Total: 8, Stages: searchStages(2)})
+ SearchProgress(c, SearchProgressOptions{Query: "Hall of Mirrors", Read: 5, Total: 5, Stages: searchStages(4), Done: true})
+ })
+ })
+}
diff --git a/send_button.go b/send_button.go
new file mode 100644
index 0000000..379b99c
--- /dev/null
+++ b/send_button.go
@@ -0,0 +1,72 @@
+package mujica
+
+import (
+ "log/slog"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/icons"
+ "mujicaui/internal"
+ "mujicaui/theme"
+)
+
+// SendButtonOptions configure a SendButton.
+type SendButtonOptions struct {
+ // Streaming turns the button into Stop while a reply is generated.
+ Streaming bool
+ // Disabled blocks sending, as for an empty prompt; Stop stays available.
+ Disabled bool
+ // Shortcut is the send key shown in the tooltip, as "Enter"; empty shows none.
+ Shortcut string
+}
+
+// SendResult is the button and which action the user took this frame.
+type SendResult struct {
+ Element *ui.Element
+ send, stop bool
+}
+
+// Sent reports a press while idle; Stopped a press while streaming.
+func (r SendResult) Sent() bool { return r.send }
+func (r SendResult) Stopped() bool { return r.stop }
+
+// SendButton sends the prompt, or stops the reply while streaming.
+func SendButton(c *ui.Context, opts SendButtonOptions) SendResult {
+ k := Tokens(c)
+ h := Density(c).ControlHeight()
+ label, icon := msg(c, "send_button.send"), "arrow-up"
+ if opts.Streaming {
+ label, icon = msg(c, "send_button.stop"), "square"
+ }
+ tip := label
+ if opts.Shortcut != "" && !opts.Streaming {
+ tip += " (" + opts.Shortcut + ")"
+ }
+ var r SendResult
+ var b *ui.Element
+ r.Element = ui.Row(c).Shrink(0).Disabled(opts.Disabled && !opts.Streaming)
+ r.Element.Children(func() {
+ b = ui.ButtonBase(c).Size(h, h).Radius(h / 2).Label(label).Tooltip(tip).Transition(colorFade)
+ internal.Ring(b, k.Focus, h/2)
+ bg, fg := k.Accent, k.OnAccent
+ switch {
+ case opts.Streaming:
+ bg, fg = k.Surface, k.Text
+ b.Border(theme.BorderWidth, k.ControlBorder)
+ if b.Hovered() {
+ bg = k.SurfaceHover
+ }
+ case b.Pressed():
+ bg = k.AccentPressed
+ case b.Hovered():
+ bg = k.AccentHover
+ }
+ b.Background(bg).TextColor(fg)
+ b.Children(func() { ui.Icon(c, icons.Must(icon)).Size(16, 16) })
+ })
+ if b.Clicked() && !b.IsDisabled() {
+ r.send, r.stop = !opts.Streaming, opts.Streaming
+ slog.Debug("mujica: send_button press", "stop", r.stop)
+ }
+ return r
+}
diff --git a/send_button_test.go b/send_button_test.go
new file mode 100644
index 0000000..0d8efe4
--- /dev/null
+++ b/send_button_test.go
@@ -0,0 +1,46 @@
+package mujica
+
+import (
+ "testing"
+
+ "github.com/egoist/mygo/ui"
+)
+
+func TestSendButtonSendStopDisabled(t *testing.T) {
+ opts := SendButtonOptions{Shortcut: "Enter"}
+ sends, stops := 0, 0
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ r := SendButton(c, opts)
+ if r.Sent() {
+ sends++
+ }
+ if r.Stopped() {
+ stops++
+ }
+ }, 200, 100)
+ _ = tt.Click("Send")
+ opts.Streaming = true
+ tt.Frame()
+ _ = tt.Click("Stop generating")
+ opts = SendButtonOptions{Disabled: true}
+ tt.Frame()
+ _ = tt.Click("Send")
+ opts.Streaming = true
+ tt.Frame()
+ tt.Key(0, ui.KeyTab)
+ tt.Key(0, ui.KeyEnter)
+ if sends != 1 || stops != 2 {
+ t.Fatalf("sends %d stops %d (disabled blocks send, not stop)", sends, stops)
+ }
+}
+
+func TestSendButtonSnapshot(t *testing.T) {
+ snapshot(t, "send_button", 200, 70, func(c *ui.Context) {
+ ui.Row(c).Padding(12).Gap(12).Children(func() {
+ SendButton(c, SendButtonOptions{})
+ SendButton(c, SendButtonOptions{Streaming: true})
+ SendButton(c, SendButtonOptions{Disabled: true})
+ })
+ })
+}
diff --git a/slash_command_menu.go b/slash_command_menu.go
new file mode 100644
index 0000000..6b3c190
--- /dev/null
+++ b/slash_command_menu.go
@@ -0,0 +1,232 @@
+package mujica
+
+import (
+ "log/slog"
+ "strings"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/icons"
+ "mujicaui/theme"
+)
+
+// SlashCommand is one command of a SlashCommandMenu.
+type SlashCommand struct {
+ // Name is typed after the slash; required, without spaces.
+ Name string
+ Description string
+}
+
+// SlashCommandResult is the menu's popup panel, nil while closed, and the command chosen this frame.
+type SlashCommandResult struct {
+ Element *ui.Element
+ chosen SlashCommand
+ ok bool
+}
+
+// Chosen returns the command picked by Enter or a click.
+func (r SlashCommandResult) Chosen() (SlashCommand, bool) { return r.chosen, r.ok }
+
+// SlashCommandMenu pops up commands while the composer's text is "/" plus a filter.
+// Up and Down move, Enter or a click chooses, Tab completes, Escape closes; typing stays in the composer.
+func SlashCommandMenu(c *ui.Context, composer InputResult, value *string, commands []SlashCommand) SlashCommandResult {
+ for _, cmd := range commands {
+ if cmd.Name == "" || strings.ContainsAny(cmd.Name, " \t\n") {
+ panic("mujica: SlashCommand Name must be one word")
+ }
+ }
+ var r SlashCommandResult
+ st := ui.Local(composer.Element, slashMenuKey{"slash"}, func() slashMenuState { return slashMenuState{} })
+ query, typing := strings.CutPrefix(*value, "/")
+ typing = typing && !strings.ContainsAny(query, " \t\n")
+ var shown []SlashCommand
+ var rows []slashMenuRow
+ if typing {
+ q := strings.ToLower(query)
+ for pass := range 2 {
+ for _, cmd := range commands {
+ n := strings.ToLower(cmd.Name)
+ if pass == 0 && strings.HasPrefix(n, q) || pass == 1 && !strings.HasPrefix(n, q) && strings.Contains(n, q) {
+ shown = append(shown, cmd)
+ rows = append(rows, slashMenuRow{label: "/" + cmd.Name, detail: cmd.Description})
+ }
+ }
+ }
+ }
+ i, how := slashMenuShow(c, composer, st, query, typing, rows, slashMenuOptions{empty: msg(c, "slash_command_menu.empty"), label: msg(c, "slash_command_menu.label")})
+ r.Element = st.panel
+ if i >= 0 {
+ *value = "/" + shown[i].Name + " "
+ if how == slashMenuChose {
+ r.chosen, r.ok = shown[i], true
+ }
+ slog.Debug("mujica: slash_command_menu pick", "name", shown[i].Name, "chosen", r.ok)
+ }
+ return r
+}
+
+type slashMenuKey struct{ name string }
+
+// slashMenuRow is one line of a composer menu; a group starts a titled section.
+type slashMenuRow struct {
+ label, detail, icon, group string
+}
+
+type slashMenuOptions struct {
+ empty, label string
+ loading bool
+}
+
+// slashMenuState is a composer menu's highlight, Escape and pending key choice.
+type slashMenuState struct {
+ hi, lastHi int
+ query string
+ dismissed *string
+ pick slashMenuHow
+ panel *ui.Element
+ held, open bool
+}
+
+type slashMenuHow int
+
+const (
+ slashMenuNone slashMenuHow = iota
+ slashMenuChose
+ slashMenuCompleted
+)
+
+// slashMenuShow runs a composer menu while open and returns the row picked this frame and how.
+func slashMenuShow(c *ui.Context, composer InputResult, st *slashMenuState, query string, open bool, rows []slashMenuRow, o slashMenuOptions) (int, slashMenuHow) {
+ st.panel = nil
+ if query != st.query {
+ st.hi, st.query = 0, query
+ if st.dismissed != nil && *st.dismissed != query {
+ st.dismissed = nil
+ }
+ }
+ // A press on the panel takes the focus from the composer; the press keeps it open.
+ open = open && st.dismissed == nil && !composer.Element.IsDisabled() && (composer.Input.Focused() || st.held)
+ pick, how := -1, slashMenuNone
+ st.open = open
+ if !open {
+ st.pick = slashMenuNone
+ return pick, how
+ }
+ st.hi = min(st.hi, len(rows)-1)
+ if st.pick != slashMenuNone && st.hi >= 0 {
+ pick, how = st.hi, st.pick
+ }
+ st.pick = slashMenuNone
+ n := len(rows)
+ promptComposerHook(composer, func(ev ui.InputEvent) bool {
+ if ev.Kind != ui.InputKeyDown || ev.Mods != 0 {
+ return false
+ }
+ switch ev.Key {
+ case ui.KeyDown, ui.KeyUp:
+ if n > 0 {
+ d := map[ui.Key]int{ui.KeyDown: 1, ui.KeyUp: -1}[ev.Key]
+ st.hi = (max(st.hi, 0) + d + n) % n
+ }
+ case ui.KeyEnter:
+ st.pick = slashMenuChose
+ case ui.KeyTab:
+ st.pick = slashMenuCompleted
+ case ui.KeyEscape:
+ q := query
+ st.dismissed = &q
+ default:
+ return false
+ }
+ c.Invalidate()
+ return true
+ })
+ if i := slashMenuPanel(c, composer.Element.Bounds(), st, rows, o); i >= 0 {
+ pick, how = i, slashMenuChose
+ }
+ if pick >= 0 {
+ composer.Input.Focus()
+ }
+ return pick, how
+}
+
+// slashMenuPanel draws the rows over or under the composer a and returns a clicked row.
+func slashMenuPanel(c *ui.Context, a ui.Rect, st *slashMenuState, rows []slashMenuRow, o slashMenuOptions) int {
+ k := Tokens(c)
+ u := Density(c).Unit()
+ rowH := Density(c).ControlHeight()
+ headH := rowH * 0.75
+ h := 2 * u
+ group := ""
+ for _, r := range rows {
+ if r.group != group {
+ h, group = h+headH, r.group
+ }
+ h += rowH
+ }
+ if len(rows) == 0 {
+ h += rowH
+ }
+ h = min(h, 280) + 2*theme.BorderWidth
+ w := min(max(a.W*0.6, 320), max(a.W, 200))
+ x, y := overlayPlace(c, a, w, h, u)
+ clicked, pressed := -1, false
+ ui.Overlay(c, func() {
+ panel := ui.Column(c).Absolute().Left(x).Top(y).Width(w).Height(h).Role(ui.RoleList).Label(o.label)
+ overlayStyle(c, panel, overlayAnchored(c, panel, a))
+ st.panel = panel
+ panel.HandleInput(func(ev ui.InputEvent) bool {
+ if ev.Kind == ui.InputPointerDown {
+ st.held = true
+ }
+ return false
+ })
+ panel.Children(func() {
+ ui.Scroll(c).Fill().Padding(u).Children(func() {
+ if len(rows) == 0 {
+ ui.Row(c).MinHeight(rowH).Padding(0, u*3).Gap(u*2).AlignItems(ui.Center).TextColor(k.TextMuted).Children(func() {
+ if o.loading {
+ Spinner(c, SpinnerOptions{Size: 14})
+ }
+ ui.Text(c, o.empty).SingleLine()
+ })
+ }
+ group := ""
+ for i, r := range rows {
+ if r.group != group {
+ group = r.group
+ ui.Text(c, group).Height(headH).Padding(u*2, u*3, 0, u*3).FontSize(FontSize(c, theme.CaptionSize)).
+ FontWeight(600).TextColor(k.TextMuted).Role(ui.RoleText)
+ }
+ row := ui.Row(c).Key(i).Height(rowH).Padding(0, u*3, 0, u*2).Gap(u*2).AlignItems(ui.Center).
+ Radius(theme.ControlRadius).Role(ui.RoleListItem).Label(r.label).Cursor(ui.CursorPointer)
+ if row.Clicked() {
+ clicked = i
+ }
+ pressed = pressed || row.Pressed()
+ hi := i == st.hi
+ if hi {
+ row.Background(k.Selection).Draw(func(p *ui.Painter, b ui.Rect) {
+ p.Fill(ui.Rect{X: b.X, Y: b.Y + 4, W: 2, H: b.H - 8}, k.AccentText, 1)
+ })
+ if st.lastHi != i {
+ st.lastHi = i
+ row.ScrollIntoView()
+ }
+ } else if row.Hovered() {
+ row.Background(k.SurfaceHover)
+ }
+ row.Children(func() {
+ if r.icon != "" {
+ ui.Icon(c, icons.Must(r.icon)).Size(16, 16).Shrink(0).TextColor(k.TextMuted)
+ }
+ ui.Text(c, r.label).SingleLine().Shrink(0).FontWeight(600)
+ ui.Text(c, r.detail).SingleLine().MinWidth(0).Shrink(1).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize))
+ })
+ }
+ })
+ })
+ })
+ st.held = st.held && pressed
+ return clicked
+}
diff --git a/slash_command_menu_test.go b/slash_command_menu_test.go
new file mode 100644
index 0000000..e4bcf89
--- /dev/null
+++ b/slash_command_menu_test.go
@@ -0,0 +1,122 @@
+package mujica
+
+import (
+ "testing"
+
+ "github.com/egoist/mygo/ui"
+)
+
+var slashCommandsSample = []SlashCommand{
+ {Name: "summarize", Description: "Summarize the thread"},
+ {Name: "search", Description: "Search the web"},
+ {Name: "translate", Description: "Translate to French"},
+}
+
+// slashTester is a composer with a slash menu; it records chosen commands and sends.
+type slashTester struct {
+ text string
+ chosen []string
+ sends int
+ off bool
+}
+
+func (s *slashTester) view(c *ui.Context) {
+ Use(c, Settings{})
+ ui.Column(c).Fill().Justify(ui.End).Padding(16).Children(func() {
+ p := PromptComposer(c, &s.text, PromptComposerOptions{Label: "Prompt", Disabled: s.off})
+ if p.Submitted() {
+ s.sends++
+ }
+ if cmd, ok := SlashCommandMenu(c, p, &s.text, slashCommandsSample).Chosen(); ok {
+ s.chosen = append(s.chosen, cmd.Name)
+ }
+ })
+}
+
+func TestSlashCommandMenuFilterAndKeys(t *testing.T) {
+ s := &slashTester{}
+ tt := ui.NewTester(s.view, 500, 400)
+ _ = tt.Click("Prompt")
+ tt.Type("/s")
+ sr, _ := tt.Find("Search the web")
+ tr, ok := tt.Find("Translate to French")
+ if !ok || !tt.HasText("/summarize") || tr.Y <= sr.Y {
+ t.Fatalf("filter /s does not list prefix matches first: %q", tt.Texts())
+ }
+ tt.Type("e")
+ if tt.HasText("/summarize") || !tt.HasText("/search") {
+ t.Fatalf("filter /se: %q", tt.Texts())
+ }
+ tt.Key(0, ui.KeyBackspace)
+ tt.Key(0, ui.KeyDown)
+ tt.Key(0, ui.KeyEnter)
+ if s.text != "/search " || len(s.chosen) != 1 || s.chosen[0] != "search" || s.sends != 0 {
+ t.Fatalf("Enter: text %q chosen %v sends %d", s.text, s.chosen, s.sends)
+ }
+ if tt.HasText("/summarize") {
+ t.Fatal("menu stays open after choosing")
+ }
+}
+
+func TestSlashCommandMenuTabCompletesAndEscapeCloses(t *testing.T) {
+ s := &slashTester{}
+ tt := ui.NewTester(s.view, 500, 400)
+ _ = tt.Click("Prompt")
+ tt.Type("/tr")
+ tt.Key(0, ui.KeyTab)
+ if s.text != "/translate " || len(s.chosen) != 0 {
+ t.Fatalf("Tab: text %q chosen %v", s.text, s.chosen)
+ }
+ s.text = ""
+ tt.Frame()
+ tt.Type("/")
+ if !tt.HasText("/summarize") {
+ t.Fatal("menu did not open on /")
+ }
+ tt.Key(0, ui.KeyEscape)
+ if tt.HasText("/summarize") {
+ t.Fatal("Escape did not close the menu")
+ }
+ tt.Type("t")
+ if !tt.HasText("/translate") {
+ t.Fatal("menu did not reopen as the filter changed")
+ }
+}
+
+func TestSlashCommandMenuClickAndEmpty(t *testing.T) {
+ s := &slashTester{}
+ tt := ui.NewTester(s.view, 500, 400)
+ _ = tt.Click("Prompt")
+ tt.Type("/zz")
+ if !tt.HasText("No matching commands") {
+ t.Fatalf("empty state: %q", tt.Texts())
+ }
+ tt.Key(0, ui.KeyBackspace)
+ tt.Key(0, ui.KeyBackspace)
+ if err := tt.Click("/translate"); err != nil {
+ t.Fatal(err)
+ }
+ if len(s.chosen) != 1 || s.chosen[0] != "translate" || !tt.Focused("Prompt") {
+ t.Fatalf("click chose %v, composer focused %v", s.chosen, tt.Focused("Prompt"))
+ }
+}
+
+func TestSlashCommandMenuDisabledComposer(t *testing.T) {
+ s := &slashTester{text: "/s", off: true}
+ tt := ui.NewTester(s.view, 500, 400)
+ _ = tt.Click("Prompt")
+ tt.Key(0, ui.KeyEnter)
+ if tt.HasText("/search") || len(s.chosen) != 0 {
+ t.Fatal("disabled composer shows or uses the menu")
+ }
+}
+
+func TestSlashCommandMenuSnapshot(t *testing.T) {
+ snapshotAfter(t, "slash_command_menu", 520, 360, func(c *ui.Context) {
+ (*ui.Local(c.Root(), "s", func() *slashTester { return &slashTester{} })).view(c)
+ }, func(tt *ui.Tester) {
+ _ = tt.Click("Prompt")
+ tt.Type("/")
+ tt.Key(0, ui.KeyDown)
+ })
+}
diff --git a/source_card.go b/source_card.go
new file mode 100644
index 0000000..d8b2e4f
--- /dev/null
+++ b/source_card.go
@@ -0,0 +1,37 @@
+package mujica
+
+import (
+ "fmt"
+ "log/slog"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/internal"
+ "mujicaui/theme"
+)
+
+// SourceCard shows source n's title, site, time and snippet; Clicked opens it.
+// It lights up with Selection and an accent edge while st holds n Active or hovered.
+func SourceCard(c *ui.Context, n int, src Source, st *CitationState) *ui.Element {
+ if n < 1 || st == nil || src.Title == "" || src.URL == "" {
+ panic("mujica: SourceCard needs n >= 1, a state and a Source with Title and URL")
+ }
+ k := Tokens(c)
+ u := Density(c).Unit()
+ b := ui.ButtonBase(c).Padding(u*2, u*3).AlignItems(ui.Start).Justify(ui.Start).Radius(theme.CardRadius).
+ Label(fmt.Sprintf(msg(c, "source_card.open"), src.Title)).Tooltip(src.URL).Transition(colorFade)
+ internal.Ring(b, k.Focus, theme.CardRadius)
+ st.track(c, b, n)
+ switch {
+ case st.lit(n):
+ b.Background(k.Selection).Border(theme.BorderWidth, k.Border).BorderWidth(theme.BorderWidth, theme.BorderWidth, theme.BorderWidth, 3).
+ BorderColor(k.AccentText)
+ default:
+ b.Background(k.Surface).Border(theme.BorderWidth, k.Border)
+ }
+ b.Children(func() { sourceSummary(c, n, src) })
+ if b.Clicked() && !b.IsDisabled() {
+ slog.Debug("mujica: source_card open", "n", n)
+ }
+ return b
+}
diff --git a/source_card_test.go b/source_card_test.go
new file mode 100644
index 0000000..4d6a804
--- /dev/null
+++ b/source_card_test.go
@@ -0,0 +1,66 @@
+package mujica
+
+import (
+ "testing"
+
+ "github.com/egoist/mygo/ui"
+)
+
+func TestSourceCardOpensAndLights(t *testing.T) {
+ var st CitationState
+ opened, off := 0, false
+ var bg []byte
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ ui.Column(c).Padding(16).Width(360).Disabled(off).Children(func() {
+ if SourceCard(c, 1, sourcesSample[0], &st).Clicked() {
+ opened++
+ }
+ })
+ }, 400, 200)
+ for _, s := range []string{"[1]", "example.com", "2025-05-04", "The Palace of Versailles", "Seat of the court from 1682."} {
+ if !tt.HasText(s) {
+ t.Errorf("missing %q in %q", s, tt.Texts())
+ }
+ }
+ bg = encode(t, tt.Image())
+ _ = tt.Click("Open The Palace of Versailles")
+ tt.Key(0, ui.KeyEnter)
+ if opened != 2 {
+ t.Fatalf("opened %d", opened)
+ }
+ st.Active = 1
+ tt.Move(390, 190)
+ tt.Frame()
+ if string(encode(t, tt.Image())) == string(bg) {
+ t.Fatal("active card does not light up")
+ }
+ off = true
+ _ = tt.Click("Open The Palace of Versailles")
+ if opened != 2 {
+ t.Fatal("disabled card opened")
+ }
+}
+
+func TestSourceCardNeedsURL(t *testing.T) {
+ defer func() {
+ if recover() == nil {
+ t.Fatal("no panic without a URL")
+ }
+ }()
+ var st CitationState
+ ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ SourceCard(c, 1, Source{Title: "x"}, &st)
+ }, 100, 100)
+}
+
+func TestSourceCardSnapshot(t *testing.T) {
+ snapshot(t, "source_card", 400, 260, func(c *ui.Context) {
+ st := ui.Local(c.Root(), "st", func() CitationState { return CitationState{Active: 2} })
+ ui.Column(c).Padding(16).Gap(8).Children(func() {
+ SourceCard(c, 1, sourcesSample[0], st)
+ SourceCard(c, 2, sourcesSample[1], st)
+ })
+ })
+}
diff --git a/sources_panel.go b/sources_panel.go
new file mode 100644
index 0000000..8a7d0b4
--- /dev/null
+++ b/sources_panel.go
@@ -0,0 +1,54 @@
+package mujica
+
+import (
+ "fmt"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/theme"
+)
+
+// OpenResult is a list's element and the 1-based item the user opened this frame.
+type OpenResult struct {
+ Element *ui.Element
+ opened int
+}
+
+// Opened returns the number of the source opened.
+func (r OpenResult) Opened() (int, bool) { return r.opened, r.opened > 0 }
+
+// SourcesPanel lists sources in citation order, numbered from 1; the Active one scrolls into view.
+// It grows to fill its container, which needs a height.
+func SourcesPanel(c *ui.Context, sources []Source, st *CitationState) OpenResult {
+ if st == nil || st.Active < 0 || st.Active > len(sources) {
+ panic("mujica: SourcesPanel needs a state whose Active is 0 or a listed source")
+ }
+ k := Tokens(c)
+ u := Density(c).Unit()
+ var r OpenResult
+ r.Element = ui.Column(c).Gap(u * 2).Grow(1).MinHeight(0).Role(ui.RoleList).Label(msg(c, "sources_panel.label"))
+ shown := ui.Local(r.Element, "active", func() int { return 0 })
+ r.Element.Children(func() {
+ ui.Text(c, fmt.Sprintf("%s (%d)", msg(c, "sources_panel.label"), len(sources))).Font(theme.SerifFont).
+ FontSize(FontSize(c, theme.H3Size)).FontWeight(600)
+ if len(sources) == 0 {
+ ui.Text(c, msg(c, "sources_panel.empty")).TextColor(k.TextMuted)
+ return
+ }
+ ui.Scroll(c).Grow(1).MinHeight(0).Children(func() {
+ ui.Column(c).Gap(u * 2).Children(func() {
+ for i, src := range sources {
+ card := SourceCard(c, i+1, src, st).Role(ui.RoleListItem)
+ if card.Clicked() && !card.IsDisabled() {
+ r.opened = i + 1
+ }
+ if st.Active == i+1 && *shown != st.Active {
+ card.ScrollIntoView()
+ }
+ }
+ })
+ })
+ })
+ *shown = st.Active
+ return r
+}
diff --git a/sources_panel_test.go b/sources_panel_test.go
new file mode 100644
index 0000000..d2ddd12
--- /dev/null
+++ b/sources_panel_test.go
@@ -0,0 +1,66 @@
+package mujica
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/egoist/mygo/ui"
+)
+
+func TestSourcesPanelOrderOpenAndScroll(t *testing.T) {
+ var many []Source
+ for i := range 20 {
+ many = append(many, Source{Title: fmt.Sprintf("Source title %d", i+1), URL: fmt.Sprintf("https://example.com/%d", i+1)})
+ }
+ var st CitationState
+ opened := 0
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ ui.Column(c).Width(360).Height(300).Children(func() {
+ if n, ok := SourcesPanel(c, many, &st).Opened(); ok {
+ opened = n
+ }
+ })
+ }, 400, 300)
+ first, _ := tt.Find("Source title 1")
+ second, _ := tt.Find("Source title 2")
+ if !tt.HasText("Sources (20)") || second.Y <= first.Y {
+ t.Fatalf("order or header: %q", tt.Texts())
+ }
+ _ = tt.Click("Open Source title 2")
+ if opened != 2 {
+ t.Fatalf("opened %d", opened)
+ }
+ st.Active = 18
+ tt.Frame()
+ tt.Frame()
+ r, ok := tt.Find("Source title 18")
+ if !ok || r.Y < 0 || r.Y+r.H > 300 {
+ t.Fatalf("active source not scrolled into view: %v %v", r, ok)
+ }
+}
+
+func TestSourcesPanelEmptyAndBadActive(t *testing.T) {
+ var st CitationState
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ SourcesPanel(c, nil, &st)
+ }, 300, 200)
+ if !tt.HasText("No sources") {
+ t.Fatalf("texts %q", tt.Texts())
+ }
+ defer func() {
+ if recover() == nil {
+ t.Fatal("no panic for an Active past the list")
+ }
+ }()
+ st.Active = 4
+ tt.Frame()
+}
+
+func TestSourcesPanelSnapshot(t *testing.T) {
+ snapshot(t, "sources_panel", 380, 420, func(c *ui.Context) {
+ st := ui.Local(c.Root(), "st", func() CitationState { return CitationState{Active: 2} })
+ ui.Column(c).Padding(16).Height(420).Children(func() { SourcesPanel(c, sourcesSample, st) })
+ })
+}
diff --git a/testdata/snapshots/darwin/attachment_chip-dark.png b/testdata/snapshots/darwin/attachment_chip-dark.png
new file mode 100644
index 0000000..93f2af5
Binary files /dev/null and b/testdata/snapshots/darwin/attachment_chip-dark.png differ
diff --git a/testdata/snapshots/darwin/attachment_chip-light.png b/testdata/snapshots/darwin/attachment_chip-light.png
new file mode 100644
index 0000000..c8422e4
Binary files /dev/null and b/testdata/snapshots/darwin/attachment_chip-light.png differ
diff --git a/testdata/snapshots/darwin/citation_badge-dark.png b/testdata/snapshots/darwin/citation_badge-dark.png
new file mode 100644
index 0000000..6f4ef22
Binary files /dev/null and b/testdata/snapshots/darwin/citation_badge-dark.png differ
diff --git a/testdata/snapshots/darwin/citation_badge-light.png b/testdata/snapshots/darwin/citation_badge-light.png
new file mode 100644
index 0000000..ad7f083
Binary files /dev/null and b/testdata/snapshots/darwin/citation_badge-light.png differ
diff --git a/testdata/snapshots/darwin/context_chips-dark.png b/testdata/snapshots/darwin/context_chips-dark.png
new file mode 100644
index 0000000..e8df2ee
Binary files /dev/null and b/testdata/snapshots/darwin/context_chips-dark.png differ
diff --git a/testdata/snapshots/darwin/context_chips-light.png b/testdata/snapshots/darwin/context_chips-light.png
new file mode 100644
index 0000000..b2e86f3
Binary files /dev/null and b/testdata/snapshots/darwin/context_chips-light.png differ
diff --git a/testdata/snapshots/darwin/context_mention_menu-dark.png b/testdata/snapshots/darwin/context_mention_menu-dark.png
new file mode 100644
index 0000000..d231ea5
Binary files /dev/null and b/testdata/snapshots/darwin/context_mention_menu-dark.png differ
diff --git a/testdata/snapshots/darwin/context_mention_menu-light.png b/testdata/snapshots/darwin/context_mention_menu-light.png
new file mode 100644
index 0000000..9dacf40
Binary files /dev/null and b/testdata/snapshots/darwin/context_mention_menu-light.png differ
diff --git a/testdata/snapshots/darwin/context_window_meter-dark.png b/testdata/snapshots/darwin/context_window_meter-dark.png
new file mode 100644
index 0000000..c6d0217
Binary files /dev/null and b/testdata/snapshots/darwin/context_window_meter-dark.png differ
diff --git a/testdata/snapshots/darwin/context_window_meter-light.png b/testdata/snapshots/darwin/context_window_meter-light.png
new file mode 100644
index 0000000..579e6d7
Binary files /dev/null and b/testdata/snapshots/darwin/context_window_meter-light.png differ
diff --git a/testdata/snapshots/darwin/drag_drop_overlay-dark.png b/testdata/snapshots/darwin/drag_drop_overlay-dark.png
new file mode 100644
index 0000000..a8de49e
Binary files /dev/null and b/testdata/snapshots/darwin/drag_drop_overlay-dark.png differ
diff --git a/testdata/snapshots/darwin/drag_drop_overlay-light.png b/testdata/snapshots/darwin/drag_drop_overlay-light.png
new file mode 100644
index 0000000..96c3711
Binary files /dev/null and b/testdata/snapshots/darwin/drag_drop_overlay-light.png differ
diff --git a/testdata/snapshots/darwin/mode_selector-dark.png b/testdata/snapshots/darwin/mode_selector-dark.png
new file mode 100644
index 0000000..6f20d1c
Binary files /dev/null and b/testdata/snapshots/darwin/mode_selector-dark.png differ
diff --git a/testdata/snapshots/darwin/mode_selector-light.png b/testdata/snapshots/darwin/mode_selector-light.png
new file mode 100644
index 0000000..5af3fb0
Binary files /dev/null and b/testdata/snapshots/darwin/mode_selector-light.png differ
diff --git a/testdata/snapshots/darwin/model_selector-dark.png b/testdata/snapshots/darwin/model_selector-dark.png
new file mode 100644
index 0000000..45239ea
Binary files /dev/null and b/testdata/snapshots/darwin/model_selector-dark.png differ
diff --git a/testdata/snapshots/darwin/model_selector-light.png b/testdata/snapshots/darwin/model_selector-light.png
new file mode 100644
index 0000000..896bbc0
Binary files /dev/null and b/testdata/snapshots/darwin/model_selector-light.png differ
diff --git a/testdata/snapshots/darwin/parameter_panel-dark.png b/testdata/snapshots/darwin/parameter_panel-dark.png
new file mode 100644
index 0000000..2562ac0
Binary files /dev/null and b/testdata/snapshots/darwin/parameter_panel-dark.png differ
diff --git a/testdata/snapshots/darwin/parameter_panel-light.png b/testdata/snapshots/darwin/parameter_panel-light.png
new file mode 100644
index 0000000..0c4913f
Binary files /dev/null and b/testdata/snapshots/darwin/parameter_panel-light.png differ
diff --git a/testdata/snapshots/darwin/paste_image_preview-dark.png b/testdata/snapshots/darwin/paste_image_preview-dark.png
new file mode 100644
index 0000000..09071e8
Binary files /dev/null and b/testdata/snapshots/darwin/paste_image_preview-dark.png differ
diff --git a/testdata/snapshots/darwin/paste_image_preview-light.png b/testdata/snapshots/darwin/paste_image_preview-light.png
new file mode 100644
index 0000000..ff1e9dd
Binary files /dev/null and b/testdata/snapshots/darwin/paste_image_preview-light.png differ
diff --git a/testdata/snapshots/darwin/prompt_composer-dark.png b/testdata/snapshots/darwin/prompt_composer-dark.png
new file mode 100644
index 0000000..61d4127
Binary files /dev/null and b/testdata/snapshots/darwin/prompt_composer-dark.png differ
diff --git a/testdata/snapshots/darwin/prompt_composer-light.png b/testdata/snapshots/darwin/prompt_composer-light.png
new file mode 100644
index 0000000..50313d7
Binary files /dev/null and b/testdata/snapshots/darwin/prompt_composer-light.png differ
diff --git a/testdata/snapshots/darwin/search_progress-dark.png b/testdata/snapshots/darwin/search_progress-dark.png
new file mode 100644
index 0000000..e8c0516
Binary files /dev/null and b/testdata/snapshots/darwin/search_progress-dark.png differ
diff --git a/testdata/snapshots/darwin/search_progress-light.png b/testdata/snapshots/darwin/search_progress-light.png
new file mode 100644
index 0000000..00b1d0c
Binary files /dev/null and b/testdata/snapshots/darwin/search_progress-light.png differ
diff --git a/testdata/snapshots/darwin/send_button-dark.png b/testdata/snapshots/darwin/send_button-dark.png
new file mode 100644
index 0000000..126b20a
Binary files /dev/null and b/testdata/snapshots/darwin/send_button-dark.png differ
diff --git a/testdata/snapshots/darwin/send_button-light.png b/testdata/snapshots/darwin/send_button-light.png
new file mode 100644
index 0000000..213ae4c
Binary files /dev/null and b/testdata/snapshots/darwin/send_button-light.png differ
diff --git a/testdata/snapshots/darwin/slash_command_menu-dark.png b/testdata/snapshots/darwin/slash_command_menu-dark.png
new file mode 100644
index 0000000..7d4ab32
Binary files /dev/null and b/testdata/snapshots/darwin/slash_command_menu-dark.png differ
diff --git a/testdata/snapshots/darwin/slash_command_menu-light.png b/testdata/snapshots/darwin/slash_command_menu-light.png
new file mode 100644
index 0000000..af12dde
Binary files /dev/null and b/testdata/snapshots/darwin/slash_command_menu-light.png differ
diff --git a/testdata/snapshots/darwin/source_card-dark.png b/testdata/snapshots/darwin/source_card-dark.png
new file mode 100644
index 0000000..3d90e99
Binary files /dev/null and b/testdata/snapshots/darwin/source_card-dark.png differ
diff --git a/testdata/snapshots/darwin/source_card-light.png b/testdata/snapshots/darwin/source_card-light.png
new file mode 100644
index 0000000..a397f52
Binary files /dev/null and b/testdata/snapshots/darwin/source_card-light.png differ
diff --git a/testdata/snapshots/darwin/sources_panel-dark.png b/testdata/snapshots/darwin/sources_panel-dark.png
new file mode 100644
index 0000000..29a75d2
Binary files /dev/null and b/testdata/snapshots/darwin/sources_panel-dark.png differ
diff --git a/testdata/snapshots/darwin/sources_panel-light.png b/testdata/snapshots/darwin/sources_panel-light.png
new file mode 100644
index 0000000..ac9753c
Binary files /dev/null and b/testdata/snapshots/darwin/sources_panel-light.png differ
diff --git a/testdata/snapshots/darwin/token_counter-dark.png b/testdata/snapshots/darwin/token_counter-dark.png
new file mode 100644
index 0000000..58690c0
Binary files /dev/null and b/testdata/snapshots/darwin/token_counter-dark.png differ
diff --git a/testdata/snapshots/darwin/token_counter-light.png b/testdata/snapshots/darwin/token_counter-light.png
new file mode 100644
index 0000000..79f791f
Binary files /dev/null and b/testdata/snapshots/darwin/token_counter-light.png differ
diff --git a/testdata/snapshots/darwin/tool_toggle_menu-dark.png b/testdata/snapshots/darwin/tool_toggle_menu-dark.png
new file mode 100644
index 0000000..8c301a1
Binary files /dev/null and b/testdata/snapshots/darwin/tool_toggle_menu-dark.png differ
diff --git a/testdata/snapshots/darwin/tool_toggle_menu-light.png b/testdata/snapshots/darwin/tool_toggle_menu-light.png
new file mode 100644
index 0000000..930e105
Binary files /dev/null and b/testdata/snapshots/darwin/tool_toggle_menu-light.png differ
diff --git a/testdata/snapshots/darwin/voice_input_button-dark.png b/testdata/snapshots/darwin/voice_input_button-dark.png
new file mode 100644
index 0000000..7b8bcc5
Binary files /dev/null and b/testdata/snapshots/darwin/voice_input_button-dark.png differ
diff --git a/testdata/snapshots/darwin/voice_input_button-light.png b/testdata/snapshots/darwin/voice_input_button-light.png
new file mode 100644
index 0000000..b77288f
Binary files /dev/null and b/testdata/snapshots/darwin/voice_input_button-light.png differ
diff --git a/testdata/snapshots/darwin/voice_waveform-dark.png b/testdata/snapshots/darwin/voice_waveform-dark.png
new file mode 100644
index 0000000..01742ab
Binary files /dev/null and b/testdata/snapshots/darwin/voice_waveform-dark.png differ
diff --git a/testdata/snapshots/darwin/voice_waveform-light.png b/testdata/snapshots/darwin/voice_waveform-light.png
new file mode 100644
index 0000000..b72974b
Binary files /dev/null and b/testdata/snapshots/darwin/voice_waveform-light.png differ
diff --git a/text_area.go b/text_area.go
index b9e82b8..c5f0fff 100644
--- a/text_area.go
+++ b/text_area.go
@@ -27,6 +27,8 @@ type TextAreaOptions struct {
ReadOnly bool
// Error marks the value invalid and shows the message below.
Error string
+ // keys sees keys before the editor, for PromptComposer's menus.
+ keys func(ui.InputEvent) bool
}
// TextArea is a multi-line field editing *value.
@@ -67,6 +69,9 @@ func TextArea(c *ui.Context, value *string, opts TextAreaOptions) InputResult {
// Taken before the editor, which would make a plain Enter a newline.
submit := ui.Local(frame, "submit", func() bool { return false })
in.HandleInput(func(ev ui.InputEvent) bool {
+ if opts.keys != nil && opts.keys(ev) {
+ return true
+ }
if ev.Kind == ui.InputKeyDown && ev.Key == key && ev.Mods == mods {
*submit = true
return true
diff --git a/token_counter.go b/token_counter.go
new file mode 100644
index 0000000..d36043b
--- /dev/null
+++ b/token_counter.go
@@ -0,0 +1,49 @@
+package mujica
+
+import (
+ "fmt"
+ "strconv"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/icons"
+ "mujicaui/theme"
+)
+
+// tokenWarnShare is the share of the limit from which the count warns.
+const tokenWarnShare = 0.9
+
+// TokenCounter shows the caller's token estimate against the limit: muted, warning past 90%, danger over it.
+func TokenCounter(c *ui.Context, used, limit int) *ui.Element {
+ if used < 0 || limit <= 0 {
+ panic("mujica: TokenCounter needs used >= 0 and limit > 0")
+ }
+ k := Tokens(c)
+ u := Density(c).Unit()
+ label := fmt.Sprintf(msg(c, "token_counter.label"), tokenThousands(used), tokenThousands(limit))
+ color, icon := k.TextMuted, ""
+ switch {
+ case used > limit:
+ color, icon = k.Danger, "circle-alert"
+ label += ", " + fmt.Sprintf(msg(c, "token_counter.over"), tokenThousands(used-limit))
+ case float64(used) >= tokenWarnShare*float64(limit):
+ color, icon = k.Warning, "triangle-alert"
+ }
+ row := ui.Row(c).Gap(u).AlignItems(ui.Center).Shrink(0).TextColor(color).Role(ui.RoleStatus).Label(label)
+ row.Children(func() {
+ if icon != "" {
+ ui.Icon(c, icons.Must(icon)).Size(12, 12)
+ }
+ ui.Text(c, "≈ "+tokenThousands(used)+" / "+tokenThousands(limit)).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").SingleLine()
+ })
+ return row
+}
+
+// tokenThousands writes n with comma thousands separators.
+func tokenThousands(n int) string {
+ s := strconv.Itoa(n)
+ for i := len(s) - 3; i > 0; i -= 3 {
+ s = s[:i] + "," + s[i:]
+ }
+ return s
+}
diff --git a/token_counter_test.go b/token_counter_test.go
new file mode 100644
index 0000000..1e62f71
--- /dev/null
+++ b/token_counter_test.go
@@ -0,0 +1,55 @@
+package mujica
+
+import (
+ "testing"
+
+ "github.com/egoist/mygo/ui"
+)
+
+func TestTokenCounterLevels(t *testing.T) {
+ for _, tc := range []struct {
+ used int
+ text string
+ label string
+ }{
+ {1234, "≈ 1,234 / 8,000", "About 1,234 of 8,000 tokens"},
+ {7500, "≈ 7,500 / 8,000", "About 7,500 of 8,000 tokens"},
+ {9100, "≈ 9,100 / 8,000", "About 9,100 of 8,000 tokens, 1,100 over the limit"},
+ } {
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ TokenCounter(c, tc.used, 8000)
+ }, 300, 60)
+ if !tt.HasText(tc.text) {
+ t.Errorf("%d: texts %q", tc.used, tt.Texts())
+ }
+ if _, ok := tt.Find(tc.label); !ok {
+ t.Errorf("%d: no %q", tc.used, tc.label)
+ }
+ }
+ if tokenThousands(1234567) != "1,234,567" || tokenThousands(12) != "12" {
+ t.Fatal("thousands separators")
+ }
+}
+
+func TestTokenCounterBadLimit(t *testing.T) {
+ defer func() {
+ if recover() == nil {
+ t.Fatal("no panic for a zero limit")
+ }
+ }()
+ ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ TokenCounter(c, 1, 0)
+ }, 100, 100)
+}
+
+func TestTokenCounterSnapshot(t *testing.T) {
+ snapshot(t, "token_counter", 240, 110, func(c *ui.Context) {
+ ui.Column(c).Padding(12).Gap(8).Children(func() {
+ TokenCounter(c, 1234, 8000)
+ TokenCounter(c, 7500, 8000)
+ TokenCounter(c, 9100, 8000)
+ })
+ })
+}
diff --git a/tool_toggle_menu.go b/tool_toggle_menu.go
new file mode 100644
index 0000000..74d9e27
--- /dev/null
+++ b/tool_toggle_menu.go
@@ -0,0 +1,122 @@
+package mujica
+
+import (
+ "fmt"
+ "log/slog"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/icons"
+ "mujicaui/internal"
+ "mujicaui/theme"
+)
+
+// ToolItem is one tool a ToolToggleMenu turns on or off.
+type ToolItem struct {
+ // ID and On are required.
+ ID string
+ Label string
+ Description string
+ On *bool
+ // NeedsAuth marks a tool the user must authorize before turning it on.
+ NeedsAuth, Authorized bool
+}
+
+// ToolToggleResult is the menu's button and what the user did this frame.
+type ToolToggleResult struct {
+ Element *ui.Element
+ changed, authorize string
+}
+
+// Changed returns the ID of the tool turned on or off.
+func (r ToolToggleResult) Changed() (string, bool) { return r.changed, r.changed != "" }
+
+// Authorize returns the ID of a tool whose authorization the user asked for.
+func (r ToolToggleResult) Authorize() (string, bool) { return r.authorize, r.authorize != "" }
+
+// ToolToggleMenu is a button opening a panel of tool switches; tools needing authorization show a mark and an authorize action.
+func ToolToggleMenu(c *ui.Context, tools []ToolItem) ToolToggleResult {
+ on := 0
+ for _, t := range tools {
+ if t.ID == "" || t.On == nil {
+ panic("mujica: ToolItem needs ID and On")
+ }
+ if *t.On {
+ on++
+ }
+ }
+ k := Tokens(c)
+ u := Density(c).Unit()
+ label := fmt.Sprintf(msg(c, "tool_toggle_menu.count"), on)
+ var r ToolToggleResult
+ b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, u*2).Gap(u).Radius(theme.ControlRadius).Label(label).TextColor(k.TextMuted)
+ r.Element = b
+ internal.Ring(b, k.Focus, theme.ControlRadius)
+ open := ui.Local(b, "open", func() bool { return false })
+ switch {
+ case *open:
+ b.Background(k.Selection).TextColor(k.Text)
+ case b.Hovered():
+ b.Background(k.SurfaceHover).TextColor(k.Text)
+ }
+ b.Children(func() {
+ ui.Icon(c, icons.Must("sliders-horizontal")).Size(16, 16)
+ if on > 0 {
+ ui.Text(c, fmt.Sprint(on)).FontFeatures("tnum").FontWeight(600).TextColor(k.AccentText).SingleLine()
+ }
+ })
+ disabled := b.IsDisabled()
+ if b.Clicked() && !disabled {
+ *open = !*open
+ }
+ if disabled {
+ *open = false
+ }
+ Popover(c, b, open, PopoverOptions{Title: msg(c, "tool_toggle_menu.label"), Width: 320}, func() {
+ for _, t := range tools {
+ toolToggleRow(c, t, &r)
+ }
+ })
+ if r.changed != "" || r.authorize != "" {
+ slog.Debug("mujica: tool_toggle_menu", "changed", r.changed, "authorize", r.authorize)
+ }
+ return r
+}
+
+// toolToggleRow shows one tool: its switch, or its authorization mark and action.
+func toolToggleRow(c *ui.Context, t ToolItem, r *ToolToggleResult) {
+ k := Tokens(c)
+ u := Density(c).Unit()
+ small := FontSize(c, theme.CaptionSize)
+ locked := t.NeedsAuth && !t.Authorized
+ ui.Row(c).Key(t.ID).Gap(u * 3).AlignItems(ui.Center).Children(func() {
+ ui.Column(c).Grow(1).MinWidth(0).Children(func() {
+ ui.Row(c).Gap(u).AlignItems(ui.Center).MinHeight(Density(c).ControlHeight()).Children(func() {
+ if locked {
+ ui.Text(c, t.Label).FontWeight(600).SingleLine()
+ } else if Switch(c, t.On, t.Label, SwitchOptions{}).Changed() {
+ r.changed = t.ID
+ }
+ if t.NeedsAuth {
+ icon := "key-round"
+ if t.Authorized {
+ icon = "shield-check"
+ }
+ ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(k.Warning).Label(msg(c, "tool_toggle_menu.needs_auth"))
+ }
+ })
+ if t.Description != "" {
+ ui.Text(c, t.Description).FontSize(small).TextColor(k.TextMuted)
+ }
+ if locked {
+ ui.Text(c, msg(c, "tool_toggle_menu.needs_auth")).FontSize(small).TextColor(k.Warning)
+ }
+ })
+ if locked {
+ full := fmt.Sprintf(msg(c, "tool_toggle_menu.authorize"), t.Label)
+ if Button(c, msg(c, "tool_toggle_menu.authorize_short"), ButtonOptions{Variant: Outline, Label: full}).Clicked() {
+ r.authorize = t.ID
+ }
+ }
+ })
+}
diff --git a/tool_toggle_menu_test.go b/tool_toggle_menu_test.go
new file mode 100644
index 0000000..74e69be
--- /dev/null
+++ b/tool_toggle_menu_test.go
@@ -0,0 +1,78 @@
+package mujica
+
+import (
+ "testing"
+
+ "github.com/egoist/mygo/ui"
+)
+
+// toolMenuApp holds tools and what the menu reported.
+type toolMenuApp struct {
+ web, code, mail bool
+ authorized bool
+ changed, auth []string
+ off bool
+}
+
+func (a *toolMenuApp) view(c *ui.Context) {
+ Use(c, Settings{})
+ ui.Column(c).Padding(16).AlignItems(ui.Start).Disabled(a.off).Children(func() {
+ r := ToolToggleMenu(c, []ToolItem{
+ {ID: "web", Label: "Web search", Description: "Search the open web", On: &a.web},
+ {ID: "code", Label: "Code execution", On: &a.code},
+ {ID: "mail", Label: "Mail", Description: "Read your inbox", On: &a.mail, NeedsAuth: true, Authorized: a.authorized},
+ })
+ if id, ok := r.Changed(); ok {
+ a.changed = append(a.changed, id)
+ }
+ if id, ok := r.Authorize(); ok {
+ a.auth = append(a.auth, id)
+ }
+ })
+}
+
+func TestToolToggleMenuSwitchesAndAuthorization(t *testing.T) {
+ a := &toolMenuApp{web: true}
+ tt := ui.NewTester(a.view, 500, 500)
+ if err := tt.Click("Tools, 1 on"); err != nil {
+ t.Fatal(err)
+ }
+ _ = tt.Click("Code execution")
+ if !a.code || len(a.changed) != 1 || a.changed[0] != "code" {
+ t.Fatalf("code %v changed %v", a.code, a.changed)
+ }
+ if !tt.HasText("Needs authorization") {
+ t.Fatalf("auth mark missing: %q", tt.Texts())
+ }
+ _ = tt.Click("Authorize Mail")
+ if a.mail || len(a.auth) != 1 {
+ t.Fatalf("mail %v auth %v", a.mail, a.auth)
+ }
+ a.authorized = true
+ tt.Frame()
+ _ = tt.Click("Mail")
+ if !a.mail {
+ t.Fatal("authorized tool did not turn on")
+ }
+ tt.Key(0, ui.KeyEscape)
+ if tt.HasText("Search the open web") {
+ t.Fatal("Escape did not close the panel")
+ }
+}
+
+func TestToolToggleMenuClosesWhenDisabled(t *testing.T) {
+ a := &toolMenuApp{}
+ tt := ui.NewTester(a.view, 500, 500)
+ _ = tt.Click("Tools, 0 on")
+ a.off = true
+ tt.Frame()
+ if tt.HasText("Search the open web") {
+ t.Fatal("panel stayed open under a disabled control")
+ }
+}
+
+func TestToolToggleMenuSnapshot(t *testing.T) {
+ snapshotAfter(t, "tool_toggle_menu", 400, 360, func(c *ui.Context) {
+ (*ui.Local(c.Root(), "a", func() *toolMenuApp { return &toolMenuApp{web: true} })).view(c)
+ }, func(tt *ui.Tester) { _ = tt.Click("Tools, 1 on") })
+}
diff --git a/voice_input_button.go b/voice_input_button.go
new file mode 100644
index 0000000..4eea543
--- /dev/null
+++ b/voice_input_button.go
@@ -0,0 +1,77 @@
+package mujica
+
+import (
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/icons"
+ "mujicaui/internal"
+ "mujicaui/theme"
+)
+
+// VoiceInputOptions describe the caller's recording.
+type VoiceInputOptions struct {
+ // Recording shows the stop state and the time since Started.
+ Recording bool
+ Started time.Time
+ // Denied shows that microphone access failed; the button then retries.
+ Denied bool
+ Disabled bool
+}
+
+// VoiceInputButton asks the caller to start or stop recording; while recording it shows the time.
+// Clicked reports a press: start when idle or denied, stop when recording.
+func VoiceInputButton(c *ui.Context, opts VoiceInputOptions) ChoiceResult {
+ if opts.Recording && (opts.Started.IsZero() || opts.Denied) {
+ panic("mujica: VoiceInputButton recording needs Started and no Denied")
+ }
+ k := Tokens(c)
+ u := Density(c).Unit()
+ var r ChoiceResult
+ r.Element = ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Shrink(0).Disabled(opts.Disabled)
+ r.Element.Children(func() {
+ elapsed := ""
+ label := msg(c, "voice_input_button.start")
+ icon := "mic"
+ if opts.Recording {
+ d := max(c.Now().Sub(opts.Started), 0)
+ elapsed = audioClock(d.Truncate(time.Second))
+ label, icon = fmt.Sprintf(msg(c, "voice_input_button.stop"), elapsed), "square"
+ c.After(time.Second - d%time.Second)
+ }
+ h := Density(c).ControlHeight()
+ b := ui.ButtonBase(c).Size(h, h).Radius(h / 2).Label(label).Tooltip(label).Transition(colorFade)
+ internal.Ring(b, k.Focus, h/2)
+ switch {
+ case opts.Recording:
+ b.Background(k.DangerFill).TextColor(k.OnDanger)
+ case b.Pressed():
+ b.Background(k.Selection).TextColor(k.Text)
+ case b.Hovered():
+ b.Background(k.SurfaceHover).TextColor(k.Text)
+ default:
+ b.TextColor(k.TextMuted)
+ }
+ b.Children(func() { ui.Icon(c, icons.Must(icon)).Size(16, 16) })
+ if b.Clicked() && !b.IsDisabled() {
+ r.changed = true
+ slog.Debug("mujica: voice_input_button press", "recording", opts.Recording)
+ }
+ switch {
+ case opts.Recording:
+ ui.Row(c).Gap(u).AlignItems(ui.Center).Children(func() {
+ ui.Box(c).Size(8, 8).Radius(4).Background(k.Danger)
+ ui.Text(c, elapsed).FontFeatures("tnum").TextColor(k.Danger).SingleLine()
+ })
+ case opts.Denied:
+ ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(k.Danger).Role(ui.RoleStatus).Children(func() {
+ ui.Icon(c, icons.Must("mic-off")).Size(14, 14)
+ ui.Text(c, msg(c, "voice_input_button.denied")).FontSize(FontSize(c, theme.CaptionSize)).SingleLine()
+ })
+ }
+ })
+ return r
+}
diff --git a/voice_input_button_test.go b/voice_input_button_test.go
new file mode 100644
index 0000000..bf94b0c
--- /dev/null
+++ b/voice_input_button_test.go
@@ -0,0 +1,76 @@
+package mujica
+
+import (
+ "testing"
+ "time"
+
+ "github.com/egoist/mygo/ui"
+)
+
+func TestVoiceInputButtonStartStopAndTime(t *testing.T) {
+ var opts VoiceInputOptions
+ presses := 0
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ if VoiceInputButton(c, opts).Changed() {
+ presses++
+ if opts.Recording {
+ opts = VoiceInputOptions{}
+ } else {
+ opts = VoiceInputOptions{Recording: true, Started: c.Now().Add(-65 * time.Second)}
+ }
+ }
+ }, 300, 100)
+ _ = tt.Click("Start voice input")
+ tt.Frame()
+ if !opts.Recording || !tt.HasText("1:05") {
+ t.Fatalf("recording %v texts %q", opts.Recording, tt.Texts())
+ }
+ tt.Key(0, ui.KeyTab)
+ tt.Key(0, ui.KeySpace)
+ if opts.Recording || presses != 2 {
+ t.Fatalf("stop: recording %v presses %d", opts.Recording, presses)
+ }
+}
+
+func TestVoiceInputButtonDeniedAndDisabled(t *testing.T) {
+ opts := VoiceInputOptions{Denied: true}
+ presses := 0
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ if VoiceInputButton(c, opts).Changed() {
+ presses++
+ }
+ }, 400, 100)
+ if !tt.HasText("Microphone access denied") {
+ t.Fatalf("texts %q", tt.Texts())
+ }
+ _ = tt.Click("Start voice input")
+ opts.Disabled = true
+ _ = tt.Click("Start voice input")
+ if presses != 1 {
+ t.Fatalf("presses %d, want 1 (retry once, then disabled)", presses)
+ }
+}
+
+func TestVoiceInputButtonRecordingNeedsStart(t *testing.T) {
+ defer func() {
+ if recover() == nil {
+ t.Fatal("no panic for recording without Started")
+ }
+ }()
+ ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ VoiceInputButton(c, VoiceInputOptions{Recording: true})
+ }, 100, 100)
+}
+
+func TestVoiceInputButtonSnapshot(t *testing.T) {
+ snapshot(t, "voice_input_button", 360, 170, func(c *ui.Context) {
+ ui.Column(c).Padding(12).Gap(8).Children(func() {
+ VoiceInputButton(c, VoiceInputOptions{})
+ VoiceInputButton(c, VoiceInputOptions{Recording: true, Started: c.Now().Add(-12 * time.Second)})
+ VoiceInputButton(c, VoiceInputOptions{Denied: true})
+ })
+ })
+}
diff --git a/voice_waveform.go b/voice_waveform.go
new file mode 100644
index 0000000..5526c60
--- /dev/null
+++ b/voice_waveform.go
@@ -0,0 +1,75 @@
+package mujica
+
+import (
+ "github.com/egoist/mygo/ui"
+
+ "mujicaui/icons"
+ "mujicaui/theme"
+)
+
+// VoiceWaveformOptions configure a VoiceWaveform.
+type VoiceWaveformOptions struct {
+ // Silence is the level below which input counts as silent; zero means 0.05.
+ Silence float32
+ // SilentFor is how many trailing samples under Silence show the hint; zero means 20.
+ SilentFor int
+ // Width is the bar area width in DIPs; zero means 200.
+ Width float32
+}
+
+// VoiceWaveform draws recent input levels, 0 to 1, newest at the right, and hints at silence.
+func VoiceWaveform(c *ui.Context, levels []float32, opts VoiceWaveformOptions) *ui.Element {
+ if opts.Silence < 0 || opts.Silence >= 1 || opts.SilentFor < 0 || opts.Width < 0 {
+ panic("mujica: VoiceWaveform needs 0 <= Silence < 1 and non-negative SilentFor and Width")
+ }
+ for _, v := range levels {
+ if !(v >= 0 && v <= 1) {
+ panic("mujica: VoiceWaveform levels must be from 0 to 1")
+ }
+ }
+ silence, need, w := opts.Silence, opts.SilentFor, opts.Width
+ if silence == 0 {
+ silence = 0.05
+ }
+ if need == 0 {
+ need = 20
+ }
+ if w == 0 {
+ w = 200
+ }
+ k := Tokens(c)
+ u := Density(c).Unit()
+ silent := len(levels) >= need
+ for _, v := range levels[max(0, len(levels)-need):] {
+ silent = silent && v < silence
+ }
+ row := ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Shrink(0).Role(ui.RoleMeter).Label(msg(c, "voice_waveform.label"))
+ row.Children(func() {
+ ui.Box(c).Size(w, 28).Shrink(0).Draw(func(p *ui.Painter, b ui.Rect) {
+ n := int((b.W + audioBarGap) / (audioBarWidth + audioBarGap))
+ cy := b.Y + b.H/2
+ for i := range n {
+ // The newest sample sits at the right edge.
+ j := len(levels) - n + i
+ v := float32(0)
+ if j >= 0 {
+ v = levels[j]
+ }
+ h := max(2, v*(b.H-4))
+ col := k.AccentText
+ if v < silence {
+ col = k.TextMuted.Alpha(0.55)
+ }
+ x := b.X + float32(i)*(audioBarWidth+audioBarGap)
+ p.Fill(ui.Rect{X: x, Y: cy - h/2, W: audioBarWidth, H: h}, col, 1)
+ }
+ })
+ if silent {
+ ui.Row(c).Gap(u).AlignItems(ui.Center).TextColor(k.Warning).Role(ui.RoleStatus).Children(func() {
+ ui.Icon(c, icons.Must("mic-off")).Size(14, 14)
+ ui.Text(c, msg(c, "voice_waveform.silent")).FontSize(FontSize(c, theme.CaptionSize)).SingleLine()
+ })
+ }
+ })
+ return row
+}
diff --git a/voice_waveform_test.go b/voice_waveform_test.go
new file mode 100644
index 0000000..3de0f8e
--- /dev/null
+++ b/voice_waveform_test.go
@@ -0,0 +1,63 @@
+package mujica
+
+import (
+ "testing"
+
+ "github.com/egoist/mygo/ui"
+)
+
+func voiceLevels(n int, v float32) []float32 {
+ out := make([]float32, n)
+ for i := range out {
+ out[i] = v
+ }
+ return out
+}
+
+func TestVoiceWaveformSilenceHint(t *testing.T) {
+ levels := append(voiceLevels(30, 0.6), voiceLevels(10, 0.01)...)
+ tt := ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ VoiceWaveform(c, levels, VoiceWaveformOptions{SilentFor: 12})
+ }, 400, 100)
+ if tt.HasText("No sound detected") {
+ t.Fatal("hint before 12 silent samples")
+ }
+ levels = append(levels, voiceLevels(2, 0.02)...)
+ tt.Frame()
+ if !tt.HasText("No sound detected") {
+ t.Fatal("no hint after 12 silent samples")
+ }
+ if _, ok := tt.Find("Input level"); !ok {
+ t.Fatal("meter not named")
+ }
+}
+
+func TestVoiceWaveformBadLevels(t *testing.T) {
+ for _, l := range [][]float32{{-0.1}, {1.5}} {
+ func() {
+ defer func() {
+ if recover() == nil {
+ t.Errorf("%v: no panic", l)
+ }
+ }()
+ ui.NewTester(func(c *ui.Context) {
+ Use(c, Settings{})
+ VoiceWaveform(c, l, VoiceWaveformOptions{})
+ }, 100, 100)
+ }()
+ }
+}
+
+func TestVoiceWaveformSnapshot(t *testing.T) {
+ levels := make([]float32, 60)
+ for i := range levels {
+ levels[i] = float32((i*7)%10) / 10
+ }
+ snapshot(t, "voice_waveform", 420, 110, func(c *ui.Context) {
+ ui.Column(c).Padding(12).Gap(12).Children(func() {
+ VoiceWaveform(c, levels, VoiceWaveformOptions{})
+ VoiceWaveform(c, voiceLevels(40, 0), VoiceWaveformOptions{Width: 160})
+ })
+ })
+}