diff --git a/pkg/tui/shared/shared.go b/pkg/tui/shared/shared.go index 1909bb6..72a579b 100644 --- a/pkg/tui/shared/shared.go +++ b/pkg/tui/shared/shared.go @@ -12,17 +12,14 @@ type Values struct { 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 { +// a convenience struct for holding rendered content for indiviudal UI +// elements +type Sections struct { Header string Content string Error string diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index e3cc93f..6517521 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -14,7 +14,6 @@ import ( "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" ) // Application model @@ -31,7 +30,6 @@ func initialModel(ctx *lmcli.Context, values shared.Values) Model { State: shared.State{ Ctx: ctx, Values: &values, - Views: &shared.Views{}, }, } @@ -126,36 +124,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 return "" } - sections := make([]string, 0, 6) - - if m.State.Views.Header != "" { - sections = append(sections, m.State.Views.Header) - } switch m.state { case shared.StateConversations: - sections = append(sections, m.State.Views.Content) - if m.State.Views.Error != "" { - sections = append(sections, m.State.Views.Error) - } + return m.conversations.View() 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.State.Views.Input) + return m.chat.View() + default: + return "" } - - if m.State.Views.Footer != "" { - sections = append(sections, m.State.Views.Footer) - } - - return lipgloss.JoinVertical(lipgloss.Left, sections...) } func Launch(ctx *lmcli.Context, convShortname string) error { diff --git a/pkg/tui/views/chat/chat.go b/pkg/tui/views/chat/chat.go index e72055d..2323d7b 100644 --- a/pkg/tui/views/chat/chat.go +++ b/pkg/tui/views/chat/chat.go @@ -57,8 +57,7 @@ type ( type Model struct { shared.State - width int - height int + shared.Sections // app state conversation *models.Conversation @@ -72,10 +71,6 @@ type Model struct { replyChunkChan chan string persistence bool // whether we will save new messages in the conversation - tokenCount uint - startTime time.Time - elapsed time.Duration - // ui state focus focusState wrap bool // whether message content is wrapped to viewport width @@ -84,6 +79,10 @@ type Model struct { messageCache []string // cache of syntax highlighted and wrapped message content messageOffsets []int + tokenCount uint + startTime time.Time + elapsed time.Duration + // ui elements content viewport.Model input textarea.Model @@ -228,7 +227,7 @@ func (m Model) Init() tea.Cmd { } func (m *Model) HandleResize(width, height int) { - m.width, m.height = width, height + m.Width, m.Height = width, height m.content.Width = width m.input.SetWidth(width - m.input.FocusedStyle.Base.GetHorizontalFrameSize()) if len(m.messages) > 0 { @@ -390,21 +389,21 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { } // update views once window dimensions are known - if m.width > 0 { - 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) + 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())) + newHeight := max(4, min((m.Height-fixedHeight-1)/2, m.input.LineCount())) m.input.SetHeight(newHeight) - m.State.Views.Input = m.input.View() + m.Input = m.input.View() // remaining height towards content - m.content.Height = m.height - fixedHeight - tuiutil.Height(m.State.Views.Input) - m.State.Views.Content = m.content.View() + 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 @@ -630,6 +629,29 @@ func (m *Model) HandleInputKey(msg tea.KeyMsg) (bool, tea.Cmd) { return false, nil } +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 *models.Message) string { icon := "" friendly := message.Role.FriendlyRole() @@ -828,9 +850,9 @@ func (m *Model) headerView() string { } else { title = "Untitled" } - title = tuiutil.TruncateToCellWidth(title, m.width-styles.Header.GetHorizontalPadding(), "...") + title = tuiutil.TruncateToCellWidth(title, m.Width-styles.Header.GetHorizontalPadding(), "...") header := titleStyle.Render(title) - return styles.Header.Width(m.width).Render(header) + return styles.Header.Width(m.Width).Render(header) } func (m *Model) footerView() string { @@ -868,7 +890,7 @@ func (m *Model) footerView() string { right := strings.Join(rightSegments, segmentSeparator) totalWidth := lipgloss.Width(left) + lipgloss.Width(right) - remaining := m.width - totalWidth + remaining := m.Width - totalWidth var padding string if remaining > 0 { @@ -877,9 +899,9 @@ func (m *Model) footerView() string { footer := left + padding + right if remaining < 0 { - footer = tuiutil.TruncateToCellWidth(footer, m.width, "...") + footer = tuiutil.TruncateToCellWidth(footer, m.Width, "...") } - return footerStyle.Width(m.width).Render(footer) + return footerStyle.Width(m.Width).Render(footer) } func (m *Model) setMessage(i int, msg models.Message) { diff --git a/pkg/tui/views/conversations/conversations.go b/pkg/tui/views/conversations/conversations.go index 997b323..52885e5 100644 --- a/pkg/tui/views/conversations/conversations.go +++ b/pkg/tui/views/conversations/conversations.go @@ -29,6 +29,7 @@ type ( type Model struct { shared.State + shared.Sections conversations []loadedConversation cursor int // index of the currently selected conversation @@ -129,12 +130,12 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { } 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.Header = m.headerView() + m.Footer = "" // TODO: show /something/ + 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.Views.Content = m.content.View() + m.Content = m.content.View() } return m, tea.Batch(cmds...) } @@ -156,6 +157,28 @@ func (m *Model) loadConversations() 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 { titleStyle := lipgloss.NewStyle().Bold(true) header := titleStyle.Render("Conversations")