diff --git a/pkg/tui/shared/shared.go b/pkg/tui/shared/shared.go index 55338cd..d757d9d 100644 --- a/pkg/tui/shared/shared.go +++ b/pkg/tui/shared/shared.go @@ -9,15 +9,12 @@ import ( type ViewModel interface { Init() tea.Cmd Update(tea.Msg) (ViewModel, tea.Cmd) - View() string - Initialized() bool // Return whether this view is initialized -} -type ViewState struct { - Initialized bool - Width int - Height int - Err error + // View methods + Header(width int) string + // Render the view's main content into a container of the given dimensions + Content(width, height int) string + Footer(width int) string } type View int @@ -29,22 +26,12 @@ const ( //StateHelp ) -// a convenience struct for holding rendered content for indiviudal UI -// elements -type Sections 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 + // sent when a recoverable error occurs (displayed to user) MsgError error // sent when the view has handled a key input MsgKeyHandled tea.KeyMsg diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 9384b3e..16c4c6c 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -7,9 +7,11 @@ import ( "git.mlow.ca/mlow/lmcli/pkg/lmcli" "git.mlow.ca/mlow/lmcli/pkg/tui/model" "git.mlow.ca/mlow/lmcli/pkg/tui/shared" + tuiutil "git.mlow.ca/mlow/lmcli/pkg/tui/util" "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 LaunchOptions struct { @@ -20,13 +22,18 @@ type LaunchOptions struct { type Model struct { App *model.AppModel + // window size + width int + height int + + // errors we will display to the user and allow them to dismiss + errs []error + activeView shared.View views map[shared.View]shared.ViewModel } -func initialModel(ctx *lmcli.Context, opts LaunchOptions) Model { - sharedData := shared.ViewState{} - +func initialModel(ctx *lmcli.Context, opts LaunchOptions) *Model { app := &model.AppModel{ Ctx: ctx, Conversation: opts.InitialConversation, @@ -36,20 +43,24 @@ func initialModel(ctx *lmcli.Context, opts LaunchOptions) Model { App: app, activeView: opts.InitialView, views: map[shared.View]shared.ViewModel{ - shared.ViewChat: chat.Chat(app, sharedData), - shared.ViewConversations: conversations.Conversations(app, sharedData), + shared.ViewChat: chat.Chat(app), + shared.ViewConversations: conversations.Conversations(app), }, } - return m + return &m } -func (m Model) Init() tea.Cmd { - return tea.Batch( +func (m *Model) Init() tea.Cmd { + cmds := []tea.Cmd{ func() tea.Msg { return shared.MsgViewChange(m.activeView) }, - ) + } + for _, v := range m.views { + cmds = append(cmds, v.Init()) + } + return tea.Batch(cmds...) } func (m *Model) handleGlobalInput(msg tea.KeyMsg) tea.Cmd { @@ -66,8 +77,10 @@ func (m *Model) handleGlobalInput(msg tea.KeyMsg) tea.Cmd { return nil } -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height case tea.KeyMsg: cmd := m.handleGlobalInput(msg) if cmd != nil { @@ -75,15 +88,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case shared.MsgViewChange: m.activeView = shared.View(msg) - view := m.views[m.activeView] - - var cmds []tea.Cmd - if !view.Initialized() { - cmds = append(cmds, view.Init()) - } - cmds = append(cmds, tea.WindowSize(), shared.ViewEnter()) - - return m, tea.Batch(cmds...) + return m, tea.Batch(tea.WindowSize(), shared.ViewEnter()) + case shared.MsgError: + m.errs = append(m.errs, msg) } view, cmd := m.views[m.activeView].Update(msg) @@ -91,8 +98,38 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } -func (m Model) View() string { - return m.views[m.activeView].View() +func (m *Model) View() string { + if m.width == 0 || m.height == 0 { + // we're dimensionless! + return "" + } + + header := m.views[m.activeView].Header(m.width) + footer := m.views[m.activeView].Footer(m.width) + fixedUIHeight := tuiutil.Height(header) + tuiutil.Height(footer) + + errBanners := make([]string, len(m.errs)) + for idx, err := range m.errs { + errBanners[idx] = tuiutil.ErrorBanner(err, m.width) + fixedUIHeight += tuiutil.Height(errBanners[idx]) + } + + content := m.views[m.activeView].Content(m.width, m.height-fixedUIHeight) + + sections := make([]string, 0, 4) + if header != "" { + sections = append(sections, header) + } + if content != "" { + sections = append(sections, content) + } + if len(errBanners) > 0 { + sections = append(sections, lipgloss.JoinVertical(lipgloss.Left, errBanners...)) + } + if footer != "" { + sections = append(sections, footer) + } + return lipgloss.JoinVertical(lipgloss.Left, sections...) } type LaunchOption func(*LaunchOptions) diff --git a/pkg/tui/views/chat/chat.go b/pkg/tui/views/chat/chat.go index 2465c4f..980289d 100644 --- a/pkg/tui/views/chat/chat.go +++ b/pkg/tui/views/chat/chat.go @@ -5,7 +5,6 @@ import ( "git.mlow.ca/mlow/lmcli/pkg/api" "git.mlow.ca/mlow/lmcli/pkg/tui/model" - "git.mlow.ca/mlow/lmcli/pkg/tui/shared" "github.com/charmbracelet/bubbles/cursor" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textarea" @@ -72,11 +71,10 @@ const ( ) type Model struct { - *shared.ViewState - shared.Sections - // App state App *model.AppModel + Height int + Width int // Chat view state state state // current overall status of the view @@ -106,14 +104,9 @@ type Model struct { elapsed time.Duration } -func (m Model) Initialized() bool { - return m.ViewState.Initialized -} - -func Chat(app *model.AppModel, shared shared.ViewState) shared.ViewModel { +func Chat(app *model.AppModel) *Model { m := Model{ App: app, - ViewState: &shared, state: idle, persistence: true, @@ -166,12 +159,10 @@ func Chat(app *model.AppModel, shared shared.ViewState) shared.ViewModel { m.input.FocusedStyle.CursorLine = lipgloss.NewStyle() m.input.FocusedStyle.Base = inputFocusedStyle m.input.BlurredStyle.Base = inputBlurredStyle - - return m + return &m } -func (m Model) Init() tea.Cmd { - m.ViewState.Initialized = true +func (m *Model) Init() tea.Cmd { return tea.Batch( m.waitForResponseChunk(), ) diff --git a/pkg/tui/views/chat/update.go b/pkg/tui/views/chat/update.go index fb87285..abacaf3 100644 --- a/pkg/tui/views/chat/update.go +++ b/pkg/tui/views/chat/update.go @@ -47,7 +47,7 @@ func (m *Model) updateContent() { } } -func (m Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) { +func (m *Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) { inputHandled := false var cmds []tea.Cmd @@ -169,8 +169,8 @@ func (m Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) { m.updateContent() case msgChatResponseError: m.state = idle - m.ViewState.Err = error(msg) m.updateContent() + return m, shared.WrapError(msg) case msgToolResults: last := len(m.App.Messages) - 1 if last < 0 { @@ -249,24 +249,6 @@ func (m Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) { } } - // update views once window dimensions are known - if m.Width > 0 { - m.Header = m.headerView() - m.Footer = m.footerView() - m.Error = tuiutil.ErrorBanner(m.Err, m.Width) - fixedHeight := tuiutil.Height(m.Header) + tuiutil.Height(m.Error) + tuiutil.Height(m.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.Input = m.input.View() - - // remaining height towards content - m.content.Height = m.Height - fixedHeight - tuiutil.Height(m.Input) - m.Content = m.content.View() - } - // this is a pretty nasty hack to ensure the input area viewport doesn't // scroll below its content, which can happen when the input viewport // height has grown, or previously entered lines have been deleted diff --git a/pkg/tui/views/chat/view.go b/pkg/tui/views/chat/view.go index ba84a87..222ee57 100644 --- a/pkg/tui/views/chat/view.go +++ b/pkg/tui/views/chat/view.go @@ -39,29 +39,6 @@ var ( footerStyle = lipgloss.NewStyle() ) -func (m Model) View() string { - if m.Width == 0 { - return "" - } - sections := make([]string, 0, 6) - - if m.Header != "" { - sections = append(sections, m.Header) - } - - sections = append(sections, m.Content) - if m.Error != "" { - sections = append(sections, m.Error) - } - sections = append(sections, m.Input) - - if m.Footer != "" { - sections = append(sections, m.Footer) - } - - return lipgloss.JoinVertical(lipgloss.Left, sections...) -} - func (m *Model) renderMessageHeading(i int, message *api.Message) string { icon := "" friendly := message.Role.FriendlyRole() @@ -254,7 +231,20 @@ func (m *Model) conversationMessagesView() string { return sb.String() } -func (m *Model) headerView() string { +func (m *Model) Content(width, height int) string { + // calculate clamped input height to accomodate input text + // minimum 4 lines, maximum half of content area + inputHeight := max(4, min(height/2, m.input.LineCount())) + m.input.SetHeight(inputHeight) + input := m.input.View() + + // remaining height towards content + m.content.Width, m.content.Height = width, height - tuiutil.Height(input) + content := m.content.View() + return lipgloss.JoinVertical(lipgloss.Left, content, input) +} + +func (m *Model) Header(width int) string { titleStyle := lipgloss.NewStyle().Bold(true) var title string if m.App.Conversation != nil && m.App.Conversation.Title != "" { @@ -262,12 +252,12 @@ func (m *Model) headerView() string { } else { title = "Untitled" } - title = tuiutil.TruncateToCellWidth(title, m.Width-styles.Header.GetHorizontalPadding(), "...") + title = tuiutil.TruncateToCellWidth(title, width-styles.Header.GetHorizontalPadding(), "...") header := titleStyle.Render(title) - return styles.Header.Width(m.Width).Render(header) + return styles.Header.Width(width).Render(header) } -func (m *Model) footerView() string { +func (m *Model) Footer(width int) string { segmentStyle := lipgloss.NewStyle().PaddingLeft(1).PaddingRight(1).Faint(true) segmentSeparator := "|" @@ -305,7 +295,7 @@ func (m *Model) footerView() string { right := strings.Join(rightSegments, segmentSeparator) totalWidth := lipgloss.Width(left) + lipgloss.Width(right) - remaining := m.Width - totalWidth + remaining := width - totalWidth var padding string if remaining > 0 { @@ -314,7 +304,7 @@ func (m *Model) footerView() string { footer := left + padding + right if remaining < 0 { - footer = tuiutil.TruncateToCellWidth(footer, m.Width, "...") + footer = tuiutil.TruncateToCellWidth(footer, width, "...") } - return footerStyle.Width(m.Width).Render(footer) + return footerStyle.Width(width).Render(footer) } diff --git a/pkg/tui/views/conversations/conversations.go b/pkg/tui/views/conversations/conversations.go index 146be52..78c5836 100644 --- a/pkg/tui/views/conversations/conversations.go +++ b/pkg/tui/views/conversations/conversations.go @@ -27,29 +27,24 @@ type ( ) type Model struct { - *shared.ViewState - shared.Sections - - App *model.AppModel - cursor int + App *model.AppModel + width int + height int + cursor int itemOffsets []int // conversation y offsets content viewport.Model confirmPrompt bubbles.ConfirmPrompt } -func Conversations(app *model.AppModel, shared shared.ViewState) Model { +func Conversations(app *model.AppModel) *Model { + viewport.New(0, 0) m := Model{ - App: app, - ViewState: &shared, - content: viewport.New(0, 0), + App: app, + content: viewport.New(0, 0), } - return m -} - -func (m Model) Initialized() bool { - return m.ViewState.Initialized + return &m } func (m *Model) handleInput(msg tea.KeyMsg) tea.Cmd { @@ -129,12 +124,11 @@ func (m *Model) handleInput(msg tea.KeyMsg) tea.Cmd { return nil } -func (m Model) Init() tea.Cmd { - m.ViewState.Initialized = true - return m.loadConversations() +func (m *Model) Init() tea.Cmd { + return nil } -func (m Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) { +func (m *Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) { isInput := false inputHandled := false @@ -144,15 +138,14 @@ func (m Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) { isInput = true cmd := m.handleInput(msg) if cmd != nil { - inputHandled = true cmds = append(cmds, cmd) + inputHandled = true } case shared.MsgViewEnter: cmds = append(cmds, m.loadConversations()) m.content.SetContent(m.renderConversationList()) case tea.WindowSizeMsg: - m.Width, m.Height = msg.Width, msg.Height - m.content.Width = msg.Width + m.width, m.height = msg.Width, msg.Height m.content.SetContent(m.renderConversationList()) case msgConversationsLoaded: m.App.Conversations = msg @@ -171,26 +164,13 @@ func (m Model) Update(msg tea.Msg) (shared.ViewModel, tea.Cmd) { } if !isInput || !inputHandled { - var cmd tea.Cmd - m.content, cmd = m.content.Update(msg) + content, cmd := m.content.Update(msg) + m.content = content if cmd != nil { cmds = append(cmds, cmd) } } - if m.Width > 0 { - wrap := lipgloss.NewStyle().Width(m.Width) - m.Header = m.headerView() - m.Footer = "" // TODO: "Press ? for help" - if m.confirmPrompt.Focused() { - m.Footer = wrap.Render(m.confirmPrompt.View()) - } - m.Error = tuiutil.ErrorBanner(m.Err, m.Width) - fixedHeight := tuiutil.Height(m.Header) + tuiutil.Height(m.Error) + tuiutil.Height(m.Footer) - m.content.Height = m.Height - fixedHeight - m.Content = m.content.View() - } - if len(cmds) > 0 { return m, tea.Batch(cmds...) } @@ -218,32 +198,22 @@ func (m *Model) deleteConversation(conv api.Conversation) tea.Cmd { } } -func (m Model) View() string { - if m.Width == 0 { - return "" - } - sections := make([]string, 0, 6) - - if m.Header != "" { - sections = append(sections, m.Header) - } - - sections = append(sections, m.Content) - if m.Error != "" { - sections = append(sections, m.Error) - } - - if m.Footer != "" { - sections = append(sections, m.Footer) - } - - return lipgloss.JoinVertical(lipgloss.Left, sections...) -} - -func (m *Model) headerView() string { +func (m *Model) Header(width int) string { titleStyle := lipgloss.NewStyle().Bold(true) header := titleStyle.Render("Conversations") - return styles.Header.Width(m.Width).Render(header) + return styles.Header.Width(width).Render(header) +} + +func (m *Model) Content(width int, height int) string { + m.content.Width, m.content.Height = width, height + return m.content.View() +} + +func (m *Model) Footer(width int) string { + if m.confirmPrompt.Focused() { + return lipgloss.NewStyle().Width(width).Render(m.confirmPrompt.View()) + } + return "" } func (m *Model) renderConversationList() string { @@ -321,7 +291,7 @@ func (m *Model) 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:] }