From 7a6a44e05d676eaedc0d20f2161b50e14b4d30d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 14:10:23 -0400 Subject: [PATCH] wip chat sessions --- artifact_card.go | 84 +++++++++++++ capability_cards.go | 94 +++++++++++++++ chat_empty_state.go | 58 +++++++++ conversation_export.go | 228 ++++++++++++++++++++++++++++++++++++ conversation_item.go | 144 +++++++++++++++++++++++ conversation_list.go | 198 +++++++++++++++++++++++++++++++ conversation_search.go | 176 ++++++++++++++++++++++++++++ cost_estimator.go | 90 ++++++++++++++ date_separator.go | 72 ++++++++++++ feedback_form.go | 108 +++++++++++++++++ messages_chatsession.go | 124 ++++++++++++++++++++ project_knowledge_panel.go | 122 +++++++++++++++++++ project_list.go | 212 +++++++++++++++++++++++++++++++++ prompt_library.go | 218 ++++++++++++++++++++++++++++++++++ regenerate_menu.go | 90 ++++++++++++++ shared_conversation_view.go | 107 +++++++++++++++++ stop_generating_button.go | 56 +++++++++ suggestion_chips.go | 76 ++++++++++++ system_prompt_editor.go | 95 +++++++++++++++ thinking_indicator.go | 55 +++++++++ welcome_screen.go | 49 ++++++++ 21 files changed, 2456 insertions(+) create mode 100644 artifact_card.go create mode 100644 capability_cards.go create mode 100644 chat_empty_state.go create mode 100644 conversation_export.go create mode 100644 conversation_item.go create mode 100644 conversation_list.go create mode 100644 conversation_search.go create mode 100644 cost_estimator.go create mode 100644 date_separator.go create mode 100644 feedback_form.go create mode 100644 messages_chatsession.go create mode 100644 project_knowledge_panel.go create mode 100644 project_list.go create mode 100644 prompt_library.go create mode 100644 regenerate_menu.go create mode 100644 shared_conversation_view.go create mode 100644 stop_generating_button.go create mode 100644 suggestion_chips.go create mode 100644 system_prompt_editor.go create mode 100644 thinking_indicator.go create mode 100644 welcome_screen.go diff --git a/artifact_card.go b/artifact_card.go new file mode 100644 index 0000000..478888f --- /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. The zero value 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 inside a message; a click or Enter asks to open the artifact panel. +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/capability_cards.go b/capability_cards.go new file mode 100644 index 0000000..da12989 --- /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 thing the assistant can do, 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 what the assistant can do; clicking an example hands it 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 prompt button; it reports 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/chat_empty_state.go b/chat_empty_state.go new file mode 100644 index 0000000..38628f9 --- /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 view is empty. The zero value 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 the kind's 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 button text: Start, Clear search or Retry. + Action string +} + +// ChatEmptyState explains an empty chat (new, no results, offline) 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/conversation_export.go b/conversation_export.go new file mode 100644 index 0000000..e9c6f40 --- /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 the text form of an export. The zero value is ExportMarkdown. +type ExportFormat int + +const ( + ExportMarkdown ExportFormat = iota + ExportJSON + ExportText +) + +// ExportRange is which messages an export holds. The zero value 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; it does not parse their 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, as Format. + Exported bool + Content string + Format ExportFormat +} + +// ConversationExport picks a format and range, previews the result and hands the text to the caller to save. +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_item.go b/conversation_item.go new file mode 100644 index 0000000..c3eb5b2 --- /dev/null +++ b/conversation_item.go @@ -0,0 +1,144 @@ +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's form; zero uses the window clock. + Today Date + Selected bool + Disabled bool + // Draggable lets the row be dragged onto a ProjectList as a ConversationDrag. + Draggable bool +} + +// ConversationDrag is the value a dragged conversation carries. +type ConversationDrag struct{ ID string } + +// ConversationItemView is a built ConversationItem and what the user asked for this frame. +type ConversationItemView struct { + Element *ui.Element + // Opened, Deleted, Exported and PinToggled are requests for the caller to carry out. + 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: title, time, and a menu to rename in place, pin, export or 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) + } + } + menu := DropdownMenu(c, "", DropdownMenuOptions{Icon: icons.Must("ellipsis"), Label: msg(c, "conversation_item.actions"), Disabled: opts.Disabled}, 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 + }) + menu.Shrink(0).MinWidth(theme.MinHitSize).MinHeight(theme.MinHitSize) + }) + 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 is the in-place title field: Enter keeps a non-blank title, 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 + } +} diff --git a/conversation_list.go b/conversation_list.go new file mode 100644 index 0000000..c6dae06 --- /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 is the grouped rows, rebuilt only when the data or day changes. +type conversationListPlan struct { + src []ChatConversation + day Date + rows []conversationListRow + built bool +} + +// conversationListRow is group header g (item < 0) or an index into the items. +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 chosen conversation. +func (v ConversationListView) Submitted() bool { return v.submitted } + +// ConversationList groups conversations into pinned, today, yesterday, past 7 days and older, building only rows in view. +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).Grow(1).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 is the group index of conv: pinned, today, yesterday, 7 days, older. +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_search.go b/conversation_search.go new file mode 100644 index 0000000..0fc7865 --- /dev/null +++ b/conversation_search.go @@ -0,0 +1,176 @@ +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 for the query is under way. + Loading bool + // Error is the caller's failed search; it shows 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 again for the new query. + QueryChanged bool + // Chosen is the hit opened by a click or Enter, empty for none. + Chosen string + Retry bool +} + +type conversationSearchState struct { + cursor int + rows ui.ListState + enter bool +} + +type conversationSearchKey struct{} + +// ConversationSearch searches titles and content; the caller supplies hits for the query and the matches are highlighted. +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 + restore := dataSelectTheme(c, 0) + list := ui.List(c, &s.rows, len(hits), func(i int) { + h := hits[i] + row := ui.Column(c).MinHeight(d.ControlHeight()).Padding(d.Unit(), d.Unit()*2).Gap(2).Justify(ui.Center) + 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)...).MaxLines(2).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)) + } + }) + if !row.IsDisabled() && row.Clicked() { + chosen = h.ID + } + }).MaxHeight(360).Padding(d.Unit()).Background(k.Surface).Border(theme.BorderWidth, k.Border). + 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 splits text into spans, marking each case-insensitive match of q. +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 of case-insensitive matches of q in text. +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/cost_estimator.go b/cost_estimator.go new file mode 100644 index 0000000..553aa80 --- /dev/null +++ b/cost_estimator.go @@ -0,0 +1,90 @@ +package mujica + +import ( + "fmt" + "math" + "strconv" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// CostEstimatorOptions configure a CostEstimator. Prices are per million tokens, from the caller. +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 cost of the input, the output and both. +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 input and output tokens and their estimated cost at the caller's 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(9).Draw(func(p *ui.Painter, r ui.Rect) { + internal.Rule(p, r.X, r.X+r.W, r.Y+r.H/2, 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/date_separator.go b/date_separator.go new file mode 100644 index 0000000..1d73c87 --- /dev/null +++ b/date_separator.go @@ -0,0 +1,72 @@ +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: Today, Yesterday, or the full date in the window's language. +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(9).Draw(func(p *ui.Painter, r ui.Rect) { + internal.Rule(p, r.X, r.X+r.W, r.Y+r.H/2, 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 time stamp: the clock today, else the relative 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/feedback_form.go b/feedback_form.go new file mode 100644 index 0000000..4edcd75 --- /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 the user gave. The zero value is FeedbackUp. +type FeedbackRating int + +const ( + FeedbackUp FeedbackRating = iota + FeedbackDown +) + +// FeedbackValue is what the user wrote: 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 the feedback and 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 why after a thumbs up or down: reason chips and a comment, then Submit or Cancel. +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/messages_chatsession.go b/messages_chatsession.go new file mode 100644 index 0000000..6151bf6 --- /dev/null +++ b/messages_chatsession.go @@ -0,0 +1,124 @@ +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.count_of": {"%d / %d characters", "%d / %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..8c9208b --- /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 file in a project's knowledge, sized in tokens by the caller. +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 what the user asked for. +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, empty for none. + Removed string +} + +// ProjectKnowledgePanel lists a project's knowledge files with add, remove and how full it is. +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 used of capacity; near and over the limit add a word and an icon, not only color. +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_list.go b/project_list.go new file mode 100644 index 0000000..06e7b56 --- /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 is a project that 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 which folders and projects are open. +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 conversation MoveID into project MoveTo. + Moved bool + MoveID, MoveTo string +} + +// ProjectList shows folders of projects and their conversations; drop a ConversationDrag on a project, or use Move to, to file it. +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, which reports expanded. +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 + } + DropdownMenu(c, "", DropdownMenuOptions{Icon: icons.Must("ellipsis"), Label: 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 + } + } + }) + }).Shrink(0) + }) +} diff --git a/prompt_library.go b/prompt_library.go new file mode 100644 index 0000000..827f12c --- /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}} in Body is a variable to fill before inserting. +type PromptTemplate struct { + ID, Category, Title, Body string +} + +// PromptLibraryOptions configure a PromptLibrary. +type PromptLibraryOptions struct { + Label string + Disabled bool +} + +// PromptLibraryState is the library's category, search, 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 the filtered templates until the data, query or category changes. +type promptLibraryShown struct { + src []PromptTemplate + query, category string + ids []int + cats []string + built bool +} + +// PromptLibraryView is a built PromptLibrary and the text inserted this frame. +type PromptLibraryView struct { + Element *ui.Element + // Inserted is the chosen template with its variables filled, empty for none. + 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 templates by category and search, previews one, fills its variables and inserts it. +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 chosen by click, Enter or Space, marked with Selection and 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 filled body, a field per variable, and Insert once all are filled. +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/regenerate_menu.go b/regenerate_menu.go new file mode 100644 index 0000000..d122e0e --- /dev/null +++ b/regenerate_menu.go @@ -0,0 +1,90 @@ +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 marked and 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 retry chosen this frame. +type RegenerateMenuView struct { + Element *ui.Element + Chosen bool + Choice RegenerateChoice +} + +// RegenerateMenu offers regenerate, retry with another model, and variants such as longer or shorter. +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}) + } + } + }) + }) + 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/shared_conversation_view.go b/shared_conversation_view.go new file mode 100644 index 0000000..00676df --- /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. The zero value is SessionUser. +type SessionRole int + +const ( + SessionUser SessionRole = iota + SessionAssistant + SessionSystem +) + +// SessionMessage is one message of a saved conversation, 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 who shared it and when; it reports 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 is one read-only message: author, time and selectable plain text. +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/stop_generating_button.go b/stop_generating_button.go new file mode 100644 index 0000000..c63739c --- /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 a reply while one is generating; Cmd+. (Ctrl+. elsewhere) 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/suggestion_chips.go b/suggestion_chips.go new file mode 100644 index 0000000..0b4fcc7 --- /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 the suggestion; by default it fills 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 reports that Chosen should be sent now; otherwise it is already in the input. + Sent bool +} + +// SuggestionChips offers starter prompts or follow-ups; a click fills *input, or with Send asks 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/system_prompt_editor.go b/system_prompt_editor.go new file mode 100644 index 0000000..93a3919 --- /dev/null +++ b/system_prompt_editor.go @@ -0,0 +1,95 @@ +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; a different value shows as unsaved. + Saved string + // MaxLength caps the characters; zero is no cap. + MaxLength int + // Saving shows the caller's save in progress and blocks editing. + Saving bool + Disabled bool +} + +// SystemPromptEditorView is a built editor and what the user asked for. +type SystemPromptEditorView struct { + Element *ui.Element + // Save asks the caller to save *value; Restored reports the default was put back. + Save, Restored bool +} + +// SystemPromptEditor edits a system prompt with a character count, restore default, an unsaved mark and Mod+S to 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 + 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() { + n := utf8.RuneCountInString(*value) + count := fmt.Sprintf(msg(c, "system_prompt_editor.count"), n) + if opts.MaxLength > 0 { + count = fmt.Sprintf(msg(c, "system_prompt_editor.count_of"), n, opts.MaxLength) + } + ui.Text(c, count).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Grow(1) + if Button(c, msg(c, "system_prompt_editor.restore"), ButtonOptions{Variant: Ghost, Icon: icons.Must("refresh-cw"), + Disabled: blocked || *value == opts.Default}).Clicked() && !col.IsDisabled() { + *value, v.Restored = opts.Default, true + } + v.Save = Button(c, msg(c, "system_prompt_editor.save"), ButtonOptions{Loading: opts.Saving, Disabled: opts.Disabled || !dirty}).Clicked() + }) + }) + mods := ui.Ctrl + if runtime.GOOS == "darwin" { + mods = ui.Cmd + } + if col.Shortcut(mods, ui.KeyS) && dirty { + 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/thinking_indicator.go b/thinking_indicator.go new file mode 100644 index 0000000..df72017 --- /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, such as "Searching the web"; empty uses "Thinking". + Phase string +} + +// ThinkingIndicator shows the assistant is working: three diamonds rising in turn and the phase; still under reduced motion. +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/welcome_screen.go b/welcome_screen.go new file mode 100644 index 0000000..fca9d27 --- /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 capability cards and a starter-prompt slot; it reports a chosen example. +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 +}