wip chat sessions 2

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 14:24:42 -04:00
1 parent 1367525c12
commit 85b41f6435
92 files changed
+3692 -22

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestArtifactCardOpens(t *testing.T) {
opens := 0
tt := chatTester(480, 160, func(c *ui.Context) {
if ArtifactCard(c, ArtifactCardOptions{Kind: ArtifactDocument, Title: "Decree", Detail: "2 pages"}).Changed() {
opens++
}
})
if !tt.HasText("Document · 2 pages") {
t.Fatalf("texts %q", tt.Texts())
}
_ = tt.Click("Decree")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if opens != 2 {
t.Fatalf("opens %d", opens)
}
}
func TestArtifactCardStreamingDisabledAndChecks(t *testing.T) {
opens := 0
tt := chatTester(480, 160, func(c *ui.Context) {
if ArtifactCard(c, ArtifactCardOptions{Kind: ArtifactWeb, Title: "Menu", Streaming: true, Disabled: true}).Changed() {
opens++
}
})
_ = tt.Click("Menu")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if opens != 0 || !tt.HasText("Web page") {
t.Fatalf("opens %d texts %q", opens, tt.Texts())
}
for _, opts := range []ArtifactCardOptions{{}, {Title: "x", Kind: 7}} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%+v: no panic", opts)
}
}()
ui.Render(func(c *ui.Context) {
Use(c, Settings{})
ArtifactCard(c, opts)
}, 100, 100, 1)
}()
}
}
func TestArtifactCardSnapshot(t *testing.T) {
snapshot(t, "artifact_card", 460, 210, func(c *ui.Context) {
ui.Column(c).Padding(16).Gap(8).Children(func() {
ArtifactCard(c, ArtifactCardOptions{Kind: ArtifactCode, Title: "seating.go", Detail: "Go · 120 lines"})
ArtifactCard(c, ArtifactCardOptions{Kind: ArtifactDocument, Title: "Royal decree", Streaming: true})
ArtifactCard(c, ArtifactCardOptions{Kind: ArtifactWeb, Title: "Ball invitation", Disabled: true})
})
})
}
+73
View File
@@ -0,0 +1,73 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestCapabilityCardsExample(t *testing.T) {
got := ""
tt := chatTester(900, 400, func(c *ui.Context) {
if v := CapabilityCards(c, chatCards(), CapabilityCardsOptions{}); v.Example != "" {
got = v.Example
}
})
if !tt.HasText("Banquets, seating and schedules.") {
t.Fatal("description missing")
}
if err := tt.Click("“Plan a ball for 200 guests”"); err != nil {
t.Fatal(err)
}
if got != "Plan a ball for 200 guests" {
t.Fatalf("example %q", got)
}
// Tab moves from the clicked example to the next card's.
got = ""
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if got != "Explain the minuet" {
t.Fatalf("keys chose %q", got)
}
}
func TestCapabilityCardsColumnsAndDisabled(t *testing.T) {
got := ""
var g *ui.Element
tt := chatTester(900, 600, func(c *ui.Context) {
v := CapabilityCards(c, chatCards(), CapabilityCardsOptions{Columns: 1, Disabled: true})
g = v.Element
if v.Example != "" {
got = v.Example
}
})
_ = tt.Click("“Explain the minuet”")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if got != "" {
t.Fatalf("disabled cards chose %q", got)
}
a, _ := tt.Find("Write")
b, _ := tt.Find("Plan")
if b.Y <= a.Y || g == nil {
t.Fatalf("one column should stack cards: %v %v", a, b)
}
}
func TestCapabilityCardsNeedTitles(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.Render(func(c *ui.Context) {
Use(c, Settings{})
CapabilityCards(c, []CapabilityCard{{}}, CapabilityCardsOptions{})
}, 100, 100, 1)
}
func TestCapabilityCardsSnapshot(t *testing.T) {
snapshot(t, "capability_cards", 860, 200, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { CapabilityCards(c, chatCards(), CapabilityCardsOptions{}) })
})
}
+59
View File
@@ -0,0 +1,59 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestChatEmptyStateKinds(t *testing.T) {
for _, tc := range []struct {
opts ChatEmptyStateOptions
texts []string
action string
}{
{ChatEmptyStateOptions{}, []string{"Start a conversation"}, "New conversation"},
{ChatEmptyStateOptions{Kind: ChatEmptyNoResults, Query: "minuet"}, []string{"minuet"}, "Clear search"},
{ChatEmptyStateOptions{Kind: ChatEmptyOffline}, []string{"You are offline"}, "Retry"},
{ChatEmptyStateOptions{Kind: ChatEmptyOffline, Title: "Lost the court", Action: "Reconnect"}, []string{"Lost the court"}, "Reconnect"},
} {
acted := 0
tt := chatTester(420, 320, func(c *ui.Context) {
if ChatEmptyState(c, tc.opts).Action {
acted++
}
})
for _, s := range tc.texts {
if !tt.HasText(s) {
t.Errorf("%+v: %q missing from %q", tc.opts, s, tt.Texts())
}
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if err := tt.Click(tc.action); err != nil || acted != 2 {
t.Errorf("%+v: action %q acted %d (%v)", tc.opts, tc.action, acted, err)
}
}
}
func TestChatEmptyStateRejectsBadKind(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.Render(func(c *ui.Context) {
Use(c, Settings{})
ChatEmptyState(c, ChatEmptyStateOptions{Kind: 9})
}, 100, 100, 1)
}
func TestChatEmptyStateSnapshot(t *testing.T) {
snapshot(t, "chat_empty_state", 820, 240, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(8).Children(func() {
for _, k := range []ChatEmptyKind{ChatEmptyNew, ChatEmptyNoResults, ChatEmptyOffline} {
ui.Column(c).Width(260).Children(func() { ChatEmptyState(c, ChatEmptyStateOptions{Kind: k, Query: "minuet"}) })
}
})
})
}
+38
View File
@@ -0,0 +1,38 @@
package mujica
import (
"time"
"mujicaui/icons"
)
// chatNow is the fixed clock of the chat session tests.
var chatNow = time.Date(2026, time.October, 5, 15, 30, 0, 0, time.UTC)
func chatConversations() []ChatConversation {
h := time.Hour
return []ChatConversation{
{ID: "a", Title: "Court etiquette notes", Updated: chatNow.Add(-2 * h)},
{ID: "b", Title: "Ballroom seating plan", Updated: chatNow.Add(-26 * h)},
{ID: "c", Title: "Harpsichord repair", Updated: chatNow.Add(-4 * 24 * h)},
{ID: "d", Title: "Coronation menu", Updated: chatNow.Add(-40 * 24 * h)},
{ID: "e", Title: "Royal letters", Updated: chatNow.Add(-90 * 24 * h), Pinned: true},
{ID: "f", Title: "Garden maze", Updated: chatNow.Add(-1 * h)},
}
}
func chatMessages() []SessionMessage {
return []SessionMessage{
{Role: SessionUser, Text: "How should guests be seated at the banquet?", Time: chatNow.Add(-10 * time.Minute)},
{Role: SessionAssistant, Text: "Seat by rank: the host at the head, honored guests to the right.", Time: chatNow.Add(-9 * time.Minute)},
{Role: SessionUser, Author: "Lady Ann", Text: "And the musicians?", Time: chatNow.Add(-5 * time.Minute)},
}
}
func chatCards() []CapabilityCard {
return []CapabilityCard{
{Title: "Write", Description: "Letters, speeches and verse.", Icon: icons.Must("file-text"), Examples: []string{"Draft a royal decree"}},
{Title: "Plan", Description: "Banquets, seating and schedules.", Icon: icons.Must("calendar"), Examples: []string{"Plan a ball for 200 guests"}},
{Title: "Research", Description: "History, heraldry and music.", Icon: icons.Must("search"), Examples: []string{"Explain the minuet"}},
}
}
+95
View File
@@ -0,0 +1,95 @@
package mujica
import (
"encoding/json"
"strings"
"testing"
"github.com/egoist/mygo/ui"
)
func TestExportConversationFormats(t *testing.T) {
msgs := chatMessages()
md := ExportConversation("Banquet", msgs, ExportMarkdown)
want := "# Banquet\n\n**User** · (2026-10-05 15:20)\n\nHow should guests be seated at the banquet?\n"
if !strings.HasPrefix(md, want) || !strings.Contains(md, "**Lady Ann**") {
t.Fatalf("markdown %q", md)
}
var doc struct {
Title string
Messages []struct{ Role, Author, Text, Time string }
}
if err := json.Unmarshal([]byte(ExportConversation("Banquet", msgs, ExportJSON)), &doc); err != nil {
t.Fatal(err)
}
if doc.Title != "Banquet" || len(doc.Messages) != 3 || doc.Messages[1].Role != "assistant" || doc.Messages[2].Author != "Lady Ann" ||
doc.Messages[0].Time != "2026-10-05T15:20:00Z" {
t.Fatalf("json %+v", doc)
}
txt := ExportConversation("Banquet", msgs[:1], ExportText)
if txt != "Banquet\n\nUser (2026-10-05 15:20):\nHow should guests be seated at the banquet?\n" {
t.Fatalf("text %q", txt)
}
if got := ExportConversation("Empty", nil, ExportJSON); !strings.Contains(got, `"messages": []`) {
t.Fatalf("empty json %q", got)
}
}
func TestConversationExportRangeAndExport(t *testing.T) {
var s ConversationExportState
var last ConversationExportView
selected := []int{2}
tt := chatTester(560, 600, func(c *ui.Context) {
if v := ConversationExport(c, &s, chatMessages(), ConversationExportOptions{Title: "Banquet", Selected: selected}); v.Exported {
last = v
}
})
_ = tt.Click("JSON")
_ = tt.Click("Selected (1)")
_ = tt.Click("Export")
if !last.Exported || last.Format != ExportJSON || !strings.Contains(last.Content, "And the musicians?") ||
strings.Contains(last.Content, "banquet?") {
t.Fatalf("export %+v", last)
}
_ = tt.Click("Plain text")
_ = tt.Click("Last")
s.Last = 2
tt.Frame()
_ = tt.Click("Export")
if last.Format != ExportText || strings.Contains(last.Content, "banquet?") || !strings.Contains(last.Content, "Seat by rank") {
t.Fatalf("last-2 export %q", last.Content)
}
}
func TestConversationExportDisabledAndChecks(t *testing.T) {
var s ConversationExportState
exported := false
tt := chatTester(560, 600, func(c *ui.Context) {
if ConversationExport(c, &s, chatMessages(), ConversationExportOptions{Title: "T", Disabled: true}).Exported {
exported = true
}
})
_ = tt.Click("JSON")
_ = tt.Click("Export")
if exported || s.Format != ExportMarkdown {
t.Fatalf("disabled export acted: %v %v", exported, s.Format)
}
defer func() {
if recover() == nil {
t.Fatal("no panic for a bad selection")
}
}()
ui.Render(func(c *ui.Context) {
Use(c, Settings{})
ConversationExport(c, &s, chatMessages(), ConversationExportOptions{Title: "T", Selected: []int{9}})
}, 100, 100, 1)
}
func TestConversationExportSnapshot(t *testing.T) {
snapshot(t, "conversation_export", 560, 470, func(c *ui.Context) {
s := &ConversationExportState{}
ui.Column(c).Padding(16).Children(func() {
ConversationExport(c, s, chatMessages(), ConversationExportOptions{Title: "Banquet seating", Selected: []int{0, 2}})
})
})
}
+14 -2
View File
@@ -94,7 +94,7 @@ func ConversationItem(c *ui.Context, conv *ChatConversation, opts ConversationIt
v.Deleted = main.Shortcut(0, ui.KeyDelete)
}
}
menu := DropdownMenu(c, "", DropdownMenuOptions{Icon: icons.Must("ellipsis"), Label: msg(c, "conversation_item.actions"), Disabled: opts.Disabled}, func(m *PopupMenu) {
conversationItemMenu(c, msg(c, "conversation_item.actions"), func(m *PopupMenu) {
rename = m.Item(msg(c, "conversation_item.rename"), PopupMenuItemOptions{}) || rename
pin := msg(c, "conversation_item.pin")
if conv.Pinned {
@@ -105,7 +105,6 @@ func ConversationItem(c *ui.Context, conv *ChatConversation, opts ConversationIt
m.Separator()
v.Deleted = m.Item(msg(c, "conversation_item.delete"), PopupMenuItemOptions{}) || v.Deleted
})
menu.Shrink(0).MinWidth(theme.MinHitSize).MinHeight(theme.MinHitSize)
})
if rename && !row.IsDisabled() {
st.editing, st.fresh, st.draft = true, true, conv.Title
@@ -142,3 +141,16 @@ func conversationItemEditor(c *ui.Context, k theme.Tokens, conv *ChatConversatio
st.editing = false
}
}
// conversationItemMenu is a borderless ellipsis button opening the menu build fills.
func conversationItemMenu(c *ui.Context, label string, build func(m *PopupMenu)) *ui.Element {
b := iconAction(c, icons.Must("ellipsis"), label).Role(ui.RoleMenuButton).Shrink(0)
open := ui.Local(b, conversationItemKey{}, func() bool { return false })
if b.IsDisabled() {
*open = false
} else if b.Clicked() || b.Shortcut(0, ui.KeyDown) {
*open = true
}
popupMenuOpen(c, b, open, build)
return b
}
+118
View File
@@ -0,0 +1,118 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func conversationItemTester(conv *ChatConversation, opts ConversationItemOptions, got *ConversationItemView) *ui.Tester {
return chatTester(420, 360, func(c *ui.Context) {
opts.Today = DateOf(chatNow)
v := ConversationItem(c, conv, opts)
if v.Opened || v.Deleted || v.Exported || v.PinToggled || v.Renamed {
*got = v
}
})
}
func TestConversationItemShowsTitleAndTime(t *testing.T) {
convs := chatConversations()
var got ConversationItemView
tt := conversationItemTester(&convs[0], ConversationItemOptions{}, &got)
if !tt.HasText("Court etiquette notes") || !tt.HasText("13:30") {
t.Fatalf("texts %q", tt.Texts())
}
tt = conversationItemTester(&convs[1], ConversationItemOptions{}, &got)
if !tt.HasText("Yesterday") {
t.Fatalf("texts %q", tt.Texts())
}
if err := tt.Click("Ballroom seating plan"); err != nil {
t.Fatal(err)
}
if !got.Opened {
t.Fatal("click did not open")
}
}
func TestConversationItemMenuActions(t *testing.T) {
for _, tc := range []struct {
item string
want func(v ConversationItemView) bool
}{
{"Pin", func(v ConversationItemView) bool { return v.PinToggled }},
{"Export", func(v ConversationItemView) bool { return v.Exported }},
{"Delete", func(v ConversationItemView) bool { return v.Deleted }},
} {
conv := chatConversations()[0]
var got ConversationItemView
tt := conversationItemTester(&conv, ConversationItemOptions{}, &got)
if err := tt.Click("Conversation actions"); err != nil {
t.Fatal(err)
}
if err := tt.Click(tc.item); err != nil {
t.Fatal(err)
}
if !tc.want(got) {
t.Errorf("%s: %+v", tc.item, got)
}
}
}
func TestConversationItemInlineRename(t *testing.T) {
conv := chatConversations()[0]
var got ConversationItemView
tt := conversationItemTester(&conv, ConversationItemOptions{}, &got)
_ = tt.Click("Conversation actions")
_ = tt.Click("Rename")
if !tt.Focused("Rename") {
t.Fatal("rename field not focused")
}
tt.Key(0, ui.KeyEnd)
tt.Type(" II")
tt.Key(0, ui.KeyEnter)
if conv.Title != "Court etiquette notes II" || !got.Renamed {
t.Fatalf("title %q renamed %v", conv.Title, got.Renamed)
}
// F2 starts a rename; Escape keeps the title; a blank title is refused.
_ = tt.Click("Court etiquette notes II")
tt.Key(0, ui.KeyF2)
tt.Type("xyz")
tt.Key(0, ui.KeyEscape)
if conv.Title != "Court etiquette notes II" {
t.Fatalf("Escape changed the title to %q", conv.Title)
}
tt.Key(0, ui.KeyF2)
tt.Key(ui.Cmd, ui.KeyA)
tt.Key(ui.Ctrl, ui.KeyA)
tt.Key(0, ui.KeyBackspace)
tt.Key(0, ui.KeyEnter)
if conv.Title != "Court etiquette notes II" {
t.Fatalf("blank rename gave %q", conv.Title)
}
}
func TestConversationItemDisabled(t *testing.T) {
conv := chatConversations()[0]
var got ConversationItemView
tt := conversationItemTester(&conv, ConversationItemOptions{Disabled: true}, &got)
_ = tt.Click("Court etiquette notes")
_ = tt.Click("Conversation actions")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyF2)
tt.Key(0, ui.KeyDelete)
if got.Opened || got.Deleted || tt.HasText("Rename") {
t.Fatalf("disabled item acted: %+v", got)
}
}
func TestConversationItemSnapshot(t *testing.T) {
snapshot(t, "conversation_item", 420, 140, func(c *ui.Context) {
convs := chatConversations()
ui.Column(c).Padding(16).Gap(4).Children(func() {
ConversationItem(c, &convs[0], ConversationItemOptions{Today: DateOf(chatNow), Selected: true})
ConversationItem(c, &convs[4], ConversationItemOptions{Today: DateOf(chatNow)})
ConversationItem(c, &convs[3], ConversationItemOptions{Today: DateOf(chatNow), Disabled: true})
})
})
}
+1 -1
View File
@@ -94,7 +94,7 @@ func ConversationList(c *ui.Context, s *ConversationListState, items []ChatConve
s.rows.Label = func(i int) string { return conversationListTitle(c, items, rows[i]) }
s.rows.Selected = cursor
var list *ui.Element
wrap := ui.Column(c).Grow(1).MinHeight(0).Disabled(opts.Disabled)
wrap := ui.Column(c).MinHeight(0).Disabled(opts.Disabled)
wrap.Children(func() {
restore := dataSelectTheme(c, 0)
list = ui.List(c, &s.rows, len(rows), func(i int) {
+114
View File
@@ -0,0 +1,114 @@
package mujica
import (
"fmt"
"slices"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func chatTester(w, h int, view func(c *ui.Context)) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(10).Fill().Children(func() { view(c) })
}, w, h)
}
func TestConversationListGroups(t *testing.T) {
var s ConversationListState
items := chatConversations()
tt := chatTester(400, 600, func(c *ui.Context) {
ConversationList(c, &s, items, ConversationListOptions{Now: chatNow}, nil).Element.Height(560)
})
want := []string{"Pinned", "Royal letters", "Today", "Garden maze", "Court etiquette notes", "Yesterday",
"Ballroom seating plan", "Previous 7 days", "Harpsichord repair", "Older", "Coronation menu"}
got := slices.Compact(slices.DeleteFunc(tt.Texts(), func(s string) bool { return !slices.Contains(want, s) }))
if !slices.Equal(got, want) {
t.Fatalf("rows %q", got)
}
}
func TestConversationListSelectAndKeys(t *testing.T) {
var s ConversationListState
items := chatConversations()
changes, opened := 0, 0
tt := chatTester(400, 600, func(c *ui.Context) {
v := ConversationList(c, &s, items, ConversationListOptions{Now: chatNow, Label: "History"}, nil)
v.Element.Height(560)
if v.Changed() {
changes++
}
if v.Submitted() {
opened++
}
})
if err := tt.Click("Garden maze"); err != nil {
t.Fatal(err)
}
if s.Selected != "f" || changes != 1 {
t.Fatalf("selected %q changes %d", s.Selected, changes)
}
tt.Key(0, ui.KeyDown)
if s.Selected != "a" {
t.Fatalf("Down chose %q", s.Selected)
}
tt.Key(0, ui.KeyDown)
if s.Selected != "b" {
t.Fatalf("Down over a header chose %q", s.Selected)
}
tt.Key(0, ui.KeyEnter)
if opened != 1 {
t.Fatalf("Enter opened %d", opened)
}
}
func TestConversationListDisabled(t *testing.T) {
var s ConversationListState
items := chatConversations()
tt := chatTester(400, 600, func(c *ui.Context) {
ConversationList(c, &s, items, ConversationListOptions{Now: chatNow, Disabled: true}, nil).Element.Height(560)
})
_ = tt.Click("Garden maze")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
if s.Selected != "" {
t.Fatalf("disabled list chose %q", s.Selected)
}
}
func TestConversationListVirtualizesAndGroupsOnce(t *testing.T) {
items := make([]ChatConversation, 100000)
for i := range items {
items[i] = ChatConversation{ID: fmt.Sprint(i), Title: fmt.Sprintf("Conversation %d", i), Updated: chatNow.Add(-time.Duration(i) * time.Minute)}
}
var s ConversationListState
tt := chatTester(400, 600, func(c *ui.Context) {
ConversationList(c, &s, items, ConversationListOptions{Now: chatNow}, nil).Element.Height(560)
})
if n := len(tt.Texts()); n > 80 {
t.Fatalf("built %d texts for 100000 conversations", n)
}
// A marked plan survives frames that bring no new data.
s.plan.rows[1].item = -7
tt.Frame()
if s.plan.rows[1].item != -7 {
t.Fatal("regrouped without a data change")
}
s.plan.rows[1].item = 0
// Today, Yesterday, Previous 7 days and Older head the rows.
if len(s.plan.rows) != len(items)+4 {
t.Fatalf("%d rows", len(s.plan.rows))
}
}
func TestConversationListSnapshot(t *testing.T) {
items := chatConversations()
snapshot(t, "conversation_list", 360, 420, func(c *ui.Context) {
s := &ConversationListState{Selected: "b"}
ui.Column(c).Padding(16).Children(func() {
ConversationList(c, s, items, ConversationListOptions{Now: chatNow, Label: "History"}, nil).Element.Height(380)
})
})
}
+5 -3
View File
@@ -97,10 +97,12 @@ func conversationSearchList(c *ui.Context, s *conversationSearchState, q string,
s.rows.Key = func(i int) any { return hits[i].ID }
s.rows.Label = func(i int) string { return hits[i].Title }
s.rows.Selected = &s.cursor
// Rows have one height, so the list can size itself before measuring them.
rowH := d.ControlHeight() + FontSize(c, theme.CaptionSize)*theme.LineHeight + 2
restore := dataSelectTheme(c, 0)
list := ui.List(c, &s.rows, len(hits), func(i int) {
h := hits[i]
row := ui.Column(c).MinHeight(d.ControlHeight()).Padding(d.Unit(), d.Unit()*2).Gap(2).Justify(ui.Center)
row := ui.Column(c).Height(rowH).Padding(d.Unit(), d.Unit()*2).Gap(2).Justify(ui.Center).ClipY()
if i == s.cursor {
row.DrawOver(func(p *ui.Painter, r ui.Rect) {
p.FillPath(internal.Diamond(r.X+3, r.Y+r.H/2, 6), k.AccentText)
@@ -109,13 +111,13 @@ func conversationSearchList(c *ui.Context, s *conversationSearchState, q string,
row.Children(func() {
ui.RichText(c, conversationSearchSpans(h.Title, q, k)...).SingleLine()
if h.Snippet != "" {
ui.RichText(c, conversationSearchSpans(h.Snippet, q, k)...).MaxLines(2).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize))
ui.RichText(c, conversationSearchSpans(h.Snippet, q, k)...).SingleLine().TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize))
}
})
if !row.IsDisabled() && row.Clicked() {
chosen = h.ID
}
}).MaxHeight(360).Padding(d.Unit()).Background(k.Surface).Border(theme.BorderWidth, k.Border).
}).Height(min(360, float32(len(hits))*rowH+2*d.Unit()+2)).Padding(d.Unit()).Background(k.Surface).Border(theme.BorderWidth, k.Border).
Radius(theme.ControlRadius).Label(msg(c, "conversation_search.results"))
restore()
internal.Ring(list, k.Focus, theme.ControlRadius)
+133
View File
@@ -0,0 +1,133 @@
package mujica
import (
"slices"
"strings"
"testing"
"github.com/egoist/mygo/ui"
)
// chatSearchBackend answers queries one frame late, as an async caller does.
type chatSearchBackend struct {
query, asked string
hits []ConversationSearchHit
loading bool
queries []string
chosen string
err string
retries int
}
func (b *chatSearchBackend) view(opts ConversationSearchOptions) func(c *ui.Context) {
return func(c *ui.Context) {
if b.loading && b.err == "" {
b.loading, b.hits = false, nil
for _, conv := range chatConversations() {
if strings.Contains(strings.ToLower(conv.Title), strings.ToLower(b.asked)) {
b.hits = append(b.hits, ConversationSearchHit{ID: conv.ID, Title: conv.Title, Snippet: "about " + conv.Title})
}
}
}
opts.Loading, opts.Error = b.loading, b.err
v := ConversationSearch(c, &b.query, b.hits, opts)
if v.QueryChanged {
b.queries = append(b.queries, b.query)
b.asked, b.loading = b.query, true
c.Invalidate()
}
if v.Chosen != "" {
b.chosen = v.Chosen
}
if v.Retry {
b.retries++
}
}
}
func TestConversationSearchAsyncAndChoose(t *testing.T) {
b := &chatSearchBackend{}
tt := chatTester(420, 400, b.view(ConversationSearchOptions{Label: "Search conversations"}))
tt.Key(0, ui.KeyTab)
tt.Type("garden")
tt.Frame()
if len(b.queries) == 0 || b.queries[len(b.queries)-1] != "garden" {
t.Fatalf("queries %q", b.queries)
}
if !tt.HasText("Garden maze") {
t.Fatalf("results %q", tt.Texts())
}
if err := tt.Click("Garden maze"); err != nil {
t.Fatal(err)
}
if b.chosen != "f" {
t.Fatalf("chose %q", b.chosen)
}
}
func TestConversationSearchKeyboard(t *testing.T) {
b := &chatSearchBackend{}
tt := chatTester(420, 400, b.view(ConversationSearchOptions{Label: "Search conversations"}))
tt.Key(0, ui.KeyTab)
tt.Type("o")
tt.Frame()
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if b.chosen == "" || b.chosen != b.hits[1].ID {
t.Fatalf("keys chose %q of %v", b.chosen, b.hits)
}
}
func TestConversationSearchStates(t *testing.T) {
b := &chatSearchBackend{query: "zzz", asked: "zzz"}
tt := chatTester(420, 400, b.view(ConversationSearchOptions{}))
if !tt.HasText("No conversations match") {
t.Fatalf("texts %q", tt.Texts())
}
b.err = "Search is down"
tt.Frame()
if !tt.HasText("Search is down") {
t.Fatal("error missing")
}
_ = tt.Click("Retry")
if b.retries != 1 {
t.Fatalf("retries %d", b.retries)
}
}
func TestConversationSearchHighlights(t *testing.T) {
got := conversationSearchMatches("Ballroom SEATING and seats", "seat")
if !slices.Equal(got, [][2]int{{9, 13}, {21, 25}}) {
t.Fatalf("matches %v", got)
}
if got := conversationSearchMatches("宫廷礼仪与宫廷音乐", "宫廷"); !slices.Equal(got, [][2]int{{0, 2}, {5, 7}}) {
t.Fatalf("CJK matches %v", got)
}
if conversationSearchMatches("abc", "") != nil {
t.Fatal("empty query matched")
}
}
func TestConversationSearchDisabled(t *testing.T) {
b := &chatSearchBackend{query: "o", hits: []ConversationSearchHit{{ID: "x", Title: "Royal"}}}
tt := chatTester(420, 400, b.view(ConversationSearchOptions{Disabled: true}))
_ = tt.Click("Royal")
tt.Key(0, ui.KeyTab)
tt.Type("q")
if b.chosen != "" || b.query != "o" || len(b.queries) != 0 {
t.Fatalf("disabled search acted: %q %q %v", b.chosen, b.query, b.queries)
}
}
func TestConversationSearchSnapshot(t *testing.T) {
snapshot(t, "conversation_search", 420, 260, func(c *ui.Context) {
q := "seat"
ui.Column(c).Padding(16).Children(func() {
ConversationSearch(c, &q, []ConversationSearchHit{
{ID: "b", Title: "Ballroom seating plan", Snippet: "…where to seat the duke…"},
{ID: "c", Title: "Theatre seats", Snippet: "Box seats for the opera"},
}, ConversationSearchOptions{})
})
})
}
+59
View File
@@ -0,0 +1,59 @@
package mujica
import (
"math"
"testing"
"github.com/egoist/mygo/ui"
)
func TestCostEstimatorTable(t *testing.T) {
tt := chatTester(420, 260, func(c *ui.Context) {
CostEstimator(c, CostEstimatorOptions{InputTokens: 12000, OutputTokens: 3400, InputPrice: 3, OutputPrice: 15})
})
for _, s := range []string{"12000", "3400", "15400", "$0.0360", "$0.0510", "$0.0870", "input $3, output $15"} {
if !tt.HasText(s) {
t.Errorf("%q missing from %q", s, tt.Texts())
}
}
tt = chatTester(420, 260, func(c *ui.Context) {
CostEstimator(c, CostEstimatorOptions{InputTokens: 1e6, InputPrice: 2.5, Currency: "¥", Digits: 2})
})
if !tt.HasText("¥2.50") {
t.Fatalf("texts %q", tt.Texts())
}
}
func TestEstimateCost(t *testing.T) {
got := EstimateCost(1_000_000, 500_000, 3, 15)
if got.Input != 3 || got.Output != 7.5 || got.Total != 10.5 {
t.Fatalf("estimate %+v", got)
}
if z := EstimateCost(0, 0, 3, 15); z.Total != 0 {
t.Fatalf("zero tokens cost %v", z.Total)
}
}
func TestCostEstimatorRejectsBadInput(t *testing.T) {
for _, opts := range []CostEstimatorOptions{{InputTokens: -1}, {InputPrice: -1}, {OutputPrice: math.NaN()}, {Digits: -1}} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%+v: no panic", opts)
}
}()
ui.Render(func(c *ui.Context) {
Use(c, Settings{})
CostEstimator(c, opts)
}, 100, 100, 1)
}()
}
}
func TestCostEstimatorSnapshot(t *testing.T) {
snapshot(t, "cost_estimator", 420, 200, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
CostEstimator(c, CostEstimatorOptions{InputTokens: 12000, OutputTokens: 3400, InputPrice: 3, OutputPrice: 15})
})
})
}
+65
View File
@@ -0,0 +1,65 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestDateSeparatorLabels(t *testing.T) {
today := DateOf(chatNow)
for _, tc := range []struct {
locale Locale
day Date
want string
}{
{EnUS, today, "Today"},
{EnUS, today.AddDays(-1), "Yesterday"},
{EnUS, Date{2026, 9, 1}, "Tuesday, September 1, 2026"},
{ZhCN, today, "今天"},
{ZhCN, today.AddDays(-1), "昨天"},
{ZhCN, Date{2026, 9, 1}, "2026年9月1日 星期二"},
} {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{Locale: tc.locale})
DateSeparator(c, tc.day, DateSeparatorOptions{Today: today})
}, 500, 80)
if !tt.HasText(tc.want) {
t.Errorf("%s %v: texts %q", tc.locale, tc.day, tt.Texts())
}
}
}
func TestDateSeparatorShortStamp(t *testing.T) {
var got []string
ui.Render(func(c *ui.Context) {
Use(c, Settings{})
today := DateOf(chatNow)
got = []string{dateSeparatorWhen(c, chatNow, today), dateSeparatorWhen(c, chatNow.AddDate(0, 0, -1), today),
dateSeparatorWhen(c, chatNow.AddDate(0, -2, 0), today)}
}, 100, 100, 1)
if got[0] != "15:30" || got[1] != "Yesterday" || got[2] != "August 5" {
t.Fatalf("stamps %q", got)
}
}
func TestDateSeparatorRejectsInvalid(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.Render(func(c *ui.Context) {
Use(c, Settings{})
DateSeparator(c, Date{2026, 2, 30}, DateSeparatorOptions{})
}, 100, 100, 1)
}
func TestDateSeparatorSnapshot(t *testing.T) {
snapshot(t, "date_separator", 520, 110, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
DateSeparator(c, DateOf(chatNow), DateSeparatorOptions{Today: DateOf(chatNow)})
DateSeparator(c, Date{2026, 9, 1}, DateSeparatorOptions{Today: DateOf(chatNow)})
})
})
}
+56
View File
@@ -0,0 +1,56 @@
# 241 ConversationList · 会话列表
## 用途
按“置顶、今天、昨天、7 天内、更早”分组显示历史会话,只构建可见行,适合十万条以上的会话历史。
## 最小示例
```go
var state mujica.ConversationListState
convs := []mujica.ChatConversation{
{ID: "1", Title: "Court etiquette", Updated: time.Now()},
{ID: "2", Title: "Royal letters", Updated: time.Now().AddDate(0, 0, -30), Pinned: true},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ConversationList(c, &state, convs, mujica.ConversationListOptions{Label: "History"}, nil).Changed() {
log.Println("open", state.Selected)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func ConversationList(c *ui.Context, s *ConversationListState, items []ChatConversation, opts ConversationListOptions, row func(conv ChatConversation)) ConversationListView`。`row` 为 nil 时显示标题;传入时可放 `ConversationItem` 等自定义内容。
`ChatConversation`:`ID`(唯一)、`Title`、`Updated`、`Pinned`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Now` | `time.Time` | 零值(窗口时钟) | 分组用的当前时间。 |
| `Label` | `string` | "" | 列表的无障碍名称。 |
| `Disabled` | `bool` | false | 禁用:不能选择,退出 Tab 序列。 |
`ConversationListState.Selected` 为当前会话 ID,调用方可直接设置。
## 状态
分组标题(衬线小字、旧金色)、普通行、悬停、选中(Selection 底色 + 左侧菱形)、聚焦(列表焦点环)、禁用。
## 事件
`Changed()`:选中了另一会话,ID 在 `State.Selected`。`Submitted()`:在选中行上按 Enter 或双击。
## 键盘操作
- Tab:聚焦列表。
- ↑/↓、Home、End:在会话间移动并选择,跳过分组标题;输入首字母定位。
- Enter:打开当前会话。
## 限制
- 分组与排序只在 `items` 切片(首元素地址或长度)或当天日期变化时重算。原地修改切片元素后,请传入新切片以触发重新分组。
- 选中行由 MyGo 列表报告选中状态;分组标题由 `ListState.Header` 标记,不可选中。
- 自定义 `row` 中若含按钮(如 `ConversationItem`),点击由按钮处理,不会改变列表选择;请在按钮事件里设置 `State.Selected`。
+50
View File
@@ -0,0 +1,50 @@
# 242 ConversationItem · 会话条目
## 用途
一行会话:标题、时间、置顶标记,以及菜单中的重命名(行内编辑)、置顶、导出、删除。删除与导出只回调,由调用方执行。
## 最小示例
```go
conv := mujica.ChatConversation{ID: "1", Title: "Court etiquette", Updated: time.Now()}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
v := mujica.ConversationItem(c, &conv, mujica.ConversationItemOptions{})
if v.Deleted {
log.Println("delete", conv.ID)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func ConversationItem(c *ui.Context, conv *ChatConversation, opts ConversationItemOptions) ConversationItemView`。`conv` 不能为 nil,`ID` 不能为空。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Today` | `Date` | 零值(窗口时钟) | 决定时间显示为时刻、“昨天”或日期。 |
| `Selected` | `bool` | false | 显示为当前会话。 |
| `Disabled` | `bool` | false | 禁用全部操作。 |
| `Draggable` | `bool` | false | 允许拖到 `ProjectList` 的项目上,携带 `ConversationDrag`。 |
## 状态
默认、悬停、选中(Selection 底色 + 酒红标题)、置顶(星形图标)、行内编辑(输入框酒红边框)、禁用。
## 事件
`ConversationItemView`:`Opened`(点击或 Enter)、`Renamed`(新标题已写入 `conv.Title`)、`PinToggled`、`Exported`、`Deleted`。
## 键盘操作
- Enter/Space:打开会话。
- F2:行内重命名;输入框中 Enter 确认,Escape 取消;空白标题不会被接受。
- Delete:请求删除。
- 菜单按钮:Enter、Space 或 ↓ 打开,方向键移动,Escape 关闭。
## 限制
- 删除没有内置确认;需要确认时由调用方弹出 AlertDialog。
- 菜单按钮不在条目的 Tab 位之外另设快捷键。
@@ -0,0 +1,53 @@
# 243 ConversationSearch · 会话搜索
## 用途
按标题与内容搜索会话。查询交给调用方异步执行,结果回传后高亮匹配文字。
## 最小示例
```go
query := "seat"
hits := []mujica.ConversationSearchHit{{ID: "1", Title: "Ballroom seating plan"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
v := mujica.ConversationSearch(c, &query, hits, mujica.ConversationSearchOptions{})
if v.QueryChanged {
log.Println("search", query)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func ConversationSearch(c *ui.Context, query *string, hits []ConversationSearchHit, opts ConversationSearchOptions) ConversationSearchView`。
`ConversationSearchHit`:`ID`、`Title`、`Snippet`(匹配的消息片段)。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Loading` | `bool` | false | 调用方正在搜索。 |
| `Error` | `string` | "" | 搜索失败的说明,附重试按钮。 |
| `Placeholder` | `string` | 内置“搜索” | 输入框占位。 |
| `Label` | `string` | "" | 无障碍名称。 |
| `Disabled` | `bool` | false | 禁用输入与选择。 |
## 状态
空查询、搜索中(旋转标记)、有结果(匹配处加粗、下划线、Selection 底)、无结果、失败(错误文字 + 重试)、禁用。
## 事件
`QueryChanged`:查询变化,调用方据此发起搜索,结果到达后传入新的 `hits` 并调用 `c.Invalidate()`。`Chosen`:点击或 Enter 打开的会话 ID。`Retry`:点击重试。
## 键盘操作
- 输入框中 ↓:进入结果列表。
- 列表中 ↑/↓ 移动,Enter 打开。
- Escape:清空查询(SearchInput 行为)。
## 限制
- 匹配高亮为不区分大小写的逐字符匹配,不做分词、拼音或模糊匹配;排序完全由调用方决定。
- 结果行固定高度(标题一行、片段一行),片段超长时截断。
+53
View File
@@ -0,0 +1,53 @@
# 244 ProjectList · 项目列表
## 用途
按文件夹列出项目,展开项目可见其中的会话;把会话拖到项目上或用“移动”菜单即可改变归属。
## 最小示例
```go
var state mujica.ProjectListState
projects := []mujica.ChatProject{{ID: "p1", Name: "Coronation", Folder: "Court"}}
convs := func(id string) []mujica.ChatConversation {
return []mujica.ChatConversation{{ID: "1", Title: "Menu"}}
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if v := mujica.ProjectList(c, &state, projects, convs, mujica.ProjectListOptions{}); v.Moved {
log.Println("move", v.MoveID, "to", v.MoveTo)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func ProjectList(c *ui.Context, s *ProjectListState, projects []ChatProject, conversations func(projectID string) []ChatConversation, opts ProjectListOptions) ProjectListView`。
`ChatProject`:`ID`、`Name`、`Folder`(文件夹名,空表示不在文件夹中)。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Label` | `string` | "" | 树的无障碍名称。 |
| `Disabled` | `bool` | false | 禁用选择、展开与移动。 |
## 状态
文件夹(展开/收起)、项目行(悬停、选中:Selection 底 + 菱形)、拖入目标(角饰边框)、会话行(拖动时半透明)、禁用。
## 事件
`ProjectListView`:`Changed`(选中项目,ID 在 `State.Selected`)、`Opened`(打开的会话 ID)、`Moved` 与 `MoveID`/`MoveTo`(把会话移入项目,由调用方更新数据)。
## 键盘操作
- Tab:进入树;↑/↓ 在文件夹、项目、会话间移动。
- 项目行上 →/← 展开/收起会话;Enter 选择项目。
- 文件夹上 Enter/Space 展开或收起。
- 会话行的菜单按钮:选择“移入项目”下的目标完成移动(拖放的键盘替代)。
## 限制
- 项目行通过 `Description` 读出“已展开/已收起”;MyGo 不向自定义元素公开 expanded 状态(ui/element.go:282),文件夹使用 `CollapsibleBase`,其展开状态可被辅助技术读到。
- 列表未虚拟化,适合数百个项目以内。
@@ -0,0 +1,47 @@
# 245 ProjectKnowledgePanel · 项目知识面板
## 用途
列出项目知识文件,提供添加、移除,并显示 token 用量与容量提示。选择与读取文件由调用方完成。
## 最小示例
```go
files := []mujica.KnowledgeFile{{ID: "1", Name: "etiquette.pdf", Tokens: 12000}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
v := mujica.ProjectKnowledgePanel(c, files, mujica.ProjectKnowledgePanelOptions{Capacity: 200000})
if v.Add {
log.Println("pick files")
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func ProjectKnowledgePanel(c *ui.Context, files []KnowledgeFile, opts ProjectKnowledgePanelOptions) ProjectKnowledgePanelView`。
`KnowledgeFile`:`ID`、`Name`、`Tokens`(不能为负)。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Capacity` | `int` | 必填 | 知识容量(tokens),必须为正,否则 panic。 |
| `Title` | `string` | 内置“项目知识” | 面板标题。 |
| `Disabled` | `bool` | false | 禁用添加与移除。 |
## 状态
正常用量(酒红进度)、≥ 80%(警告色 + 三角图标 + “即将用满”)、超出容量(危险色 + 圆形警示图标 + “超出容量”)、空列表、禁用。
## 事件
`Add`:请调用方打开文件选择。`Removed`:要移除的文件 ID。
## 键盘操作
- Tab:添加按钮,再进入文件列表;↑/↓ 在移除按钮间移动;Enter/Space 移除。
## 限制
- token 数由调用方计算;面板只做求和与比例显示。
+46
View File
@@ -0,0 +1,46 @@
# 246 WelcomeScreen · 欢迎页
## 用途
新会话的欢迎页:衬线问候、菱形分隔、副标题、能力卡片,以及放置起始提示的插槽。
## 最小示例
```go
cards := []mujica.CapabilityCard{{Title: "Write", Examples: []string{"Draft a decree"}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if v := mujica.WelcomeScreen(c, mujica.WelcomeScreenOptions{Greeting: "Good evening", Cards: cards}); v.Example != "" {
log.Println("ask", v.Example)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func WelcomeScreen(c *ui.Context, opts WelcomeScreenOptions) CapabilityCardsView`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Greeting` | `string` | 必填 | 问候语,空时 panic。 |
| `Subtitle` | `string` | "" | 副标题。 |
| `Cards` | `[]CapabilityCard` | nil | 能力卡片,最多三列。 |
| `Prompts` | `func()` | nil | 起始提示插槽,例如 `SuggestionChips`。 |
| `Disabled` | `bool` | false | 禁用卡片示例与插槽内容。 |
## 状态
默认、禁用(由 MyGo 置灰)。卡片与示例按钮的状态见 249 CapabilityCards。
## 事件
返回值的 `Example`:点击的示例提示。插槽内组件的事件由调用方自行读取。
## 键盘操作
- Tab 依次经过各卡片的示例与插槽内控件;卡片内 ↑/↓ 在示例间移动;Enter/Space 选择。
## 限制
- 问候语按纯文本显示,不解析 Markdown。
+42
View File
@@ -0,0 +1,42 @@
# 247 SuggestionChips · 建议标签
## 用途
起始提示或追问建议。点击可填入输入框,或配置为直接发送。
## 最小示例
```go
input := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.SuggestionChips(c, &input, []string{"Plan a ball", "Write a sonnet"}, mujica.SuggestionChipsOptions{})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func SuggestionChips(c *ui.Context, input *string, suggestions []string, opts SuggestionChipsOptions) SuggestionChipsView`。建议不能为空字符串。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Send` | `bool` | false | 点击即请求发送;默认把建议填入 `*input`。 |
| `Label` | `string` | "" | 组名称,如“追问建议”。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
默认(右箭头)、发送模式(上箭头)、悬停、按下、聚焦、禁用。
## 事件
`Chosen`:点击的建议。`Sent` 为 true 时调用方应立即发送 `Chosen`;否则建议已写入 `*input`。
## 键盘操作
- Tab 进入组;←/→ 在建议间移动;Enter/Space 选择。
## 限制
- 建议换行排列;数量很多时请调用方截取。
+49
View File
@@ -0,0 +1,49 @@
# 248 PromptLibrary · 提示模板库
## 用途
按分类与搜索浏览提示模板,预览模板,填写 `{{变量}}` 后插入。
## 最小示例
```go
var state mujica.PromptLibraryState
templates := []mujica.PromptTemplate{{ID: "1", Category: "Letters", Title: "Invitation", Body: "Invite {{guest}}."}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if v := mujica.PromptLibrary(c, &state, templates, mujica.PromptLibraryOptions{}); v.Inserted != "" {
log.Println("insert", v.Inserted)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func PromptLibrary(c *ui.Context, s *PromptLibraryState, templates []PromptTemplate, opts PromptLibraryOptions) PromptLibraryView`。另有 `PromptLibraryVariables(body)` 与 `PromptLibraryFill(body, values)`。
`PromptTemplate`:`ID`、`Category`、`Title`(ID 与 Title 必填)、`Body`(`{{name}}` 为变量,名称可含字母、数字、`_.-`)。
`PromptLibraryState`:`Category`(空为全部)、`Query`、`Selected`、`Values`(变量值)。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Label` | `string` | "" | 无障碍名称。 |
| `Disabled` | `bool` | false | 禁用全部操作。 |
## 状态
分类与模板行(选中:Selection 底 + 菱形)、无匹配、未选模板、预览(未填变量以 [名称] 标出,已填值酒红加粗)、插入按钮(变量未填全时禁用)、禁用。
## 事件
`Inserted`:变量已填好的模板正文,非空时由调用方插入输入框。
## 键盘操作
- Tab 依次进入分类列、搜索框、模板列、变量输入框、插入按钮;列内 ↑/↓ 移动;Enter/Space 选择。
## 限制
- 过滤只在模板切片、查询或分类变化时重算。
- 变量为简单占位替换,不支持条件或循环。
+45
View File
@@ -0,0 +1,45 @@
# 249 CapabilityCards · 能力卡片
## 用途
用卡片说明助手能做什么,每张卡片附示例提示,点击示例交给调用方。
## 最小示例
```go
cards := []mujica.CapabilityCard{{Title: "Plan", Icon: icons.Must("calendar"), Examples: []string{"Plan a ball"}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if v := mujica.CapabilityCards(c, cards, mujica.CapabilityCardsOptions{}); v.Example != "" {
log.Println("ask", v.Example)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func CapabilityCards(c *ui.Context, cards []CapabilityCard, opts CapabilityCardsOptions) CapabilityCardsView`。
`CapabilityCard`:`Title`(必填)、`Description`、`Icon`、`Examples`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Columns` | `int` | 0 → 3 | 每行卡片数;负数 panic。 |
| `Disabled` | `bool` | false | 禁用示例按钮。 |
## 状态
卡片(衬线标题、酒红图标)、示例按钮(悬停、按下、聚焦)、禁用。
## 事件
`Example`:点击的示例文字。
## 键盘操作
- Tab 在各卡片示例组间移动;组内 ↑/↓;Enter/Space 选择。
## 限制
- 卡片等宽分栏,不随窗口宽度自动减少列数;窄窗口时请调用方传入较小的 `Columns`。
+46
View File
@@ -0,0 +1,46 @@
# 250 FeedbackForm · 反馈表单
## 用途
在点赞或点踩之后询问原因:原因标签多选与补充文字,然后提交或取消。
## 最小示例
```go
var value mujica.FeedbackValue
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.FeedbackForm(c, &value, mujica.FeedbackFormOptions{Rating: mujica.FeedbackDown}).Submitted {
log.Println("reasons", value.Reasons)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func FeedbackForm(c *ui.Context, value *FeedbackValue, opts FeedbackFormOptions) FeedbackFormView`。`FeedbackValue`:`Reasons`、`Comment`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Rating` | `FeedbackRating` | `FeedbackUp` | 赞(`FeedbackUp`)或踩(`FeedbackDown`),决定标题与内置原因。 |
| `Reasons` | `[]string` | nil(内置) | 替换内置原因。 |
| `Submitting` | `bool` | false | 调用方正在提交:提交按钮显示加载,表单不可编辑。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
原因标签(按下:Selection 底 + 下划条)、文字框、提交中、禁用。
## 事件
`Submitted`:提交;`Cancelled`:取消或 Escape。
## 键盘操作
- Tab 依次经过原因组、文字框、取消、提交;原因组内 ←/→;Space 切换原因。
- Escape:取消。
## 限制
- 表单不发送任何数据;提交由调用方完成。
+39
View File
@@ -0,0 +1,39 @@
# 251 DateSeparator · 日期分隔
## 用途
在消息流中标出新的一天:今天、昨天,其余显示本地化的完整日期。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.DateSeparator(c, mujica.DateOf(time.Now()), mujica.DateSeparatorOptions{})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func DateSeparator(c *ui.Context, day Date, opts DateSeparatorOptions) *ui.Element`。`day` 必须是有效日期。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Today` | `Date` | 零值(窗口时钟) | 判断“今天/昨天”的当天。 |
## 状态
细线 + 旧金色菱形 + 说明文字;无交互状态。
## 事件
无。
## 键盘操作
不获取焦点;辅助技术读出日期文字。
## 限制
- 日期格式沿用 Calendar 的文案(`calendar.day_label`),en-US 为“Tuesday, September 1, 2026”,zh-CN 为“2026年9月1日 星期二”。
+39
View File
@@ -0,0 +1,39 @@
# 252 ThinkingIndicator · 思考指示
## 用途
显示助手正在思考:三个菱形依次起伏,旁边是阶段文字;减少动画时静止并加省略号。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ThinkingIndicator(c, mujica.ThinkingIndicatorOptions{Phase: "Searching the archives"})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func ThinkingIndicator(c *ui.Context, opts ThinkingIndicatorOptions) *ui.Element`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Phase` | `string` | 内置“正在思考” | 阶段文字,如“正在检索资料”。 |
## 状态
动画(1.2 秒一周)、减少动画(静止菱形 + “…”)。
## 事件
无。
## 键盘操作
不获取焦点;角色为 Status,辅助技术读出阶段文字。
## 限制
- 动画只在绘制时请求下一帧;组件隐藏后不再重绘。
@@ -0,0 +1,44 @@
# 253 StopGeneratingButton · 停止生成按钮
## 用途
生成回复时显示的停止按钮,并提供 Cmd+.(其他平台 Ctrl+.)快捷键;生成结束后不显示。
## 最小示例
```go
generating := true
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.StopGeneratingButton(c, mujica.StopGeneratingButtonOptions{Generating: generating}).Changed() {
generating = false
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func StopGeneratingButton(c *ui.Context, opts StopGeneratingButtonOptions) ChoiceResult`。不显示时返回的 `Element` 为 nil。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Generating` | `bool` | false | 正在生成时显示。 |
| `Disabled` | `bool` | false | 禁用按钮与快捷键。 |
## 状态
默认(方块图标 + 文字 + 快捷键提示)、悬停、聚焦、禁用、隐藏。
## 事件
`Changed()`:请求停止。
## 键盘操作
- Enter/Space:停止。
- Cmd+.(macOS)/ Ctrl+.(Windows、Linux):窗口级快捷键,焦点所在控件未占用该键时生效。
## 限制
- 快捷键用 MyGo 窗口快捷键注册,焦点控件若注册了同一组合键会优先获得。
@@ -0,0 +1,47 @@
# 254 SystemPromptEditor · 系统提示编辑器
## 用途
多行编辑系统提示,显示字数,可恢复默认,有未保存标记,Cmd/Ctrl+S 保存。
## 最小示例
```go
prompt, saved := "You are helpful.", "You are helpful."
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.SystemPromptEditor(c, &prompt, mujica.SystemPromptEditorOptions{Default: "You are helpful.", Saved: saved}).Save {
saved = prompt
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func SystemPromptEditor(c *ui.Context, value *string, opts SystemPromptEditorOptions) SystemPromptEditorView`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Default` | `string` | "" | 恢复默认时写回的文字。 |
| `Saved` | `string` | "" | 最近一次保存的文字;与当前值不同即显示“未保存”。 |
| `MaxLength` | `int` | 0 | 字数上限:显示 n / 上限,超出时提示并禁止保存。 |
| `Saving` | `bool` | false | 调用方正在保存,编辑被阻止。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
已保存、未保存(警告色菱形 + 文字)、超出上限(错误提示)、保存中、禁用。
## 事件
`Save`:请求保存当前值;`Restored`:已恢复默认。
## 键盘操作
- Tab:进入文本框,再到恢复默认与保存按钮。
- Cmd+S(macOS)/ Ctrl+S:保存(有改动且未超限时)。
## 限制
- 超出上限不会截断文字,只阻止保存。
+42
View File
@@ -0,0 +1,42 @@
# 255 CostEstimator · 费用估算
## 用途
显示输入、输出 token 数及按调用方单价估算的费用。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.CostEstimator(c, mujica.CostEstimatorOptions{InputTokens: 12000, OutputTokens: 3400, InputPrice: 3, OutputPrice: 15})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func CostEstimator(c *ui.Context, opts CostEstimatorOptions) *ui.Element`。另有 `EstimateCost(inputTokens, outputTokens, inputPrice, outputPrice) CostEstimate`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `InputTokens`, `OutputTokens` | `int` | 0 | token 数,不能为负。 |
| `InputPrice`, `OutputPrice` | `float64` | 0 | 每百万 tokens 单价,须有限且不为负。 |
| `Currency` | `string` | "$" | 金额前缀。 |
| `Digits` | `int` | 0 → 4 | 金额小数位;负数 panic。 |
## 状态
只读表格:输入、输出、合计(衬线标签、酒红加粗金额)与单价说明。
## 事件
无。
## 键盘操作
不获取焦点;整体读作“预计费用 金额”。
## 限制
- 估算按 tokens × 单价 / 1,000,000 的浮点运算;显示按 `Digits` 位小数四舍五入,不用于结算。
@@ -0,0 +1,48 @@
# 256 SharedConversationView · 分享会话视图
## 用途
只读显示他人分享的会话,带分享者与时间,提供复制链接回调。
## 最小示例
```go
msgs := []mujica.SessionMessage{{Role: mujica.SessionUser, Text: "Hello"}, {Role: mujica.SessionAssistant, Text: "Good day."}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.SharedConversationView(c, msgs, mujica.SharedConversationViewOptions{Title: "Greeting", SharedBy: "Ann"}).Changed() {
c.WriteClipboard("https://example.com/share/1")
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func SharedConversationView(c *ui.Context, messages []SessionMessage, opts SharedConversationViewOptions) ChoiceResult`。
`SessionMessage`:`Role`(`SessionUser`、`SessionAssistant`、`SessionSystem`)、`Author`、`Text`、`Time`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Title` | `string` | 必填 | 会话标题。 |
| `SharedBy` | `string` | "" | 分享者,空显示“未知”。 |
| `SharedAt` | `time.Time` | 零值 | 分享时间,零值不显示。 |
| `Disabled` | `bool` | false | 禁用复制链接。 |
## 状态
标题区、只读标记(锁形图标)、用户消息(描边气泡)、助手消息、禁用。
## 事件
`Changed()`:点击“复制链接”,由调用方生成链接并写入剪贴板。
## 键盘操作
- Tab:复制链接按钮,再到消息列表;消息文字可选择复制。
## 限制
- 消息以纯文本显示,不渲染 Markdown(Markdown 渲染属于 MarkdownView 组件)。
- 消息列表虚拟化,只构建可见消息。
@@ -0,0 +1,48 @@
# 257 ConversationExport · 会话导出
## 用途
选择格式(Markdown、JSON、纯文本)与范围,预览后生成导出文字;写文件由调用方完成。
## 最小示例
```go
var state mujica.ConversationExportState
msgs := []mujica.SessionMessage{{Role: mujica.SessionUser, Text: "Hello"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if v := mujica.ConversationExport(c, &state, msgs, mujica.ConversationExportOptions{Title: "Greeting"}); v.Exported {
log.Println(len(v.Content), "bytes to save")
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func ConversationExport(c *ui.Context, s *ConversationExportState, messages []SessionMessage, opts ConversationExportOptions) ConversationExportView`。另有纯函数 `ExportConversation(title, messages, format) string`。
`ConversationExportState`:`Format`(`ExportMarkdown`、`ExportJSON`、`ExportText`)、`Range`(`ExportAll`、`ExportLast`、`ExportSelected`)、`Last`(0 → 10)。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Title` | `string` | 必填 | 会话标题,写入导出内容。 |
| `Selected` | `[]int` | nil | `ExportSelected` 使用的消息下标;越界 panic。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
格式单选、范围单选(无选中消息时“已选”不可选)、最近 N 条数字框、等宽字体预览、导出按钮、禁用。
## 事件
`Exported`:导出请求;`Content` 为生成的文字,`Format` 为格式。
## 键盘操作
- Tab 依次经过格式组、范围组、数字框、预览、导出按钮;单选组内方向键选择。
## 限制
- 导出不解析消息文字:Markdown 只加标题与作者行,原文原样写入。
- 预览只在格式、范围或消息变化时重新生成。
+44
View File
@@ -0,0 +1,44 @@
# 258 RegenerateMenu · 重新生成菜单
## 用途
重新生成回复:同模型重试、换模型重试,以及更长、更短等改写变体。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if v := mujica.RegenerateMenu(c, mujica.RegenerateMenuOptions{Models: []string{"Opus", "Sonnet"}, Current: "Opus"}); v.Chosen {
log.Println("retry", v.Choice.Kind, v.Choice.Model, v.Choice.Variant)
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func RegenerateMenu(c *ui.Context, opts RegenerateMenuOptions) RegenerateMenuView`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Models` | `[]string` | nil | 可换用的模型;为空或只有当前模型时子菜单禁用。 |
| `Current` | `string` | "" | 当前模型,不出现在子菜单中。 |
| `Variants` | `[]string` | nil(内置) | 替换内置的更长、更短、更简单、更详细。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
触发按钮(刷新图标)、菜单打开、子菜单、分组标题、禁用项。
## 事件
`Chosen` 与 `Choice`:`Kind` 为 `RegenerateAgain`、`RegenerateModel`(`Model`)或 `RegenerateVariant`(`Variant`)。
## 键盘操作
- Enter、Space 或 ↓ 打开菜单;↑/↓ 移动;→ 打开子菜单,← 返回;Enter 选择;Escape 关闭。
## 限制
- 菜单在窗口内绘制(DropdownMenu),不使用系统菜单。
+45
View File
@@ -0,0 +1,45 @@
# 259 ArtifactCard · 产物卡片
## 用途
消息中的产物卡片(代码、文档、网页),点击打开产物面板。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ArtifactCard(c, mujica.ArtifactCardOptions{Title: "seating.go", Detail: "Go · 120 lines"}).Changed() {
log.Println("open artifact panel")
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func ArtifactCard(c *ui.Context, opts ArtifactCardOptions) ChoiceResult`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Kind` | `ArtifactKind` | `ArtifactCode` | `ArtifactCode`、`ArtifactDocument`、`ArtifactWeb`;越界 panic。 |
| `Title` | `string` | 必填 | 产物标题。 |
| `Detail` | `string` | "" | 简短说明,如“Go · 120 行”。 |
| `Streaming` | `bool` | false | 仍在生成,显示旋转标记。 |
| `Disabled` | `bool` | false | 禁用。 |
## 状态
默认、悬停、聚焦、生成中、禁用。种类同时用图标与文字表示。
## 事件
`Changed()`:请求打开产物面板。
## 键盘操作
- Tab 聚焦;Enter/Space 打开。
## 限制
- 卡片不渲染产物内容,面板由调用方实现。
+44
View File
@@ -0,0 +1,44 @@
# 260 ChatEmptyState · 会话空状态
## 用途
会话界面的三种空状态:新会话、无搜索结果、离线,并给出下一步操作。
## 最小示例
```go
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ChatEmptyState(c, mujica.ChatEmptyStateOptions{Kind: mujica.ChatEmptyOffline}).Action {
log.Println("reconnect")
}
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func ChatEmptyState(c *ui.Context, opts ChatEmptyStateOptions) EmptyStateResult`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Kind` | `ChatEmptyKind` | `ChatEmptyNew` | `ChatEmptyNew`、`ChatEmptyNoResults`、`ChatEmptyOffline`;越界 panic。 |
| `Query` | `string` | "" | 无结果时在说明中显示的查询。 |
| `Title`, `Description` | `string` | 内置 | 替换文案。 |
| `Action` | `string` | 内置 | 按钮文字:新建对话、清除搜索、重试。 |
## 状态
新会话(星形图标)、无结果(搜索图标)、离线(断网图标)。
## 事件
返回值 `Action`:点击按钮。
## 键盘操作
- Tab 聚焦按钮;Enter/Space 触发。
## 限制
- 基于 EmptyState 组件,布局与其一致。
+231
View File
@@ -0,0 +1,231 @@
package mujica_test
import (
"log"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
"mujicaui/icons"
)
func ExampleConversationList() {
var state mujica.ConversationListState
convs := []mujica.ChatConversation{
{ID: "1", Title: "Court etiquette", Updated: time.Now()},
{ID: "2", Title: "Royal letters", Updated: time.Now().AddDate(0, 0, -30), Pinned: true},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ConversationList(c, &state, convs, mujica.ConversationListOptions{Label: "History"}, nil).Changed() {
log.Println("open", state.Selected)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleConversationItem() {
conv := mujica.ChatConversation{ID: "1", Title: "Court etiquette", Updated: time.Now()}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
v := mujica.ConversationItem(c, &conv, mujica.ConversationItemOptions{})
if v.Deleted {
log.Println("delete", conv.ID)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleConversationSearch() {
query := "seat"
hits := []mujica.ConversationSearchHit{{ID: "1", Title: "Ballroom seating plan"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
v := mujica.ConversationSearch(c, &query, hits, mujica.ConversationSearchOptions{})
if v.QueryChanged {
log.Println("search", query)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleProjectList() {
var state mujica.ProjectListState
projects := []mujica.ChatProject{{ID: "p1", Name: "Coronation", Folder: "Court"}}
convs := func(id string) []mujica.ChatConversation {
return []mujica.ChatConversation{{ID: "1", Title: "Menu"}}
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if v := mujica.ProjectList(c, &state, projects, convs, mujica.ProjectListOptions{}); v.Moved {
log.Println("move", v.MoveID, "to", v.MoveTo)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleProjectKnowledgePanel() {
files := []mujica.KnowledgeFile{{ID: "1", Name: "etiquette.pdf", Tokens: 12000}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
v := mujica.ProjectKnowledgePanel(c, files, mujica.ProjectKnowledgePanelOptions{Capacity: 200000})
if v.Add {
log.Println("pick files")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleWelcomeScreen() {
cards := []mujica.CapabilityCard{{Title: "Write", Examples: []string{"Draft a decree"}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if v := mujica.WelcomeScreen(c, mujica.WelcomeScreenOptions{Greeting: "Good evening", Cards: cards}); v.Example != "" {
log.Println("ask", v.Example)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleSuggestionChips() {
input := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.SuggestionChips(c, &input, []string{"Plan a ball", "Write a sonnet"}, mujica.SuggestionChipsOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExamplePromptLibrary() {
var state mujica.PromptLibraryState
templates := []mujica.PromptTemplate{{ID: "1", Category: "Letters", Title: "Invitation", Body: "Invite {{guest}}."}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if v := mujica.PromptLibrary(c, &state, templates, mujica.PromptLibraryOptions{}); v.Inserted != "" {
log.Println("insert", v.Inserted)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleCapabilityCards() {
cards := []mujica.CapabilityCard{{Title: "Plan", Icon: icons.Must("calendar"), Examples: []string{"Plan a ball"}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if v := mujica.CapabilityCards(c, cards, mujica.CapabilityCardsOptions{}); v.Example != "" {
log.Println("ask", v.Example)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleFeedbackForm() {
var value mujica.FeedbackValue
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.FeedbackForm(c, &value, mujica.FeedbackFormOptions{Rating: mujica.FeedbackDown}).Submitted {
log.Println("reasons", value.Reasons)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleDateSeparator() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.DateSeparator(c, mujica.DateOf(time.Now()), mujica.DateSeparatorOptions{})
}
ui.Render(view, 400, 300, 1)
}
func ExampleThinkingIndicator() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ThinkingIndicator(c, mujica.ThinkingIndicatorOptions{Phase: "Searching the archives"})
}
ui.Render(view, 400, 300, 1)
}
func ExampleStopGeneratingButton() {
generating := true
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.StopGeneratingButton(c, mujica.StopGeneratingButtonOptions{Generating: generating}).Changed() {
generating = false
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleSystemPromptEditor() {
prompt, saved := "You are helpful.", "You are helpful."
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.SystemPromptEditor(c, &prompt, mujica.SystemPromptEditorOptions{Default: "You are helpful.", Saved: saved}).Save {
saved = prompt
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleCostEstimator() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.CostEstimator(c, mujica.CostEstimatorOptions{InputTokens: 12000, OutputTokens: 3400, InputPrice: 3, OutputPrice: 15})
}
ui.Render(view, 400, 300, 1)
}
func ExampleSharedConversationView() {
msgs := []mujica.SessionMessage{{Role: mujica.SessionUser, Text: "Hello"}, {Role: mujica.SessionAssistant, Text: "Good day."}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.SharedConversationView(c, msgs, mujica.SharedConversationViewOptions{Title: "Greeting", SharedBy: "Ann"}).Changed() {
c.WriteClipboard("https://example.com/share/1")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleConversationExport() {
var state mujica.ConversationExportState
msgs := []mujica.SessionMessage{{Role: mujica.SessionUser, Text: "Hello"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if v := mujica.ConversationExport(c, &state, msgs, mujica.ConversationExportOptions{Title: "Greeting"}); v.Exported {
log.Println(len(v.Content), "bytes to save")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleRegenerateMenu() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if v := mujica.RegenerateMenu(c, mujica.RegenerateMenuOptions{Models: []string{"Opus", "Sonnet"}, Current: "Opus"}); v.Chosen {
log.Println("retry", v.Choice.Kind, v.Choice.Model, v.Choice.Variant)
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleArtifactCard() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ArtifactCard(c, mujica.ArtifactCardOptions{Title: "seating.go", Detail: "Go · 120 lines"}).Changed() {
log.Println("open artifact panel")
}
}
ui.Render(view, 400, 300, 1)
}
func ExampleChatEmptyState() {
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ChatEmptyState(c, mujica.ChatEmptyStateOptions{Kind: mujica.ChatEmptyOffline}).Action {
log.Println("reconnect")
}
}
ui.Render(view, 400, 300, 1)
}
+184 -1
View File
@@ -1,3 +1,186 @@
package main
var chatsessionPages = []page{}
const chatsessionCategory = "Chat Sessions"
var chatsessionPages = []page{
{ID: "241", Name: "ConversationList", ZhName: "会话列表", Category: chatsessionCategory, Demo: chatDemoConversationList,
Keys: "Up/Down choose a conversation, skipping group headers; typing jumps; Enter opens.", Code: `var state mujica.ConversationListState
convs := []mujica.ChatConversation{
{ID: "1", Title: "Court etiquette", Updated: time.Now()},
{ID: "2", Title: "Royal letters", Updated: time.Now().AddDate(0, 0, -30), Pinned: true},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ConversationList(c, &state, convs, mujica.ConversationListOptions{Label: "History"}, nil).Changed() {
log.Println("open", state.Selected)
}
}
ui.Render(view, 400, 300, 1)`},
{ID: "242", Name: "ConversationItem", ZhName: "会话条目", Category: chatsessionCategory, Demo: chatDemoConversationItem,
Keys: "Enter opens; F2 renames in place (Enter keeps, Escape cancels); Delete asks to delete; the ellipsis opens the menu.", Code: `conv := mujica.ChatConversation{ID: "1", Title: "Court etiquette", Updated: time.Now()}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
v := mujica.ConversationItem(c, &conv, mujica.ConversationItemOptions{})
if v.Deleted {
log.Println("delete", conv.ID)
}
}
ui.Render(view, 400, 300, 1)`},
{ID: "243", Name: "ConversationSearch", ZhName: "会话搜索", Category: chatsessionCategory, Demo: chatDemoConversationSearch,
Keys: "Type to search; Down moves into the results; Up/Down move; Enter opens; Escape clears.", Code: `query := "seat"
hits := []mujica.ConversationSearchHit{{ID: "1", Title: "Ballroom seating plan"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
v := mujica.ConversationSearch(c, &query, hits, mujica.ConversationSearchOptions{})
if v.QueryChanged {
log.Println("search", query)
}
}
ui.Render(view, 400, 300, 1)`},
{ID: "244", Name: "ProjectList", ZhName: "项目列表", Category: chatsessionCategory, Demo: chatDemoProjectList,
Keys: "Up/Down move through folders, projects and conversations; Right/Left show or hide a project's conversations; the ellipsis menu moves a conversation.", Code: `var state mujica.ProjectListState
projects := []mujica.ChatProject{{ID: "p1", Name: "Coronation", Folder: "Court"}}
convs := func(id string) []mujica.ChatConversation {
return []mujica.ChatConversation{{ID: "1", Title: "Menu"}}
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if v := mujica.ProjectList(c, &state, projects, convs, mujica.ProjectListOptions{}); v.Moved {
log.Println("move", v.MoveID, "to", v.MoveTo)
}
}
ui.Render(view, 400, 300, 1)`},
{ID: "245", Name: "ProjectKnowledgePanel", ZhName: "项目知识面板", Category: chatsessionCategory, Demo: chatDemoProjectKnowledgePanel,
Keys: "Tab reaches Add files and the file list; Up/Down move between remove buttons; Enter removes.", Code: `files := []mujica.KnowledgeFile{{ID: "1", Name: "etiquette.pdf", Tokens: 12000}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
v := mujica.ProjectKnowledgePanel(c, files, mujica.ProjectKnowledgePanelOptions{Capacity: 200000})
if v.Add {
log.Println("pick files")
}
}
ui.Render(view, 400, 300, 1)`},
{ID: "246", Name: "WelcomeScreen", ZhName: "欢迎页", Category: chatsessionCategory, Demo: chatDemoWelcomeScreen,
Keys: "Tab moves through the cards' examples and the prompt slot; Enter picks.", Code: `cards := []mujica.CapabilityCard{{Title: "Write", Examples: []string{"Draft a decree"}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if v := mujica.WelcomeScreen(c, mujica.WelcomeScreenOptions{Greeting: "Good evening", Cards: cards}); v.Example != "" {
log.Println("ask", v.Example)
}
}
ui.Render(view, 400, 300, 1)`},
{ID: "247", Name: "SuggestionChips", ZhName: "建议标签", Category: chatsessionCategory, Demo: chatDemoSuggestionChips,
Keys: "Tab enters the chips; Left/Right move; Enter or Space picks.", Code: `input := ""
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.SuggestionChips(c, &input, []string{"Plan a ball", "Write a sonnet"}, mujica.SuggestionChipsOptions{})
}
ui.Render(view, 400, 300, 1)`},
{ID: "248", Name: "PromptLibrary", ZhName: "提示模板库", Category: chatsessionCategory, Demo: chatDemoPromptLibrary,
Keys: "Tab moves between categories, search, templates and fields; Up/Down move in a column; Enter picks.", Code: `var state mujica.PromptLibraryState
templates := []mujica.PromptTemplate{{ID: "1", Category: "Letters", Title: "Invitation", Body: "Invite {{guest}}."}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if v := mujica.PromptLibrary(c, &state, templates, mujica.PromptLibraryOptions{}); v.Inserted != "" {
log.Println("insert", v.Inserted)
}
}
ui.Render(view, 400, 300, 1)`},
{ID: "249", Name: "CapabilityCards", ZhName: "能力卡片", Category: chatsessionCategory, Demo: chatDemoCapabilityCards,
Keys: "Tab moves between cards; Up/Down move among a card's examples; Enter picks.", Code: `cards := []mujica.CapabilityCard{{Title: "Plan", Icon: icons.Must("calendar"), Examples: []string{"Plan a ball"}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if v := mujica.CapabilityCards(c, cards, mujica.CapabilityCardsOptions{}); v.Example != "" {
log.Println("ask", v.Example)
}
}
ui.Render(view, 400, 300, 1)`},
{ID: "250", Name: "FeedbackForm", ZhName: "反馈表单", Category: chatsessionCategory, Demo: chatDemoFeedbackForm,
Keys: "Left/Right move among reasons, Space toggles; Escape cancels.", Code: `var value mujica.FeedbackValue
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.FeedbackForm(c, &value, mujica.FeedbackFormOptions{Rating: mujica.FeedbackDown}).Submitted {
log.Println("reasons", value.Reasons)
}
}
ui.Render(view, 400, 300, 1)`},
{ID: "251", Name: "DateSeparator", ZhName: "日期分隔", Category: chatsessionCategory, Demo: chatDemoDateSeparator,
Keys: "Not focusable; read as the date.", Code: `view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.DateSeparator(c, mujica.DateOf(time.Now()), mujica.DateSeparatorOptions{})
}
ui.Render(view, 400, 300, 1)`},
{ID: "252", Name: "ThinkingIndicator", ZhName: "思考指示", Category: chatsessionCategory, Demo: chatDemoThinkingIndicator,
Keys: "Not focusable; read as a status.", Code: `view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ThinkingIndicator(c, mujica.ThinkingIndicatorOptions{Phase: "Searching the archives"})
}
ui.Render(view, 400, 300, 1)`},
{ID: "253", Name: "StopGeneratingButton", ZhName: "停止生成按钮", Category: chatsessionCategory, Demo: chatDemoStopGeneratingButton,
Keys: "Enter or Space stops; Cmd+. (Ctrl+. elsewhere) stops from anywhere.", Code: `generating := true
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.StopGeneratingButton(c, mujica.StopGeneratingButtonOptions{Generating: generating}).Changed() {
generating = false
}
}
ui.Render(view, 400, 300, 1)`},
{ID: "254", Name: "SystemPromptEditor", ZhName: "系统提示编辑器", Category: chatsessionCategory, Demo: chatDemoSystemPromptEditor,
Keys: "Cmd+S (Ctrl+S elsewhere) saves.", Code: `prompt, saved := "You are helpful.", "You are helpful."
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.SystemPromptEditor(c, &prompt, mujica.SystemPromptEditorOptions{Default: "You are helpful.", Saved: saved}).Save {
saved = prompt
}
}
ui.Render(view, 400, 300, 1)`},
{ID: "255", Name: "CostEstimator", ZhName: "费用估算", Category: chatsessionCategory, Demo: chatDemoCostEstimator,
Keys: "Not focusable; read as the estimated cost.", Code: `view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.CostEstimator(c, mujica.CostEstimatorOptions{InputTokens: 12000, OutputTokens: 3400, InputPrice: 3, OutputPrice: 15})
}
ui.Render(view, 400, 300, 1)`},
{ID: "256", Name: "SharedConversationView", ZhName: "分享会话视图", Category: chatsessionCategory, Demo: chatDemoSharedConversationView,
Keys: "Tab reaches Copy link and the messages; message text is selectable.", Code: `msgs := []mujica.SessionMessage{{Role: mujica.SessionUser, Text: "Hello"}, {Role: mujica.SessionAssistant, Text: "Good day."}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.SharedConversationView(c, msgs, mujica.SharedConversationViewOptions{Title: "Greeting", SharedBy: "Ann"}).Changed() {
c.WriteClipboard("https://example.com/share/1")
}
}
ui.Render(view, 400, 300, 1)`},
{ID: "257", Name: "ConversationExport", ZhName: "会话导出", Category: chatsessionCategory, Demo: chatDemoConversationExport,
Keys: "Arrows choose a format and range; Tab reaches Export.", Code: `var state mujica.ConversationExportState
msgs := []mujica.SessionMessage{{Role: mujica.SessionUser, Text: "Hello"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if v := mujica.ConversationExport(c, &state, msgs, mujica.ConversationExportOptions{Title: "Greeting"}); v.Exported {
log.Println(len(v.Content), "bytes to save")
}
}
ui.Render(view, 400, 300, 1)`},
{ID: "258", Name: "RegenerateMenu", ZhName: "重新生成菜单", Category: chatsessionCategory, Demo: chatDemoRegenerateMenu,
Keys: "Enter, Space or Down open; Up/Down move; Right opens the model list; Escape closes.", Code: `view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if v := mujica.RegenerateMenu(c, mujica.RegenerateMenuOptions{Models: []string{"Opus", "Sonnet"}, Current: "Opus"}); v.Chosen {
log.Println("retry", v.Choice.Kind, v.Choice.Model, v.Choice.Variant)
}
}
ui.Render(view, 400, 300, 1)`},
{ID: "259", Name: "ArtifactCard", ZhName: "产物卡片", Category: chatsessionCategory, Demo: chatDemoArtifactCard,
Keys: "Enter or Space opens the artifact panel.", Code: `view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ArtifactCard(c, mujica.ArtifactCardOptions{Title: "seating.go", Detail: "Go · 120 lines"}).Changed() {
log.Println("open artifact panel")
}
}
ui.Render(view, 400, 300, 1)`},
{ID: "260", Name: "ChatEmptyState", ZhName: "会话空状态", Category: chatsessionCategory, Demo: chatDemoChatEmptyState,
Keys: "Tab reaches the action; Enter runs it.", Code: `view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
if mujica.ChatEmptyState(c, mujica.ChatEmptyStateOptions{Kind: mujica.ChatEmptyOffline}).Action {
log.Println("reconnect")
}
}
ui.Render(view, 400, 300, 1)`},
}
+249
View File
@@ -0,0 +1,249 @@
package main
import (
"fmt"
"strings"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
"mujicaui/icons"
)
// chatHistory is the gallery's sample conversations, dated from now.
func chatHistory(now time.Time) []mujica.ChatConversation {
day := 24 * time.Hour
return []mujica.ChatConversation{
{ID: "a", Title: "Court etiquette notes", Updated: now.Add(-2 * time.Hour)},
{ID: "b", Title: "Ballroom seating plan", Updated: now.Add(-day)},
{ID: "c", Title: "Harpsichord repair", Updated: now.Add(-4 * day)},
{ID: "d", Title: "Coronation menu", Updated: now.Add(-40 * day)},
{ID: "e", Title: "Royal letters", Updated: now.Add(-90 * day), Pinned: true},
{ID: "f", Title: "Garden maze", Updated: now.Add(-time.Hour)},
}
}
func chatSampleMessages(now time.Time) []mujica.SessionMessage {
return []mujica.SessionMessage{
{Role: mujica.SessionUser, Text: "How should guests be seated at the banquet?", Time: now.Add(-10 * time.Minute)},
{Role: mujica.SessionAssistant, Text: "Seat by rank: the host at the head, honored guests to the right.", Time: now.Add(-9 * time.Minute)},
{Role: mujica.SessionUser, Author: "Lady Ann", Text: "And the musicians?", Time: now.Add(-5 * time.Minute)},
{Role: mujica.SessionAssistant, Text: "Place the quartet behind a screen near the gallery.", Time: now.Add(-4 * time.Minute)},
}
}
func chatSampleCards() []mujica.CapabilityCard {
return []mujica.CapabilityCard{
{Title: "Write", Description: "Letters, speeches and verse.", Icon: icons.Must("file-text"), Examples: []string{"Draft a royal decree", "Write a sonnet"}},
{Title: "Plan", Description: "Banquets, seating and schedules.", Icon: icons.Must("calendar"), Examples: []string{"Plan a ball for 200 guests"}},
{Title: "Research", Description: "History, heraldry and music.", Icon: icons.Must("search"), Examples: []string{"Explain the minuet"}},
}
}
func chatDemoConversationList(c *ui.Context, log func(string)) {
type state struct {
list mujica.ConversationListState
items []mujica.ChatConversation
many, rich bool
disabled bool
}
s := *ui.Local(c.Root(), "241", func() *state { return &state{items: chatHistory(time.Now())} })
choiceDemo(c, func() {
if mujica.Switch(c, &s.many, "100,000 conversations", mujica.SwitchOptions{}).Changed() {
s.items = chatHistory(time.Now())
if s.many {
now := time.Now()
for i := range 100000 {
s.items = append(s.items, mujica.ChatConversation{ID: fmt.Sprint("n", i), Title: fmt.Sprintf("Archive %d", i), Updated: now.Add(-time.Duration(i) * 7 * time.Minute)})
}
}
log(fmt.Sprintf("conversations = %d", len(s.items)))
}
choiceFlag(c, &s.rich, "ConversationItem rows", log)
choiceFlag(c, &s.disabled, "Disabled", log)
}, func() {
var row func(conv mujica.ChatConversation)
if s.rich {
row = func(conv mujica.ChatConversation) {
v := mujica.ConversationItem(c, &conv, mujica.ConversationItemOptions{Selected: conv.ID == s.list.Selected})
if v.Opened {
s.list.Selected = conv.ID
log("open " + conv.Title)
}
}
}
v := mujica.ConversationList(c, &s.list, s.items, mujica.ConversationListOptions{Label: "History", Disabled: s.disabled}, row)
v.Element.Width(380).Height(380)
if v.Changed() {
log("select " + s.list.Selected)
}
if v.Submitted() {
log("open " + s.list.Selected)
}
})
}
func chatDemoConversationItem(c *ui.Context, log func(string)) {
type state struct {
convs []mujica.ChatConversation
selected, disabled bool
}
s := *ui.Local(c.Root(), "242", func() *state { return &state{convs: chatHistory(time.Now())[:3]} })
choiceDemo(c, func() {
choiceFlag(c, &s.selected, "Selected", log)
choiceFlag(c, &s.disabled, "Disabled", log)
}, func() {
ui.Column(c).Width(420).Gap(4).Children(func() {
for i := range s.convs {
conv := &s.convs[i]
ui.Row(c).Key(conv.ID).Children(func() {
v := mujica.ConversationItem(c, conv, mujica.ConversationItemOptions{Selected: s.selected && i == 0, Disabled: s.disabled})
switch {
case v.Opened:
log("open " + conv.Title)
case v.Renamed:
log("rename to " + conv.Title)
case v.PinToggled:
conv.Pinned = !conv.Pinned
log(fmt.Sprintf("pinned %s = %v", conv.Title, conv.Pinned))
case v.Exported:
log("export " + conv.Title)
case v.Deleted:
log("delete " + conv.Title)
}
})
}
})
})
}
func chatDemoConversationSearch(c *ui.Context, log func(string)) {
type state struct {
query, asked string
at time.Time
hits []mujica.ConversationSearchHit
fail, slow bool
disabled bool
}
s := *ui.Local(c.Root(), "243", func() *state { return &state{} })
choiceDemo(c, func() {
choiceFlag(c, &s.slow, "Slow server", log)
choiceFlag(c, &s.fail, "Fail searches", log)
choiceFlag(c, &s.disabled, "Disabled", log)
}, func() {
loading := s.asked != s.query
if loading && (!s.slow || choiceDue(c, s.at)) {
s.asked, s.hits, loading = s.query, nil, false
for _, conv := range chatHistory(time.Now()) {
if q := strings.ToLower(s.query); q != "" && strings.Contains(strings.ToLower(conv.Title), q) {
s.hits = append(s.hits, mujica.ConversationSearchHit{ID: conv.ID, Title: conv.Title, Snippet: "…mentions " + strings.ToLower(conv.Title) + "…"})
}
}
log(fmt.Sprintf("results for %q: %d", s.query, len(s.hits)))
}
errText := ""
if s.fail && s.query != "" {
errText = "The archive did not answer."
}
ui.Column(c).Width(420).Children(func() {
v := mujica.ConversationSearch(c, &s.query, s.hits, mujica.ConversationSearchOptions{Loading: loading, Error: errText, Disabled: s.disabled, Label: "Search conversations"})
if v.QueryChanged {
s.at = c.Now()
log(fmt.Sprintf("query %q", s.query))
}
if v.Chosen != "" {
log("open " + v.Chosen)
}
if v.Retry {
s.fail, s.asked = false, ""
log("retry")
}
})
})
}
func chatDemoProjectList(c *ui.Context, log func(string)) {
type state struct {
list mujica.ProjectListState
owner map[string]string
disabled bool
}
s := *ui.Local(c.Root(), "244", func() *state {
return &state{owner: map[string]string{"a": "p1", "b": "p1", "c": "p3", "f": "p2"}}
})
projects := []mujica.ChatProject{{ID: "p1", Name: "Coronation", Folder: "Court"}, {ID: "p2", Name: "Gardens", Folder: "Court"}, {ID: "p3", Name: "Music"}}
choiceDemo(c, func() {
choiceFlag(c, &s.disabled, "Disabled", log)
}, func() {
ui.Row(c).Gap(24).AlignItems(ui.Start).Children(func() {
ui.Column(c).Width(380).Children(func() {
convs := func(id string) []mujica.ChatConversation {
var out []mujica.ChatConversation
for _, conv := range chatHistory(time.Now()) {
if s.owner[conv.ID] == id {
out = append(out, conv)
}
}
return out
}
v := mujica.ProjectList(c, &s.list, projects, convs, mujica.ProjectListOptions{Label: "Projects", Disabled: s.disabled})
switch {
case v.Moved:
s.owner[v.MoveID] = v.MoveTo
log(fmt.Sprintf("move %s to %s", v.MoveID, v.MoveTo))
case v.Changed:
log("project " + s.list.Selected)
case v.Opened != "":
log("open " + v.Opened)
}
})
choiceLabeled(c, "Drag onto a project", func() {
conv := chatHistory(time.Now())[3]
v := mujica.ConversationItem(c, &conv, mujica.ConversationItemOptions{Draggable: true})
v.Element.Width(300)
if v.Opened {
log("open " + conv.Title)
}
})
})
})
}
func chatDemoProjectKnowledgePanel(c *ui.Context, log func(string)) {
type state struct {
files []mujica.KnowledgeFile
next int
small bool
disabled bool
}
s := *ui.Local(c.Root(), "245", func() *state {
return &state{files: []mujica.KnowledgeFile{{ID: "1", Name: "etiquette.pdf", Tokens: 120000}, {ID: "2", Name: "menu.md", Tokens: 30000}}}
})
choiceDemo(c, func() {
choiceFlag(c, &s.small, "Capacity 150,000", log)
choiceFlag(c, &s.disabled, "Disabled", log)
}, func() {
capacity := 400000
if s.small {
capacity = 150000
}
ui.Column(c).Width(440).Children(func() {
v := mujica.ProjectKnowledgePanel(c, s.files, mujica.ProjectKnowledgePanelOptions{Capacity: capacity, Disabled: s.disabled})
if v.Add {
s.next++
s.files = append(s.files, mujica.KnowledgeFile{ID: fmt.Sprint("n", s.next), Name: fmt.Sprintf("notes-%d.txt", s.next), Tokens: 15000})
log("add (the demo invents a file)")
}
if v.Removed != "" {
for i, f := range s.files {
if f.ID == v.Removed {
s.files = append(s.files[:i:i], s.files[i+1:]...)
log("remove " + f.Name)
break
}
}
}
})
})
}
+341
View File
@@ -0,0 +1,341 @@
package main
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func chatDemoWelcomeScreen(c *ui.Context, log func(string)) {
type state struct {
input string
cards, disabled bool
}
s := *ui.Local(c.Root(), "246", func() *state { return &state{cards: true} })
choiceDemo(c, func() {
choiceFlag(c, &s.cards, "Capability cards", log)
choiceFlag(c, &s.disabled, "Disabled", log)
}, func() {
var cards []mujica.CapabilityCard
if s.cards {
cards = chatSampleCards()
}
ui.Column(c).Width(860).Children(func() {
v := mujica.WelcomeScreen(c, mujica.WelcomeScreenOptions{Greeting: "Good afternoon", Subtitle: "How may I serve the court?", Cards: cards, Disabled: s.disabled,
Prompts: func() {
if mujica.SuggestionChips(c, &s.input, []string{"Plan a ball", "Write a sonnet"}, mujica.SuggestionChipsOptions{Disabled: s.disabled}).Chosen != "" {
log(fmt.Sprintf("fill %q", s.input))
}
}})
if v.Example != "" {
log(fmt.Sprintf("ask %q", v.Example))
}
})
})
}
func chatDemoSuggestionChips(c *ui.Context, log func(string)) {
type state struct {
input string
send, disabled bool
}
s := *ui.Local(c.Root(), "247", func() *state { return &state{} })
choiceDemo(c, func() {
choiceFlag(c, &s.send, "Send directly", log)
choiceFlag(c, &s.disabled, "Disabled", log)
}, func() {
v := mujica.SuggestionChips(c, &s.input, []string{"Plan a ball", "Write a sonnet", "Explain the minuet"},
mujica.SuggestionChipsOptions{Send: s.send, Label: "Suggestions", Disabled: s.disabled})
switch {
case v.Sent:
log(fmt.Sprintf("send %q", v.Chosen))
case v.Chosen != "":
log(fmt.Sprintf("fill %q", v.Chosen))
}
ui.Column(c).Width(420).Children(func() {
mujica.TextInput(c, &s.input, mujica.TextInputOptions{Label: "Message", Placeholder: "Message"})
})
})
}
func chatDemoPromptLibrary(c *ui.Context, log func(string)) {
type state struct {
lib mujica.PromptLibraryState
disabled bool
}
s := *ui.Local(c.Root(), "248", func() *state { return &state{} })
templates := []mujica.PromptTemplate{
{ID: "t1", Category: "Letters", Title: "Invitation", Body: "Write an invitation to {{guest}} for the {{event}}."},
{ID: "t2", Category: "Letters", Title: "Thank-you note", Body: "Thank {{host}} for the evening."},
{ID: "t3", Category: "Music", Title: "Recital program", Body: "List pieces for a {{instrument}} recital."},
{ID: "t4", Category: "Planning", Title: "Seating chart", Body: "Seat {{count}} guests by rank."},
}
choiceDemo(c, func() {
choiceFlag(c, &s.disabled, "Disabled", log)
}, func() {
before := s.lib.Selected
v := mujica.PromptLibrary(c, &s.lib, templates, mujica.PromptLibraryOptions{Label: "Prompt library", Disabled: s.disabled})
if s.lib.Selected != before {
log("preview " + s.lib.Selected)
}
if v.Inserted != "" {
log(fmt.Sprintf("insert %q", v.Inserted))
}
})
}
func chatDemoCapabilityCards(c *ui.Context, log func(string)) {
type state struct {
one, disabled bool
}
s := *ui.Local(c.Root(), "249", func() *state { return &state{} })
choiceDemo(c, func() {
choiceFlag(c, &s.one, "One column", log)
choiceFlag(c, &s.disabled, "Disabled", log)
}, func() {
cols := 0
if s.one {
cols = 1
}
ui.Column(c).Width(860).Children(func() {
if v := mujica.CapabilityCards(c, chatSampleCards(), mujica.CapabilityCardsOptions{Columns: cols, Disabled: s.disabled}); v.Example != "" {
log(fmt.Sprintf("ask %q", v.Example))
}
})
})
}
func chatDemoFeedbackForm(c *ui.Context, log func(string)) {
type state struct {
value mujica.FeedbackValue
down, submitting bool
disabled bool
at time.Time
}
s := *ui.Local(c.Root(), "250", func() *state { return &state{down: true} })
choiceDemo(c, func() {
choiceFlag(c, &s.down, "Thumbs down", log)
choiceFlag(c, &s.disabled, "Disabled", log)
}, func() {
rating := mujica.FeedbackUp
if s.down {
rating = mujica.FeedbackDown
}
if s.submitting && choiceDue(c, s.at) {
s.submitting, s.value = false, mujica.FeedbackValue{}
log("feedback sent")
}
ui.Column(c).Width(480).Children(func() {
v := mujica.FeedbackForm(c, &s.value, mujica.FeedbackFormOptions{Rating: rating, Submitting: s.submitting, Disabled: s.disabled})
if v.Submitted {
s.submitting, s.at = true, c.Now()
log(fmt.Sprintf("submit reasons %q, %d characters", s.value.Reasons, len(s.value.Comment)))
}
if v.Cancelled {
s.value = mujica.FeedbackValue{}
log("cancel")
}
})
})
}
func chatDemoDateSeparator(c *ui.Context, log func(string)) {
today := mujica.DateOf(time.Now())
choiceDemo(c, func() {
ui.Text(c, "No parameters; the language follows the window.")
}, func() {
ui.Column(c).Width(520).Children(func() {
for _, d := range []int{0, -1, -12} {
mujica.DateSeparator(c, today.AddDays(d), mujica.DateSeparatorOptions{})
}
})
})
}
func chatDemoThinkingIndicator(c *ui.Context, log func(string)) {
type state struct{ phase int }
s := *ui.Local(c.Root(), "252", func() *state { return &state{} })
phases := []string{"", "Searching the archives", "Reading 3 letters", "Composing a reply"}
choiceDemo(c, func() {
if mujica.Button(c, "Next phase", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
s.phase = (s.phase + 1) % len(phases)
log(fmt.Sprintf("phase %q", phases[s.phase]))
}
}, func() {
mujica.ThinkingIndicator(c, mujica.ThinkingIndicatorOptions{Phase: phases[s.phase]})
})
}
func chatDemoStopGeneratingButton(c *ui.Context, log func(string)) {
type state struct {
generating, disabled bool
at time.Time
}
s := *ui.Local(c.Root(), "253", func() *state { return &state{} })
choiceDemo(c, func() {
if mujica.Button(c, "Generate", mujica.ButtonOptions{Disabled: s.generating}).Clicked() {
s.generating, s.at = true, c.Now()
log("generate")
}
choiceFlag(c, &s.disabled, "Disabled", log)
}, func() {
if s.generating && c.Now().Sub(s.at) > 8*time.Second {
s.generating = false
log("finished")
} else if s.generating {
c.After(time.Second)
}
if mujica.StopGeneratingButton(c, mujica.StopGeneratingButtonOptions{Generating: s.generating, Disabled: s.disabled}).Changed() {
s.generating = false
log("stop")
}
if !s.generating {
ui.Text(c, "Idle: the button is hidden.").TextColor(mujica.Tokens(c).TextMuted)
}
})
}
func chatDemoSystemPromptEditor(c *ui.Context, log func(string)) {
type state struct {
value, saved string
limit, disabled bool
saving bool
at time.Time
}
s := *ui.Local(c.Root(), "254", func() *state {
return &state{value: "You are a courtly assistant.", saved: "You are a courtly assistant."}
})
choiceDemo(c, func() {
choiceFlag(c, &s.limit, "Limit 60", log)
choiceFlag(c, &s.disabled, "Disabled", log)
}, func() {
if s.saving && choiceDue(c, s.at) {
s.saving, s.saved = false, s.value
log("saved")
}
limit := 0
if s.limit {
limit = 60
}
ui.Column(c).Width(520).Children(func() {
v := mujica.SystemPromptEditor(c, &s.value, mujica.SystemPromptEditorOptions{Default: "You are helpful.", Saved: s.saved,
MaxLength: limit, Saving: s.saving, Disabled: s.disabled})
if v.Save {
s.saving, s.at = true, c.Now()
log(fmt.Sprintf("save %d characters", len(s.value)))
}
if v.Restored {
log("restore default")
}
})
})
}
func chatDemoCostEstimator(c *ui.Context, log func(string)) {
type state struct{ in, out, inPrice, outPrice float64 }
s := *ui.Local(c.Root(), "255", func() *state { return &state{in: 12000, out: 3400, inPrice: 3, outPrice: 15} })
choiceDemo(c, func() {
for _, f := range []struct {
v *float64
label string
}{{&s.in, "Input tokens"}, {&s.out, "Output tokens"}, {&s.inPrice, "Input $/M"}, {&s.outPrice, "Output $/M"}} {
choiceLabeled(c, f.label, func() {
r := mujica.NumberInput(c, f.v, mujica.NumberInputOptions{Min: 0, Max: 1e7, Step: 100, Label: f.label})
r.Element.Width(150)
if r.Changed() {
log(fmt.Sprintf("%s = %v", f.label, *f.v))
}
})
}
}, func() {
ui.Column(c).Width(420).Children(func() {
mujica.CostEstimator(c, mujica.CostEstimatorOptions{InputTokens: int(s.in), OutputTokens: int(s.out), InputPrice: s.inPrice, OutputPrice: s.outPrice})
})
})
}
func chatDemoSharedConversationView(c *ui.Context, log func(string)) {
type state struct{ disabled bool }
s := *ui.Local(c.Root(), "256", func() *state { return &state{} })
choiceDemo(c, func() {
choiceFlag(c, &s.disabled, "Disabled", log)
}, func() {
ui.Column(c).Width(600).Height(420).Children(func() {
if mujica.SharedConversationView(c, chatSampleMessages(time.Now()), mujica.SharedConversationViewOptions{Title: "Banquet seating",
SharedBy: "Lady Ann", SharedAt: time.Now(), Disabled: s.disabled}).Changed() {
c.WriteClipboard("https://example.com/share/banquet")
log("copy link")
}
})
})
}
func chatDemoConversationExport(c *ui.Context, log func(string)) {
type state struct {
export mujica.ConversationExportState
disabled bool
}
s := *ui.Local(c.Root(), "257", func() *state { return &state{} })
choiceDemo(c, func() {
choiceFlag(c, &s.disabled, "Disabled", log)
}, func() {
ui.Column(c).Width(580).Children(func() {
v := mujica.ConversationExport(c, &s.export, chatSampleMessages(time.Now()), mujica.ConversationExportOptions{Title: "Banquet seating",
Selected: []int{1, 3}, Disabled: s.disabled})
if v.Exported {
log(fmt.Sprintf("export %d bytes as format %d (the caller writes the file)", len(v.Content), v.Format))
}
})
})
}
func chatDemoRegenerateMenu(c *ui.Context, log func(string)) {
type state struct{ disabled bool }
s := *ui.Local(c.Root(), "258", func() *state { return &state{} })
choiceDemo(c, func() {
choiceFlag(c, &s.disabled, "Disabled", log)
}, func() {
v := mujica.RegenerateMenu(c, mujica.RegenerateMenuOptions{Models: []string{"Opus", "Sonnet", "Haiku"}, Current: "Opus", Disabled: s.disabled})
if v.Chosen {
log(fmt.Sprintf("retry kind %d model %q variant %q", v.Choice.Kind, v.Choice.Model, v.Choice.Variant))
}
})
}
func chatDemoArtifactCard(c *ui.Context, log func(string)) {
type state struct{ streaming, disabled bool }
s := *ui.Local(c.Root(), "259", func() *state { return &state{} })
choiceDemo(c, func() {
choiceFlag(c, &s.streaming, "Streaming", log)
choiceFlag(c, &s.disabled, "Disabled", log)
}, func() {
for _, a := range []mujica.ArtifactCardOptions{
{Kind: mujica.ArtifactCode, Title: "seating.go", Detail: "Go · 120 lines"},
{Kind: mujica.ArtifactDocument, Title: "Royal decree", Detail: "2 pages"},
{Kind: mujica.ArtifactWeb, Title: "Ball invitation"},
} {
a.Streaming, a.Disabled = s.streaming, s.disabled
if mujica.ArtifactCard(c, a).Changed() {
log("open " + a.Title)
}
}
})
}
func chatDemoChatEmptyState(c *ui.Context, log func(string)) {
type state struct{ kind int }
s := *ui.Local(c.Root(), "260", func() *state { return &state{} })
choiceDemo(c, func() {
mujica.RadioGroup(c, &s.kind, []mujica.RadioItem[int]{{Value: 0, Label: "New"}, {Value: 1, Label: "No results"}, {Value: 2, Label: "Offline"}},
mujica.RadioGroupOptions{Horizontal: true, Label: "Kind"})
}, func() {
ui.Column(c).Width(420).Children(func() {
if mujica.ChatEmptyState(c, mujica.ChatEmptyStateOptions{Kind: mujica.ChatEmptyKind(s.kind), Query: "minuet"}).Action {
log(fmt.Sprintf("action for kind %d", s.kind))
}
})
})
}
+75
View File
@@ -0,0 +1,75 @@
package mujica
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
)
func TestFeedbackFormReasonsAndSubmit(t *testing.T) {
var v FeedbackValue
var last FeedbackFormView
tt := chatTester(480, 420, func(c *ui.Context) {
if r := FeedbackForm(c, &v, FeedbackFormOptions{Rating: FeedbackDown}); r.Submitted || r.Cancelled {
last = r
}
})
if !tt.HasText("What went wrong?") || !tt.HasText("Harmful or unsafe") {
t.Fatalf("texts %q", tt.Texts())
}
_ = tt.Click("Incorrect")
_ = tt.Click("Not helpful")
_ = tt.Click("Incorrect")
if !slices.Equal(v.Reasons, []string{"Not helpful"}) {
t.Fatalf("reasons %q", v.Reasons)
}
_ = tt.Click("Tell us more (optional)")
tt.Type("Wrong date")
_ = tt.Click("Submit")
if !last.Submitted || v.Comment != "Wrong date" {
t.Fatalf("submit %+v comment %q", last, v.Comment)
}
}
func TestFeedbackFormCancelAndUpReasons(t *testing.T) {
var v FeedbackValue
var last FeedbackFormView
tt := chatTester(480, 420, func(c *ui.Context) {
if r := FeedbackForm(c, &v, FeedbackFormOptions{Reasons: []string{"Elegant"}}); r.Submitted || r.Cancelled {
last = r
}
})
if !tt.HasText("What did you like?") || !tt.HasText("Elegant") {
t.Fatalf("texts %q", tt.Texts())
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEscape)
if !last.Cancelled {
t.Fatal("Escape did not cancel")
}
}
func TestFeedbackFormBlocked(t *testing.T) {
for _, opts := range []FeedbackFormOptions{{Disabled: true}, {Submitting: true}} {
var v FeedbackValue
submitted := false
tt := chatTester(480, 420, func(c *ui.Context) {
if FeedbackForm(c, &v, opts).Submitted {
submitted = true
}
})
_ = tt.Click("Accurate")
_ = tt.Click("Submit")
if submitted || len(v.Reasons) != 0 {
t.Fatalf("%+v: blocked form acted", opts)
}
}
}
func TestFeedbackFormSnapshot(t *testing.T) {
snapshot(t, "feedback_form", 480, 300, func(c *ui.Context) {
v := FeedbackValue{Reasons: []string{"Incorrect"}}
ui.Column(c).Padding(16).Children(func() { FeedbackForm(c, &v, FeedbackFormOptions{Rating: FeedbackDown}) })
})
}
+5 -6
View File
@@ -65,12 +65,11 @@ func init() {
"stop_generating_button.stop": {"Stop generating", "停止生成"},
"system_prompt_editor.title": {"System prompt", "系统提示"},
"system_prompt_editor.unsaved": {"Unsaved changes", "未保存"},
"system_prompt_editor.count": {"%d characters", "%d 字"},
"system_prompt_editor.count_of": {"%d / %d characters", "%d / %d 字"},
"system_prompt_editor.restore": {"Restore default", "恢复默认"},
"system_prompt_editor.save": {"Save", "保存"},
"system_prompt_editor.title": {"System prompt", "系统提示"},
"system_prompt_editor.unsaved": {"Unsaved changes", "未保存"},
"system_prompt_editor.count": {"%d characters", "%d 字"},
"system_prompt_editor.restore": {"Restore default", "恢复默认"},
"system_prompt_editor.save": {"Save", "保存"},
"cost_estimator.kind": {"Kind", "类别"},
"cost_estimator.tokens": {"Tokens", "Tokens"},
+110
View File
@@ -0,0 +1,110 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func knowledgeFiles() []KnowledgeFile {
return []KnowledgeFile{{ID: "1", Name: "etiquette.pdf", Tokens: 120000}, {ID: "2", Name: "menu.md", Tokens: 50000}}
}
func TestProjectKnowledgePanelAddRemove(t *testing.T) {
files := knowledgeFiles()
adds := 0
tt := chatTester(420, 400, func(c *ui.Context) {
v := ProjectKnowledgePanel(c, files, ProjectKnowledgePanelOptions{Capacity: 400000})
if v.Add {
adds++
}
if v.Removed != "" {
for i, f := range files {
if f.ID == v.Removed {
files = append(files[:i:i], files[i+1:]...)
break
}
}
}
})
if !tt.HasText("170000 of 400000 tokens (42%)") {
t.Fatalf("texts %q", tt.Texts())
}
_ = tt.Click("Add files")
if adds != 1 {
t.Fatalf("adds %d", adds)
}
if err := tt.Click("Remove menu.md"); err != nil {
t.Fatal(err)
}
if len(files) != 1 || files[0].ID != "1" || !tt.HasText("120000 of 400000 tokens (30%)") {
t.Fatalf("files %v texts %q", files, tt.Texts())
}
}
func TestProjectKnowledgePanelCapacityStates(t *testing.T) {
for _, tc := range []struct {
capacity int
note string
}{{200000, "Nearly full"}, {150000, "Over capacity"}} {
tt := chatTester(420, 400, func(c *ui.Context) {
ProjectKnowledgePanel(c, knowledgeFiles(), ProjectKnowledgePanelOptions{Capacity: tc.capacity})
})
if !tt.HasText(tc.note) {
t.Errorf("%d: texts %q", tc.capacity, tt.Texts())
}
}
tt := chatTester(420, 400, func(c *ui.Context) {
ProjectKnowledgePanel(c, nil, ProjectKnowledgePanelOptions{Capacity: 10})
})
if !tt.HasText("No files yet") {
t.Fatal("empty state missing")
}
}
func TestProjectKnowledgePanelDisabledAndKeys(t *testing.T) {
removed := ""
tt := chatTester(420, 400, func(c *ui.Context) {
if v := ProjectKnowledgePanel(c, knowledgeFiles(), ProjectKnowledgePanelOptions{Capacity: 400000, Disabled: true}); v.Removed != "" {
removed = v.Removed
}
})
_ = tt.Click("Remove menu.md")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if removed != "" {
t.Fatalf("disabled panel removed %q", removed)
}
tt = chatTester(420, 400, func(c *ui.Context) {
if v := ProjectKnowledgePanel(c, knowledgeFiles(), ProjectKnowledgePanelOptions{Capacity: 400000}); v.Removed != "" {
removed = v.Removed
}
})
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if removed != "2" {
t.Fatalf("keys removed %q", removed)
}
}
func TestProjectKnowledgePanelRejectsBadCapacity(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.Render(func(c *ui.Context) {
Use(c, Settings{})
ProjectKnowledgePanel(c, nil, ProjectKnowledgePanelOptions{})
}, 100, 100, 1)
}
func TestProjectKnowledgePanelSnapshot(t *testing.T) {
snapshot(t, "project_knowledge_panel", 420, 240, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
ProjectKnowledgePanel(c, knowledgeFiles(), ProjectKnowledgePanelOptions{Capacity: 200000})
})
})
}
+2 -2
View File
@@ -199,7 +199,7 @@ func projectListConversation(c *ui.Context, conv ChatConversation, home ChatProj
if b.Clicked() {
v.Opened = conv.ID
}
DropdownMenu(c, "", DropdownMenuOptions{Icon: icons.Must("ellipsis"), Label: fmt.Sprintf(msg(c, "project_list.move_to"), conv.Title)}, func(m *PopupMenu) {
conversationItemMenu(c, fmt.Sprintf(msg(c, "project_list.move_to"), conv.Title), func(m *PopupMenu) {
m.Group(msg(c, "project_list.move_group"), func() {
for _, p := range all {
if p.ID != home.ID && m.Item(p.Name, PopupMenuItemOptions{}) {
@@ -207,6 +207,6 @@ func projectListConversation(c *ui.Context, conv ChatConversation, home ChatProj
}
}
})
}).Shrink(0)
})
})
}
+138
View File
@@ -0,0 +1,138 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
type projectFixture struct {
s ProjectListState
projects []ChatProject
owner map[string]string
last ProjectListView
moves int
}
func newProjectFixture() *projectFixture {
return &projectFixture{
projects: []ChatProject{{ID: "p1", Name: "Coronation", Folder: "Court"}, {ID: "p2", Name: "Gardens", Folder: "Court"}, {ID: "p3", Name: "Music"}},
owner: map[string]string{"a": "p1", "b": "p1", "c": "p3"},
}
}
func (f *projectFixture) view(opts ProjectListOptions) func(c *ui.Context) {
return func(c *ui.Context) {
convs := func(id string) []ChatConversation {
var out []ChatConversation
for _, conv := range chatConversations() {
if f.owner[conv.ID] == id {
out = append(out, conv)
}
}
return out
}
v := ProjectList(c, &f.s, f.projects, convs, opts)
if v.Moved {
f.owner[v.MoveID] = v.MoveTo
f.moves++
}
if v.Changed || v.Opened != "" || v.Moved {
f.last = v
}
}
}
func TestProjectListSelectAndExpand(t *testing.T) {
f := newProjectFixture()
tt := chatTester(420, 500, f.view(ProjectListOptions{Label: "Projects"}))
if err := tt.Click("Gardens"); err != nil {
t.Fatal(err)
}
if f.s.Selected != "p2" || !f.last.Changed {
t.Fatalf("selected %q", f.s.Selected)
}
if tt.HasText("Court etiquette notes") {
t.Fatal("conversations shown before expanding")
}
_ = tt.Click("Show conversations in Coronation")
if !tt.HasText("Court etiquette notes") || !tt.HasText("Ballroom seating plan") {
t.Fatalf("texts %q", tt.Texts())
}
_ = tt.Click("Court etiquette notes")
if f.last.Opened != "a" {
t.Fatalf("opened %q", f.last.Opened)
}
// The folder collapses its projects.
_ = tt.Click("Court")
if tt.HasText("Gardens") {
t.Fatal("folder did not collapse")
}
}
func TestProjectListKeyboard(t *testing.T) {
f := newProjectFixture()
tt := chatTester(420, 500, f.view(ProjectListOptions{}))
tt.Key(0, ui.KeyTab)
if !tt.Focused("Court") {
t.Fatal("Tab did not enter at the folder")
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyRight)
if !tt.HasText("Court etiquette notes") {
t.Fatal("Right did not expand")
}
tt.Key(0, ui.KeyEnter)
if f.s.Selected != "p1" {
t.Fatalf("Enter chose %q", f.s.Selected)
}
tt.Key(0, ui.KeyLeft)
if tt.HasText("Court etiquette notes") {
t.Fatal("Left did not collapse")
}
}
func TestProjectListMoveByMenuAndDrag(t *testing.T) {
f := newProjectFixture()
tt := chatTester(420, 500, f.view(ProjectListOptions{}))
_ = tt.Click("Show conversations in Coronation")
if err := tt.Click("Move Court etiquette notes"); err != nil {
t.Fatal(err)
}
// The menu opens on Gardens; Down reaches Music.
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if f.owner["a"] != "p3" || f.moves != 1 {
t.Fatalf("menu move: owner %q moves %d", f.owner["a"], f.moves)
}
from, _ := tt.Find("Ballroom seating plan")
to, _ := tt.Find("Gardens")
tt.Press(from.X+5, from.Y+from.H/2)
tt.Move(from.X+20, from.Y+from.H/2+5)
tt.Move(to.X+10, to.Y+to.H/2)
tt.Release(to.X+10, to.Y+to.H/2)
if f.owner["b"] != "p2" || f.last.MoveTo != "p2" {
t.Fatalf("drag move: owner %q", f.owner["b"])
}
}
func TestProjectListDisabled(t *testing.T) {
f := newProjectFixture()
tt := chatTester(420, 500, f.view(ProjectListOptions{Disabled: true}))
_ = tt.Click("Gardens")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if f.s.Selected != "" || f.moves != 0 {
t.Fatalf("disabled list acted: %q", f.s.Selected)
}
}
func TestProjectListSnapshot(t *testing.T) {
snapshot(t, "project_list", 380, 280, func(c *ui.Context) {
f := newProjectFixture()
f.s.Selected = "p1"
f.s.open = map[string]bool{"p1": true}
f.s.closed = map[string]bool{}
ui.Column(c).Padding(16).Children(func() { f.view(ProjectListOptions{})(c) })
})
}
+105
View File
@@ -0,0 +1,105 @@
package mujica
import (
"slices"
"testing"
"github.com/egoist/mygo/ui"
)
func promptTemplates() []PromptTemplate {
return []PromptTemplate{
{ID: "t1", Category: "Letters", Title: "Invitation", Body: "Write an invitation to {{guest}} for the {{event}}."},
{ID: "t2", Category: "Music", Title: "Recital program", Body: "List pieces for a {{ instrument }} recital, then thank {{guest}}."},
{ID: "t3", Category: "Letters", Title: "Thank-you note", Body: "Thank the hosts."},
}
}
func TestPromptLibraryVariables(t *testing.T) {
if got := PromptLibraryVariables("{{a}} and {{ b }} and {{a}}"); !slices.Equal(got, []string{"a", "b"}) {
t.Fatalf("variables %q", got)
}
if got := PromptLibraryFill("Hi {{ name }}, {{x}}!", map[string]string{"name": "Ann"}); got != "Hi Ann, !" {
t.Fatalf("fill %q", got)
}
}
func TestPromptLibraryBrowseFillInsert(t *testing.T) {
var s PromptLibraryState
templates := promptTemplates()
inserted := ""
tt := chatTester(940, 520, func(c *ui.Context) {
if v := PromptLibrary(c, &s, templates, PromptLibraryOptions{Label: "Prompts"}); v.Inserted != "" {
inserted = v.Inserted
}
})
_ = tt.Click("Music")
if tt.HasText("Invitation") || !tt.HasText("Recital program") {
t.Fatalf("category filter: %q", tt.Texts())
}
_ = tt.Click("All")
_ = tt.Click("Search prompts")
tt.Type("thank")
if tt.HasText("Invitation") || !tt.HasText("Thank-you note") || !tt.HasText("Recital program") {
t.Fatalf("search covers titles and bodies: %q", tt.Texts())
}
_ = tt.Click("Recital program")
if !tt.HasText("[instrument]") {
t.Fatal("placeholder not previewed")
}
_ = tt.Click("Insert")
if inserted != "" {
t.Fatal("inserted with empty variables")
}
// Each variable's field sits under its label.
for _, f := range [][2]string{{"instrument", "harp"}, {"guest", "the duke"}} {
r, _ := tt.Find(f[0])
tt.ClickAt(r.X+20, r.Y+r.H+18)
tt.Type(f[1])
}
_ = tt.Click("Insert")
if inserted != "List pieces for a harp recital, then thank the duke." {
t.Fatalf("inserted %q", inserted)
}
}
func TestPromptLibraryKeyboardAndDisabled(t *testing.T) {
var s PromptLibraryState
tt := chatTester(940, 520, func(c *ui.Context) { PromptLibrary(c, &s, promptTemplates(), PromptLibraryOptions{}) })
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if s.Category != "Letters" {
t.Fatalf("keys chose category %q", s.Category)
}
var d PromptLibraryState
tt = chatTester(940, 520, func(c *ui.Context) { PromptLibrary(c, &d, promptTemplates(), PromptLibraryOptions{Disabled: true}) })
_ = tt.Click("Invitation")
_ = tt.Click("Music")
if d.Selected != "" || d.Category != "" {
t.Fatalf("disabled library chose %q %q", d.Selected, d.Category)
}
}
func TestPromptLibraryFiltersOnlyOnChange(t *testing.T) {
var s PromptLibraryState
templates := promptTemplates()
tt := chatTester(940, 520, func(c *ui.Context) { PromptLibrary(c, &s, templates, PromptLibraryOptions{}) })
s.shown.cats = append(s.shown.cats, "Marker")
tt.Frame()
if !slices.Contains(s.shown.cats, "Marker") {
t.Fatal("refiltered without a change")
}
s.Query = "invit"
tt.Frame()
if !slices.Equal(s.shown.ids, []int{0}) {
t.Fatalf("ids %v", s.shown.ids)
}
}
func TestPromptLibrarySnapshot(t *testing.T) {
snapshot(t, "prompt_library", 900, 360, func(c *ui.Context) {
s := &PromptLibraryState{Selected: "t1", Values: map[string]string{"guest": "Lady Ann"}}
ui.Column(c).Padding(16).Children(func() { PromptLibrary(c, s, promptTemplates(), PromptLibraryOptions{}) })
})
}
+1
View File
@@ -80,6 +80,7 @@ func RegenerateMenu(c *ui.Context, opts RegenerateMenuOptions) RegenerateMenuVie
}
})
})
v.Element.AlignSelf(ui.Start)
if v.Element.IsDisabled() {
v.Chosen = false
}
+70
View File
@@ -0,0 +1,70 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func regenerateTester(opts RegenerateMenuOptions, got *[]RegenerateChoice) *ui.Tester {
return chatTester(500, 420, func(c *ui.Context) {
if v := RegenerateMenu(c, opts); v.Chosen {
*got = append(*got, v.Choice)
}
})
}
func TestRegenerateMenuChoices(t *testing.T) {
var got []RegenerateChoice
opts := RegenerateMenuOptions{Models: []string{"Opus", "Sonnet"}, Current: "Opus"}
tt := regenerateTester(opts, &got)
_ = tt.Click("Regenerate")
_ = tt.Click("Try again")
_ = tt.Click("Regenerate")
_ = tt.Click("Shorter")
if len(got) != 2 || got[0].Kind != RegenerateAgain || got[1] != (RegenerateChoice{Kind: RegenerateVariant, Variant: "Shorter"}) {
t.Fatalf("choices %+v", got)
}
_ = tt.Click("Regenerate")
_ = tt.Click("Try another model")
if tt.HasText("Opus") || !tt.HasText("Sonnet") {
t.Fatalf("model submenu %q", tt.Texts())
}
_ = tt.Click("Sonnet")
if len(got) != 3 || got[2] != (RegenerateChoice{Kind: RegenerateModel, Model: "Sonnet"}) {
t.Fatalf("choices %+v", got)
}
}
func TestRegenerateMenuKeyboardAndVariants(t *testing.T) {
var got []RegenerateChoice
tt := regenerateTester(RegenerateMenuOptions{Variants: []string{"In verse"}}, &got)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnter)
if len(got) != 1 || got[0].Kind != RegenerateAgain {
t.Fatalf("keys chose %+v", got)
}
_ = tt.Click("Regenerate")
if !tt.HasText("In verse") || tt.HasText("Longer") {
t.Fatalf("variants %q", tt.Texts())
}
}
func TestRegenerateMenuDisabled(t *testing.T) {
var got []RegenerateChoice
tt := regenerateTester(RegenerateMenuOptions{Disabled: true}, &got)
_ = tt.Click("Regenerate")
_ = tt.Click("Try again")
if len(got) != 0 || tt.HasText("Try again") {
t.Fatalf("disabled menu chose %+v", got)
}
}
func TestRegenerateMenuSnapshot(t *testing.T) {
snapshotAfter(t, "regenerate_menu", 360, 330, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
RegenerateMenu(c, RegenerateMenuOptions{Models: []string{"Opus", "Sonnet"}, Current: "Opus"})
})
}, func(tt *ui.Tester) { _ = tt.Click("Regenerate") })
}
+70
View File
@@ -0,0 +1,70 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
func TestSharedConversationViewShowsAndCopies(t *testing.T) {
copies := 0
tt := chatTester(560, 460, func(c *ui.Context) {
if SharedConversationView(c, chatMessages(), SharedConversationViewOptions{Title: "Banquet seating", SharedBy: "Lady Ann", SharedAt: chatNow}).Changed() {
copies++
}
})
for _, s := range []string{"Banquet seating", "Shared by Lady Ann", "Read only", "You", "Assistant", "Lady Ann", "And the musicians?"} {
if !tt.HasText(s) {
t.Errorf("%q missing from %q", s, tt.Texts())
}
}
_ = tt.Click("Copy link")
tt.Key(0, ui.KeyEnter)
if copies != 2 {
t.Fatalf("copies %d", copies)
}
}
func TestSharedConversationViewVirtualizes(t *testing.T) {
msgs := make([]SessionMessage, 100000)
for i := range msgs {
msgs[i] = SessionMessage{Role: SessionRole(i % 2), Text: fmt.Sprintf("Message %d", i)}
}
tt := chatTester(560, 460, func(c *ui.Context) {
SharedConversationView(c, msgs, SharedConversationViewOptions{Title: "Long"})
})
if n := len(tt.Texts()); n > 80 {
t.Fatalf("built %d texts for 100000 messages", n)
}
}
func TestSharedConversationViewDisabledAndChecks(t *testing.T) {
copies := 0
tt := chatTester(560, 460, func(c *ui.Context) {
if SharedConversationView(c, chatMessages(), SharedConversationViewOptions{Title: "T", Disabled: true}).Changed() {
copies++
}
})
_ = tt.Click("Copy link")
if copies != 0 || !tt.HasText("Shared by someone") {
t.Fatalf("copies %d texts %q", copies, tt.Texts())
}
defer func() {
if recover() == nil {
t.Fatal("no panic for a missing title")
}
}()
ui.Render(func(c *ui.Context) {
Use(c, Settings{})
SharedConversationView(c, nil, SharedConversationViewOptions{})
}, 100, 100, 1)
}
func TestSharedConversationViewSnapshot(t *testing.T) {
snapshot(t, "shared_conversation_view", 560, 380, func(c *ui.Context) {
ui.Column(c).Padding(16).Height(380).Children(func() {
SharedConversationView(c, chatMessages(), SharedConversationViewOptions{Title: "Banquet seating", SharedBy: "Lady Ann", SharedAt: chatNow})
})
})
}
+65
View File
@@ -0,0 +1,65 @@
package mujica
import (
"runtime"
"testing"
"github.com/egoist/mygo/ui"
)
func chatMod() ui.Modifiers {
if runtime.GOOS == "darwin" {
return ui.Cmd
}
return ui.Ctrl
}
func TestStopGeneratingButtonClickAndShortcut(t *testing.T) {
generating, stops := true, 0
tt := chatTester(400, 120, func(c *ui.Context) {
if StopGeneratingButton(c, StopGeneratingButtonOptions{Generating: generating}).Changed() {
stops++
generating = false
}
})
if err := tt.Click("Stop generating"); err != nil {
t.Fatal(err)
}
if stops != 1 || tt.HasText("Stop generating") {
t.Fatalf("stops %d, still shown %v", stops, tt.HasText("Stop generating"))
}
generating = true
tt.Frame()
tt.Key(chatMod(), ui.KeyPeriod)
if stops != 2 {
t.Fatalf("shortcut stops %d", stops)
}
tt.Key(chatMod(), ui.KeyPeriod)
if stops != 2 {
t.Fatal("shortcut acted while hidden")
}
}
func TestStopGeneratingButtonDisabled(t *testing.T) {
stops := 0
tt := chatTester(400, 120, func(c *ui.Context) {
if StopGeneratingButton(c, StopGeneratingButtonOptions{Generating: true, Disabled: true}).Changed() {
stops++
}
})
_ = tt.Click("Stop generating")
tt.Key(chatMod(), ui.KeyPeriod)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if stops != 0 {
t.Fatalf("disabled button stopped %d times", stops)
}
}
func TestStopGeneratingButtonSnapshot(t *testing.T) {
snapshot(t, "stop_generating_button", 300, 70, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
StopGeneratingButton(c, StopGeneratingButtonOptions{Generating: true})
})
})
}
+76
View File
@@ -0,0 +1,76 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
var chipTexts = []string{"Plan a ball", "Write a sonnet", "Explain the minuet"}
func TestSuggestionChipsFill(t *testing.T) {
input := "draft"
var last SuggestionChipsView
tt := chatTester(500, 200, func(c *ui.Context) {
if v := SuggestionChips(c, &input, chipTexts, SuggestionChipsOptions{Label: "Suggestions"}); v.Chosen != "" {
last = v
}
})
_ = tt.Click("Write a sonnet")
if input != "Write a sonnet" || last.Sent {
t.Fatalf("input %q sent %v", input, last.Sent)
}
}
func TestSuggestionChipsSendAndKeys(t *testing.T) {
input := "draft"
var last SuggestionChipsView
tt := chatTester(500, 200, func(c *ui.Context) {
if v := SuggestionChips(c, &input, chipTexts, SuggestionChipsOptions{Send: true}); v.Chosen != "" {
last = v
}
})
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnter)
if last.Chosen != "Explain the minuet" || !last.Sent || input != "draft" {
t.Fatalf("chose %+v input %q", last, input)
}
}
func TestSuggestionChipsDisabled(t *testing.T) {
input := ""
tt := chatTester(500, 200, func(c *ui.Context) {
SuggestionChips(c, &input, chipTexts, SuggestionChipsOptions{Disabled: true})
})
_ = tt.Click("Plan a ball")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if input != "" {
t.Fatalf("disabled chips filled %q", input)
}
}
func TestSuggestionChipsRejectEmpty(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
in := ""
ui.Render(func(c *ui.Context) {
Use(c, Settings{})
SuggestionChips(c, &in, []string{""}, SuggestionChipsOptions{})
}, 100, 100, 1)
}
func TestSuggestionChipsSnapshot(t *testing.T) {
snapshot(t, "suggestion_chips", 520, 120, func(c *ui.Context) {
in := ""
ui.Column(c).Padding(16).Gap(8).Children(func() {
SuggestionChips(c, &in, chipTexts, SuggestionChipsOptions{})
SuggestionChips(c, &in, chipTexts[:2], SuggestionChipsOptions{Send: true, Disabled: true})
})
})
}
+8 -7
View File
@@ -19,7 +19,7 @@ type SystemPromptEditorOptions struct {
Default string
// Saved is the text last saved; a different value shows as unsaved.
Saved string
// MaxLength caps the characters; zero is no cap.
// MaxLength is the character limit; over it, Save is blocked. Zero is none.
MaxLength int
// Saving shows the caller's save in progress and blocks editing.
Saving bool
@@ -47,6 +47,7 @@ func SystemPromptEditor(c *ui.Context, value *string, opts SystemPromptEditorOpt
col := ui.Column(c).Gap(d.Unit() * 2).Disabled(blocked)
v := SystemPromptEditorView{Element: col}
dirty := *value != opts.Saved
over := opts.MaxLength > 0 && utf8.RuneCountInString(*value) > opts.MaxLength
col.Children(func() {
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, msg(c, "system_prompt_editor.title")).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text).Grow(1)
@@ -62,24 +63,24 @@ func SystemPromptEditor(c *ui.Context, value *string, opts SystemPromptEditorOpt
TextArea(c, value, TextAreaOptions{Label: msg(c, "system_prompt_editor.title"), MinHeight: 120, MaxHeight: 320,
MaxLength: opts.MaxLength, Disabled: blocked})
ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
n := utf8.RuneCountInString(*value)
count := fmt.Sprintf(msg(c, "system_prompt_editor.count"), n)
if opts.MaxLength > 0 {
count = fmt.Sprintf(msg(c, "system_prompt_editor.count_of"), n, opts.MaxLength)
// With MaxLength the TextArea shows "n / max" itself.
count := ""
if opts.MaxLength == 0 {
count = fmt.Sprintf(msg(c, "system_prompt_editor.count"), utf8.RuneCountInString(*value))
}
ui.Text(c, count).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Grow(1)
if Button(c, msg(c, "system_prompt_editor.restore"), ButtonOptions{Variant: Ghost, Icon: icons.Must("refresh-cw"),
Disabled: blocked || *value == opts.Default}).Clicked() && !col.IsDisabled() {
*value, v.Restored = opts.Default, true
}
v.Save = Button(c, msg(c, "system_prompt_editor.save"), ButtonOptions{Loading: opts.Saving, Disabled: opts.Disabled || !dirty}).Clicked()
v.Save = Button(c, msg(c, "system_prompt_editor.save"), ButtonOptions{Loading: opts.Saving, Disabled: opts.Disabled || !dirty || over}).Clicked()
})
})
mods := ui.Ctrl
if runtime.GOOS == "darwin" {
mods = ui.Cmd
}
if col.Shortcut(mods, ui.KeyS) && dirty {
if col.Shortcut(mods, ui.KeyS) && dirty && !over {
v.Save = true
}
if col.IsDisabled() {
+81
View File
@@ -0,0 +1,81 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestSystemPromptEditorEditSaveRestore(t *testing.T) {
value, saved := "Be brief.", "Be brief."
saves, restores := 0, 0
tt := chatTester(520, 360, func(c *ui.Context) {
v := SystemPromptEditor(c, &value, SystemPromptEditorOptions{Default: "You are helpful.", Saved: saved})
if v.Save {
saves++
saved = value
}
if v.Restored {
restores++
}
})
if tt.HasText("Unsaved changes") || !tt.HasText("9 characters") {
t.Fatalf("texts %q", tt.Texts())
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnd)
tt.Type(" Be kind.")
if !tt.HasText("Unsaved changes") || !tt.HasText("18 characters") {
t.Fatalf("texts %q", tt.Texts())
}
tt.Key(chatMod(), ui.KeyS)
if saves != 1 || saved != "Be brief. Be kind." || tt.HasText("Unsaved changes") {
t.Fatalf("saves %d saved %q", saves, saved)
}
_ = tt.Click("Restore default")
if value != "You are helpful." || restores != 1 || !tt.HasText("Unsaved changes") {
t.Fatalf("restore gave %q", value)
}
_ = tt.Click("Save")
if saves != 2 {
t.Fatalf("saves %d", saves)
}
}
func TestSystemPromptEditorLimitAndDisabled(t *testing.T) {
value, saves := "abc", 0
tt := chatTester(520, 360, func(c *ui.Context) {
if SystemPromptEditor(c, &value, SystemPromptEditorOptions{MaxLength: 5}).Save {
saves++
}
})
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnd)
tt.Type("defgh")
tt.Key(chatMod(), ui.KeyS)
_ = tt.Click("Save")
if saves != 0 || !tt.HasText("3 characters over the limit") {
t.Fatalf("over the limit: saves %d, texts %q", saves, tt.Texts())
}
value = "abcde"
tt = chatTester(520, 360, func(c *ui.Context) {
if SystemPromptEditor(c, &value, SystemPromptEditorOptions{Saved: "x", Disabled: true}).Save {
saves++
}
})
_ = tt.Click("Save")
tt.Key(chatMod(), ui.KeyS)
_ = tt.Click("Restore default")
if saves != 0 || value != "abcde" {
t.Fatalf("disabled editor acted: %d %q", saves, value)
}
}
func TestSystemPromptEditorSnapshot(t *testing.T) {
snapshot(t, "system_prompt_editor", 520, 280, func(c *ui.Context) {
v := "You are a courtly assistant."
ui.Column(c).Padding(16).Children(func() {
SystemPromptEditor(c, &v, SystemPromptEditorOptions{Default: "You are helpful.", Saved: "x", MaxLength: 2000})
})
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+51
View File
@@ -0,0 +1,51 @@
package mujica
import (
"bytes"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func TestThinkingIndicatorPhase(t *testing.T) {
tt := chatTester(400, 100, func(c *ui.Context) { ThinkingIndicator(c, ThinkingIndicatorOptions{}) })
if !tt.HasText("Thinking") {
t.Fatalf("texts %q", tt.Texts())
}
tt = chatTester(400, 100, func(c *ui.Context) {
ThinkingIndicator(c, ThinkingIndicatorOptions{Phase: "Reading the archives"})
})
if !tt.HasText("Reading the archives") {
t.Fatalf("texts %q", tt.Texts())
}
}
func TestThinkingIndicatorReducedMotionIsStill(t *testing.T) {
frame := func(reduce bool, at time.Duration) []byte {
tt := chatTester(200, 60, func(c *ui.Context) { ThinkingIndicator(c, ThinkingIndicatorOptions{}) })
tt.SetPreferences(ui.Preferences{ReduceMotion: reduce, TextScale: 1})
tt.Frame()
if at > 0 {
time.Sleep(at)
tt.Frame()
}
return encode(t, tt.Image())
}
if !bytes.Equal(frame(true, 0), frame(true, 300*time.Millisecond)) {
t.Fatal("reduced motion still moves")
}
tt := chatTester(200, 60, func(c *ui.Context) { ThinkingIndicator(c, ThinkingIndicatorOptions{}) })
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
if !tt.HasText("Thinking…") {
t.Fatalf("static label missing: %q", tt.Texts())
}
}
func TestThinkingIndicatorSnapshot(t *testing.T) {
snapshot(t, "thinking_indicator", 360, 70, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
ThinkingIndicator(c, ThinkingIndicatorOptions{Phase: "Searching the archives"})
})
})
}
+66
View File
@@ -0,0 +1,66 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestWelcomeScreenGreetsAndChooses(t *testing.T) {
input, got := "", ""
tt := chatTester(900, 600, func(c *ui.Context) {
v := WelcomeScreen(c, WelcomeScreenOptions{Greeting: "Good evening", Subtitle: "How may I serve?", Cards: chatCards(),
Prompts: func() { SuggestionChips(c, &input, []string{"Plan a ball"}, SuggestionChipsOptions{}) }})
if v.Example != "" {
got = v.Example
}
})
for _, s := range []string{"Good evening", "How may I serve?", "Research"} {
if !tt.HasText(s) {
t.Fatalf("%q missing from %q", s, tt.Texts())
}
}
_ = tt.Click("“Explain the minuet”")
if got != "Explain the minuet" {
t.Fatalf("example %q", got)
}
_ = tt.Click("Plan a ball")
if input != "Plan a ball" {
t.Fatalf("slot prompt left %q", input)
}
}
func TestWelcomeScreenDisabled(t *testing.T) {
got := ""
tt := chatTester(900, 600, func(c *ui.Context) {
if v := WelcomeScreen(c, WelcomeScreenOptions{Greeting: "Hello", Cards: chatCards(), Disabled: true}); v.Example != "" {
got = v.Example
}
})
_ = tt.Click("“Explain the minuet”")
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if got != "" {
t.Fatalf("disabled screen chose %q", got)
}
}
func TestWelcomeScreenNeedsGreeting(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.Render(func(c *ui.Context) {
Use(c, Settings{})
WelcomeScreen(c, WelcomeScreenOptions{})
}, 100, 100, 1)
}
func TestWelcomeScreenSnapshot(t *testing.T) {
snapshot(t, "welcome_screen", 900, 420, func(c *ui.Context) {
in := ""
WelcomeScreen(c, WelcomeScreenOptions{Greeting: "Good afternoon", Subtitle: "How may I serve the court?", Cards: chatCards(),
Prompts: func() { SuggestionChips(c, &in, []string{"Plan a ball", "Write a sonnet"}, SuggestionChipsOptions{}) }})
})
}