diff --git a/artifact_card.go b/artifact_card.go new file mode 100644 index 0000000..1bf76d4 --- /dev/null +++ b/artifact_card.go @@ -0,0 +1,84 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// ArtifactKind is what an artifact holds; zero is ArtifactCode. +type ArtifactKind int + +const ( + ArtifactCode ArtifactKind = iota + ArtifactDocument + ArtifactWeb +) + +// ArtifactCardOptions configure an ArtifactCard. Title is required. +type ArtifactCardOptions struct { + Kind ArtifactKind + Title string + // Detail is a short fact, such as "Go, 120 lines". + Detail string + // Streaming shows the artifact is still being written. + Streaming bool + Disabled bool +} + +var artifactKinds = [3]struct{ icon, name string }{ + {"file", "artifact_card.code"}, {"file-text", "artifact_card.document"}, {"layout-grid", "artifact_card.web"}, +} + +// ArtifactCard stands for an artifact; activating asks to open it. +func ArtifactCard(c *ui.Context, opts ArtifactCardOptions) ChoiceResult { + if opts.Title == "" { + panic("mujica: ArtifactCard needs a Title") + } + if opts.Kind < ArtifactCode || opts.Kind > ArtifactWeb { + panic("mujica: ArtifactCard Kind out of range") + } + k := Tokens(c) + d := Density(c) + kind := artifactKinds[opts.Kind] + var b *ui.Element + ui.Row(c).Shrink(0).Disabled(opts.Disabled).Children(func() { + b = ui.ButtonBase(c).MinWidth(260).MaxWidth(420).MinHeight(56).Padding(d.Unit()*2, d.Unit()*3).Gap(d.Unit()*3). + Justify(ui.Start).Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder). + Label(msg(c, kind.name) + ": " + opts.Title) + }) + internal.Ring(b, k.Focus, theme.CardRadius) + if b.Hovered() { + b.Background(k.SurfaceHover) + } + b.Transition(colorFade) + b.Children(func() { + ui.Box(c).Size(36, 36).Shrink(0).Center().Radius(theme.ControlRadius).Background(k.Selection).Children(func() { + ui.Icon(c, icons.Must(kind.icon)).Size(18, 18).TextColor(k.AccentText) + }) + ui.Column(c).Grow(1).MinWidth(0).Gap(2).Children(func() { + ui.Text(c, opts.Title).SingleLine().Bold().TextColor(k.Text) + ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Children(func() { + detail := msg(c, kind.name) + if opts.Detail != "" { + detail += " · " + opts.Detail + } + ui.Text(c, detail).SingleLine().FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + if opts.Streaming { + Spinner(c, SpinnerOptions{Size: 12, Label: msg(c, "artifact_card.writing")}) + } + }) + }) + ui.Text(c, msg(c, "artifact_card.open")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.AccentText).Shrink(0) + ui.Icon(c, icons.Must("chevron-right")).Size(14, 14).TextColor(k.AccentText) + }) + open := b.Clicked() && !b.IsDisabled() + if open { + slog.Debug("mujica: artifact_card open", "kind", opts.Kind) + } + return ChoiceResult{Element: b, changed: open} +} 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.go b/capability_cards.go new file mode 100644 index 0000000..fb977cf --- /dev/null +++ b/capability_cards.go @@ -0,0 +1,94 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// CapabilityCard explains one assistant capability with example prompts. +type CapabilityCard struct { + Title, Description string + Icon *ui.SVG + Examples []string +} + +// CapabilityCardsOptions configure CapabilityCards. +type CapabilityCardsOptions struct { + // Columns is the most cards per row; zero is 3. + Columns int + Disabled bool +} + +// CapabilityCardsView is built CapabilityCards and the example clicked this frame. +type CapabilityCardsView struct { + Element *ui.Element + // Example is the example prompt chosen, empty for none. + Example string +} + +// CapabilityCards shows capabilities; a clicked example goes to the caller. +func CapabilityCards(c *ui.Context, cards []CapabilityCard, opts CapabilityCardsOptions) CapabilityCardsView { + if opts.Columns < 0 { + panic("mujica: CapabilityCards Columns is negative") + } + cols := opts.Columns + if cols == 0 { + cols = 3 + } + k := Tokens(c) + d := Density(c) + g := ui.Grid(c).Columns(cols).Gap(d.Unit() * 3).Disabled(opts.Disabled) + v := CapabilityCardsView{Element: g} + g.Children(func() { + for i, card := range cards { + if card.Title == "" { + panic("mujica: CapabilityCard needs a Title") + } + box := ui.Column(c).Key(i).Gap(d.Unit()*2).Padding(d.Unit()*4).Background(k.Surface). + Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius).Role(ui.RoleGroup).Label(card.Title) + box.Children(func() { + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + if card.Icon != nil { + ui.Icon(c, card.Icon).Size(20, 20).TextColor(k.AccentText) + } + ui.Text(c, card.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text) + }) + if card.Description != "" { + ui.Text(c, card.Description).TextColor(k.TextMuted) + } + ui.Column(c).Gap(d.Unit()).FocusGroup(ui.Vertical).Children(func() { + for _, ex := range card.Examples { + if capabilityExample(c, k, ex) { + v.Example = ex + } + } + }) + }) + } + }) + if v.Example != "" { + slog.Debug("mujica: capability_cards example", "len", len(v.Example)) + } + return v +} + +// capabilityExample is a quoted example button reporting a click. +func capabilityExample(c *ui.Context, k theme.Tokens, text string) bool { + d := Density(c) + b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Padding(d.Unit(), d.Unit()*3).Justify(ui.Start). + Radius(theme.ControlRadius).Background(k.Background).Border(theme.BorderWidth, k.Border).Label(text) + internal.Ring(b, k.Focus, theme.ControlRadius) + switch { + case b.Pressed(): + b.Background(k.Selection) + case b.Hovered(): + b.Background(k.SurfaceHover).Border(theme.BorderWidth, k.ControlBorder) + } + b.Transition(colorFade) + b.Children(func() { ui.Text(c, "“"+text+"”").TextColor(k.Text) }) + return b.Clicked() && !b.IsDisabled() +} 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.go b/chat_empty_state.go new file mode 100644 index 0000000..a386f0a --- /dev/null +++ b/chat_empty_state.go @@ -0,0 +1,58 @@ +package mujica + +import ( + "cmp" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +// ChatEmptyKind is why a chat is empty; zero is ChatEmptyNew. +type ChatEmptyKind int + +const ( + // ChatEmptyNew: a new conversation with no messages. + ChatEmptyNew ChatEmptyKind = iota + // ChatEmptyNoResults: a conversation search matched nothing. + ChatEmptyNoResults + // ChatEmptyOffline: the connection is down. + ChatEmptyOffline +) + +// ChatEmptyStateOptions configure a ChatEmptyState; empty texts use built-in copy. +type ChatEmptyStateOptions struct { + Kind ChatEmptyKind + // Query, for ChatEmptyNoResults, is named in the description. + Query string + Title string + Description string + // Action replaces the kind's built-in button text. + Action string +} + +// ChatEmptyState explains an empty chat and offers the next step. +func ChatEmptyState(c *ui.Context, opts ChatEmptyStateOptions) EmptyStateResult { + switch opts.Kind { + case ChatEmptyNew: + return EmptyState(c, EmptyStateOptions{ + Title: cmp.Or(opts.Title, msg(c, "chat_empty_state.new.title")), + Description: cmp.Or(opts.Description, msg(c, "chat_empty_state.new.description")), + Icon: icons.Must("star"), + Action: cmp.Or(opts.Action, msg(c, "chat_empty_state.new.action")), + }) + case ChatEmptyNoResults: + return EmptyState(c, EmptyStateOptions{ + Kind: EmptyNoResults, Query: opts.Query, Title: opts.Title, Description: opts.Description, + Action: cmp.Or(opts.Action, msg(c, "chat_empty_state.no_results.action")), + }) + case ChatEmptyOffline: + return EmptyState(c, EmptyStateOptions{ + Title: cmp.Or(opts.Title, msg(c, "chat_empty_state.offline.title")), + Description: cmp.Or(opts.Description, msg(c, "chat_empty_state.offline.description")), + Icon: icons.Must("wifi-off"), + Action: cmp.Or(opts.Action, msg(c, "chat_empty_state.offline.action")), + }) + } + panic("mujica: ChatEmptyState Kind out of range") +} 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.go b/conversation_export.go new file mode 100644 index 0000000..3acf073 --- /dev/null +++ b/conversation_export.go @@ -0,0 +1,228 @@ +package mujica + +import ( + "encoding/json" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// ExportFormat is an export's text form; zero is ExportMarkdown. +type ExportFormat int + +const ( + ExportMarkdown ExportFormat = iota + ExportJSON + ExportText +) + +// ExportRange is which messages an export holds; zero is ExportAll. +type ExportRange int + +const ( + ExportAll ExportRange = iota + // ExportLast holds the last State.Last messages. + ExportLast + // ExportSelected holds the messages at Options.Selected. + ExportSelected +) + +var exportRoles = map[SessionRole]string{SessionUser: "user", SessionAssistant: "assistant", SessionSystem: "system"} + +// ExportConversation writes messages as Markdown, JSON or plain text. +func ExportConversation(title string, messages []SessionMessage, format ExportFormat) string { + var b strings.Builder + switch format { + case ExportMarkdown: + fmt.Fprintf(&b, "# %s\n", title) + for _, m := range messages { + fmt.Fprintf(&b, "\n**%s**%s\n\n%s\n", exportAuthor(m), exportTime(m, " · "), m.Text) + } + case ExportJSON: + type msgJSON struct { + Role string `json:"role"` + Author string `json:"author,omitempty"` + Text string `json:"text"` + Time string `json:"time,omitempty"` + } + doc := struct { + Title string `json:"title"` + Messages []msgJSON `json:"messages"` + }{Title: title, Messages: []msgJSON{}} + for _, m := range messages { + doc.Messages = append(doc.Messages, msgJSON{exportRoles[m.Role], m.Author, m.Text, exportTime(m, "")}) + } + out, err := json.MarshalIndent(doc, "", " ") + if err != nil { + panic(err) + } + b.Write(out) + b.WriteByte('\n') + case ExportText: + fmt.Fprintf(&b, "%s\n", title) + for _, m := range messages { + fmt.Fprintf(&b, "\n%s%s:\n%s\n", exportAuthor(m), exportTime(m, " "), m.Text) + } + default: + panic("mujica: unknown ExportFormat") + } + return b.String() +} + +func exportAuthor(m SessionMessage) string { + if m.Author != "" { + return m.Author + } + r, ok := exportRoles[m.Role] + if !ok { + panic("mujica: SessionMessage Role out of range") + } + return strings.ToUpper(r[:1]) + r[1:] +} + +func exportTime(m SessionMessage, sep string) string { + if m.Time.IsZero() { + return "" + } + if sep == "" { + return m.Time.Format(time.RFC3339) + } + return sep + "(" + m.Time.Format("2006-01-02 15:04") + ")" +} + +// ConversationExportState is the chosen format and range. +type ConversationExportState struct { + Format ExportFormat + Range ExportRange + // Last is the count for ExportLast; zero is 10. + Last float64 + cache conversationExportCache +} + +// conversationExportCache holds the last export text until its inputs change. +type conversationExportCache struct { + key conversationExportInputs + content string +} + +type conversationExportInputs struct { + title string + format ExportFormat + rng ExportRange + last float64 + first *SessionMessage + n int + sel string + built bool +} + +// conversationExportContent rebuilds the export only when its inputs change. +func conversationExportContent(s *ConversationExportState, messages []SessionMessage, opts ConversationExportOptions) string { + in := conversationExportInputs{title: opts.Title, format: s.Format, rng: s.Range, last: s.Last, n: len(messages), sel: fmt.Sprint(opts.Selected), built: true} + if len(messages) > 0 { + in.first = &messages[0] + } + if s.cache.key != in { + s.cache = conversationExportCache{in, ExportConversation(opts.Title, conversationExportPick(s, messages, opts.Selected), s.Format)} + } + return s.cache.content +} + +// ConversationExportOptions configure a ConversationExport. Title is required. +type ConversationExportOptions struct { + Title string + // Selected are message indices for ExportSelected. + Selected []int + Disabled bool +} + +// ConversationExportView is a built ConversationExport and the export asked for. +type ConversationExportView struct { + Element *ui.Element + // Exported reports Content is ready for the caller to write. + Exported bool + Content string + Format ExportFormat +} + +// ConversationExport picks format and range, previews, and hands over text. +func ConversationExport(c *ui.Context, s *ConversationExportState, messages []SessionMessage, opts ConversationExportOptions) ConversationExportView { + if s == nil || opts.Title == "" { + panic("mujica: ConversationExport needs a state and a Title") + } + for _, i := range opts.Selected { + if i < 0 || i >= len(messages) { + panic("mujica: ConversationExport Selected index out of range") + } + } + if s.Last == 0 { + s.Last = 10 + } + k := Tokens(c) + d := Density(c) + col := ui.Column(c).Gap(d.Unit() * 3).Disabled(opts.Disabled) + v := ConversationExportView{Element: col, Format: s.Format} + content := conversationExportContent(s, messages, opts) + col.Children(func() { + FormField(c, msg(c, "conversation_export.format"), FormFieldOptions{}, func() *ui.Element { + return RadioGroup(c, &s.Format, []RadioItem[ExportFormat]{ + {Value: ExportMarkdown, Label: "Markdown"}, {Value: ExportJSON, Label: "JSON"}, + {Value: ExportText, Label: msg(c, "conversation_export.text")}, + }, RadioGroupOptions{Horizontal: true, Label: msg(c, "conversation_export.format"), Disabled: opts.Disabled}).Element + }) + FormField(c, msg(c, "conversation_export.range"), FormFieldOptions{}, func() *ui.Element { + return ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Children(func() { + RadioGroup(c, &s.Range, []RadioItem[ExportRange]{ + {Value: ExportAll, Label: fmt.Sprintf(msg(c, "conversation_export.all"), len(messages))}, + {Value: ExportLast, Label: msg(c, "conversation_export.last")}, + {Value: ExportSelected, Label: fmt.Sprintf(msg(c, "conversation_export.selected"), len(opts.Selected)), Disabled: len(opts.Selected) == 0}, + }, RadioGroupOptions{Horizontal: true, Label: msg(c, "conversation_export.range"), Disabled: opts.Disabled}) + if s.Range == ExportLast { + NumberInput(c, &s.Last, NumberInputOptions{Min: 1, Max: float64(max(1, len(messages))), Step: 1, + Label: msg(c, "conversation_export.last"), Disabled: opts.Disabled}).Element.Width(120) + } + }) + }) + ui.Text(c, msg(c, "conversation_export.preview")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + ui.Scroll(c).MaxHeight(220).Padding(d.Unit()*3).Background(k.Background).Border(theme.BorderWidth, k.Border). + Radius(theme.ControlRadius).Children(func() { + ui.Text(c, content).Font(theme.MonoFont).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Text).Selectable() + }) + ui.Row(c).Justify(ui.End).Children(func() { + v.Exported = Button(c, msg(c, "conversation_export.export"), ButtonOptions{Icon: icons.Must("upload"), Disabled: opts.Disabled}).Clicked() + }) + }) + if v.Exported && !col.IsDisabled() { + v.Content = content + slog.Debug("mujica: conversation_export export", "format", s.Format, "bytes", len(content)) + } else { + v.Exported = false + } + return v +} + +// conversationExportPick returns the messages the range holds, in order. +func conversationExportPick(s *ConversationExportState, messages []SessionMessage, selected []int) []SessionMessage { + switch s.Range { + case ExportLast: + return messages[max(0, len(messages)-int(s.Last)):] + case ExportSelected: + var out []SessionMessage + for i := range messages { + for _, j := range selected { + if i == j { + out = append(out, messages[i]) + break + } + } + } + return out + } + return messages +} 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 new file mode 100644 index 0000000..2846c97 --- /dev/null +++ b/conversation_item.go @@ -0,0 +1,156 @@ +package mujica + +import ( + "cmp" + "log/slog" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// ConversationItemOptions configure a ConversationItem. +type ConversationItemOptions struct { + // Today decides the time stamp; zero uses the clock. + Today Date + Selected bool + Disabled bool + // Draggable lets the row be dropped on a ProjectList. + Draggable bool +} + +// ConversationDrag is the value a dragged conversation carries. +type ConversationDrag struct{ ID string } + +// ConversationItemView is a built ConversationItem and its requests. +type ConversationItemView struct { + Element *ui.Element + // Opened, Deleted, Exported and PinToggled are requests for the caller. + Opened, Deleted, Exported, PinToggled bool + // Renamed reports a confirmed new title, already in conv.Title. + Renamed bool +} + +type conversationItemState struct { + editing, fresh bool + draft string +} + +type conversationItemKey struct{} + +// ConversationItem is a conversation row with rename, pin, export, delete. +func ConversationItem(c *ui.Context, conv *ChatConversation, opts ConversationItemOptions) ConversationItemView { + if conv == nil || conv.ID == "" { + panic("mujica: ConversationItem needs a conversation with an ID") + } + k := Tokens(c) + d := Density(c) + today := opts.Today + if today.IsZero() { + today = DateOf(c.Now()) + } + row := ui.Row(c).Key(conv.ID).FillWidth().MinHeight(d.ControlHeight()).Gap(d.Unit()).AlignItems(ui.Center). + Radius(theme.ControlRadius).Disabled(opts.Disabled) + v := ConversationItemView{Element: row} + st := ui.Local(row, conversationItemKey{}, func() conversationItemState { return conversationItemState{} }) + if row.IsDisabled() { + st.editing = false + } + if opts.Selected { + row.Background(k.Selection) + } + title := cmp.Or(conv.Title, msg(c, "conversation_list.untitled")) + rename := false + row.Children(func() { + if st.editing { + conversationItemEditor(c, k, conv, st, &v) + } else { + main := ui.ButtonBase(c).Grow(1).MinWidth(0).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2). + Justify(ui.Start).Radius(theme.ControlRadius).Label(title) + internal.Ring(main, k.Focus, theme.ControlRadius) + if opts.Draggable { + main.Drag(ConversationDrag{ID: conv.ID}) + } + if main.Hovered() && !opts.Selected { + main.Background(k.SurfaceHover) + } + main.Transition(colorFade) + main.Children(func() { + if conv.Pinned { + ui.Icon(c, icons.Must("star")).Size(14, 14).Shrink(0).TextColor(k.AccentText).Label(msg(c, "conversation_item.pinned")) + } + t := ui.Text(c, title).SingleLine().Grow(1).MinWidth(0) + if opts.Selected { + t.TextColor(k.AccentText) + } + ui.Text(c, dateSeparatorWhen(c, conv.Updated, today)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Shrink(0) + }) + if !main.IsDisabled() { + v.Opened = main.Clicked() + rename = main.Shortcut(0, ui.KeyF2) + v.Deleted = main.Shortcut(0, ui.KeyDelete) + } + } + 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 { + pin = msg(c, "conversation_item.unpin") + } + v.PinToggled = m.Item(pin, PopupMenuItemOptions{}) + v.Exported = m.Item(msg(c, "conversation_item.export"), PopupMenuItemOptions{}) + m.Separator() + v.Deleted = m.Item(msg(c, "conversation_item.delete"), PopupMenuItemOptions{}) || v.Deleted + }) + }) + if rename && !row.IsDisabled() { + st.editing, st.fresh, st.draft = true, true, conv.Title + } + switch { + case v.Opened: + slog.Debug("mujica: conversation_item open", "id", conv.ID) + case v.Deleted, v.Exported, v.PinToggled: + slog.Debug("mujica: conversation_item action", "id", conv.ID, "delete", v.Deleted, "export", v.Exported, "pin", v.PinToggled) + } + return v +} + +// conversationItemEditor edits the title in place; Escape cancels. +func conversationItemEditor(c *ui.Context, k theme.Tokens, conv *ChatConversation, st *conversationItemState, v *ConversationItemView) { + d := Density(c) + in := ui.TextInputBase(c, &st.draft).Grow(1).MinHeight(d.ControlHeight()-4).Padding(0, d.Unit()*2). + Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.AccentText). + Label(msg(c, "conversation_item.rename")) + internal.Ring(in, k.Focus, theme.ControlRadius) + switch { + case st.fresh: + st.fresh = false + in.Focus() + case in.IsDisabled(): + case in.Submitted(): + if t := strings.TrimSpace(st.draft); t != "" { + v.Renamed = t != conv.Title + conv.Title = t + } + st.editing = false + slog.Debug("mujica: conversation_item rename", "id", conv.ID, "changed", v.Renamed) + case in.Shortcut(0, ui.KeyEscape): + st.editing = false + } +} + +// conversationItemMenu is a borderless ellipsis button opening a menu. +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 new file mode 100644 index 0000000..9700a1b --- /dev/null +++ b/conversation_list.go @@ -0,0 +1,198 @@ +package mujica + +import ( + "cmp" + "log/slog" + "slices" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ChatConversation is one saved conversation, as the caller stores it. +type ChatConversation struct { + ID, Title string + Updated time.Time + Pinned bool +} + +// ConversationListOptions configure a ConversationList. +type ConversationListOptions struct { + // Now dates the groups; zero uses the window clock. + Now time.Time + // Label names the list for assistive technology. + Label string + Disabled bool +} + +// ConversationListState is the chosen conversation and the list's place. +type ConversationListState struct { + // Selected is the chosen conversation's ID; empty for none. + Selected string + rows ui.ListState + cursor int + plan conversationListPlan +} + +// conversationListPlan holds grouped rows, rebuilt on new data or day. +type conversationListPlan struct { + src []ChatConversation + day Date + rows []conversationListRow + built bool +} + +// conversationListRow is a header (item < 0) or item index. +type conversationListRow struct { + group, item int +} + +// ConversationListView is a built ConversationList and this frame's events. +type ConversationListView struct { + Element *ui.Element + changed bool + submitted bool +} + +// Changed reports a newly chosen conversation, in State.Selected. +func (v ConversationListView) Changed() bool { return v.changed } + +// Submitted reports Enter or a double click on the choice. +func (v ConversationListView) Submitted() bool { return v.submitted } + +// ConversationList groups conversations by recency, building only visible rows. +func ConversationList(c *ui.Context, s *ConversationListState, items []ChatConversation, opts ConversationListOptions, row func(conv ChatConversation)) ConversationListView { + if s == nil { + panic("mujica: ConversationList needs a state") + } + now := opts.Now + if now.IsZero() { + now = c.Now() + } + conversationListPlanRows(&s.plan, items, DateOf(now)) + rows := s.plan.rows + s.cursor = slices.IndexFunc(rows, func(r conversationListRow) bool { return r.item >= 0 && items[r.item].ID == s.Selected }) + before := s.cursor + k := Tokens(c) + d := Density(c) + cursor := &s.cursor + if opts.Disabled { + // A disabled list moves a copy of the cursor. + cp := s.cursor + cursor = &cp + } + s.rows.Key = func(i int) any { + if r := rows[i]; r.item >= 0 { + return items[r.item].ID + } + return conversationListGroups[rows[i].group] + } + s.rows.Header = func(i int) bool { return rows[i].item < 0 } + 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).MinHeight(0).Disabled(opts.Disabled) + wrap.Children(func() { + restore := dataSelectTheme(c, 0) + list = ui.List(c, &s.rows, len(rows), func(i int) { + r := rows[i] + if r.item < 0 { + conversationListHeader(c, k, msg(c, conversationListGroups[r.group])) + return + } + conv := items[r.item] + ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Box(c).Size(8, 8).Shrink(0).Draw(func(p *ui.Painter, b ui.Rect) { + if conv.ID == s.Selected { + p.FillPath(internal.Diamond(b.X+b.W/2, b.Y+b.H/2, 8), k.AccentText) + } + }) + if row != nil { + ui.Row(c).Grow(1).MinWidth(0).Children(func() { row(conv) }) + return + } + ui.Text(c, conversationListTitle(c, items, r)).SingleLine().Grow(1).MinWidth(0) + }) + }).Grow(1).MinHeight(0).Padding(d.Unit()).Background(k.Surface).Border(theme.BorderWidth, k.Border). + Radius(theme.ControlRadius).Label(opts.Label) + restore() + internal.Ring(list, k.Focus, theme.ControlRadius) + }) + v := ConversationListView{Element: wrap} + if !wrap.IsDisabled() { + if s.cursor != before && s.cursor >= 0 && rows[s.cursor].item >= 0 { + s.Selected, v.changed = items[rows[s.cursor].item].ID, true + slog.Debug("mujica: conversation_list select", "id", s.Selected) + } + v.submitted = list.Submitted() && s.Selected != "" + } + return v +} + +func conversationListTitle(c *ui.Context, items []ChatConversation, r conversationListRow) string { + if r.item < 0 { + return msg(c, conversationListGroups[r.group]) + } + return cmp.Or(items[r.item].Title, msg(c, "conversation_list.untitled")) +} + +func conversationListHeader(c *ui.Context, k theme.Tokens, title string) { + d := Density(c) + ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).AlignItems(ui.Center).Background(k.Surface).Children(func() { + ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Ornament.Mix(k.Text, 0.35)).SingleLine() + }) +} + +// conversationListPlanRows regroups items when the slice or the day changed. +func conversationListPlanRows(p *conversationListPlan, items []ChatConversation, today Date) { + same := p.built && p.day == today && len(p.src) == len(items) && (len(items) == 0 || &p.src[0] == &items[0]) + if same { + return + } + p.src, p.day, p.built = items, today, true + order := make([]int, len(items)) + for i := range order { + order[i] = i + } + slices.SortStableFunc(order, func(a, b int) int { return items[b].Updated.Compare(items[a].Updated) }) + groups := [5][]int{} + for _, i := range order { + g := conversationListGroup(items[i], today) + groups[g] = append(groups[g], i) + } + p.rows = p.rows[:0] + for g, idx := range groups { + if len(idx) == 0 { + continue + } + p.rows = append(p.rows, conversationListRow{group: g, item: -1}) + for _, i := range idx { + p.rows = append(p.rows, conversationListRow{item: i}) + } + } + slog.Debug("mujica: conversation_list regroup", "count", len(items)) +} + +var conversationListGroups = [5]string{ + "conversation_list.pinned", "conversation_list.today", "conversation_list.yesterday", + "conversation_list.week", "conversation_list.older", +} + +// conversationListGroup returns the recency group index of conv. +func conversationListGroup(conv ChatConversation, today Date) int { + day := DateOf(conv.Updated) + switch { + case conv.Pinned: + return 0 + case day.Compare(today) >= 0: + return 1 + case day == today.AddDays(-1): + return 2 + case day.Compare(today.AddDays(-7)) >= 0: + return 3 + } + return 4 +} 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 new file mode 100644 index 0000000..f711f30 --- /dev/null +++ b/conversation_search.go @@ -0,0 +1,178 @@ +package mujica + +import ( + "log/slog" + "unicode" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// ConversationSearchHit is one conversation the caller found for the query. +type ConversationSearchHit struct { + ID, Title string + // Snippet is matching message text, shown under the title. + Snippet string +} + +// ConversationSearchOptions configure a ConversationSearch. +type ConversationSearchOptions struct { + // Loading shows the caller's search is under way. + Loading bool + // Error describes a failed search, shown with a retry button. + Error string + Placeholder string + Label string + Disabled bool +} + +// ConversationSearchView is a built ConversationSearch and this frame's events. +type ConversationSearchView struct { + Element *ui.Element + // QueryChanged asks the caller to search for the new query. + QueryChanged bool + // Chosen is the hit opened this frame, or empty. + Chosen string + Retry bool +} + +type conversationSearchState struct { + cursor int + rows ui.ListState + enter bool +} + +type conversationSearchKey struct{} + +// ConversationSearch shows caller-supplied hits for a query, highlighting matches. +func ConversationSearch(c *ui.Context, query *string, hits []ConversationSearchHit, opts ConversationSearchOptions) ConversationSearchView { + if query == nil { + panic("mujica: ConversationSearch needs a query") + } + k := Tokens(c) + d := Density(c) + col := ui.Column(c).Gap(d.Unit() * 2).MinHeight(0).Disabled(opts.Disabled) + v := ConversationSearchView{Element: col} + st := ui.Local(col, conversationSearchKey{}, func() *conversationSearchState { return &conversationSearchState{cursor: -1} }) + s := *st + s.cursor = min(s.cursor, len(hits)-1) + col.Children(func() { + in := SearchInput(c, query, SearchInputOptions{Placeholder: opts.Placeholder, Label: opts.Label, Disabled: opts.Disabled}) + v.QueryChanged = in.Changed() + if in.Input.Shortcut(0, ui.KeyDown) && !in.Input.IsDisabled() && len(hits) > 0 { + s.enter, s.cursor = true, max(s.cursor, 0) + } + switch { + case opts.Error != "": + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).TextColor(k.Danger).Children(func() { + ui.Icon(c, icons.Must("circle-alert")).Size(16, 16) + ui.Text(c, opts.Error).Role(ui.RoleStatus).Grow(1) + v.Retry = Button(c, msg(c, "conversation_search.retry"), ButtonOptions{Variant: Secondary, Icon: icons.Must("refresh-cw")}).Clicked() + }) + case opts.Loading && len(hits) == 0: + Spinner(c, SpinnerOptions{Label: msg(c, "conversation_search.searching"), ShowLabel: true}) + case len(hits) == 0 && *query != "": + ui.Text(c, msg(c, "conversation_search.no_results")).TextColor(k.TextMuted).Role(ui.RoleStatus) + case len(hits) > 0: + v.Chosen = conversationSearchList(c, s, *query, hits, opts.Loading) + } + }) + if v.QueryChanged { + s.cursor = -1 + slog.Debug("mujica: conversation_search query", "len", len(*query)) + } + if v.Chosen != "" { + slog.Debug("mujica: conversation_search choose", "id", v.Chosen) + } + return v +} + +func conversationSearchList(c *ui.Context, s *conversationSearchState, q string, hits []ConversationSearchHit, loading bool) string { + k := Tokens(c) + d := Density(c) + chosen := "" + 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 share one height, so the list sizes itself early. + 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).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) + }) + } + row.Children(func() { + ui.RichText(c, conversationSearchSpans(h.Title, q, k)...).SingleLine() + if h.Snippet != "" { + 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 + } + }).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) + if s.enter { + s.enter = false + list.Focus() + } + if loading { + Spinner(c, SpinnerOptions{Size: 12, Label: msg(c, "conversation_search.searching")}) + } + if list.Submitted() && !list.IsDisabled() && s.cursor >= 0 && s.cursor < len(hits) { + chosen = hits[s.cursor].ID + } + return chosen +} + +// conversationSearchSpans marks each case-insensitive match of q in text. +func conversationSearchSpans(text, q string, k theme.Tokens) []ui.Span { + r := []rune(text) + var spans []ui.Span + at := 0 + for _, m := range conversationSearchMatches(text, q) { + if m[0] > at { + spans = append(spans, ui.Span{Text: string(r[at:m[0]])}) + } + spans = append(spans, ui.Span{Text: string(r[m[0]:m[1]]), Weight: 700, Background: k.Selection, Underline: true}) + at = m[1] + } + if at < len(r) || len(spans) == 0 { + spans = append(spans, ui.Span{Text: string(r[at:])}) + } + return spans +} + +// conversationSearchMatches returns rune ranges where q matches, ignoring case. +func conversationSearchMatches(text, q string) [][2]int { + t, p := []rune(text), []rune(q) + if len(p) == 0 { + return nil + } + var out [][2]int + for i := 0; i+len(p) <= len(t); { + ok := true + for j := range p { + if unicode.ToLower(t[i+j]) != unicode.ToLower(p[j]) { + ok = false + break + } + } + if ok { + out = append(out, [2]int{i, i + len(p)}) + i += len(p) + continue + } + i++ + } + return out +} 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.go b/cost_estimator.go new file mode 100644 index 0000000..b70e411 --- /dev/null +++ b/cost_estimator.go @@ -0,0 +1,87 @@ +package mujica + +import ( + "fmt" + "math" + "strconv" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// CostEstimatorOptions configure a CostEstimator; prices are per million tokens. +type CostEstimatorOptions struct { + InputTokens, OutputTokens int + InputPrice, OutputPrice float64 + // Currency prefixes amounts; empty is "$". + Currency string + // Digits is the decimals shown for amounts; zero is 4. + Digits int +} + +// CostEstimate is the input, output and total cost. +type CostEstimate struct{ Input, Output, Total float64 } + +// EstimateCost prices token counts at per-million rates. +func EstimateCost(inputTokens, outputTokens int, inputPrice, outputPrice float64) CostEstimate { + in := float64(inputTokens) * inputPrice / 1e6 + out := float64(outputTokens) * outputPrice / 1e6 + return CostEstimate{in, out, in + out} +} + +// CostEstimator shows token counts and cost at caller prices. +func CostEstimator(c *ui.Context, opts CostEstimatorOptions) *ui.Element { + if opts.InputTokens < 0 || opts.OutputTokens < 0 { + panic("mujica: CostEstimator token counts must not be negative") + } + for _, p := range []float64{opts.InputPrice, opts.OutputPrice} { + if p < 0 || math.IsNaN(p) || math.IsInf(p, 0) { + panic("mujica: CostEstimator prices must be finite and not negative") + } + } + if opts.Digits < 0 { + panic("mujica: CostEstimator Digits is negative") + } + digits := opts.Digits + if digits == 0 { + digits = 4 + } + cur := opts.Currency + if cur == "" { + cur = "$" + } + money := func(v float64) string { return cur + strconv.FormatFloat(v, 'f', digits, 64) } + est := EstimateCost(opts.InputTokens, opts.OutputTokens, opts.InputPrice, opts.OutputPrice) + k := Tokens(c) + d := Density(c) + total := fmt.Sprintf(msg(c, "cost_estimator.total_label"), money(est.Total)) + g := ui.Grid(c).ColumnTracks(ui.Fr(1), ui.FitContent(), ui.FitContent()).GapX(d.Unit()*4).GapY(d.Unit()). + Padding(d.Unit()*3).Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.CardRadius). + Role(ui.RoleGroup).Label(total) + cell := func(s string, ink ui.Color, right bool) { + t := ui.Text(c, s).TextColor(ink).SingleLine().FontFeatures("tnum") + if right { + t.JustifySelf(ui.End) + } + } + g.Children(func() { + cell(msg(c, "cost_estimator.kind"), k.TextMuted, false) + cell(msg(c, "cost_estimator.tokens"), k.TextMuted, true) + cell(msg(c, "cost_estimator.cost"), k.TextMuted, true) + cell(msg(c, "cost_estimator.input"), k.Text, false) + cell(strconv.Itoa(opts.InputTokens), k.Text, true) + cell(money(est.Input), k.Text, true) + cell(msg(c, "cost_estimator.output"), k.Text, false) + cell(strconv.Itoa(opts.OutputTokens), k.Text, true) + cell(money(est.Output), k.Text, true) + ui.Box(c).ColumnSpan(3).Height(theme.BorderWidth).MarginY(d.Unit()).Background(k.Ornament) + ui.Text(c, msg(c, "cost_estimator.total")).Font(theme.SerifFont).TextColor(k.Text) + cell(strconv.Itoa(opts.InputTokens+opts.OutputTokens), k.Text, true) + ui.Text(c, money(est.Total)).Bold().TextColor(k.AccentText).JustifySelf(ui.End).FontFeatures("tnum") + ui.Text(c, fmt.Sprintf(msg(c, "cost_estimator.rates"), cur+strconv.FormatFloat(opts.InputPrice, 'f', -1, 64), + cur+strconv.FormatFloat(opts.OutputPrice, 'f', -1, 64))). + ColumnSpan(3).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + }) + return g +} 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.go b/date_separator.go new file mode 100644 index 0000000..7e03af2 --- /dev/null +++ b/date_separator.go @@ -0,0 +1,70 @@ +package mujica + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// DateSeparatorOptions configure a DateSeparator. +type DateSeparatorOptions struct { + // Today decides "Today" and "Yesterday"; zero uses the window clock. + Today Date +} + +// DateSeparator marks where a day's messages begin, localized. +func DateSeparator(c *ui.Context, day Date, opts DateSeparatorOptions) *ui.Element { + if !day.Valid() { + panic("mujica: DateSeparator needs a valid date") + } + today := opts.Today + if today.IsZero() { + today = DateOf(c.Now()) + } + k := Tokens(c) + u := Density(c).Unit() + label := dateSeparatorLabel(c, day, today) + row := ui.Row(c).FillWidth().Gap(u*3).AlignItems(ui.Center).Padding(u*2, 0).Role(ui.RoleText).Label(label) + line := func() { + ui.Box(c).Grow(1).Height(theme.BorderWidth).Background(k.Border) + } + row.Children(func() { + line() + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Shrink(0).Children(func() { + ui.Box(c).Size(6, 6).Draw(func(p *ui.Painter, r ui.Rect) { + p.FillPath(internal.Diamond(r.X+3, r.Y+3, 6), k.Ornament) + }) + ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).SingleLine() + }) + line() + }) + return row +} + +// dateSeparatorLabel names day relative to today, in the window's language. +func dateSeparatorLabel(c *ui.Context, day, today Date) string { + switch day { + case today: + return msg(c, "date_separator.today") + case today.AddDays(-1): + return msg(c, "date_separator.yesterday") + } + return fmt.Sprintf(msg(c, "calendar.day_label"), msg(c, fmt.Sprintf("calendar.weekday_long_%d", day.Weekday())), + msg(c, fmt.Sprintf("calendar.month_%d", day.Month)), day.Day, day.Year) +} + +// dateSeparatorWhen is a short stamp: time, day or date. +func dateSeparatorWhen(c *ui.Context, t time.Time, today Date) string { + day := DateOf(t) + if day == today { + return t.Format("15:04") + } + if day == today.AddDays(-1) { + return msg(c, "date_separator.yesterday") + } + return fmt.Sprintf(msg(c, "date_separator.short"), msg(c, fmt.Sprintf("calendar.month_%d", day.Month)), day.Day) +} 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.go b/feedback_form.go new file mode 100644 index 0000000..0b7e0b5 --- /dev/null +++ b/feedback_form.go @@ -0,0 +1,108 @@ +package mujica + +import ( + "log/slog" + "slices" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// FeedbackRating is the thumb given; zero is FeedbackUp. +type FeedbackRating int + +const ( + FeedbackUp FeedbackRating = iota + FeedbackDown +) + +// FeedbackValue is the chosen reasons and a comment. +type FeedbackValue struct { + Reasons []string + Comment string +} + +// FeedbackFormOptions configure a FeedbackForm. +type FeedbackFormOptions struct { + Rating FeedbackRating + // Reasons replaces the built-in reasons for the rating. + Reasons []string + // Submitting shows the caller sending; it blocks the form. + Submitting bool + Disabled bool +} + +// FeedbackFormView is a built FeedbackForm and this frame's outcome. +type FeedbackFormView struct { + Element *ui.Element + Submitted, Cancelled bool +} + +var feedbackReasons = [2][]string{ + {"feedback_form.up.accurate", "feedback_form.up.helpful", "feedback_form.up.clear"}, + {"feedback_form.down.wrong", "feedback_form.down.unhelpful", "feedback_form.down.unsafe", "feedback_form.down.style"}, +} + +// FeedbackForm asks for reasons and a comment after a rating. +func FeedbackForm(c *ui.Context, value *FeedbackValue, opts FeedbackFormOptions) FeedbackFormView { + if value == nil { + panic("mujica: FeedbackForm needs a value") + } + if opts.Rating != FeedbackUp && opts.Rating != FeedbackDown { + panic("mujica: FeedbackForm Rating out of range") + } + reasons := opts.Reasons + if reasons == nil { + for _, key := range feedbackReasons[opts.Rating] { + reasons = append(reasons, msg(c, key)) + } + } + k := Tokens(c) + d := Density(c) + blocked := opts.Disabled || opts.Submitting + col := ui.Column(c).Gap(d.Unit()*3).Padding(d.Unit()*4).Background(k.Surface).Border(theme.BorderWidth, k.Border). + Radius(theme.CardRadius).Role(ui.RoleGroup).Disabled(blocked) + v := FeedbackFormView{Element: col} + title := map[FeedbackRating]string{FeedbackUp: "feedback_form.up.title", FeedbackDown: "feedback_form.down.title"}[opts.Rating] + col.Label(msg(c, title)) + col.Children(func() { + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, msg(c, title)).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text) + }) + ui.Row(c).Wrap().Gap(d.Unit() * 2).FocusGroup(ui.Horizontal).Children(func() { + for _, r := range reasons { + on := slices.Contains(value.Reasons, r) + was := on + Toggle(c, &on, r, ToggleOptions{Disabled: blocked}) + if on != was && !col.IsDisabled() { + if on { + value.Reasons = append(slices.Clone(value.Reasons), r) + } else { + value.Reasons = slices.DeleteFunc(slices.Clone(value.Reasons), func(x string) bool { return x == r }) + } + } + } + }) + TextArea(c, &value.Comment, TextAreaOptions{Placeholder: msg(c, "feedback_form.comment"), Label: msg(c, "feedback_form.comment"), + MinHeight: 72, Disabled: blocked}) + ui.Row(c).Gap(d.Unit() * 2).Justify(ui.End).Children(func() { + v.Cancelled = Button(c, msg(c, "feedback_form.cancel"), ButtonOptions{Variant: Ghost, Disabled: blocked}).Clicked() + v.Submitted = Button(c, msg(c, "feedback_form.submit"), ButtonOptions{Icon: icons.Must("check"), Loading: opts.Submitting, Disabled: opts.Disabled}).Clicked() + }) + }) + if !col.IsDisabled() && col.Shortcut(0, ui.KeyEscape) { + v.Cancelled = true + } + if col.IsDisabled() { + v.Submitted, v.Cancelled = false, false + } + switch { + case v.Submitted: + slog.Debug("mujica: feedback_form submit", "rating", opts.Rating, "reasons", len(value.Reasons)) + case v.Cancelled: + slog.Debug("mujica: feedback_form cancel") + } + return v +} 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 new file mode 100644 index 0000000..94f4957 --- /dev/null +++ b/messages_chatsession.go @@ -0,0 +1,123 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "conversation_list.pinned": {"Pinned", "置顶"}, + "conversation_list.today": {"Today", "今天"}, + "conversation_list.yesterday": {"Yesterday", "昨天"}, + "conversation_list.week": {"Previous 7 days", "7 天内"}, + "conversation_list.older": {"Older", "更早"}, + "conversation_list.untitled": {"New conversation", "新对话"}, + + "conversation_item.actions": {"Conversation actions", "对话操作"}, + "conversation_item.rename": {"Rename", "重命名"}, + "conversation_item.pin": {"Pin", "置顶"}, + "conversation_item.unpin": {"Unpin", "取消置顶"}, + "conversation_item.pinned": {"Pinned", "已置顶"}, + "conversation_item.export": {"Export", "导出"}, + "conversation_item.delete": {"Delete", "删除"}, + + "conversation_search.searching": {"Searching", "正在搜索"}, + "conversation_search.no_results": {"No conversations match", "没有匹配的对话"}, + "conversation_search.results": {"Search results", "搜索结果"}, + "conversation_search.retry": {"Retry", "重试"}, + + "project_list.expanded": {"expanded", "已展开"}, + "project_list.collapsed": {"collapsed", "已收起"}, + "project_list.expand": {"Show conversations in %s", "展开 %s 的对话"}, + "project_list.collapse": {"Hide conversations in %s", "收起 %s 的对话"}, + "project_list.move_to": {"Move %s", "移动 %s"}, + "project_list.move_group": {"Move to project", "移入项目"}, + + "project_knowledge_panel.title": {"Project knowledge", "项目知识"}, + "project_knowledge_panel.add": {"Add files", "添加文件"}, + "project_knowledge_panel.empty": {"No files yet", "暂无文件"}, + "project_knowledge_panel.tokens": {"%d tokens", "%d tokens"}, + "project_knowledge_panel.remove": {"Remove %s", "移除 %s"}, + "project_knowledge_panel.usage": {"%d of %d tokens (%d%%)", "已用 %d / %d tokens(%d%%)"}, + "project_knowledge_panel.near": {"Nearly full", "即将用满"}, + "project_knowledge_panel.over": {"Over capacity", "超出容量"}, + + "prompt_library.all": {"All", "全部"}, + "prompt_library.search": {"Search prompts", "搜索提示"}, + "prompt_library.no_results": {"No prompts match", "没有匹配的提示"}, + "prompt_library.pick": {"Choose a prompt to preview", "选择一个提示以预览"}, + "prompt_library.insert": {"Insert", "插入"}, + + "feedback_form.up.title": {"What did you like?", "哪里令你满意?"}, + "feedback_form.down.title": {"What went wrong?", "哪里出了问题?"}, + "feedback_form.up.accurate": {"Accurate", "准确"}, + "feedback_form.up.helpful": {"Helpful", "有帮助"}, + "feedback_form.up.clear": {"Clear", "清晰"}, + "feedback_form.down.wrong": {"Incorrect", "内容错误"}, + "feedback_form.down.unhelpful": {"Not helpful", "没有帮助"}, + "feedback_form.down.unsafe": {"Harmful or unsafe", "有害或不安全"}, + "feedback_form.down.style": {"Too long or unclear", "冗长或不清楚"}, + "feedback_form.comment": {"Tell us more (optional)", "补充说明(可选)"}, + "feedback_form.submit": {"Submit", "提交"}, + "feedback_form.cancel": {"Cancel", "取消"}, + + "date_separator.today": {"Today", "今天"}, + "date_separator.yesterday": {"Yesterday", "昨天"}, + "date_separator.short": {"%s %d", "%s%d日"}, + + "thinking_indicator.thinking": {"Thinking", "正在思考"}, + + "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.restore": {"Restore default", "恢复默认"}, + "system_prompt_editor.save": {"Save", "保存"}, + + "cost_estimator.kind": {"Kind", "类别"}, + "cost_estimator.tokens": {"Tokens", "Tokens"}, + "cost_estimator.cost": {"Cost", "费用"}, + "cost_estimator.input": {"Input", "输入"}, + "cost_estimator.output": {"Output", "输出"}, + "cost_estimator.total": {"Total", "合计"}, + "cost_estimator.total_label": {"Estimated cost %s", "预计费用 %s"}, + "cost_estimator.rates": {"Rates per million tokens: input %s, output %s", "每百万 tokens 单价:输入 %s,输出 %s"}, + + "shared_conversation_view.shared_by": {"Shared by %s", "分享者:%s"}, + "shared_conversation_view.someone": {"someone", "未知"}, + "shared_conversation_view.read_only": {"Read only", "只读"}, + "shared_conversation_view.copy_link": {"Copy link", "复制链接"}, + "shared_conversation_view.user": {"You", "你"}, + "shared_conversation_view.assistant": {"Assistant", "助手"}, + "shared_conversation_view.system": {"System", "系统"}, + + "conversation_export.format": {"Format", "格式"}, + "conversation_export.text": {"Plain text", "纯文本"}, + "conversation_export.range": {"Messages", "消息范围"}, + "conversation_export.all": {"All (%d)", "全部(%d)"}, + "conversation_export.last": {"Last", "最近"}, + "conversation_export.selected": {"Selected (%d)", "已选(%d)"}, + "conversation_export.preview": {"Preview", "预览"}, + "conversation_export.export": {"Export", "导出"}, + + "regenerate_menu.regenerate": {"Regenerate", "重新生成"}, + "regenerate_menu.again": {"Try again", "重试"}, + "regenerate_menu.model": {"Try another model", "换模型重试"}, + "regenerate_menu.variants": {"Rewrite", "改写"}, + "regenerate_menu.longer": {"Longer", "更长"}, + "regenerate_menu.shorter": {"Shorter", "更短"}, + "regenerate_menu.simpler": {"Simpler", "更简单"}, + "regenerate_menu.detailed": {"More detailed", "更详细"}, + + "artifact_card.code": {"Code", "代码"}, + "artifact_card.document": {"Document", "文档"}, + "artifact_card.web": {"Web page", "网页"}, + "artifact_card.open": {"Open", "打开"}, + "artifact_card.writing": {"Writing", "正在生成"}, + + "chat_empty_state.new.title": {"Start a conversation", "开始新对话"}, + "chat_empty_state.new.description": {"Ask a question or pick a suggestion.", "提出问题,或选择一个建议。"}, + "chat_empty_state.new.action": {"New conversation", "新建对话"}, + "chat_empty_state.no_results.action": {"Clear search", "清除搜索"}, + "chat_empty_state.offline.title": {"You are offline", "当前离线"}, + "chat_empty_state.offline.description": {"Messages will send when the connection returns.", "恢复连接后即可发送消息。"}, + "chat_empty_state.offline.action": {"Retry", "重试"}, + }) +} diff --git a/project_knowledge_panel.go b/project_knowledge_panel.go new file mode 100644 index 0000000..4a2cce1 --- /dev/null +++ b/project_knowledge_panel.go @@ -0,0 +1,122 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// KnowledgeFile is a project knowledge file, sized in tokens. +type KnowledgeFile struct { + ID, Name string + Tokens int +} + +// ProjectKnowledgePanelOptions configure a ProjectKnowledgePanel. Capacity is required. +type ProjectKnowledgePanelOptions struct { + // Capacity is how many tokens the project's knowledge holds. + Capacity int + // Title replaces the built-in "Project knowledge". + Title string + Disabled bool +} + +// ProjectKnowledgePanelView is a built panel and its requests. +type ProjectKnowledgePanelView struct { + Element *ui.Element + // Add asks the caller to pick files to add. + Add bool + // Removed is the ID of a file to remove. + Removed string +} + +// ProjectKnowledgePanel lists knowledge files with add, remove and usage. +func ProjectKnowledgePanel(c *ui.Context, files []KnowledgeFile, opts ProjectKnowledgePanelOptions) ProjectKnowledgePanelView { + if opts.Capacity <= 0 { + panic("mujica: ProjectKnowledgePanel needs a positive Capacity") + } + used := 0 + for _, f := range files { + if f.Tokens < 0 { + panic("mujica: KnowledgeFile with negative Tokens") + } + used += f.Tokens + } + k := Tokens(c) + d := Density(c) + col := ui.Column(c).Gap(d.Unit()*2).Padding(d.Unit()*3).Background(k.Surface).Border(theme.BorderWidth, k.Border). + Radius(theme.CardRadius).Disabled(opts.Disabled) + v := ProjectKnowledgePanelView{Element: col} + col.Children(func() { + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + title := opts.Title + if title == "" { + title = msg(c, "project_knowledge_panel.title") + } + ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text).Grow(1) + v.Add = Button(c, msg(c, "project_knowledge_panel.add"), ButtonOptions{Variant: Secondary, Icon: icons.Must("plus"), Disabled: opts.Disabled}).Clicked() + }) + ui.Box(c).FillWidth().Height(9).Draw(func(p *ui.Painter, r ui.Rect) { + internal.RuleDiamond(p, r, 6, 5, k.Ornament) + }) + projectKnowledgeMeter(c, k, used, opts.Capacity) + if len(files) == 0 { + ui.Text(c, msg(c, "project_knowledge_panel.empty")).TextColor(k.TextMuted).Padding(d.Unit()*2, 0) + return + } + ui.Column(c).Gap(2).FocusGroup(ui.Vertical).Children(func() { + for _, f := range files { + ui.Row(c).Key(f.ID).MinHeight(d.ControlHeight()).Gap(d.Unit()*2).AlignItems(ui.Center).Padding(0, d.Unit()).Children(func() { + ui.Icon(c, icons.Must("file-text")).Size(16, 16).TextColor(k.TextMuted) + ui.Text(c, f.Name).SingleLine().Grow(1).MinWidth(0) + ui.Text(c, fmt.Sprintf(msg(c, "project_knowledge_panel.tokens"), f.Tokens)). + FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + x := iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "project_knowledge_panel.remove"), f.Name)) + if x.Clicked() && !x.IsDisabled() { + v.Removed = f.ID + } + }) + } + }) + }) + if v.Add || v.Removed != "" { + slog.Debug("mujica: project_knowledge_panel action", "add", v.Add, "remove", v.Removed) + } + return v +} + +// projectKnowledgeMeter shows usage; limits get words and icons. +func projectKnowledgeMeter(c *ui.Context, k theme.Tokens, used, capacity int) { + d := Density(c) + frac := float64(used) / float64(capacity) + ink, note, icon := k.AccentText, "", "" + switch { + case used > capacity: + ink, note, icon = k.Danger, msg(c, "project_knowledge_panel.over"), "circle-alert" + case frac >= 0.8: + ink, note, icon = k.Warning, msg(c, "project_knowledge_panel.near"), "triangle-alert" + } + pct := int(frac * 100) + label := fmt.Sprintf(msg(c, "project_knowledge_panel.usage"), used, capacity, pct) + ui.Column(c).Gap(d.Unit()).Role(ui.RoleMeter).Label(label).Children(func() { + ui.Box(c).FillWidth().Height(6).Draw(func(p *ui.Painter, r ui.Rect) { + p.Fill(r, k.Border, 3) + p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * float32(min(frac, 1)), H: r.H}, ink, 3) + }) + ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Children(func() { + if icon != "" { + ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(ink) + } + t := ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + if note != "" { + t.TextColor(ink) + ui.Text(c, note).FontSize(FontSize(c, theme.CaptionSize)).TextColor(ink).Role(ui.RoleStatus) + } + }) + }) +} 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 new file mode 100644 index 0000000..6c73018 --- /dev/null +++ b/project_list.go @@ -0,0 +1,212 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// ChatProject holds conversations; Folder groups projects, empty for none. +type ChatProject struct { + ID, Name, Folder string +} + +// ProjectListOptions configure a ProjectList. +type ProjectListOptions struct { + Label string + Disabled bool +} + +// ProjectListState is the chosen project and open folders and projects. +type ProjectListState struct { + // Selected is the chosen project's ID; empty for none. + Selected string + closed map[string]bool + open map[string]bool +} + +// ProjectListView is a built ProjectList and this frame's events. +type ProjectListView struct { + Element *ui.Element + // Changed reports a newly chosen project, in State.Selected. + Changed bool + // Opened is a conversation the user opened, empty for none. + Opened string + // Moved asks the caller to move MoveID into MoveTo. + Moved bool + MoveID, MoveTo string +} + +// ProjectList shows project folders and conversations; drops move them. +func ProjectList(c *ui.Context, s *ProjectListState, projects []ChatProject, conversations func(projectID string) []ChatConversation, opts ProjectListOptions) ProjectListView { + if s == nil || conversations == nil { + panic("mujica: ProjectList needs a state and conversations") + } + if s.closed == nil { + s.closed, s.open = map[string]bool{}, map[string]bool{} + } + d := Density(c) + col := ui.Column(c).Gap(2).FocusGroup(ui.Vertical).Role(ui.RoleTree).Label(opts.Label).Disabled(opts.Disabled) + v := ProjectListView{Element: col} + col.Children(func() { + seen := map[string]bool{} + for _, p := range projects { + if p.Folder == "" || seen[p.Folder] { + continue + } + seen[p.Folder] = true + open := !s.closed[p.Folder] + ui.Column(c).Key("folder:" + p.Folder).Children(func() { projectListFolder(c, p.Folder, &open) }) + s.closed[p.Folder] = !open + if !open { + continue + } + for _, q := range projects { + if q.Folder == p.Folder { + projectListProject(c, s, q, projects, conversations, d.Unit()*6, &v) + } + } + } + for _, p := range projects { + if p.Folder == "" { + projectListProject(c, s, p, projects, conversations, 0, &v) + } + } + }) + if col.IsDisabled() { + v.Changed, v.Opened, v.Moved = false, "", false + } + switch { + case v.Moved: + slog.Debug("mujica: project_list move", "conversation", v.MoveID, "project", v.MoveTo) + case v.Changed: + slog.Debug("mujica: project_list select", "project", s.Selected) + } + return v +} + +// projectListFolder is a folder heading on a CollapsibleBase trigger. +func projectListFolder(c *ui.Context, title string, open *bool) *ui.Element { + k := Tokens(c) + d := Density(c) + parts := ui.CollapsibleBase(c, open) + tr := parts.Trigger.MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2).AlignItems(ui.Center). + Radius(theme.ControlRadius).Label(title) + internal.Ring(tr, k.Focus, theme.ControlRadius) + if tr.Hovered() { + tr.Background(k.SurfaceHover) + } + chevron := "chevron-right" + if *open { + chevron = "chevron-down" + } + tr.Children(func() { + ui.Icon(c, icons.Must(chevron)).Size(14, 14).TextColor(k.TextMuted) + ui.Icon(c, icons.Must("folder")).Size(16, 16).TextColor(k.Ornament.Mix(k.Text, 0.3)) + ui.Text(c, title).Font(theme.SerifFont).SingleLine() + }) + return tr +} + +func projectListProject(c *ui.Context, s *ProjectListState, p ChatProject, all []ChatProject, conversations func(string) []ChatConversation, indent float32, v *ProjectListView) { + k := Tokens(c) + d := Density(c) + convs := conversations(p.ID) + chosen := s.Selected == p.ID + var b *ui.Element + line := ui.Row(c).Key("project:" + p.ID).AlignItems(ui.Center) + line.Children(func() { b = ui.ButtonBase(c).Grow(1).MinWidth(0).MinHeight(d.ControlHeight()) }) + b.Padding(0, d.Unit()*2, 0, d.Unit()*2+indent).Padding(0, d.Unit()*2, 0, d.Unit()*2+indent). + Gap(d.Unit() * 2).Justify(ui.Start).Radius(theme.ControlRadius).Role(ui.RoleTreeItem).Label(p.Name).Selected(chosen) + b.Background(ui.Transparent).TextColor(k.Text) + internal.Ring(b, k.Focus, theme.ControlRadius) + _, over := ui.DragOver[ConversationDrag](b) + switch { + case chosen: + b.Background(k.Selection).TextColor(k.AccentText) + case b.Hovered(): + b.Background(k.SurfaceHover) + } + if over { + b.DrawOver(func(pt *ui.Painter, r ui.Rect) { internal.Frame(pt, r, 1, 5, k.AccentText) }) + } + if drop, ok := ui.Drop[ConversationDrag](b); ok && !b.IsDisabled() { + v.Moved, v.MoveID, v.MoveTo = true, drop.ID, p.ID + } + if b.Clicked() && !chosen { + s.Selected, v.Changed = p.ID, true + } + open := s.open[p.ID] + if !b.IsDisabled() && len(convs) > 0 { + switch { + case b.Shortcut(0, ui.KeyRight): + s.open[p.ID] = true + case b.Shortcut(0, ui.KeyLeft): + s.open[p.ID] = false + } + } + if len(convs) > 0 { + b.Description(msg(c, map[bool]string{true: "project_list.expanded", false: "project_list.collapsed"}[open])) + } + b.Children(func() { + ui.Box(c).Size(8, 8).Shrink(0).Draw(func(pt *ui.Painter, r ui.Rect) { + if chosen { + pt.FillPath(internal.Diamond(r.X+4, r.Y+4, 8), k.AccentText) + } + }) + ui.Text(c, p.Name).SingleLine().Grow(1).MinWidth(0) + ui.Text(c, fmt.Sprint(len(convs))).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + }) + if len(convs) > 0 { + chevron := map[bool]string{true: "chevron-down", false: "chevron-right"}[open] + line.Children(func() { + t := iconAction(c, icons.Must(chevron), fmt.Sprintf(msg(c, map[bool]string{true: "project_list.collapse", false: "project_list.expand"}[open]), p.Name)) + if t.Clicked() && !t.IsDisabled() { + s.open[p.ID] = !open + } + }) + } + if !open { + return + } + for _, conv := range convs { + projectListConversation(c, conv, p, all, indent+d.Unit()*6, v) + } +} + +func projectListConversation(c *ui.Context, conv ChatConversation, home ChatProject, all []ChatProject, indent float32, v *ProjectListView) { + k := Tokens(c) + d := Density(c) + ui.Row(c).Key("conv:" + conv.ID).AlignItems(ui.Center).Children(func() { + b := ui.ButtonBase(c).Grow(1).MinWidth(0).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2, 0, d.Unit()*2+indent). + Justify(ui.Start).Radius(theme.ControlRadius).Label(conv.Title).Drag(ConversationDrag{ID: conv.ID}) + internal.Ring(b, k.Focus, theme.ControlRadius) + if b.Dragging() { + b.Opacity(0.4) + } + if b.Hovered() { + b.Background(k.SurfaceHover) + } + b.Children(func() { + ui.Text(c, cmp.Or(conv.Title, msg(c, "conversation_list.untitled"))).SingleLine().TextColor(k.TextMuted) + }) + if b.Clicked() { + v.Opened = conv.ID + } + 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{}) { + v.Moved, v.MoveID, v.MoveTo = true, conv.ID, p.ID + } + } + }) + }) + }) +} 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.go b/prompt_library.go new file mode 100644 index 0000000..738566e --- /dev/null +++ b/prompt_library.go @@ -0,0 +1,218 @@ +package mujica + +import ( + "log/slog" + "regexp" + "slices" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// PromptTemplate is a reusable prompt; {{name}} marks a variable. +type PromptTemplate struct { + ID, Category, Title, Body string +} + +// PromptLibraryOptions configure a PromptLibrary. +type PromptLibraryOptions struct { + Label string + Disabled bool +} + +// PromptLibraryState holds category, query, chosen template and variable values. +type PromptLibraryState struct { + // Category filters templates; empty shows all. + Category string + Query string + Selected string + Values map[string]string + shown promptLibraryShown +} + +// promptLibraryShown caches filtered templates until data, query or category change. +type promptLibraryShown struct { + src []PromptTemplate + query, category string + ids []int + cats []string + built bool +} + +// PromptLibraryView is a built PromptLibrary and inserted text. +type PromptLibraryView struct { + Element *ui.Element + // Inserted is the filled template, or empty. + Inserted string +} + +var promptLibraryVar = regexp.MustCompile(`\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}`) + +// PromptLibraryVariables lists a body's {{name}} variables in order, once each. +func PromptLibraryVariables(body string) []string { + var out []string + for _, m := range promptLibraryVar.FindAllStringSubmatch(body, -1) { + if !slices.Contains(out, m[1]) { + out = append(out, m[1]) + } + } + return out +} + +// PromptLibraryFill replaces each {{name}} in body with values[name]. +func PromptLibraryFill(body string, values map[string]string) string { + return promptLibraryVar.ReplaceAllStringFunc(body, func(m string) string { + return values[promptLibraryVar.FindStringSubmatch(m)[1]] + }) +} + +// PromptLibrary browses, previews, fills and inserts prompt templates. +func PromptLibrary(c *ui.Context, s *PromptLibraryState, templates []PromptTemplate, opts PromptLibraryOptions) PromptLibraryView { + if s == nil { + panic("mujica: PromptLibrary needs a state") + } + if s.Values == nil { + s.Values = map[string]string{} + } + k := Tokens(c) + d := Density(c) + promptLibraryFilter(&s.shown, templates, s.Query, s.Category) + row := ui.Row(c).Gap(d.Unit() * 4).AlignItems(ui.Stretch).Role(ui.RoleGroup).Label(opts.Label).Disabled(opts.Disabled) + v := PromptLibraryView{Element: row} + row.Children(func() { + ui.Column(c).Width(150).Shrink(0).Gap(2).FocusGroup(ui.Vertical).Children(func() { + promptLibraryCategory(c, k, s, "", msg(c, "prompt_library.all")) + for _, cat := range s.shown.cats { + promptLibraryCategory(c, k, s, cat, cat) + } + }) + ui.Column(c).Width(240).Shrink(0).Gap(d.Unit() * 2).Children(func() { + SearchInput(c, &s.Query, SearchInputOptions{Placeholder: msg(c, "prompt_library.search"), Disabled: opts.Disabled}) + ui.Scroll(c).MaxHeight(320).Children(func() { + ui.Column(c).Gap(2).FocusGroup(ui.Vertical).Children(func() { + if len(s.shown.ids) == 0 { + ui.Text(c, msg(c, "prompt_library.no_results")).TextColor(k.TextMuted).Padding(d.Unit() * 2) + } + for _, i := range s.shown.ids { + t := templates[i] + if selectableRow(c, k, t.ID, t.Title, s.Selected == t.ID) && s.Selected != t.ID { + s.Selected = t.ID + slog.Debug("mujica: prompt_library select", "id", t.ID) + } + } + }) + }) + }) + ui.Column(c).Grow(1).MinWidth(200).Gap(d.Unit() * 2).Children(func() { + i := slices.IndexFunc(templates, func(t PromptTemplate) bool { return t.ID == s.Selected }) + if i < 0 { + ui.Text(c, msg(c, "prompt_library.pick")).TextColor(k.TextMuted) + return + } + v.Inserted = promptLibraryPreview(c, k, s, templates[i], opts.Disabled) + }) + }) + if v.Inserted != "" { + slog.Debug("mujica: prompt_library insert", "id", s.Selected) + } + return v +} + +func promptLibraryFilter(sh *promptLibraryShown, templates []PromptTemplate, query, category string) { + same := sh.built && sh.query == query && sh.category == category && len(sh.src) == len(templates) && + (len(templates) == 0 || &sh.src[0] == &templates[0]) + if same { + return + } + *sh = promptLibraryShown{src: templates, query: query, category: category, built: true} + q := strings.ToLower(strings.TrimSpace(query)) + for i, t := range templates { + if t.ID == "" || t.Title == "" { + panic("mujica: PromptTemplate needs an ID and a Title") + } + if t.Category != "" && !slices.Contains(sh.cats, t.Category) { + sh.cats = append(sh.cats, t.Category) + } + if category != "" && t.Category != category { + continue + } + if q == "" || strings.Contains(strings.ToLower(t.Title+"\n"+t.Body), q) { + sh.ids = append(sh.ids, i) + } + } +} + +func promptLibraryCategory(c *ui.Context, k theme.Tokens, s *PromptLibraryState, cat, label string) { + if selectableRow(c, k, "cat:"+cat, label, s.Category == cat) && s.Category != cat { + s.Category = cat + } +} + +// selectableRow is a focusable row marked chosen with a diamond. +func selectableRow(c *ui.Context, k theme.Tokens, key, label string, chosen bool) bool { + d := Density(c) + b := ui.ButtonBase(c).Key(key).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).Gap(d.Unit() * 2).Justify(ui.Start). + Radius(theme.ControlRadius).Label(label).Selected(chosen) + b.Background(ui.Transparent).TextColor(k.Text) + internal.Ring(b, k.Focus, theme.ControlRadius) + switch { + case chosen: + b.Background(k.Selection).TextColor(k.AccentText) + case b.Hovered(): + b.Background(k.SurfaceHover) + } + b.Children(func() { + ui.Box(c).Size(8, 8).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { + if chosen { + p.FillPath(internal.Diamond(r.X+4, r.Y+4, 8), k.AccentText) + } + }) + ui.Text(c, label).SingleLine() + }) + return b.Clicked() && !b.IsDisabled() +} + +// promptLibraryPreview shows the body, variable fields and Insert. +func promptLibraryPreview(c *ui.Context, k theme.Tokens, s *PromptLibraryState, t PromptTemplate, disabled bool) string { + d := Density(c) + vars := PromptLibraryVariables(t.Body) + ui.Text(c, t.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text) + missing := false + var spans []ui.Span + at := 0 + for _, m := range promptLibraryVar.FindAllStringSubmatchIndex(t.Body, -1) { + spans = append(spans, ui.Span{Text: t.Body[at:m[0]]}) + name := t.Body[m[2]:m[3]] + if val := s.Values[name]; val != "" { + spans = append(spans, ui.Span{Text: val, Weight: 600, Color: k.AccentText}) + } else { + missing = true + spans = append(spans, ui.Span{Text: "[" + name + "]", Background: k.Selection, Color: k.AccentText, Underline: true}) + } + at = m[1] + } + spans = append(spans, ui.Span{Text: t.Body[at:]}) + ui.Column(c).Padding(d.Unit()*3).Background(k.Background).Border(theme.BorderWidth, k.Border).Radius(theme.ControlRadius). + Children(func() { ui.RichText(c, spans...).Selectable() }) + for _, name := range vars { + val := s.Values[name] + ui.Column(c).Key("var:" + name).Children(func() { + FormField(c, name, FormFieldOptions{Required: true}, func() *ui.Element { + r := TextInput(c, &val, TextInputOptions{Label: name, Disabled: disabled}) + if r.Changed() { + s.Values[name] = val + } + return r.Element + }) + }) + } + insert := Button(c, msg(c, "prompt_library.insert"), ButtonOptions{Icon: icons.Must("plus"), Disabled: missing || disabled}).Clicked() + if insert && !missing { + return PromptLibraryFill(t.Body, s.Values) + } + return "" +} 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 new file mode 100644 index 0000000..95fe6c4 --- /dev/null +++ b/regenerate_menu.go @@ -0,0 +1,91 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +// RegenerateKind is what a RegenerateMenu choice asks for. +type RegenerateKind int + +const ( + // RegenerateAgain retries with the same model. + RegenerateAgain RegenerateKind = iota + // RegenerateModel retries with Choice.Model. + RegenerateModel + // RegenerateVariant retries asking for Choice.Variant, such as longer or shorter. + RegenerateVariant +) + +// RegenerateChoice is the retry the user picked. +type RegenerateChoice struct { + Kind RegenerateKind + Model string + Variant string +} + +// RegenerateMenuOptions configure a RegenerateMenu. +type RegenerateMenuOptions struct { + // Models lists models to retry with; Current is left out. + Models []string + Current string + // Variants replaces the built-in Longer, Shorter, Simpler and More detailed. + Variants []string + Disabled bool +} + +// RegenerateMenuView is a built menu and the chosen retry. +type RegenerateMenuView struct { + Element *ui.Element + Chosen bool + Choice RegenerateChoice +} + +// RegenerateMenu offers retry, another model, and rewrite variants. +func RegenerateMenu(c *ui.Context, opts RegenerateMenuOptions) RegenerateMenuView { + variants := opts.Variants + if variants == nil { + for _, key := range []string{"regenerate_menu.longer", "regenerate_menu.shorter", "regenerate_menu.simpler", "regenerate_menu.detailed"} { + variants = append(variants, msg(c, key)) + } + } + var v RegenerateMenuView + pick := func(ch RegenerateChoice) { v.Chosen, v.Choice = true, ch } + v.Element = DropdownMenu(c, msg(c, "regenerate_menu.regenerate"), DropdownMenuOptions{Icon: icons.Must("refresh-cw"), Disabled: opts.Disabled}, func(m *PopupMenu) { + if m.Item(msg(c, "regenerate_menu.again"), PopupMenuItemOptions{}) { + pick(RegenerateChoice{Kind: RegenerateAgain}) + } + others := 0 + for _, model := range opts.Models { + if model != opts.Current { + others++ + } + } + m.Submenu(msg(c, "regenerate_menu.model"), PopupMenuItemOptions{Disabled: others == 0}, func(m *PopupMenu) { + for _, model := range opts.Models { + if model != opts.Current && m.Item(model, PopupMenuItemOptions{}) { + pick(RegenerateChoice{Kind: RegenerateModel, Model: model}) + } + } + }) + m.Separator() + m.Group(msg(c, "regenerate_menu.variants"), func() { + for _, variant := range variants { + if m.Item(variant, PopupMenuItemOptions{}) { + pick(RegenerateChoice{Kind: RegenerateVariant, Variant: variant}) + } + } + }) + }) + v.Element.AlignSelf(ui.Start) + if v.Element.IsDisabled() { + v.Chosen = false + } + if v.Chosen { + slog.Debug("mujica: regenerate_menu choose", "kind", v.Choice.Kind, "model", v.Choice.Model, "variant", v.Choice.Variant) + } + return v +} 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.go b/shared_conversation_view.go new file mode 100644 index 0000000..cda049f --- /dev/null +++ b/shared_conversation_view.go @@ -0,0 +1,107 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// SessionRole is who wrote a message; zero is SessionUser. +type SessionRole int + +const ( + SessionUser SessionRole = iota + SessionAssistant + SessionSystem +) + +// SessionMessage is one saved message, as plain text. +type SessionMessage struct { + Role SessionRole + // Author names the writer; empty uses the role's name. + Author string + Text string + Time time.Time +} + +// SharedConversationViewOptions configure a SharedConversationView. +type SharedConversationViewOptions struct { + Title string + SharedBy string + SharedAt time.Time + Disabled bool +} + +// SharedConversationView shows a shared conversation read-only with Copy link. +func SharedConversationView(c *ui.Context, messages []SessionMessage, opts SharedConversationViewOptions) ChoiceResult { + if opts.Title == "" { + panic("mujica: SharedConversationView needs a Title") + } + k := Tokens(c) + d := Density(c) + col := ui.Column(c).Grow(1).MinHeight(0).Gap(d.Unit() * 3).Disabled(opts.Disabled) + rows := *ui.Local(col, sharedConversationKey{}, func() *ui.ListState { return &ui.ListState{} }) + copied := false + col.Children(func() { + ui.Row(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Children(func() { + ui.Column(c).Grow(1).Gap(2).Children(func() { + ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H2Size)).TextColor(k.Text) + by := fmt.Sprintf(msg(c, "shared_conversation_view.shared_by"), cmp.Or(opts.SharedBy, msg(c, "shared_conversation_view.someone"))) + if !opts.SharedAt.IsZero() { + by += " · " + dateSeparatorLabel(c, DateOf(opts.SharedAt), DateOf(c.Now())) + } + ui.Text(c, by).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + }) + ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Children(func() { + ui.Icon(c, icons.Must("lock")).Size(14, 14).TextColor(k.TextMuted) + ui.Text(c, msg(c, "shared_conversation_view.read_only")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + }) + copied = Button(c, msg(c, "shared_conversation_view.copy_link"), ButtonOptions{Variant: Secondary, Icon: icons.Must("copy"), + Disabled: opts.Disabled}).Clicked() + }) + ui.Box(c).FillWidth().Height(9).Draw(func(p *ui.Painter, r ui.Rect) { internal.RuleDiamond(p, r, 6, 5, k.Ornament) }) + rows.Key = func(i int) any { return i } + ui.List(c, rows, len(messages), func(i int) { sessionMessageView(c, k, messages[i]) }).Grow(1).MinHeight(0).Gap(d.Unit() * 3) + }) + if copied { + slog.Debug("mujica: shared_conversation_view copy_link") + } + return ChoiceResult{Element: col, changed: copied} +} + +type sharedConversationKey struct{} + +// sessionMessageView shows one read-only message with author and time. +func sessionMessageView(c *ui.Context, k theme.Tokens, m SessionMessage) { + d := Density(c) + if m.Role < SessionUser || m.Role > SessionSystem { + panic("mujica: SessionMessage Role out of range") + } + author := cmp.Or(m.Author, msg(c, sessionRoleKeys[m.Role])) + box := ui.Column(c).Gap(d.Unit()).Padding(d.Unit()*2, d.Unit()*3).Radius(theme.CardRadius) + if m.Role == SessionUser { + box.Background(k.Background).Border(theme.BorderWidth, k.Border) + } + box.Children(func() { + ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, author).Bold().TextColor(k.Text) + if !m.Time.IsZero() { + ui.Text(c, m.Time.Format("15:04")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + } + }) + // Plain text: Markdown is not rendered here. + Text(c, m.Text, TextOptions{Selectable: true}) + }) +} + +var sessionRoleKeys = map[SessionRole]string{ + SessionUser: "shared_conversation_view.user", SessionAssistant: "shared_conversation_view.assistant", + SessionSystem: "shared_conversation_view.system", +} 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.go b/stop_generating_button.go new file mode 100644 index 0000000..0d5fe29 --- /dev/null +++ b/stop_generating_button.go @@ -0,0 +1,56 @@ +package mujica + +import ( + "log/slog" + "runtime" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// StopGeneratingButtonOptions configure a StopGeneratingButton. +type StopGeneratingButtonOptions struct { + // Generating shows the button; it hides once generation ends. + Generating bool + Disabled bool +} + +// StopGeneratingButton asks to stop generating; Mod+. does too. +func StopGeneratingButton(c *ui.Context, opts StopGeneratingButtonOptions) ChoiceResult { + if !opts.Generating { + return ChoiceResult{} + } + k := Tokens(c) + d := Density(c) + mods := ui.Ctrl + if runtime.GOOS == "darwin" { + mods = ui.Cmd + } + var b *ui.Element + wrap := ui.Row(c).Shrink(0).Disabled(opts.Disabled) + wrap.Children(func() { + b = ui.ButtonBase(c).MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).Padding(0, d.Unit()*3).Gap(d.Unit()*2). + Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).Label(msg(c, "stop_generating_button.stop")) + }) + internal.Ring(b, k.Focus, theme.ControlRadius) + if b.Hovered() { + b.Background(k.SurfaceHover) + } + b.Transition(colorFade) + b.Children(func() { + ui.Box(c).Size(10, 10).Shrink(0).Background(k.Text).Radius(1) + ui.Text(c, msg(c, "stop_generating_button.stop")).SingleLine() + Kbd(c, "Mod", ".") + }) + stop := b.Clicked() + if c.Shortcut(mods, ui.KeyPeriod) { + stop = true + } + stop = stop && !b.IsDisabled() + if stop { + slog.Debug("mujica: stop_generating_button stop") + } + return ChoiceResult{Element: b, changed: stop} +} 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.go b/suggestion_chips.go new file mode 100644 index 0000000..c11de04 --- /dev/null +++ b/suggestion_chips.go @@ -0,0 +1,76 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// SuggestionChipsOptions configure SuggestionChips. +type SuggestionChipsOptions struct { + // Send makes a click send instead of filling the input. + Send bool + // Label names the group, such as "Follow-up questions". + Label string + Disabled bool +} + +// SuggestionChipsView is built chips and the suggestion used this frame. +type SuggestionChipsView struct { + Element *ui.Element + // Chosen is the suggestion clicked, empty for none. + Chosen string + // Sent asks to send Chosen now instead of filling. + Sent bool +} + +// SuggestionChips offers prompts that fill *input or ask to send. +func SuggestionChips(c *ui.Context, input *string, suggestions []string, opts SuggestionChipsOptions) SuggestionChipsView { + if input == nil { + panic("mujica: SuggestionChips needs an input") + } + k := Tokens(c) + d := Density(c) + row := ui.Row(c).Wrap().Gap(d.Unit() * 2).FocusGroup(ui.Horizontal).Role(ui.RoleGroup).Label(opts.Label).Disabled(opts.Disabled) + v := SuggestionChipsView{Element: row} + icon := icons.Must("chevron-right") + if opts.Send { + icon = icons.Must("arrow-up") + } + row.Children(func() { + for i, s := range suggestions { + if s == "" { + panic("mujica: SuggestionChips with an empty suggestion") + } + b := ui.ButtonBase(c).Key(i).MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).Padding(0, d.Unit()*3). + Gap(d.Unit()*2).Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder).Label(s) + internal.Ring(b, k.Focus, theme.ControlRadius) + switch { + case b.Pressed(): + b.Background(k.Selection) + case b.Hovered(): + b.Background(k.SurfaceHover) + } + b.Transition(colorFade) + b.Children(func() { + ui.Text(c, s).SingleLine() + ui.Icon(c, icon).Size(14, 14).TextColor(k.TextMuted) + }) + if b.Clicked() && !b.IsDisabled() { + v.Chosen = s + } + } + }) + if v.Chosen != "" { + v.Sent = opts.Send + if !opts.Send { + *input = v.Chosen + } + slog.Debug("mujica: suggestion_chips choose", "send", v.Sent) + } + return v +} 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 new file mode 100644 index 0000000..ffb0792 --- /dev/null +++ b/system_prompt_editor.go @@ -0,0 +1,96 @@ +package mujica + +import ( + "fmt" + "log/slog" + "runtime" + "unicode/utf8" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// SystemPromptEditorOptions configure a SystemPromptEditor. +type SystemPromptEditorOptions struct { + // Default is what Restore default puts back. + Default string + // Saved is the text last saved; differences show as unsaved. + Saved string + // MaxLength limits characters, blocking Save beyond it; zero is none. + MaxLength int + // Saving shows the caller's save in progress and blocks editing. + Saving bool + Disabled bool +} + +// SystemPromptEditorView is a built editor and its requests. +type SystemPromptEditorView struct { + Element *ui.Element + // Save asks to save *value; Restored reports the default returned. + Save, Restored bool +} + +// SystemPromptEditor edits a system prompt with count, restore and save. +func SystemPromptEditor(c *ui.Context, value *string, opts SystemPromptEditorOptions) SystemPromptEditorView { + if value == nil { + panic("mujica: SystemPromptEditor needs a value") + } + if opts.MaxLength < 0 { + panic("mujica: SystemPromptEditor MaxLength is negative") + } + k := Tokens(c) + d := Density(c) + blocked := opts.Disabled || opts.Saving + 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) + if dirty { + ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center).Role(ui.RoleStatus).Children(func() { + ui.Box(c).Size(8, 8).Draw(func(p *ui.Painter, r ui.Rect) { + p.FillPath(internal.Diamond(r.X+4, r.Y+4, 8), k.Warning) + }) + ui.Text(c, msg(c, "system_prompt_editor.unsaved")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Warning) + }) + } + }) + 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() { + // 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 || over}).Clicked() + }) + }) + mods := ui.Ctrl + if runtime.GOOS == "darwin" { + mods = ui.Cmd + } + if col.Shortcut(mods, ui.KeyS) && dirty && !over { + v.Save = true + } + if col.IsDisabled() { + v.Save = false + } + switch { + case v.Save: + slog.Debug("mujica: system_prompt_editor save", "len", len(*value)) + case v.Restored: + slog.Debug("mujica: system_prompt_editor restore") + } + return v +} 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..775de36 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..ba51165 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.go b/thinking_indicator.go new file mode 100644 index 0000000..aa1f61d --- /dev/null +++ b/thinking_indicator.go @@ -0,0 +1,55 @@ +package mujica + +import ( + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ThinkingIndicatorOptions configure a ThinkingIndicator. +type ThinkingIndicatorOptions struct { + // Phase is the caller's stage text; empty uses "Thinking". + Phase string +} + +// ThinkingIndicator shows rising diamonds and a phase, still when reduced. +func ThinkingIndicator(c *ui.Context, opts ThinkingIndicatorOptions) *ui.Element { + k := Tokens(c) + d := Density(c) + phase := opts.Phase + if phase == "" { + phase = msg(c, "thinking_indicator.thinking") + } + still := c.Preferences().ReduceMotion + row := ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Center).MinHeight(d.ControlHeight()).Role(ui.RoleStatus).Label(phase) + row.Children(func() { + ui.Box(c).Size(30, 14).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { + t := 0.0 + if !still { + t = float64(p.Now().UnixMilli()%1200) / 1200 + p.AnimationFrame() + } + for i := range 3 { + lift := float32(0) + alpha := float32(1) + if !still { + phase := math.Mod(t-float64(i)*0.18+1, 1) + wave := float32(math.Max(0, math.Sin(phase*2*math.Pi))) + lift, alpha = wave*3, 0.45+0.55*wave + } + x := internal.Snap(p, r.X+5+float32(i)*10) + y := internal.Snap(p, r.Y+r.H/2-lift) + p.FillPath(internal.Diamond(x, y, 7), k.AccentText.Alpha(alpha)) + } + }) + text := phase + if still { + text += "…" + } + ui.Text(c, text).FontSize(FontSize(c, theme.BodySize)).TextColor(k.TextMuted).Italic() + }) + return row +} 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.go b/welcome_screen.go new file mode 100644 index 0000000..cd0d46e --- /dev/null +++ b/welcome_screen.go @@ -0,0 +1,49 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// WelcomeScreenOptions configure a WelcomeScreen. Greeting is required. +type WelcomeScreenOptions struct { + Greeting string + Subtitle string + Cards []CapabilityCard + // Prompts is the slot for starter prompts, such as SuggestionChips. + Prompts func() + Disabled bool +} + +// WelcomeScreen greets a new conversation with cards and prompts. +func WelcomeScreen(c *ui.Context, opts WelcomeScreenOptions) CapabilityCardsView { + if opts.Greeting == "" { + panic("mujica: WelcomeScreen needs a Greeting") + } + k := Tokens(c) + d := Density(c) + var v CapabilityCardsView + col := ui.Column(c).FillWidth().MaxWidth(880).AlignSelf(ui.Center).AlignItems(ui.Center).Gap(d.Unit()*4). + Padding(d.Unit()*8, d.Unit()*4).Disabled(opts.Disabled) + col.Children(func() { + ui.Text(c, opts.Greeting).Font(theme.SerifFont).FontSize(FontSize(c, theme.H1Size)).TextColor(k.Text). + TextAlign(ui.Center).Role(ui.RoleText) + ui.Box(c).Width(220).Height(9).Draw(func(p *ui.Painter, r ui.Rect) { + internal.RuleDiamond(p, r, 8, 6, k.Ornament) + }) + if opts.Subtitle != "" { + ui.Text(c, opts.Subtitle).TextColor(k.TextMuted).TextAlign(ui.Center) + } + if len(opts.Cards) > 0 { + v = CapabilityCards(c, opts.Cards, CapabilityCardsOptions{Columns: min(3, len(opts.Cards)), Disabled: opts.Disabled}) + v.Element.FillWidth() + } + if opts.Prompts != nil { + ui.Column(c).FillWidth().AlignItems(ui.Center).Children(opts.Prompts) + } + }) + v.Element = col + return v +} 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{}) }}) + }) +}