diff --git a/pkg/tui/chat.go b/pkg/tui/chat.go index d6eeddf..a3c8b2a 100644 --- a/pkg/tui/chat.go +++ b/pkg/tui/chat.go @@ -5,9 +5,13 @@ import ( "encoding/json" "fmt" "strings" + "time" cmdutil "git.mlow.ca/mlow/lmcli/pkg/cmd/util" models "git.mlow.ca/mlow/lmcli/pkg/lmcli/model" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/muesli/reflow/wordwrap" @@ -46,6 +50,86 @@ type ( msgMessagesLoaded []models.Message ) +type chatModel struct { + basemodel + width int + height int + + // app state + conversation *models.Conversation + messages []models.Message + selectedMessage int + waitingForReply bool + editorTarget editorTarget + stopSignal chan struct{} + replyChan chan models.Message + replyChunkChan chan string + persistence bool // whether we will save new messages in the conversation + + // ui state + focus focusState + wrap bool // whether message content is wrapped to viewport width + status string // a general status message + showToolResults bool // whether tool calls and results are shown + messageCache []string // cache of syntax highlighted and wrapped message content + messageOffsets []int + + // ui elements + content viewport.Model + input textarea.Model + spinner spinner.Model +} + +func newChatModel(tui *model) chatModel { + m := chatModel{ + basemodel: basemodel{ + opts: tui.opts, + ctx: tui.ctx, + views: tui.views, + }, + + conversation: &models.Conversation{}, + persistence: true, + + stopSignal: make(chan struct{}), + replyChan: make(chan models.Message), + replyChunkChan: make(chan string), + + wrap: true, + selectedMessage: -1, + + content: viewport.New(0, 0), + input: textarea.New(), + spinner: spinner.New(spinner.WithSpinner( + spinner.Spinner{ + Frames: []string{ + ". ", + ".. ", + "...", + ".. ", + ". ", + " ", + }, + FPS: time.Second / 3, + }, + )), + } + + m.input.Focus() + m.input.MaxHeight = 0 + m.input.CharLimit = 0 + m.input.ShowLineNumbers = false + m.input.Placeholder = "Enter a message" + + m.input.FocusedStyle.CursorLine = lipgloss.NewStyle() + m.input.FocusedStyle.Base = inputFocusedStyle + m.input.BlurredStyle.Base = inputBlurredStyle + + m.waitingForReply = false + m.status = "Press ctrl+s to send" + return m +} + // styles var ( headerStyle = lipgloss.NewStyle(). @@ -77,11 +161,25 @@ var ( footerStyle = lipgloss.NewStyle() ) -func (m *model) handleChatInput(msg tea.KeyMsg) tea.Cmd { +func (m *chatModel) handleInput(msg tea.KeyMsg) tea.Cmd { + switch m.focus { + case focusInput: + cmd := m.handleInputKey(msg) + if cmd != nil { + return cmd + } + case focusMessages: + cmd := m.handleMessagesKey(msg) + if cmd != nil { + return cmd + } + } + switch msg.String() { case "esc": - m.state = stateConversations - return m.loadConversations() + return func() tea.Msg { + return msgChangeState(stateConversations) + } case "ctrl+p": m.persistence = !m.persistence case "ctrl+t": @@ -92,20 +190,35 @@ func (m *model) handleChatInput(msg tea.KeyMsg) tea.Cmd { m.wrap = !m.wrap m.rebuildMessageCache() m.updateContent() - default: - switch m.focus { - case focusInput: - return m.handleChatInputKey(msg) - case focusMessages: - return m.handleChatMessagesKey(msg) - } } return nil } -func (m *model) handleChatUpdate(msg tea.Msg) []tea.Cmd { +func (m chatModel) Init() tea.Cmd { + return tea.Batch( + textarea.Blink, + m.spinner.Tick, + m.waitForChunk(), + m.waitForReply(), + ) +} + +func (m chatModel) Update(msg tea.Msg) (chatModel, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { + case msgChangeState: + cmds = append(cmds, m.Init()) + + if m.opts.convShortname != "" && m.conversation.ShortName.String != m.opts.convShortname { + cmds = append(cmds, m.loadConversation(m.opts.convShortname)) + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.content.Width = msg.Width + m.input.SetWidth(msg.Width - m.input.FocusedStyle.Base.GetHorizontalBorderSize()) + m.rebuildMessageCache() + m.updateContent() case msgTempfileEditorClosed: contents := string(msg) switch m.editorTarget { @@ -225,7 +338,7 @@ func (m *model) handleChatUpdate(msg tea.Msg) []tea.Cmd { if m.width > 0 { m.views.header = m.headerView() m.views.footer = m.footerView() - m.views.error = m.errorView() + m.views.error = errorBanner(m.err, m.width) fixedHeight := height(m.views.header) + height(m.views.error) + height(m.views.footer) // calculate clamped input height to accomodate input text @@ -259,10 +372,10 @@ func (m *model) handleChatUpdate(msg tea.Msg) []tea.Cmd { } } - return cmds + return m, tea.Batch(cmds...) } -func (m *model) handleChatMessagesKey(msg tea.KeyMsg) tea.Cmd { +func (m *chatModel) handleMessagesKey(msg tea.KeyMsg) tea.Cmd { switch msg.String() { case "tab": m.focus = focusInput @@ -301,19 +414,22 @@ func (m *model) handleChatMessagesKey(msg tea.KeyMsg) tea.Cmd { return nil } -func (m *model) handleChatInputKey(msg tea.KeyMsg) tea.Cmd { +func (m *chatModel) handleInputKey(msg tea.KeyMsg) tea.Cmd { switch msg.String() { case "esc": - m.focus = focusMessages - if len(m.messages) > 0 { - if m.selectedMessage < 0 || m.selectedMessage >= len(m.messages) { - m.selectedMessage = len(m.messages) - 1 - } - offset := m.messageOffsets[m.selectedMessage] - scrollIntoView(&m.content, offset, 0.1) + return func() tea.Msg { + return msgChangeState(stateConversations) } - m.updateContent() - m.input.Blur() + //m.focus = focusMessages + //if len(m.messages) > 0 { + // if m.selectedMessage < 0 || m.selectedMessage >= len(m.messages) { + // m.selectedMessage = len(m.messages) - 1 + // } + // offset := m.messageOffsets[m.selectedMessage] + // scrollIntoView(&m.content, offset, 0.1) + //} + //m.updateContent() + //m.input.Blur() case "ctrl+s": userInput := strings.TrimSpace(m.input.Value()) if strings.TrimSpace(userInput) == "" { @@ -365,7 +481,7 @@ func (m *model) handleChatInputKey(msg tea.KeyMsg) tea.Cmd { return nil } -func (m *model) renderMessageHeading(i int, message *models.Message) string { +func (m *chatModel) renderMessageHeading(i int, message *models.Message) string { icon := "" friendly := message.Role.FriendlyRole() style := lipgloss.NewStyle().Faint(true).Bold(true) @@ -403,7 +519,7 @@ func (m *model) renderMessageHeading(i int, message *models.Message) string { return messageHeadingStyle.Render(prefix + user + suffix) } -func (m *model) renderMessage(msg *models.Message) string { +func (m *chatModel) renderMessage(msg *models.Message) string { sb := &strings.Builder{} sb.Grow(len(msg.Content) * 2) if msg.Content != "" { @@ -479,7 +595,7 @@ func (m *model) renderMessage(msg *models.Message) string { } // render the conversation into a string -func (m *model) conversationMessagesView() string { +func (m *chatModel) conversationMessagesView() string { sb := strings.Builder{} m.messageOffsets = make([]int, len(m.messages)) @@ -512,12 +628,68 @@ func (m *model) conversationMessagesView() string { return sb.String() } -func (m *model) setMessages(messages []models.Message) { +func (m *chatModel) headerView() string { + titleStyle := lipgloss.NewStyle().Bold(true) + var title string + if m.conversation != nil && m.conversation.Title != "" { + title = m.conversation.Title + } else { + title = "Untitled" + } + title = truncateToCellWidth(title, m.width-headerStyle.GetHorizontalPadding(), "...") + header := titleStyle.Render(title) + return headerStyle.Width(m.width).Render(header) +} + +func (m *chatModel) footerView() string { + segmentStyle := lipgloss.NewStyle().PaddingLeft(1).PaddingRight(1).Faint(true) + segmentSeparator := "|" + + savingStyle := segmentStyle.Copy().Bold(true) + saving := "" + if m.persistence { + saving = savingStyle.Foreground(lipgloss.Color("2")).Render("✅💾") + } else { + saving = savingStyle.Foreground(lipgloss.Color("1")).Render("❌💾") + } + + status := m.status + if m.waitingForReply { + status += m.spinner.View() + } + + leftSegments := []string{ + saving, + segmentStyle.Render(status), + } + rightSegments := []string{ + segmentStyle.Render(fmt.Sprintf("Model: %s", *m.ctx.Config.Defaults.Model)), + } + + left := strings.Join(leftSegments, segmentSeparator) + right := strings.Join(rightSegments, segmentSeparator) + + totalWidth := lipgloss.Width(left) + lipgloss.Width(right) + remaining := m.width - totalWidth + + var padding string + if remaining > 0 { + padding = strings.Repeat(" ", remaining) + } + + footer := left + padding + right + if remaining < 0 { + footer = truncateToCellWidth(footer, m.width, "...") + } + return footerStyle.Width(m.width).Render(footer) +} + +func (m *chatModel) setMessages(messages []models.Message) { m.messages = messages m.rebuildMessageCache() } -func (m *model) setMessage(i int, msg models.Message) { +func (m *chatModel) setMessage(i int, msg models.Message) { if i >= len(m.messages) { panic("i out of range") } @@ -525,12 +697,12 @@ func (m *model) setMessage(i int, msg models.Message) { m.messageCache[i] = m.renderMessage(&msg) } -func (m *model) addMessage(msg models.Message) { +func (m *chatModel) addMessage(msg models.Message) { m.messages = append(m.messages, msg) m.messageCache = append(m.messageCache, m.renderMessage(&msg)) } -func (m *model) setMessageContents(i int, content string) { +func (m *chatModel) setMessageContents(i int, content string) { if i >= len(m.messages) { panic("i out of range") } @@ -538,14 +710,14 @@ func (m *model) setMessageContents(i int, content string) { m.messageCache[i] = m.renderMessage(&m.messages[i]) } -func (m *model) rebuildMessageCache() { +func (m *chatModel) rebuildMessageCache() { m.messageCache = make([]string, len(m.messages)) for i, msg := range m.messages { m.messageCache[i] = m.renderMessage(&msg) } } -func (m *model) updateContent() { +func (m *chatModel) updateContent() { atBottom := m.content.AtBottom() m.content.SetContent(m.conversationMessagesView()) if atBottom { @@ -554,7 +726,7 @@ func (m *model) updateContent() { } } -func (m *model) loadConversation(shortname string) tea.Cmd { +func (m *chatModel) loadConversation(shortname string) tea.Cmd { return func() tea.Msg { if shortname == "" { return nil @@ -570,7 +742,7 @@ func (m *model) loadConversation(shortname string) tea.Cmd { } } -func (m *model) loadMessages(c *models.Conversation) tea.Cmd { +func (m *chatModel) loadMessages(c *models.Conversation) tea.Cmd { return func() tea.Msg { messages, err := m.ctx.Store.Messages(c) if err != nil { @@ -580,7 +752,7 @@ func (m *model) loadMessages(c *models.Conversation) tea.Cmd { } } -func (m *model) persistConversation() tea.Cmd { +func (m *chatModel) persistConversation() tea.Cmd { existingMessages, err := m.ctx.Store.Messages(m.conversation) if err != nil { return wrapError(fmt.Errorf("Could not retrieve existing conversation messages while trying to save: %v", err)) @@ -633,7 +805,7 @@ func (m *model) persistConversation() tea.Cmd { return nil } -func (m *model) generateConversationTitle() tea.Cmd { +func (m *chatModel) generateConversationTitle() tea.Cmd { return func() tea.Msg { title, err := cmdutil.GenerateTitle(m.ctx, m.conversation) if err != nil { @@ -643,20 +815,19 @@ func (m *model) generateConversationTitle() tea.Cmd { } } -func (m *model) waitForReply() tea.Cmd { +func (m *chatModel) waitForReply() tea.Cmd { return func() tea.Msg { return msgAssistantReply(<-m.replyChan) } } -func (m *model) waitForChunk() tea.Cmd { +func (m *chatModel) waitForChunk() tea.Cmd { return func() tea.Msg { return msgResponseChunk(<-m.replyChunkChan) } } - -func (m *model) promptLLM() tea.Cmd { +func (m *chatModel) promptLLM() tea.Cmd { m.waitingForReply = true m.status = "Press ctrl+c to cancel" diff --git a/pkg/tui/conversations.go b/pkg/tui/conversations.go index 51adecb..9094c05 100644 --- a/pkg/tui/conversations.go +++ b/pkg/tui/conversations.go @@ -8,6 +8,7 @@ import ( models "git.mlow.ca/mlow/lmcli/pkg/lmcli/model" "git.mlow.ca/mlow/lmcli/pkg/util" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -17,12 +18,36 @@ type ( msgConversationsLoaded []models.Conversation ) -func (m *model) handleConversationsInput(msg tea.KeyMsg) tea.Cmd { +type conversationsModel struct { + basemodel + + conversations []models.Conversation + lastReplies []models.Message + + 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, + }, + content: viewport.New(0, 0), + } + return m +} + +func (m *conversationsModel) handleInput(msg tea.KeyMsg) tea.Cmd { switch msg.String() { case "enter": - m.state = stateChat - m.updateContent() - // load selected conversation and switch state + // how to notify chats model + return func() tea.Msg { + return msgChangeState(stateChat) + } case "n": // new conversation case "d": @@ -37,9 +62,17 @@ func (m *model) handleConversationsInput(msg tea.KeyMsg) tea.Cmd { return nil } -func (m *model) handleConversationsUpdate(msg tea.Msg) []tea.Cmd { +func (m conversationsModel) Init() tea.Cmd { + return nil +} + +func (m conversationsModel) Update(msg tea.Msg) (conversationsModel, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { + case msgChangeState: + cmds = append(cmds, m.loadConversations()) + case tea.WindowSizeMsg: + m.content.Width = msg.Width case msgConversationsLoaded: m.conversations = msg m.content.SetContent(m.renderConversationList()) @@ -53,17 +86,16 @@ func (m *model) handleConversationsUpdate(msg tea.Msg) []tea.Cmd { if m.width > 0 { m.views.header = m.headerView() - m.views.footer = m.footerView() - m.views.error = m.errorView() + 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() } - return cmds + return m, tea.Batch(cmds...) } -func (m *model) loadConversations() tea.Cmd { +func (m *conversationsModel) loadConversations() tea.Cmd { return func() tea.Msg { c, err := m.ctx.Store.Conversations() if err != nil { @@ -73,17 +105,23 @@ func (m *model) loadConversations() tea.Cmd { } } -func (m *model) loadConversationLastestReplies(conversations []models.Conversation) tea.Cmd { - return func() tea.Msg { - //lastMessage, err := m.ctx.Store.LastMessage(&conversation) - return nil - } +//func (m *conversationsModel) loadConversationLastestReplies(conversations []models.Conversation) tea.Cmd { +// return func() tea.Msg { +// //lastMessage, err := m.ctx.Store.LastMessage(&conversation) +// return nil +// } +//} + +//func (m *conversationsModel) setConversations(conversations []models.Conversation) { +//} + +func (m *conversationsModel) headerView() string { + titleStyle := lipgloss.NewStyle().Bold(true) + header := titleStyle.Render("Conversations") + return headerStyle.Width(m.width).Render(header) } -func (m *model) setConversations(conversations []models.Conversation) { -} - -func (m *model) renderConversationList() string { +func (m *conversationsModel) renderConversationList() string { sb := &strings.Builder{} type AgeGroup struct { name string diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index d947480..79ef931 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -9,19 +9,13 @@ package tui import ( "fmt" - "strings" - "time" "git.mlow.ca/mlow/lmcli/pkg/lmcli" - models "git.mlow.ca/mlow/lmcli/pkg/lmcli/model" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) -type appState int +type state int const ( stateChat = iota @@ -32,7 +26,7 @@ const ( // 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 view +// these elements into the final output type views struct { header string content string @@ -42,135 +36,72 @@ type views struct { } type ( + // send to change the current app state + msgChangeState state // sent when an error occurs msgError error ) -type model struct { - width int - height int - - ctx *lmcli.Context +type Options struct { convShortname string - - // application state - state appState - conversations []models.Conversation - lastReplies []models.Message - conversation *models.Conversation - messages []models.Message - selectedMessage int - waitingForReply bool - editorTarget editorTarget - stopSignal chan interface{} - replyChan chan models.Message - replyChunkChan chan string - persistence bool // whether we will save new messages in the conversation - err error - - // ui state - focus focusState - wrap bool // whether message content is wrapped to viewport width - status string // a general status message - showToolResults bool // whether tool calls and results are shown - messageCache []string // cache of syntax highlighted and wrapped message content - messageOffsets []int - - // ui elements - content viewport.Model - input textarea.Model - spinner spinner.Model - views *views } -func initialModel(ctx *lmcli.Context, convShortname string) model { +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{ - ctx: ctx, - convShortname: convShortname, - conversation: &models.Conversation{}, - persistence: true, - - stopSignal: make(chan interface{}), - replyChan: make(chan models.Message), - replyChunkChan: make(chan string), - - wrap: true, - selectedMessage: -1, - - views: &views{}, - } - - m.state = stateChat - - m.content = viewport.New(0, 0) - - m.input = textarea.New() - m.input.MaxHeight = 0 - m.input.CharLimit = 0 - m.input.Placeholder = "Enter a message" - - m.input.Focus() - m.input.FocusedStyle.CursorLine = lipgloss.NewStyle() - m.input.FocusedStyle.Base = inputFocusedStyle - m.input.BlurredStyle.Base = inputBlurredStyle - m.input.ShowLineNumbers = false - - m.spinner = spinner.New(spinner.WithSpinner( - spinner.Spinner{ - Frames: []string{ - ". ", - ".. ", - "...", - ".. ", - ". ", - " ", - }, - FPS: time.Second / 3, + basemodel: basemodel{ + opts: &opts, + ctx: ctx, + views: &views{}, }, - )) - - m.waitingForReply = false - m.status = "Press ctrl+s to send" + } + m.state = stateChat + m.chat = newChatModel(&m) + m.conversations = newConversationsModel(&m) return m } func (m model) Init() tea.Cmd { - cmds := []tea.Cmd{ - textarea.Blink, - m.spinner.Tick, - m.waitForChunk(), - m.waitForReply(), + return func() tea.Msg { + return msgChangeState(m.state) } - switch m.state { - case stateChat: - if m.convShortname != "" { - cmds = append(cmds, m.loadConversation(m.convShortname)) - } - case stateConversations: - cmds = append(cmds, m.loadConversations()) - } - return tea.Batch(cmds...) } func (m *model) handleGlobalInput(msg tea.KeyMsg) tea.Cmd { switch msg.String() { case "ctrl+c": - if m.waitingForReply { - m.stopSignal <- "" + if m.chat.waitingForReply { + m.chat.stopSignal <- struct{}{} return nil } else { return tea.Quit } case "q": - if m.focus != focusInput { + if m.chat.focus != focusInput { return tea.Quit } default: switch m.state { case stateChat: - return m.handleChatInput(msg) + return m.chat.handleInput(msg) case stateConversations: - return m.handleConversationsInput(msg) + return m.conversations.handleInput(msg) } } return nil @@ -185,22 +116,26 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd != nil { return m, cmd } + case msgChangeState: + m.state = state(msg) case tea.WindowSizeMsg: - m.content.Width = msg.Width - m.input.SetWidth(msg.Width - m.input.FocusedStyle.Base.GetHorizontalBorderSize()) - m.rebuildMessageCache() - m.updateContent() - m.width = msg.Width - m.height = msg.Height + w, h := msg.Width, msg.Height + m.width, m.height = w, h + m.chat.width, m.chat.height = w, h + m.conversations.width, m.conversations.height = w, h case msgError: m.err = msg } + var cmd tea.Cmd switch m.state { case stateConversations: - cmds = append(cmds, m.handleConversationsUpdate(msg)...) + m.conversations, cmd = m.conversations.Update(msg) case stateChat: - cmds = append(cmds, m.handleChatUpdate(msg)...) + m.chat, cmd = m.chat.Update(msg) + } + if cmd != nil { + cmds = append(cmds, cmd) } return m, tea.Batch(cmds...) @@ -240,78 +175,16 @@ func (m model) View() string { return lipgloss.JoinVertical(lipgloss.Left, sections...) } -func (m *model) headerView() string { - titleStyle := lipgloss.NewStyle().Bold(true) - var header string - switch m.state { - case stateChat: - var title string - if m.conversation != nil && m.conversation.Title != "" { - title = m.conversation.Title - } else { - title = "Untitled" - } - title = truncateToCellWidth(title, m.width-headerStyle.GetHorizontalPadding(), "...") - header = titleStyle.Render(title) - case stateConversations: - header = titleStyle.Render("Conversations") - } - return headerStyle.Width(m.width).Render(header) -} - -func (m *model) errorView() string { - if m.err == nil { +func errorBanner(err error, width int) string { + if err == nil { return "" } return lipgloss.NewStyle(). - Width(m.width). + Width(width). AlignHorizontal(lipgloss.Center). Bold(true). Foreground(lipgloss.Color("1")). - Render(fmt.Sprintf("%s", m.err)) -} - -func (m *model) footerView() string { - segmentStyle := lipgloss.NewStyle().PaddingLeft(1).PaddingRight(1).Faint(true) - segmentSeparator := "|" - - savingStyle := segmentStyle.Copy().Bold(true) - saving := "" - if m.persistence { - saving = savingStyle.Foreground(lipgloss.Color("2")).Render("✅💾") - } else { - saving = savingStyle.Foreground(lipgloss.Color("1")).Render("❌💾") - } - - status := m.status - if m.waitingForReply { - status += m.spinner.View() - } - - leftSegments := []string{ - saving, - segmentStyle.Render(status), - } - rightSegments := []string{ - segmentStyle.Render(fmt.Sprintf("Model: %s", *m.ctx.Config.Defaults.Model)), - } - - left := strings.Join(leftSegments, segmentSeparator) - right := strings.Join(rightSegments, segmentSeparator) - - totalWidth := lipgloss.Width(left) + lipgloss.Width(right) - remaining := m.width - totalWidth - - var padding string - if remaining > 0 { - padding = strings.Repeat(" ", remaining) - } - - footer := left + padding + right - if remaining < 0 { - footer = truncateToCellWidth(footer, m.width, "...") - } - return footerStyle.Width(m.width).Render(footer) + Render(fmt.Sprintf("%s", err)) } func wrapError(err error) tea.Cmd { @@ -321,7 +194,7 @@ func wrapError(err error) tea.Cmd { } func Launch(ctx *lmcli.Context, convShortname string) error { - p := tea.NewProgram(initialModel(ctx, convShortname), tea.WithAltScreen()) + p := tea.NewProgram(initialModel(ctx, Options{convShortname}), tea.WithAltScreen()) if _, err := p.Run(); err != nil { return fmt.Errorf("Error running program: %v", err) }