From c1792f27ffe912ea9ed1ea31060cd56f08bd2cf6 Mon Sep 17 00:00:00 2001 From: Matt Low Date: Thu, 30 May 2024 06:44:40 +0000 Subject: [PATCH] Split up tui code into packages (views/*, shared, util) --- pkg/tui/shared/shared.go | 55 +++++ pkg/tui/styles/styles.go | 8 + pkg/tui/tui.go | 171 ++++++---------- pkg/tui/{ => util}/util.go | 35 +++- pkg/tui/{ => views/chat}/chat.go | 190 +++++++++--------- .../conversations}/conversations.go | 75 ++++--- 6 files changed, 274 insertions(+), 260 deletions(-) create mode 100644 pkg/tui/shared/shared.go create mode 100644 pkg/tui/styles/styles.go rename pkg/tui/{ => util}/util.go (73%) rename pkg/tui/{ => views/chat}/chat.go (80%) rename pkg/tui/{ => views/conversations}/conversations.go (76%) diff --git a/pkg/tui/shared/shared.go b/pkg/tui/shared/shared.go new file mode 100644 index 0000000..1909bb6 --- /dev/null +++ b/pkg/tui/shared/shared.go @@ -0,0 +1,55 @@ +package shared + +import ( + "git.mlow.ca/mlow/lmcli/pkg/lmcli" + tea "github.com/charmbracelet/bubbletea" +) + +type Values struct { + ConvShortname string +} + +type State struct { + Ctx *lmcli.Context + Values *Values + Views *Views + Width int + Height int + Err error +} + +// this struct holds the final rendered content of various UI components, and +// gets populated in the application's Update() method. View() simply composes +// these elements into the final output +// TODO: consider removing this, let each view be responsible +type Views struct { + Header string + Content string + Error string + Input string + Footer string +} + +type ( + // send to change the current state + MsgViewChange View + // sent to a state when it is entered + MsgViewEnter struct{} + // sent when an error occurs + MsgError error +) + +func WrapError(err error) tea.Cmd { + return func() tea.Msg { + return MsgError(err) + } +} + +type View int + +const ( + StateChat View = iota + StateConversations + //StateSettings + //StateHelp +) diff --git a/pkg/tui/styles/styles.go b/pkg/tui/styles/styles.go new file mode 100644 index 0000000..31209f0 --- /dev/null +++ b/pkg/tui/styles/styles.go @@ -0,0 +1,8 @@ +package styles + +import "github.com/charmbracelet/lipgloss" + +var Header = lipgloss.NewStyle(). + PaddingLeft(1). + PaddingRight(1). + Background(lipgloss.Color("0")) diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index f7f111c..e3cc93f 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -10,99 +10,62 @@ import ( "fmt" "git.mlow.ca/mlow/lmcli/pkg/lmcli" + "git.mlow.ca/mlow/lmcli/pkg/tui/shared" + "git.mlow.ca/mlow/lmcli/pkg/tui/views/chat" + "git.mlow.ca/mlow/lmcli/pkg/tui/views/conversations" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) -type state int +// Application model +type Model struct { + shared.State -const ( - stateChat = iota - stateConversations - //stateModelSelect // stateOptions? - //stateHelp -) - -// this struct holds the final rendered content of various UI components, and -// gets populated in the application's Update() method. View() simply composes -// these elements into the final output -type views struct { - header string - content string - error string - input string - footer string + state shared.View + chat chat.Model + conversations conversations.Model } -type ( - // send to change the current state - msgStateChange state - // sent to a state when it is entered - msgStateEnter struct{} - // sent when an error occurs - msgError error -) - -type Options struct { - convShortname string -} - -type basemodel struct { - opts *Options - ctx *lmcli.Context - views *views - err error - width int - height int -} - -type model struct { - basemodel - - state state - chat chatModel - conversations conversationsModel -} - -func initialModel(ctx *lmcli.Context, opts Options) model { - m := model{ - basemodel: basemodel{ - opts: &opts, - ctx: ctx, - views: &views{}, +func initialModel(ctx *lmcli.Context, values shared.Values) Model { + m := Model{ + State: shared.State{ + Ctx: ctx, + Values: &values, + Views: &shared.Views{}, }, } - m.state = stateChat - m.chat = newChatModel(&m) - m.conversations = newConversationsModel(&m) + + m.state = shared.StateChat + m.chat = chat.Chat(m.State) + m.conversations = conversations.Conversations(m.State) return m } -func (m model) Init() tea.Cmd { +func (m Model) Init() tea.Cmd { return tea.Batch( m.conversations.Init(), m.chat.Init(), func() tea.Msg { - return msgStateChange(m.state) + return shared.MsgViewChange(m.state) }, ) } -func (m *model) handleGlobalInput(msg tea.KeyMsg) (bool, tea.Cmd) { +func (m *Model) handleGlobalInput(msg tea.KeyMsg) (bool, tea.Cmd) { // delegate input to the active child state first, only handling it at the // global level if the child state does not var cmds []tea.Cmd switch m.state { - case stateChat: - handled, cmd := m.chat.handleInput(msg) + case shared.StateChat: + handled, cmd := m.chat.HandleInput(msg) cmds = append(cmds, cmd) if handled { m.chat, cmd = m.chat.Update(nil) cmds = append(cmds, cmd) return true, tea.Batch(cmds...) } - case stateConversations: - handled, cmd := m.conversations.handleInput(msg) + case shared.StateConversations: + handled, cmd := m.conversations.HandleInput(msg) cmds = append(cmds, cmd) if handled { m.conversations, cmd = m.conversations.Update(nil) @@ -117,7 +80,7 @@ func (m *model) handleGlobalInput(msg tea.KeyMsg) (bool, tea.Cmd) { return false, nil } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { @@ -126,32 +89,32 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if handled { return m, cmd } - case msgStateChange: - m.state = state(msg) + case shared.MsgViewChange: + m.state = shared.View(msg) switch m.state { - case stateChat: - m.chat.handleResize(m.width, m.height) - case stateConversations: - m.conversations.handleResize(m.width, m.height) + case shared.StateChat: + m.chat.HandleResize(m.State.Width, m.State.Height) + case shared.StateConversations: + m.conversations.HandleResize(m.State.Width, m.State.Height) } - return m, func() tea.Msg { return msgStateEnter(struct{}{}) } - case msgConversationSelected: + return m, func() tea.Msg { return shared.MsgViewEnter(struct{}{}) } + case conversations.MsgConversationSelected: // passed up through conversation list model - m.opts.convShortname = msg.ShortName.String + m.State.Values.ConvShortname = msg.ShortName.String cmds = append(cmds, func() tea.Msg { - return msgStateChange(stateChat) + return shared.MsgViewChange(shared.StateChat) }) case tea.WindowSizeMsg: - m.width, m.height = msg.Width, msg.Height - case msgError: - m.err = msg + m.State.Width, m.State.Height = msg.Width, msg.Height + case shared.MsgError: + m.State.Err = msg } var cmd tea.Cmd switch m.state { - case stateConversations: + case shared.StateConversations: m.conversations, cmd = m.conversations.Update(msg) - case stateChat: + case shared.StateChat: m.chat, cmd = m.chat.Update(msg) } if cmd != nil { @@ -161,8 +124,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m model) View() string { - if m.width == 0 { +func (m Model) View() string { + if m.State.Width == 0 { // this is the case upon initial startup, but it's also a safe bet that // we can just skip rendering if the terminal is really 0 width... // without this, the m.*View() functions may crash @@ -170,51 +133,33 @@ func (m model) View() string { } sections := make([]string, 0, 6) - if m.views.header != "" { - sections = append(sections, m.views.header) + if m.State.Views.Header != "" { + sections = append(sections, m.State.Views.Header) } switch m.state { - case stateConversations: - sections = append(sections, m.views.content) - if m.views.error != "" { - sections = append(sections, m.views.error) + case shared.StateConversations: + sections = append(sections, m.State.Views.Content) + if m.State.Views.Error != "" { + sections = append(sections, m.State.Views.Error) } - case stateChat: - sections = append(sections, m.views.content) - if m.views.error != "" { - sections = append(sections, m.views.error) + case shared.StateChat: + sections = append(sections, m.State.Views.Content) + if m.State.Views.Error != "" { + sections = append(sections, m.State.Views.Error) } - sections = append(sections, m.views.input) + sections = append(sections, m.State.Views.Input) } - if m.views.footer != "" { - sections = append(sections, m.views.footer) + if m.State.Views.Footer != "" { + sections = append(sections, m.State.Views.Footer) } return lipgloss.JoinVertical(lipgloss.Left, sections...) } -func errorBanner(err error, width int) string { - if err == nil { - return "" - } - return lipgloss.NewStyle(). - Width(width). - AlignHorizontal(lipgloss.Center). - Bold(true). - Foreground(lipgloss.Color("1")). - Render(fmt.Sprintf("%s", err)) -} - -func wrapError(err error) tea.Cmd { - return func() tea.Msg { - return msgError(err) - } -} - func Launch(ctx *lmcli.Context, convShortname string) error { - p := tea.NewProgram(initialModel(ctx, Options{convShortname}), tea.WithAltScreen()) + p := tea.NewProgram(initialModel(ctx, shared.Values{ConvShortname: convShortname}), tea.WithAltScreen()) if _, err := p.Run(); err != nil { return fmt.Errorf("Error running program: %v", err) } diff --git a/pkg/tui/util.go b/pkg/tui/util/util.go similarity index 73% rename from pkg/tui/util.go rename to pkg/tui/util/util.go index 3a6a8a5..9691c16 100644 --- a/pkg/tui/util.go +++ b/pkg/tui/util/util.go @@ -1,26 +1,28 @@ -package tui +package util import ( + "fmt" "os" "os/exec" "strings" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/muesli/reflow/ansi" ) -type msgTempfileEditorClosed string +type MsgTempfileEditorClosed string -// openTempfileEditor opens an $EDITOR on a new temporary file with the given +// OpenTempfileEditor opens an $EDITOR on a new temporary file with the given // content. Upon closing, the contents of the file are read back returned // wrapped in a msgTempfileEditorClosed returned by the tea.Cmd -func openTempfileEditor(pattern string, content string, placeholder string) tea.Cmd { +func OpenTempfileEditor(pattern string, content string, placeholder string) tea.Cmd { msgFile, _ := os.CreateTemp("/tmp", pattern) err := os.WriteFile(msgFile.Name(), []byte(placeholder+content), os.ModeAppend) if err != nil { - return wrapError(err) + return func() tea.Msg { return err } } editor := os.Getenv("EDITOR") @@ -32,7 +34,7 @@ func openTempfileEditor(pattern string, content string, placeholder string) tea. return tea.ExecProcess(c, func(err error) tea.Msg { bytes, err := os.ReadFile(msgFile.Name()) if err != nil { - return msgError(err) + return err } os.Remove(msgFile.Name()) fileContents := string(bytes) @@ -40,12 +42,12 @@ func openTempfileEditor(pattern string, content string, placeholder string) tea. fileContents = fileContents[len(placeholder):] } stripped := strings.Trim(fileContents, "\n \t") - return msgTempfileEditorClosed(stripped) + return MsgTempfileEditorClosed(stripped) }) } // similar to lipgloss.Height, except returns 0 on empty strings -func height(str string) int { +func Height(str string) int { if str == "" { return 0 } @@ -54,7 +56,7 @@ func height(str string) int { // truncate a string until its rendered cell width + the provided tail fits // within the given width -func truncateToCellWidth(str string, width int, tail string) string { +func TruncateToCellWidth(str string, width int, tail string) string { cellWidth := ansi.PrintableRuneWidth(str) if cellWidth <= width { return str @@ -73,7 +75,7 @@ func truncateToCellWidth(str string, width int, tail string) string { // fraction is the fraction of the total screen height into view the offset // should be scrolled into view. 0.5 = items will be snapped to middle of // view -func scrollIntoView(vp *viewport.Model, offset int, edge int) { +func ScrollIntoView(vp *viewport.Model, offset int, edge int) { currentOffset := vp.YOffset if offset >= currentOffset && offset < currentOffset+vp.Height { return @@ -87,3 +89,16 @@ func scrollIntoView(vp *viewport.Model, offset int, edge int) { vp.SetYOffset(currentOffset - distance - edge) } } + +func ErrorBanner(err error, width int) string { + if err == nil { + return "" + } + return lipgloss.NewStyle(). + Width(width). + AlignHorizontal(lipgloss.Center). + Bold(true). + Foreground(lipgloss.Color("1")). + Render(fmt.Sprintf("%s", err)) +} + diff --git a/pkg/tui/chat.go b/pkg/tui/views/chat/chat.go similarity index 80% rename from pkg/tui/chat.go rename to pkg/tui/views/chat/chat.go index fe67caf..e72055d 100644 --- a/pkg/tui/chat.go +++ b/pkg/tui/views/chat/chat.go @@ -1,4 +1,4 @@ -package tui +package chat import ( "context" @@ -9,6 +9,9 @@ import ( cmdutil "git.mlow.ca/mlow/lmcli/pkg/cmd/util" models "git.mlow.ca/mlow/lmcli/pkg/lmcli/model" + "git.mlow.ca/mlow/lmcli/pkg/tui/shared" + "git.mlow.ca/mlow/lmcli/pkg/tui/styles" + tuiutil "git.mlow.ca/mlow/lmcli/pkg/tui/util" "github.com/charmbracelet/bubbles/cursor" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textarea" @@ -40,7 +43,7 @@ type ( msgResponseChunk string // sent when response is finished being received msgResponseEnd string - // a special case of msgError that stops the response waiting animation + // a special case of common.MsgError that stops the response waiting animation msgResponseError error // sent on each completed reply msgAssistantReply models.Message @@ -52,8 +55,8 @@ type ( msgMessagesLoaded []models.Message ) -type chatModel struct { - basemodel +type Model struct { + shared.State width int height int @@ -88,13 +91,9 @@ type chatModel struct { replyCursor cursor.Model // cursor to indicate incoming response } -func newChatModel(tui *model) chatModel { - m := chatModel{ - basemodel: basemodel{ - opts: tui.opts, - ctx: tui.ctx, - views: tui.views, - }, +func Chat(tui shared.State) Model { + m := Model{ + State: tui, conversation: &models.Conversation{}, persistence: true, @@ -127,7 +126,7 @@ func newChatModel(tui *model) chatModel { m.replyCursor.SetChar(" ") m.replyCursor.Focus() - system := tui.ctx.GetSystemPrompt() + system := tui.Ctx.GetSystemPrompt() if system != "" { m.messages = []models.Message{{ Role: models.MessageRoleSystem, @@ -152,11 +151,6 @@ func newChatModel(tui *model) chatModel { // styles var ( - headerStyle = lipgloss.NewStyle(). - PaddingLeft(1). - PaddingRight(1). - Background(lipgloss.Color("0")) - messageHeadingStyle = lipgloss.NewStyle(). MarginTop(1). MarginBottom(1). @@ -181,10 +175,10 @@ var ( footerStyle = lipgloss.NewStyle() ) -func (m *chatModel) handleInput(msg tea.KeyMsg) (bool, tea.Cmd) { +func (m *Model) HandleInput(msg tea.KeyMsg) (bool, tea.Cmd) { switch m.focus { case focusInput: - consumed, cmd := m.handleInputKey(msg) + consumed, cmd := m.HandleInputKey(msg) if consumed { return true, cmd } @@ -202,7 +196,7 @@ func (m *chatModel) handleInput(msg tea.KeyMsg) (bool, tea.Cmd) { return true, nil } return true, func() tea.Msg { - return msgStateChange(stateConversations) + return shared.MsgViewChange(shared.StateConversations) } case "ctrl+c": if m.waitingForReply { @@ -226,14 +220,14 @@ func (m *chatModel) handleInput(msg tea.KeyMsg) (bool, tea.Cmd) { return false, nil } -func (m chatModel) Init() tea.Cmd { +func (m Model) Init() tea.Cmd { return tea.Batch( m.waitForChunk(), m.waitForReply(), ) } -func (m *chatModel) handleResize(width, height int) { +func (m *Model) HandleResize(width, height int) { m.width, m.height = width, height m.content.Width = width m.input.SetWidth(width - m.input.FocusedStyle.Base.GetHorizontalFrameSize()) @@ -243,22 +237,22 @@ func (m *chatModel) handleResize(width, height int) { } } -func (m chatModel) Update(msg tea.Msg) (chatModel, tea.Cmd) { +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { - case msgStateEnter: + case shared.MsgViewEnter: // wake up spinners and cursors cmds = append(cmds, cursor.Blink, m.spinner.Tick) - if m.opts.convShortname != "" && m.conversation.ShortName.String != m.opts.convShortname { - cmds = append(cmds, m.loadConversation(m.opts.convShortname)) + if m.State.Values.ConvShortname != "" && m.conversation.ShortName.String != m.State.Values.ConvShortname { + cmds = append(cmds, m.loadConversation(m.State.Values.ConvShortname)) } m.rebuildMessageCache() m.updateContent() case tea.WindowSizeMsg: - m.handleResize(msg.Width, msg.Height) - case msgTempfileEditorClosed: + m.HandleResize(msg.Width, msg.Height) + case tuiutil.MsgTempfileEditorClosed: contents := string(msg) switch m.editorTarget { case input: @@ -267,16 +261,16 @@ func (m chatModel) Update(msg tea.Msg) (chatModel, tea.Cmd) { m.setMessageContents(m.selectedMessage, contents) if m.persistence && m.messages[m.selectedMessage].ID > 0 { // update persisted message - err := m.ctx.Store.UpdateMessage(&m.messages[m.selectedMessage]) + err := m.State.Ctx.Store.UpdateMessage(&m.messages[m.selectedMessage]) if err != nil { - cmds = append(cmds, wrapError(fmt.Errorf("Could not save edited message: %v", err))) + cmds = append(cmds, shared.WrapError(fmt.Errorf("Could not save edited message: %v", err))) } } m.updateContent() } case msgConversationLoaded: m.conversation = (*models.Conversation)(msg) - m.rootMessages, _ = m.ctx.Store.RootMessages(m.conversation.ID) + m.rootMessages, _ = m.State.Ctx.Store.RootMessages(m.conversation.ID) cmds = append(cmds, m.loadMessages(m.conversation)) case msgMessagesLoaded: m.selectedMessage = len(msg) - 1 @@ -330,7 +324,7 @@ func (m chatModel) Update(msg tea.Msg) (chatModel, tea.Cmd) { if m.persistence { err := m.persistConversation() if err != nil { - cmds = append(cmds, wrapError(err)) + cmds = append(cmds, shared.WrapError(err)) } } @@ -352,15 +346,15 @@ func (m chatModel) Update(msg tea.Msg) (chatModel, tea.Cmd) { case msgResponseError: m.waitingForReply = false m.status = "Press ctrl+s to send" - m.err = error(msg) + m.State.Err = error(msg) m.updateContent() case msgConversationTitleChanged: title := string(msg) m.conversation.Title = title if m.persistence { - err := m.ctx.Store.UpdateConversation(m.conversation) + err := m.State.Ctx.Store.UpdateConversation(m.conversation) if err != nil { - cmds = append(cmds, wrapError(err)) + cmds = append(cmds, shared.WrapError(err)) } } case cursor.BlinkMsg: @@ -397,20 +391,20 @@ func (m chatModel) Update(msg tea.Msg) (chatModel, tea.Cmd) { // update views once window dimensions are known if m.width > 0 { - m.views.header = m.headerView() - m.views.footer = m.footerView() - m.views.error = errorBanner(m.err, m.width) - fixedHeight := height(m.views.header) + height(m.views.error) + height(m.views.footer) + m.State.Views.Header = m.headerView() + m.State.Views.Footer = m.footerView() + m.State.Views.Error = tuiutil.ErrorBanner(m.State.Err, m.width) + fixedHeight := tuiutil.Height(m.State.Views.Header) + tuiutil.Height(m.State.Views.Error) + tuiutil.Height(m.State.Views.Footer) // calculate clamped input height to accomodate input text // minimum 4 lines, maximum half of content area newHeight := max(4, min((m.height-fixedHeight-1)/2, m.input.LineCount())) m.input.SetHeight(newHeight) - m.views.input = m.input.View() + m.State.Views.Input = m.input.View() // remaining height towards content - m.content.Height = m.height - fixedHeight - height(m.views.input) - m.views.content = m.content.View() + m.content.Height = m.height - fixedHeight - tuiutil.Height(m.State.Views.Input) + m.State.Views.Content = m.content.View() } // this is a pretty nasty hack to ensure the input area viewport doesn't @@ -438,7 +432,7 @@ func (m chatModel) Update(msg tea.Msg) (chatModel, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m *chatModel) handleMessagesKey(msg tea.KeyMsg) (bool, tea.Cmd) { +func (m *Model) handleMessagesKey(msg tea.KeyMsg) (bool, tea.Cmd) { switch msg.String() { case "tab", "enter": m.focus = focusInput @@ -447,7 +441,7 @@ func (m *chatModel) handleMessagesKey(msg tea.KeyMsg) (bool, tea.Cmd) { return true, nil case "e": message := m.messages[m.selectedMessage] - cmd := openTempfileEditor("message.*.md", message.Content, "# Edit the message below\n") + cmd := tuiutil.OpenTempfileEditor("message.*.md", message.Content, "# Edit the message below\n") m.editorTarget = selectedMessage return true, cmd case "ctrl+k": @@ -455,7 +449,7 @@ func (m *chatModel) handleMessagesKey(msg tea.KeyMsg) (bool, tea.Cmd) { m.selectedMessage-- m.updateContent() offset := m.messageOffsets[m.selectedMessage] - scrollIntoView(&m.content, offset, m.content.Height/2) + tuiutil.ScrollIntoView(&m.content, offset, m.content.Height/2) } return true, nil case "ctrl+j": @@ -463,7 +457,7 @@ func (m *chatModel) handleMessagesKey(msg tea.KeyMsg) (bool, tea.Cmd) { m.selectedMessage++ m.updateContent() offset := m.messageOffsets[m.selectedMessage] - scrollIntoView(&m.content, offset, m.content.Height/2) + tuiutil.ScrollIntoView(&m.content, offset, m.content.Height/2) } return true, nil case "ctrl+h", "ctrl+l": @@ -477,12 +471,12 @@ func (m *chatModel) handleMessagesKey(msg tea.KeyMsg) (bool, tea.Cmd) { if m.selectedMessage == 0 { selected, err = m.cycleSelectedRoot(m.conversation, dir) if err != nil { - return true, wrapError(fmt.Errorf("Could not cycle conversation root: %v", err)) + return true, shared.WrapError(fmt.Errorf("Could not cycle conversation root: %v", err)) } } else if m.selectedMessage > 0 { selected, err = m.cycleSelectedReply(&m.messages[m.selectedMessage-1], dir) if err != nil { - return true, wrapError(fmt.Errorf("Could not cycle reply: %v", err)) + return true, shared.WrapError(fmt.Errorf("Could not cycle reply: %v", err)) } } @@ -491,9 +485,9 @@ func (m *chatModel) handleMessagesKey(msg tea.KeyMsg) (bool, tea.Cmd) { } // Retrieve updated view at this point - newPath, err := m.ctx.Store.PathToLeaf(selected) + newPath, err := m.State.Ctx.Store.PathToLeaf(selected) if err != nil { - m.err = fmt.Errorf("Could not fetch messages: %v", err) + m.State.Err = fmt.Errorf("Could not fetch messages: %v", err) } m.messages = append(m.messages[:m.selectedMessage], newPath...) @@ -546,7 +540,7 @@ func cycleMessages(m *models.Message, msgs []models.Message, dir CycleDirection) return &msgs[next], nil } -func (m *chatModel) cycleSelectedRoot(conv *models.Conversation, dir CycleDirection) (*models.Message, error) { +func (m *Model) cycleSelectedRoot(conv *models.Conversation, dir CycleDirection) (*models.Message, error) { if len(m.rootMessages) < 2 { return nil, nil } @@ -557,14 +551,14 @@ func (m *chatModel) cycleSelectedRoot(conv *models.Conversation, dir CycleDirect } conv.SelectedRoot = nextRoot - err = m.ctx.Store.UpdateConversation(conv) + err = m.State.Ctx.Store.UpdateConversation(conv) if err != nil { return nil, fmt.Errorf("Could not update conversation: %v", err) } return nextRoot, nil } -func (m *chatModel) cycleSelectedReply(message *models.Message, dir CycleDirection) (*models.Message, error) { +func (m *Model) cycleSelectedReply(message *models.Message, dir CycleDirection) (*models.Message, error) { if len(message.Replies) < 2 { return nil, nil } @@ -575,14 +569,14 @@ func (m *chatModel) cycleSelectedReply(message *models.Message, dir CycleDirecti } message.SelectedReply = nextReply - err = m.ctx.Store.UpdateMessage(message) + err = m.State.Ctx.Store.UpdateMessage(message) if err != nil { return nil, fmt.Errorf("Could not update message: %v", err) } return nextReply, nil } -func (m *chatModel) handleInputKey(msg tea.KeyMsg) (bool, tea.Cmd) { +func (m *Model) HandleInputKey(msg tea.KeyMsg) (bool, tea.Cmd) { switch msg.String() { case "esc": m.focus = focusMessages @@ -591,7 +585,7 @@ func (m *chatModel) handleInputKey(msg tea.KeyMsg) (bool, tea.Cmd) { m.selectedMessage = len(m.messages) - 1 } offset := m.messageOffsets[m.selectedMessage] - scrollIntoView(&m.content, offset, m.content.Height/2) + tuiutil.ScrollIntoView(&m.content, offset, m.content.Height/2) } m.updateContent() m.input.Blur() @@ -607,7 +601,7 @@ func (m *chatModel) handleInputKey(msg tea.KeyMsg) (bool, tea.Cmd) { } if len(m.messages) > 0 && m.messages[len(m.messages)-1].Role == models.MessageRoleUser { - return true, wrapError(fmt.Errorf("Can't reply to a user message")) + return true, shared.WrapError(fmt.Errorf("Can't reply to a user message")) } m.addMessage(models.Message{ @@ -620,7 +614,7 @@ func (m *chatModel) handleInputKey(msg tea.KeyMsg) (bool, tea.Cmd) { if m.persistence { err := m.persistConversation() if err != nil { - return true, wrapError(err) + return true, shared.WrapError(err) } } @@ -629,14 +623,14 @@ func (m *chatModel) handleInputKey(msg tea.KeyMsg) (bool, tea.Cmd) { m.content.GotoBottom() return true, cmd case "ctrl+e": - cmd := openTempfileEditor("message.*.md", m.input.Value(), "# Edit your input below\n") + cmd := tuiutil.OpenTempfileEditor("message.*.md", m.input.Value(), "# Edit your input below\n") m.editorTarget = input return true, cmd } return false, nil } -func (m *chatModel) renderMessageHeading(i int, message *models.Message) string { +func (m *Model) renderMessageHeading(i int, message *models.Message) string { icon := "" friendly := message.Role.FriendlyRole() style := lipgloss.NewStyle().Faint(true).Bold(true) @@ -697,14 +691,14 @@ func (m *chatModel) renderMessageHeading(i int, message *models.Message) string return messageHeadingStyle.Render(prefix + user + suffix) } -func (m *chatModel) renderMessage(i int) string { +func (m *Model) renderMessage(i int) string { msg := &m.messages[i] // Write message contents sb := &strings.Builder{} sb.Grow(len(msg.Content) * 2) if msg.Content != "" { - err := m.ctx.Chroma.Highlight(sb, msg.Content) + err := m.State.Ctx.Chroma.Highlight(sb, msg.Content) if err != nil { sb.Reset() sb.WriteString(msg.Content) @@ -768,7 +762,7 @@ func (m *chatModel) renderMessage(i int) string { if msg.Content != "" { sb.WriteString("\n\n") } - _ = m.ctx.Chroma.HighlightLang(sb, toolString, "yaml") + _ = m.State.Ctx.Chroma.HighlightLang(sb, toolString, "yaml") } content := strings.TrimRight(sb.String(), "\n") @@ -785,7 +779,7 @@ func (m *chatModel) renderMessage(i int) string { } // render the conversation into a string -func (m *chatModel) conversationMessagesView() string { +func (m *Model) conversationMessagesView() string { sb := strings.Builder{} m.messageOffsets = make([]int, len(m.messages)) @@ -826,7 +820,7 @@ func (m *chatModel) conversationMessagesView() string { return sb.String() } -func (m *chatModel) headerView() string { +func (m *Model) headerView() string { titleStyle := lipgloss.NewStyle().Bold(true) var title string if m.conversation != nil && m.conversation.Title != "" { @@ -834,12 +828,12 @@ func (m *chatModel) headerView() string { } else { title = "Untitled" } - title = truncateToCellWidth(title, m.width-headerStyle.GetHorizontalPadding(), "...") + title = tuiutil.TruncateToCellWidth(title, m.width-styles.Header.GetHorizontalPadding(), "...") header := titleStyle.Render(title) - return headerStyle.Width(m.width).Render(header) + return styles.Header.Width(m.width).Render(header) } -func (m *chatModel) footerView() string { +func (m *Model) footerView() string { segmentStyle := lipgloss.NewStyle().PaddingLeft(1).PaddingRight(1).Faint(true) segmentSeparator := "|" @@ -867,7 +861,7 @@ func (m *chatModel) footerView() string { rightSegments = append(rightSegments, segmentStyle.Render(throughput)) } - model := fmt.Sprintf("Model: %s", *m.ctx.Config.Defaults.Model) + model := fmt.Sprintf("Model: %s", *m.State.Ctx.Config.Defaults.Model) rightSegments = append(rightSegments, segmentStyle.Render(model)) left := strings.Join(leftSegments, segmentSeparator) @@ -883,12 +877,12 @@ func (m *chatModel) footerView() string { footer := left + padding + right if remaining < 0 { - footer = truncateToCellWidth(footer, m.width, "...") + footer = tuiutil.TruncateToCellWidth(footer, m.width, "...") } return footerStyle.Width(m.width).Render(footer) } -func (m *chatModel) setMessage(i int, msg models.Message) { +func (m *Model) setMessage(i int, msg models.Message) { if i >= len(m.messages) { panic("i out of range") } @@ -896,12 +890,12 @@ func (m *chatModel) setMessage(i int, msg models.Message) { m.messageCache[i] = m.renderMessage(i) } -func (m *chatModel) addMessage(msg models.Message) { +func (m *Model) addMessage(msg models.Message) { m.messages = append(m.messages, msg) m.messageCache = append(m.messageCache, m.renderMessage(len(m.messages)-1)) } -func (m *chatModel) setMessageContents(i int, content string) { +func (m *Model) setMessageContents(i int, content string) { if i >= len(m.messages) { panic("i out of range") } @@ -909,14 +903,14 @@ func (m *chatModel) setMessageContents(i int, content string) { m.messageCache[i] = m.renderMessage(i) } -func (m *chatModel) rebuildMessageCache() { +func (m *Model) rebuildMessageCache() { m.messageCache = make([]string, len(m.messages)) for i := range m.messages { m.messageCache[i] = m.renderMessage(i) } } -func (m *chatModel) updateContent() { +func (m *Model) updateContent() { atBottom := m.content.AtBottom() m.content.SetContent(m.conversationMessagesView()) if atBottom { @@ -925,36 +919,36 @@ func (m *chatModel) updateContent() { } } -func (m *chatModel) loadConversation(shortname string) tea.Cmd { +func (m *Model) loadConversation(shortname string) tea.Cmd { return func() tea.Msg { if shortname == "" { return nil } - c, err := m.ctx.Store.ConversationByShortName(shortname) + c, err := m.State.Ctx.Store.ConversationByShortName(shortname) if err != nil { - return msgError(fmt.Errorf("Could not lookup conversation: %v", err)) + return shared.MsgError(fmt.Errorf("Could not lookup conversation: %v", err)) } if c.ID == 0 { - return msgError(fmt.Errorf("Conversation not found: %s", shortname)) + return shared.MsgError(fmt.Errorf("Conversation not found: %s", shortname)) } return msgConversationLoaded(c) } } -func (m *chatModel) loadMessages(c *models.Conversation) tea.Cmd { +func (m *Model) loadMessages(c *models.Conversation) tea.Cmd { return func() tea.Msg { - messages, err := m.ctx.Store.PathToLeaf(c.SelectedRoot) + messages, err := m.State.Ctx.Store.PathToLeaf(c.SelectedRoot) if err != nil { - return msgError(fmt.Errorf("Could not load conversation messages: %v\n", err)) + return shared.MsgError(fmt.Errorf("Could not load conversation messages: %v\n", err)) } return msgMessagesLoaded(messages) } } -func (m *chatModel) persistConversation() error { +func (m *Model) persistConversation() error { if m.conversation.ID == 0 { // Start a new conversation with all messages so far - c, messages, err := m.ctx.Store.StartConversation(m.messages...) + c, messages, err := m.State.Ctx.Store.StartConversation(m.messages...) if err != nil { return err } @@ -969,13 +963,13 @@ func (m *chatModel) persistConversation() error { if m.messages[i].ID > 0 { // message has an ID, update its contents // TODO: check for content/tool equality before updating? - err := m.ctx.Store.UpdateMessage(&m.messages[i]) + err := m.State.Ctx.Store.UpdateMessage(&m.messages[i]) if err != nil { return err } } else if i > 0 { // messages is new, so add it as a reply to previous message - saved, err := m.ctx.Store.Reply(&m.messages[i-1], m.messages[i]) + saved, err := m.State.Ctx.Store.Reply(&m.messages[i-1], m.messages[i]) if err != nil { return err } @@ -992,29 +986,29 @@ func (m *chatModel) persistConversation() error { return nil } -func (m *chatModel) generateConversationTitle() tea.Cmd { +func (m *Model) generateConversationTitle() tea.Cmd { return func() tea.Msg { - title, err := cmdutil.GenerateTitle(m.ctx, m.messages) + title, err := cmdutil.GenerateTitle(m.State.Ctx, m.messages) if err != nil { - return msgError(err) + return shared.MsgError(err) } return msgConversationTitleChanged(title) } } -func (m *chatModel) waitForReply() tea.Cmd { +func (m *Model) waitForReply() tea.Cmd { return func() tea.Msg { return msgAssistantReply(<-m.replyChan) } } -func (m *chatModel) waitForChunk() tea.Cmd { +func (m *Model) waitForChunk() tea.Cmd { return func() tea.Msg { return msgResponseChunk(<-m.replyChunkChan) } } -func (m *chatModel) promptLLM() tea.Cmd { +func (m *Model) promptLLM() tea.Cmd { m.waitingForReply = true m.replyCursor.Blink = false m.status = "Press ctrl+c to cancel" @@ -1034,16 +1028,16 @@ func (m *chatModel) promptLLM() tea.Cmd { m.elapsed = 0 return func() tea.Msg { - completionProvider, err := m.ctx.GetCompletionProvider(*m.ctx.Config.Defaults.Model) + completionProvider, err := m.State.Ctx.GetCompletionProvider(*m.State.Ctx.Config.Defaults.Model) if err != nil { - return msgError(err) + return shared.MsgError(err) } requestParams := models.RequestParameters{ - Model: *m.ctx.Config.Defaults.Model, - MaxTokens: *m.ctx.Config.Defaults.MaxTokens, - Temperature: *m.ctx.Config.Defaults.Temperature, - ToolBag: m.ctx.EnabledTools, + Model: *m.State.Ctx.Config.Defaults.Model, + MaxTokens: *m.State.Ctx.Config.Defaults.MaxTokens, + Temperature: *m.State.Ctx.Config.Defaults.Temperature, + ToolBag: m.State.Ctx.EnabledTools, } replyHandler := func(msg models.Message) { diff --git a/pkg/tui/conversations.go b/pkg/tui/views/conversations/conversations.go similarity index 76% rename from pkg/tui/conversations.go rename to pkg/tui/views/conversations/conversations.go index f3a2f4f..997b323 100644 --- a/pkg/tui/conversations.go +++ b/pkg/tui/views/conversations/conversations.go @@ -1,4 +1,4 @@ -package tui +package conversations import ( "fmt" @@ -6,6 +6,9 @@ import ( "time" models "git.mlow.ca/mlow/lmcli/pkg/lmcli/model" + "git.mlow.ca/mlow/lmcli/pkg/tui/shared" + "git.mlow.ca/mlow/lmcli/pkg/tui/styles" + tuiutil "git.mlow.ca/mlow/lmcli/pkg/tui/util" "git.mlow.ca/mlow/lmcli/pkg/util" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -21,11 +24,11 @@ type ( // sent when conversation list is loaded msgConversationsLoaded ([]loadedConversation) // sent when a conversation is selected - msgConversationSelected models.Conversation + MsgConversationSelected models.Conversation ) -type conversationsModel struct { - basemodel +type Model struct { + shared.State conversations []loadedConversation cursor int // index of the currently selected conversation @@ -34,26 +37,20 @@ type conversationsModel struct { content viewport.Model } -func newConversationsModel(tui *model) conversationsModel { - m := conversationsModel{ - basemodel: basemodel{ - opts: tui.opts, - ctx: tui.ctx, - views: tui.views, - width: tui.width, - height: tui.height, - }, +func Conversations(state shared.State) Model { + m := Model{ + State: state, content: viewport.New(0, 0), } return m } -func (m *conversationsModel) handleInput(msg tea.KeyMsg) (bool, tea.Cmd) { +func (m *Model) HandleInput(msg tea.KeyMsg) (bool, tea.Cmd) { switch msg.String() { case "enter": if len(m.conversations) > 0 && m.cursor < len(m.conversations) { return true, func() tea.Msg { - return msgConversationSelected(m.conversations[m.cursor].conv) + return MsgConversationSelected(m.conversations[m.cursor].conv) } } case "j", "down": @@ -66,7 +63,7 @@ func (m *conversationsModel) handleInput(msg tea.KeyMsg) (bool, tea.Cmd) { // this hack positions the *next* conversatoin slightly // *off* the screen, ensuring the entire m.cursor is shown, // even if its height may not be constant due to wrapping. - scrollIntoView(&m.content, m.itemOffsets[m.cursor+1], -1) + tuiutil.ScrollIntoView(&m.content, m.itemOffsets[m.cursor+1], -1) } m.content.SetContent(m.renderConversationList()) } else { @@ -80,7 +77,7 @@ func (m *conversationsModel) handleInput(msg tea.KeyMsg) (bool, tea.Cmd) { if m.cursor == 0 { m.content.GotoTop() } else { - scrollIntoView(&m.content, m.itemOffsets[m.cursor], 1) + tuiutil.ScrollIntoView(&m.content, m.itemOffsets[m.cursor], 1) } m.content.SetContent(m.renderConversationList()) } else { @@ -102,23 +99,23 @@ func (m *conversationsModel) handleInput(msg tea.KeyMsg) (bool, tea.Cmd) { return false, nil } -func (m conversationsModel) Init() tea.Cmd { +func (m Model) Init() tea.Cmd { return nil } -func (m *conversationsModel) handleResize(width, height int) { - m.width, m.height = width, height +func (m *Model) HandleResize(width, height int) { + m.Width, m.Height = width, height m.content.Width = width } -func (m conversationsModel) Update(msg tea.Msg) (conversationsModel, tea.Cmd) { +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { - case msgStateEnter: + case shared.MsgViewEnter: cmds = append(cmds, m.loadConversations()) m.content.SetContent(m.renderConversationList()) case tea.WindowSizeMsg: - m.handleResize(msg.Width, msg.Height) + m.HandleResize(msg.Width, msg.Height) m.content.SetContent(m.renderConversationList()) case msgConversationsLoaded: m.conversations = msg @@ -131,22 +128,22 @@ func (m conversationsModel) Update(msg tea.Msg) (conversationsModel, tea.Cmd) { cmds = append(cmds, cmd) } - if m.width > 0 { - m.views.header = m.headerView() - m.views.footer = "" // TODO: show /something/ - m.views.error = errorBanner(m.err, m.width) - fixedHeight := height(m.views.header) + height(m.views.error) + height(m.views.footer) - m.content.Height = m.height - fixedHeight - m.views.content = m.content.View() + if m.Width > 0 { + m.Views.Header = m.headerView() + m.Views.Footer = "" // TODO: show /something/ + m.Views.Error = tuiutil.ErrorBanner(m.Err, m.Width) + fixedHeight := tuiutil.Height(m.Views.Header) + tuiutil.Height(m.Views.Error) + tuiutil.Height(m.Views.Footer) + m.content.Height = m.Height - fixedHeight + m.Views.Content = m.content.View() } return m, tea.Batch(cmds...) } -func (m *conversationsModel) loadConversations() tea.Cmd { +func (m *Model) loadConversations() tea.Cmd { return func() tea.Msg { - messages, err := m.ctx.Store.LatestConversationMessages() + messages, err := m.Ctx.Store.LatestConversationMessages() if err != nil { - return msgError(fmt.Errorf("Could not load conversations: %v", err)) + return shared.MsgError(fmt.Errorf("Could not load conversations: %v", err)) } loaded := make([]loadedConversation, len(messages)) @@ -159,13 +156,13 @@ func (m *conversationsModel) loadConversations() tea.Cmd { } } -func (m *conversationsModel) headerView() string { +func (m *Model) headerView() string { titleStyle := lipgloss.NewStyle().Bold(true) header := titleStyle.Render("Conversations") - return headerStyle.Width(m.width).Render(header) + return styles.Header.Width(m.Width).Render(header) } -func (m *conversationsModel) renderConversationList() string { +func (m *Model) renderConversationList() string { now := time.Now() midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) @@ -228,7 +225,7 @@ func (m *conversationsModel) renderConversationList() string { currentCategory = category heading := categoryStyle.Render(currentCategory) sb.WriteString(heading) - currentOffset += height(heading) + currentOffset += tuiutil.Height(heading) sb.WriteRune('\n') } @@ -240,7 +237,7 @@ func (m *conversationsModel) renderConversationList() string { tStyle = tStyle.Inherit(selectedStyle) } - title := tStyle.Width(m.width - 3).PaddingLeft(2).Render(c.conv.Title) + title := tStyle.Width(m.Width - 3).PaddingLeft(2).Render(c.conv.Title) if i == m.cursor { title = ">" + title[1:] } @@ -252,7 +249,7 @@ func (m *conversationsModel) renderConversationList() string { ageStyle.Render(util.HumanTimeElapsedSince(lastReplyAge)), )) sb.WriteString(item) - currentOffset += height(item) + currentOffset += tuiutil.Height(item) if i < len(m.conversations)-1 { sb.WriteRune('\n') }