Matt Low
676aa7b004
Improves render handling by moving the responsibility of laying out the whole UI from each view and into the main `tui` model. Our `ViewModel` interface has now diverged from bubbletea's `Model` and introduces individual `Header`, `Content`, and `Footer` methods for rendering those UI elements. Also moved away from using value receivers on our Update and View functions (as is common across Bubbletea) to pointer receivers, which cleaned up some of the weirder aspects of the code (e.g. before we essentially had no choice but to do our rendering in `Update` in order to calculate and update the final height of the main content's `viewport`).
311 lines
8.4 KiB
Go
311 lines
8.4 KiB
Go
package chat
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"git.mlow.ca/mlow/lmcli/pkg/api"
|
|
"git.mlow.ca/mlow/lmcli/pkg/tui/styles"
|
|
tuiutil "git.mlow.ca/mlow/lmcli/pkg/tui/util"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/muesli/reflow/wordwrap"
|
|
"github.com/muesli/reflow/wrap"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// styles
|
|
var (
|
|
messageHeadingStyle = lipgloss.NewStyle().
|
|
MarginTop(1).
|
|
MarginBottom(1).
|
|
Bold(true)
|
|
|
|
userStyle = lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("10"))
|
|
|
|
assistantStyle = lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("12"))
|
|
|
|
messageStyle = lipgloss.NewStyle().
|
|
PaddingLeft(2).
|
|
PaddingRight(2)
|
|
|
|
inputFocusedStyle = lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder(), true, true, true, false)
|
|
|
|
inputBlurredStyle = lipgloss.NewStyle().
|
|
Faint(true).
|
|
Border(lipgloss.RoundedBorder(), true, true, true, false)
|
|
|
|
footerStyle = lipgloss.NewStyle()
|
|
)
|
|
|
|
func (m *Model) renderMessageHeading(i int, message *api.Message) string {
|
|
icon := ""
|
|
friendly := message.Role.FriendlyRole()
|
|
style := lipgloss.NewStyle().Faint(true).Bold(true)
|
|
|
|
switch message.Role {
|
|
case api.MessageRoleSystem:
|
|
icon = "⚙️"
|
|
case api.MessageRoleUser:
|
|
style = userStyle
|
|
case api.MessageRoleAssistant:
|
|
style = assistantStyle
|
|
case api.MessageRoleToolCall:
|
|
style = assistantStyle
|
|
friendly = api.MessageRoleAssistant.FriendlyRole()
|
|
case api.MessageRoleToolResult:
|
|
icon = "🔧"
|
|
}
|
|
|
|
user := style.Render(icon + friendly)
|
|
|
|
var prefix string
|
|
var suffix string
|
|
|
|
faint := lipgloss.NewStyle().Faint(true)
|
|
|
|
if i == 0 && len(m.App.RootMessages) > 1 && m.App.Conversation.SelectedRootID != nil {
|
|
selectedRootIndex := 0
|
|
for j, reply := range m.App.RootMessages {
|
|
if reply.ID == *m.App.Conversation.SelectedRootID {
|
|
selectedRootIndex = j
|
|
break
|
|
}
|
|
}
|
|
suffix += faint.Render(fmt.Sprintf(" <%d/%d>", selectedRootIndex+1, len(m.App.RootMessages)))
|
|
}
|
|
if i > 0 && len(m.App.Messages[i-1].Replies) > 1 {
|
|
// Find the selected reply index
|
|
selectedReplyIndex := 0
|
|
for j, reply := range m.App.Messages[i-1].Replies {
|
|
if reply.ID == *m.App.Messages[i-1].SelectedReplyID {
|
|
selectedReplyIndex = j
|
|
break
|
|
}
|
|
}
|
|
suffix += faint.Render(fmt.Sprintf(" <%d/%d>", selectedReplyIndex+1, len(m.App.Messages[i-1].Replies)))
|
|
}
|
|
|
|
if i == m.selectedMessage {
|
|
prefix = "> "
|
|
} else {
|
|
prefix = " "
|
|
}
|
|
|
|
if message.ID == 0 {
|
|
suffix += faint.Render(" (not saved)")
|
|
}
|
|
|
|
return messageHeadingStyle.Render(prefix + user + suffix)
|
|
}
|
|
|
|
// renderMessages renders the message at the given index as it should be shown
|
|
// *at this moment* - we render differently depending on the current application
|
|
// state (window size, etc, etc).
|
|
func (m *Model) renderMessage(i int) string {
|
|
msg := &m.App.Messages[i]
|
|
|
|
// Write message contents
|
|
sb := &strings.Builder{}
|
|
sb.Grow(len(msg.Content) * 2)
|
|
if msg.Content != "" {
|
|
err := m.App.Ctx.Chroma.Highlight(sb, msg.Content)
|
|
if err != nil {
|
|
sb.Reset()
|
|
sb.WriteString(msg.Content)
|
|
}
|
|
}
|
|
|
|
isLast := i == len(m.App.Messages)-1
|
|
isAssistant := msg.Role == api.MessageRoleAssistant
|
|
|
|
if m.state == pendingResponse && isLast && isAssistant {
|
|
// Show the assistant's cursor
|
|
sb.WriteString(m.replyCursor.View())
|
|
}
|
|
|
|
// Write tool call info
|
|
var toolString string
|
|
switch msg.Role {
|
|
case api.MessageRoleToolCall:
|
|
bytes, err := yaml.Marshal(msg.ToolCalls)
|
|
if err != nil {
|
|
toolString = "Could not serialize ToolCalls"
|
|
} else {
|
|
toolString = "tool_calls:\n" + string(bytes)
|
|
}
|
|
case api.MessageRoleToolResult:
|
|
type renderedResult struct {
|
|
ToolName string `yaml:"tool"`
|
|
Result any `yaml:"result,omitempty"`
|
|
}
|
|
|
|
var toolResults []renderedResult
|
|
for _, result := range msg.ToolResults {
|
|
if m.showToolResults {
|
|
var jsonResult interface{}
|
|
err := json.Unmarshal([]byte(result.Result), &jsonResult)
|
|
if err != nil {
|
|
// If parsing as JSON fails, treat Result as a plain string
|
|
toolResults = append(toolResults, renderedResult{
|
|
ToolName: result.ToolName,
|
|
Result: result.Result,
|
|
})
|
|
} else {
|
|
// If parsing as JSON succeeds, marshal the parsed JSON into YAML
|
|
toolResults = append(toolResults, renderedResult{
|
|
ToolName: result.ToolName,
|
|
Result: &jsonResult,
|
|
})
|
|
}
|
|
} else {
|
|
// Only show the tool name when results are hidden
|
|
toolResults = append(toolResults, renderedResult{
|
|
ToolName: result.ToolName,
|
|
Result: "(hidden, press ctrl+t to view)",
|
|
})
|
|
}
|
|
}
|
|
|
|
bytes, err := yaml.Marshal(toolResults)
|
|
if err != nil {
|
|
toolString = "Could not serialize ToolResults"
|
|
} else {
|
|
toolString = "tool_results:\n" + string(bytes)
|
|
}
|
|
}
|
|
|
|
if toolString != "" {
|
|
toolString = strings.TrimRight(toolString, "\n")
|
|
if msg.Content != "" {
|
|
sb.WriteString("\n\n")
|
|
}
|
|
_ = m.App.Ctx.Chroma.HighlightLang(sb, toolString, "yaml")
|
|
}
|
|
|
|
content := strings.TrimRight(sb.String(), "\n")
|
|
|
|
if m.wrap {
|
|
wrapWidth := m.content.Width - messageStyle.GetHorizontalPadding()
|
|
// first we word-wrap text to slightly less than desired width (since
|
|
// wordwrap seems to have an off-by-1 issue), then hard wrap at
|
|
// desired with
|
|
content = wrap.String(wordwrap.String(content, wrapWidth-2), wrapWidth)
|
|
}
|
|
|
|
return messageStyle.Width(0).Render(content)
|
|
}
|
|
|
|
// render the conversation into a string
|
|
func (m *Model) conversationMessagesView() string {
|
|
sb := strings.Builder{}
|
|
|
|
m.messageOffsets = make([]int, len(m.App.Messages))
|
|
lineCnt := 1
|
|
for i, message := range m.App.Messages {
|
|
m.messageOffsets[i] = lineCnt
|
|
|
|
heading := m.renderMessageHeading(i, &message)
|
|
sb.WriteString(heading)
|
|
sb.WriteString("\n")
|
|
lineCnt += lipgloss.Height(heading)
|
|
|
|
rendered := m.messageCache[i]
|
|
sb.WriteString(rendered)
|
|
sb.WriteString("\n")
|
|
lineCnt += lipgloss.Height(rendered)
|
|
}
|
|
|
|
// Render a placeholder for the incoming assistant reply
|
|
if m.state == pendingResponse && m.App.Messages[len(m.App.Messages)-1].Role != api.MessageRoleAssistant {
|
|
heading := m.renderMessageHeading(-1, &api.Message{
|
|
Role: api.MessageRoleAssistant,
|
|
})
|
|
sb.WriteString(heading)
|
|
sb.WriteString("\n")
|
|
sb.WriteString(messageStyle.Width(0).Render(m.replyCursor.View()))
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
return sb.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 != "" {
|
|
title = m.App.Conversation.Title
|
|
} else {
|
|
title = "Untitled"
|
|
}
|
|
title = tuiutil.TruncateToCellWidth(title, width-styles.Header.GetHorizontalPadding(), "...")
|
|
header := titleStyle.Render(title)
|
|
return styles.Header.Width(width).Render(header)
|
|
}
|
|
|
|
func (m *Model) Footer(width int) string {
|
|
segmentStyle := lipgloss.NewStyle().PaddingLeft(1).PaddingRight(1).Faint(true)
|
|
segmentSeparator := "|"
|
|
|
|
savingStyle := segmentStyle.Bold(true)
|
|
saving := ""
|
|
if m.persistence {
|
|
saving = savingStyle.Foreground(lipgloss.Color("2")).Render("✅💾")
|
|
} else {
|
|
saving = savingStyle.Foreground(lipgloss.Color("1")).Render("❌💾")
|
|
}
|
|
|
|
var status string
|
|
switch m.state {
|
|
case pendingResponse:
|
|
status = "Press ctrl+c to cancel" + m.spinner.View()
|
|
default:
|
|
status = "Press ctrl+s to send"
|
|
}
|
|
|
|
leftSegments := []string{
|
|
saving,
|
|
segmentStyle.Render(status),
|
|
}
|
|
rightSegments := []string{}
|
|
|
|
if m.elapsed > 0 && m.tokenCount > 0 {
|
|
throughput := fmt.Sprintf("%.0f t/sec", float64(m.tokenCount)/m.elapsed.Seconds())
|
|
rightSegments = append(rightSegments, segmentStyle.Render(throughput))
|
|
}
|
|
|
|
model := fmt.Sprintf("Model: %s", *m.App.Ctx.Config.Defaults.Model)
|
|
rightSegments = append(rightSegments, segmentStyle.Render(model))
|
|
|
|
left := strings.Join(leftSegments, segmentSeparator)
|
|
right := strings.Join(rightSegments, segmentSeparator)
|
|
|
|
totalWidth := lipgloss.Width(left) + lipgloss.Width(right)
|
|
remaining := width - totalWidth
|
|
|
|
var padding string
|
|
if remaining > 0 {
|
|
padding = strings.Repeat(" ", remaining)
|
|
}
|
|
|
|
footer := left + padding + right
|
|
if remaining < 0 {
|
|
footer = tuiutil.TruncateToCellWidth(footer, width, "...")
|
|
}
|
|
return footerStyle.Width(width).Render(footer)
|
|
}
|