diff --git a/artifact_card_test.go b/artifact_card_test.go new file mode 100644 index 0000000..409109c --- /dev/null +++ b/artifact_card_test.go @@ -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}) + }) + }) +} diff --git a/capability_cards_test.go b/capability_cards_test.go new file mode 100644 index 0000000..a80add7 --- /dev/null +++ b/capability_cards_test.go @@ -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{}) }) + }) +} diff --git a/chat_empty_state_test.go b/chat_empty_state_test.go new file mode 100644 index 0000000..c0eaa73 --- /dev/null +++ b/chat_empty_state_test.go @@ -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"}) }) + } + }) + }) +} diff --git a/chatsession_fixture_test.go b/chatsession_fixture_test.go new file mode 100644 index 0000000..4f2b199 --- /dev/null +++ b/chatsession_fixture_test.go @@ -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"}}, + } +} diff --git a/conversation_export_test.go b/conversation_export_test.go new file mode 100644 index 0000000..68c63aa --- /dev/null +++ b/conversation_export_test.go @@ -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}}) + }) + }) +} diff --git a/conversation_item.go b/conversation_item.go index c3eb5b2..9d04be3 100644 --- a/conversation_item.go +++ b/conversation_item.go @@ -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 +} diff --git a/conversation_item_test.go b/conversation_item_test.go new file mode 100644 index 0000000..1d358fa --- /dev/null +++ b/conversation_item_test.go @@ -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}) + }) + }) +} diff --git a/conversation_list.go b/conversation_list.go index c6dae06..f2d1a0d 100644 --- a/conversation_list.go +++ b/conversation_list.go @@ -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) { diff --git a/conversation_list_test.go b/conversation_list_test.go new file mode 100644 index 0000000..3307cd6 --- /dev/null +++ b/conversation_list_test.go @@ -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) + }) + }) +} diff --git a/conversation_search.go b/conversation_search.go index 0fc7865..44bc501 100644 --- a/conversation_search.go +++ b/conversation_search.go @@ -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) diff --git a/conversation_search_test.go b/conversation_search_test.go new file mode 100644 index 0000000..2afb0f9 --- /dev/null +++ b/conversation_search_test.go @@ -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{}) + }) + }) +} diff --git a/cost_estimator_test.go b/cost_estimator_test.go new file mode 100644 index 0000000..f1dfb2a --- /dev/null +++ b/cost_estimator_test.go @@ -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}) + }) + }) +} diff --git a/date_separator_test.go b/date_separator_test.go new file mode 100644 index 0000000..bd68bc1 --- /dev/null +++ b/date_separator_test.go @@ -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)}) + }) + }) +} diff --git a/docs/components/241-conversation-list.md b/docs/components/241-conversation-list.md new file mode 100644 index 0000000..c2d1703 --- /dev/null +++ b/docs/components/241-conversation-list.md @@ -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`。 diff --git a/docs/components/242-conversation-item.md b/docs/components/242-conversation-item.md new file mode 100644 index 0000000..84246ff --- /dev/null +++ b/docs/components/242-conversation-item.md @@ -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 位之外另设快捷键。 diff --git a/docs/components/243-conversation-search.md b/docs/components/243-conversation-search.md new file mode 100644 index 0000000..4195cdc --- /dev/null +++ b/docs/components/243-conversation-search.md @@ -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 行为)。 + +## 限制 + +- 匹配高亮为不区分大小写的逐字符匹配,不做分词、拼音或模糊匹配;排序完全由调用方决定。 +- 结果行固定高度(标题一行、片段一行),片段超长时截断。 diff --git a/docs/components/244-project-list.md b/docs/components/244-project-list.md new file mode 100644 index 0000000..a3ed67b --- /dev/null +++ b/docs/components/244-project-list.md @@ -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`,其展开状态可被辅助技术读到。 +- 列表未虚拟化,适合数百个项目以内。 diff --git a/docs/components/245-project-knowledge-panel.md b/docs/components/245-project-knowledge-panel.md new file mode 100644 index 0000000..158c9e5 --- /dev/null +++ b/docs/components/245-project-knowledge-panel.md @@ -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 数由调用方计算;面板只做求和与比例显示。 diff --git a/docs/components/246-welcome-screen.md b/docs/components/246-welcome-screen.md new file mode 100644 index 0000000..910f58f --- /dev/null +++ b/docs/components/246-welcome-screen.md @@ -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。 diff --git a/docs/components/247-suggestion-chips.md b/docs/components/247-suggestion-chips.md new file mode 100644 index 0000000..e8ae72f --- /dev/null +++ b/docs/components/247-suggestion-chips.md @@ -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 选择。 + +## 限制 + +- 建议换行排列;数量很多时请调用方截取。 diff --git a/docs/components/248-prompt-library.md b/docs/components/248-prompt-library.md new file mode 100644 index 0000000..bd57cd9 --- /dev/null +++ b/docs/components/248-prompt-library.md @@ -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 选择。 + +## 限制 + +- 过滤只在模板切片、查询或分类变化时重算。 +- 变量为简单占位替换,不支持条件或循环。 diff --git a/docs/components/249-capability-cards.md b/docs/components/249-capability-cards.md new file mode 100644 index 0000000..0c1c886 --- /dev/null +++ b/docs/components/249-capability-cards.md @@ -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`。 diff --git a/docs/components/250-feedback-form.md b/docs/components/250-feedback-form.md new file mode 100644 index 0000000..3fa8600 --- /dev/null +++ b/docs/components/250-feedback-form.md @@ -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:取消。 + +## 限制 + +- 表单不发送任何数据;提交由调用方完成。 diff --git a/docs/components/251-date-separator.md b/docs/components/251-date-separator.md new file mode 100644 index 0000000..edd1bdc --- /dev/null +++ b/docs/components/251-date-separator.md @@ -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日 星期二”。 diff --git a/docs/components/252-thinking-indicator.md b/docs/components/252-thinking-indicator.md new file mode 100644 index 0000000..355da7b --- /dev/null +++ b/docs/components/252-thinking-indicator.md @@ -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,辅助技术读出阶段文字。 + +## 限制 + +- 动画只在绘制时请求下一帧;组件隐藏后不再重绘。 diff --git a/docs/components/253-stop-generating-button.md b/docs/components/253-stop-generating-button.md new file mode 100644 index 0000000..33620d7 --- /dev/null +++ b/docs/components/253-stop-generating-button.md @@ -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 窗口快捷键注册,焦点控件若注册了同一组合键会优先获得。 diff --git a/docs/components/254-system-prompt-editor.md b/docs/components/254-system-prompt-editor.md new file mode 100644 index 0000000..27fdb0b --- /dev/null +++ b/docs/components/254-system-prompt-editor.md @@ -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:保存(有改动且未超限时)。 + +## 限制 + +- 超出上限不会截断文字,只阻止保存。 diff --git a/docs/components/255-cost-estimator.md b/docs/components/255-cost-estimator.md new file mode 100644 index 0000000..aa9bded --- /dev/null +++ b/docs/components/255-cost-estimator.md @@ -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` 位小数四舍五入,不用于结算。 diff --git a/docs/components/256-shared-conversation-view.md b/docs/components/256-shared-conversation-view.md new file mode 100644 index 0000000..cda30ed --- /dev/null +++ b/docs/components/256-shared-conversation-view.md @@ -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 组件)。 +- 消息列表虚拟化,只构建可见消息。 diff --git a/docs/components/257-conversation-export.md b/docs/components/257-conversation-export.md new file mode 100644 index 0000000..61a5f86 --- /dev/null +++ b/docs/components/257-conversation-export.md @@ -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 只加标题与作者行,原文原样写入。 +- 预览只在格式、范围或消息变化时重新生成。 diff --git a/docs/components/258-regenerate-menu.md b/docs/components/258-regenerate-menu.md new file mode 100644 index 0000000..c22739d --- /dev/null +++ b/docs/components/258-regenerate-menu.md @@ -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),不使用系统菜单。 diff --git a/docs/components/259-artifact-card.md b/docs/components/259-artifact-card.md new file mode 100644 index 0000000..ed8601b --- /dev/null +++ b/docs/components/259-artifact-card.md @@ -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 打开。 + +## 限制 + +- 卡片不渲染产物内容,面板由调用方实现。 diff --git a/docs/components/260-chat-empty-state.md b/docs/components/260-chat-empty-state.md new file mode 100644 index 0000000..3b3852e --- /dev/null +++ b/docs/components/260-chat-empty-state.md @@ -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 组件,布局与其一致。 diff --git a/example_chatsession_test.go b/example_chatsession_test.go new file mode 100644 index 0000000..53a5849 --- /dev/null +++ b/example_chatsession_test.go @@ -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) +} diff --git a/examples/gallery/pages_chatsession.go b/examples/gallery/pages_chatsession.go index e315674..7cd7b79 100644 --- a/examples/gallery/pages_chatsession.go +++ b/examples/gallery/pages_chatsession.go @@ -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)`}, +} diff --git a/examples/gallery/pages_chatsession_demos.go b/examples/gallery/pages_chatsession_demos.go new file mode 100644 index 0000000..8594e36 --- /dev/null +++ b/examples/gallery/pages_chatsession_demos.go @@ -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 + } + } + } + }) + }) +} diff --git a/examples/gallery/pages_chatsession_more.go b/examples/gallery/pages_chatsession_more.go new file mode 100644 index 0000000..b6fd37d --- /dev/null +++ b/examples/gallery/pages_chatsession_more.go @@ -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)) + } + }) + }) +} diff --git a/feedback_form_test.go b/feedback_form_test.go new file mode 100644 index 0000000..8cb86e0 --- /dev/null +++ b/feedback_form_test.go @@ -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}) }) + }) +} diff --git a/messages_chatsession.go b/messages_chatsession.go index 6151bf6..94f4957 100644 --- a/messages_chatsession.go +++ b/messages_chatsession.go @@ -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"}, diff --git a/project_knowledge_panel_test.go b/project_knowledge_panel_test.go new file mode 100644 index 0000000..1b6ac0f --- /dev/null +++ b/project_knowledge_panel_test.go @@ -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}) + }) + }) +} diff --git a/project_list.go b/project_list.go index 06e7b56..4c21553 100644 --- a/project_list.go +++ b/project_list.go @@ -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) + }) }) } diff --git a/project_list_test.go b/project_list_test.go new file mode 100644 index 0000000..4042954 --- /dev/null +++ b/project_list_test.go @@ -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) }) + }) +} diff --git a/prompt_library_test.go b/prompt_library_test.go new file mode 100644 index 0000000..63f56e7 --- /dev/null +++ b/prompt_library_test.go @@ -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{}) }) + }) +} diff --git a/regenerate_menu.go b/regenerate_menu.go index d122e0e..b224406 100644 --- a/regenerate_menu.go +++ b/regenerate_menu.go @@ -80,6 +80,7 @@ func RegenerateMenu(c *ui.Context, opts RegenerateMenuOptions) RegenerateMenuVie } }) }) + v.Element.AlignSelf(ui.Start) if v.Element.IsDisabled() { v.Chosen = false } diff --git a/regenerate_menu_test.go b/regenerate_menu_test.go new file mode 100644 index 0000000..1f44c46 --- /dev/null +++ b/regenerate_menu_test.go @@ -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") }) +} diff --git a/shared_conversation_view_test.go b/shared_conversation_view_test.go new file mode 100644 index 0000000..43fae0e --- /dev/null +++ b/shared_conversation_view_test.go @@ -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}) + }) + }) +} diff --git a/stop_generating_button_test.go b/stop_generating_button_test.go new file mode 100644 index 0000000..c6663b4 --- /dev/null +++ b/stop_generating_button_test.go @@ -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}) + }) + }) +} diff --git a/suggestion_chips_test.go b/suggestion_chips_test.go new file mode 100644 index 0000000..87eb259 --- /dev/null +++ b/suggestion_chips_test.go @@ -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}) + }) + }) +} diff --git a/system_prompt_editor.go b/system_prompt_editor.go index 93a3919..7fcba8d 100644 --- a/system_prompt_editor.go +++ b/system_prompt_editor.go @@ -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() { diff --git a/system_prompt_editor_test.go b/system_prompt_editor_test.go new file mode 100644 index 0000000..ace4f4b --- /dev/null +++ b/system_prompt_editor_test.go @@ -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}) + }) + }) +} diff --git a/testdata/snapshots/darwin/artifact_card-dark.png b/testdata/snapshots/darwin/artifact_card-dark.png new file mode 100644 index 0000000..375162b Binary files /dev/null and b/testdata/snapshots/darwin/artifact_card-dark.png differ diff --git a/testdata/snapshots/darwin/artifact_card-light.png b/testdata/snapshots/darwin/artifact_card-light.png new file mode 100644 index 0000000..693b537 Binary files /dev/null and b/testdata/snapshots/darwin/artifact_card-light.png differ diff --git a/testdata/snapshots/darwin/capability_cards-dark.png b/testdata/snapshots/darwin/capability_cards-dark.png new file mode 100644 index 0000000..3d1e014 Binary files /dev/null and b/testdata/snapshots/darwin/capability_cards-dark.png differ diff --git a/testdata/snapshots/darwin/capability_cards-light.png b/testdata/snapshots/darwin/capability_cards-light.png new file mode 100644 index 0000000..35b9a97 Binary files /dev/null and b/testdata/snapshots/darwin/capability_cards-light.png differ diff --git a/testdata/snapshots/darwin/chat_empty_state-dark.png b/testdata/snapshots/darwin/chat_empty_state-dark.png new file mode 100644 index 0000000..aababdc Binary files /dev/null and b/testdata/snapshots/darwin/chat_empty_state-dark.png differ diff --git a/testdata/snapshots/darwin/chat_empty_state-light.png b/testdata/snapshots/darwin/chat_empty_state-light.png new file mode 100644 index 0000000..8368eef Binary files /dev/null and b/testdata/snapshots/darwin/chat_empty_state-light.png differ diff --git a/testdata/snapshots/darwin/conversation_export-dark.png b/testdata/snapshots/darwin/conversation_export-dark.png new file mode 100644 index 0000000..2dec7db Binary files /dev/null and b/testdata/snapshots/darwin/conversation_export-dark.png differ diff --git a/testdata/snapshots/darwin/conversation_export-light.png b/testdata/snapshots/darwin/conversation_export-light.png new file mode 100644 index 0000000..cf9085c Binary files /dev/null and b/testdata/snapshots/darwin/conversation_export-light.png differ diff --git a/testdata/snapshots/darwin/conversation_item-dark.png b/testdata/snapshots/darwin/conversation_item-dark.png new file mode 100644 index 0000000..577f3a3 Binary files /dev/null and b/testdata/snapshots/darwin/conversation_item-dark.png differ diff --git a/testdata/snapshots/darwin/conversation_item-light.png b/testdata/snapshots/darwin/conversation_item-light.png new file mode 100644 index 0000000..cb33c94 Binary files /dev/null and b/testdata/snapshots/darwin/conversation_item-light.png differ diff --git a/testdata/snapshots/darwin/conversation_list-dark.png b/testdata/snapshots/darwin/conversation_list-dark.png new file mode 100644 index 0000000..493b247 Binary files /dev/null and b/testdata/snapshots/darwin/conversation_list-dark.png differ diff --git a/testdata/snapshots/darwin/conversation_list-light.png b/testdata/snapshots/darwin/conversation_list-light.png new file mode 100644 index 0000000..24d91af Binary files /dev/null and b/testdata/snapshots/darwin/conversation_list-light.png differ diff --git a/testdata/snapshots/darwin/conversation_search-dark.png b/testdata/snapshots/darwin/conversation_search-dark.png new file mode 100644 index 0000000..c97673f Binary files /dev/null and b/testdata/snapshots/darwin/conversation_search-dark.png differ diff --git a/testdata/snapshots/darwin/conversation_search-light.png b/testdata/snapshots/darwin/conversation_search-light.png new file mode 100644 index 0000000..8e4136f Binary files /dev/null and b/testdata/snapshots/darwin/conversation_search-light.png differ diff --git a/testdata/snapshots/darwin/cost_estimator-dark.png b/testdata/snapshots/darwin/cost_estimator-dark.png new file mode 100644 index 0000000..43d1f41 Binary files /dev/null and b/testdata/snapshots/darwin/cost_estimator-dark.png differ diff --git a/testdata/snapshots/darwin/cost_estimator-light.png b/testdata/snapshots/darwin/cost_estimator-light.png new file mode 100644 index 0000000..8aa5c2c Binary files /dev/null and b/testdata/snapshots/darwin/cost_estimator-light.png differ diff --git a/testdata/snapshots/darwin/date_separator-dark.png b/testdata/snapshots/darwin/date_separator-dark.png new file mode 100644 index 0000000..907f647 Binary files /dev/null and b/testdata/snapshots/darwin/date_separator-dark.png differ diff --git a/testdata/snapshots/darwin/date_separator-light.png b/testdata/snapshots/darwin/date_separator-light.png new file mode 100644 index 0000000..ab2d287 Binary files /dev/null and b/testdata/snapshots/darwin/date_separator-light.png differ diff --git a/testdata/snapshots/darwin/feedback_form-dark.png b/testdata/snapshots/darwin/feedback_form-dark.png new file mode 100644 index 0000000..a14bcd5 Binary files /dev/null and b/testdata/snapshots/darwin/feedback_form-dark.png differ diff --git a/testdata/snapshots/darwin/feedback_form-light.png b/testdata/snapshots/darwin/feedback_form-light.png new file mode 100644 index 0000000..14dbb88 Binary files /dev/null and b/testdata/snapshots/darwin/feedback_form-light.png differ diff --git a/testdata/snapshots/darwin/project_knowledge_panel-dark.png b/testdata/snapshots/darwin/project_knowledge_panel-dark.png new file mode 100644 index 0000000..eb1eca7 Binary files /dev/null and b/testdata/snapshots/darwin/project_knowledge_panel-dark.png differ diff --git a/testdata/snapshots/darwin/project_knowledge_panel-light.png b/testdata/snapshots/darwin/project_knowledge_panel-light.png new file mode 100644 index 0000000..6efc4e7 Binary files /dev/null and b/testdata/snapshots/darwin/project_knowledge_panel-light.png differ diff --git a/testdata/snapshots/darwin/project_list-dark.png b/testdata/snapshots/darwin/project_list-dark.png new file mode 100644 index 0000000..5a3ec14 Binary files /dev/null and b/testdata/snapshots/darwin/project_list-dark.png differ diff --git a/testdata/snapshots/darwin/project_list-light.png b/testdata/snapshots/darwin/project_list-light.png new file mode 100644 index 0000000..2fc92d8 Binary files /dev/null and b/testdata/snapshots/darwin/project_list-light.png differ diff --git a/testdata/snapshots/darwin/prompt_library-dark.png b/testdata/snapshots/darwin/prompt_library-dark.png new file mode 100644 index 0000000..6205353 Binary files /dev/null and b/testdata/snapshots/darwin/prompt_library-dark.png differ diff --git a/testdata/snapshots/darwin/prompt_library-light.png b/testdata/snapshots/darwin/prompt_library-light.png new file mode 100644 index 0000000..1c2ad29 Binary files /dev/null and b/testdata/snapshots/darwin/prompt_library-light.png differ diff --git a/testdata/snapshots/darwin/regenerate_menu-dark.png b/testdata/snapshots/darwin/regenerate_menu-dark.png new file mode 100644 index 0000000..5d4356e Binary files /dev/null and b/testdata/snapshots/darwin/regenerate_menu-dark.png differ diff --git a/testdata/snapshots/darwin/regenerate_menu-light.png b/testdata/snapshots/darwin/regenerate_menu-light.png new file mode 100644 index 0000000..9cea599 Binary files /dev/null and b/testdata/snapshots/darwin/regenerate_menu-light.png differ diff --git a/testdata/snapshots/darwin/shared_conversation_view-dark.png b/testdata/snapshots/darwin/shared_conversation_view-dark.png new file mode 100644 index 0000000..212d179 Binary files /dev/null and b/testdata/snapshots/darwin/shared_conversation_view-dark.png differ diff --git a/testdata/snapshots/darwin/shared_conversation_view-light.png b/testdata/snapshots/darwin/shared_conversation_view-light.png new file mode 100644 index 0000000..09fa005 Binary files /dev/null and b/testdata/snapshots/darwin/shared_conversation_view-light.png differ diff --git a/testdata/snapshots/darwin/stop_generating_button-dark.png b/testdata/snapshots/darwin/stop_generating_button-dark.png new file mode 100644 index 0000000..f0a9cd3 Binary files /dev/null and b/testdata/snapshots/darwin/stop_generating_button-dark.png differ diff --git a/testdata/snapshots/darwin/stop_generating_button-light.png b/testdata/snapshots/darwin/stop_generating_button-light.png new file mode 100644 index 0000000..974880f Binary files /dev/null and b/testdata/snapshots/darwin/stop_generating_button-light.png differ diff --git a/testdata/snapshots/darwin/suggestion_chips-dark.png b/testdata/snapshots/darwin/suggestion_chips-dark.png new file mode 100644 index 0000000..8d62552 Binary files /dev/null and b/testdata/snapshots/darwin/suggestion_chips-dark.png differ diff --git a/testdata/snapshots/darwin/suggestion_chips-light.png b/testdata/snapshots/darwin/suggestion_chips-light.png new file mode 100644 index 0000000..5d1b6fe Binary files /dev/null and b/testdata/snapshots/darwin/suggestion_chips-light.png differ diff --git a/testdata/snapshots/darwin/system_prompt_editor-dark.png b/testdata/snapshots/darwin/system_prompt_editor-dark.png new file mode 100644 index 0000000..2444177 Binary files /dev/null and b/testdata/snapshots/darwin/system_prompt_editor-dark.png differ diff --git a/testdata/snapshots/darwin/system_prompt_editor-light.png b/testdata/snapshots/darwin/system_prompt_editor-light.png new file mode 100644 index 0000000..6dced57 Binary files /dev/null and b/testdata/snapshots/darwin/system_prompt_editor-light.png differ diff --git a/testdata/snapshots/darwin/thinking_indicator-dark.png b/testdata/snapshots/darwin/thinking_indicator-dark.png new file mode 100644 index 0000000..36e4f45 Binary files /dev/null and b/testdata/snapshots/darwin/thinking_indicator-dark.png differ diff --git a/testdata/snapshots/darwin/thinking_indicator-light.png b/testdata/snapshots/darwin/thinking_indicator-light.png new file mode 100644 index 0000000..d913285 Binary files /dev/null and b/testdata/snapshots/darwin/thinking_indicator-light.png differ diff --git a/testdata/snapshots/darwin/welcome_screen-dark.png b/testdata/snapshots/darwin/welcome_screen-dark.png new file mode 100644 index 0000000..dc49fa8 Binary files /dev/null and b/testdata/snapshots/darwin/welcome_screen-dark.png differ diff --git a/testdata/snapshots/darwin/welcome_screen-light.png b/testdata/snapshots/darwin/welcome_screen-light.png new file mode 100644 index 0000000..49286a0 Binary files /dev/null and b/testdata/snapshots/darwin/welcome_screen-light.png differ diff --git a/thinking_indicator_test.go b/thinking_indicator_test.go new file mode 100644 index 0000000..6fb8e22 --- /dev/null +++ b/thinking_indicator_test.go @@ -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"}) + }) + }) +} diff --git a/welcome_screen_test.go b/welcome_screen_test.go new file mode 100644 index 0000000..febf2ae --- /dev/null +++ b/welcome_screen_test.go @@ -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{}) }}) + }) +}