Matt Low
bb48bc9abd
Adjusted `ctrl+t` in chat view to toggle `showDetails` which toggles the display of system messages, message metadata (generation model), and tool call details Modified message selection update logic to skip messages that aren't shown
370 lines
10 KiB
Go
370 lines
10 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 (
|
|
boldStyle = lipgloss.NewStyle().Bold(true)
|
|
faintStyle = lipgloss.NewStyle().Faint(true)
|
|
boldFaintStyle = lipgloss.NewStyle().Faint(true).Bold(true)
|
|
|
|
messageHeadingStyle = lipgloss.NewStyle().
|
|
MarginTop(1).
|
|
MarginBottom(1)
|
|
|
|
userStyle = boldFaintStyle.Foreground(lipgloss.Color("10"))
|
|
|
|
assistantStyle = boldFaintStyle.Foreground(lipgloss.Color("12"))
|
|
|
|
systemStyle = boldStyle.Foreground(lipgloss.Color("8"))
|
|
|
|
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 {
|
|
friendly := message.Role.FriendlyRole()
|
|
style := systemStyle
|
|
|
|
switch message.Role {
|
|
case api.MessageRoleUser:
|
|
style = userStyle
|
|
case api.MessageRoleAssistant:
|
|
style = assistantStyle
|
|
case api.MessageRoleToolCall:
|
|
style = assistantStyle
|
|
friendly = api.MessageRoleAssistant.FriendlyRole()
|
|
case api.MessageRoleSystem:
|
|
case api.MessageRoleToolResult:
|
|
}
|
|
|
|
user := style.Render(friendly)
|
|
|
|
var prefix, suffix string
|
|
|
|
if i == m.selectedMessage {
|
|
prefix = "> "
|
|
} else {
|
|
prefix = " "
|
|
}
|
|
|
|
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 += faintStyle.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 += faintStyle.Render(fmt.Sprintf(" <%d/%d>", selectedReplyIndex+1, len(m.App.Messages[i-1].Replies)))
|
|
}
|
|
|
|
if message.ID == 0 {
|
|
suffix += faintStyle.Render(" (not saved)")
|
|
}
|
|
|
|
heading := prefix + user + suffix
|
|
|
|
if message.Metadata.GenerationModel != nil && m.showDetails {
|
|
heading += faintStyle.Render(
|
|
fmt.Sprintf(" | %s", *message.Metadata.GenerationModel),
|
|
)
|
|
}
|
|
|
|
return messageHeadingStyle.Render(heading)
|
|
}
|
|
|
|
// 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.showDetails {
|
|
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 {
|
|
m.messageOffsets = make([]int, len(m.App.Messages))
|
|
lineCnt := 1
|
|
|
|
sb := strings.Builder{}
|
|
for i, message := range m.App.Messages {
|
|
m.messageOffsets[i] = lineCnt
|
|
|
|
if !m.showDetails && message.Role.IsSystem() {
|
|
continue
|
|
}
|
|
|
|
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,
|
|
Metadata: api.MessageMeta {
|
|
GenerationModel: &m.App.Model,
|
|
},
|
|
})
|
|
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 := "|"
|
|
|
|
// Left segments
|
|
|
|
leftSegments := []string{}
|
|
|
|
savingStyle := segmentStyle.Bold(true)
|
|
saving := ""
|
|
if m.persistence {
|
|
saving = savingStyle.Foreground(lipgloss.Color("2")).Render("✅💾")
|
|
} else {
|
|
saving = savingStyle.Foreground(lipgloss.Color("1")).Render("❌💾")
|
|
}
|
|
leftSegments = append(leftSegments, saving)
|
|
|
|
// Right segments
|
|
|
|
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 := segmentStyle.Render(m.App.ActiveModel(lipgloss.NewStyle()))
|
|
rightSegments = append(rightSegments, model)
|
|
|
|
// Status
|
|
|
|
var status string
|
|
switch m.state {
|
|
case pendingResponse:
|
|
status = "Press ctrl+c to cancel" + m.spinner.View()
|
|
default:
|
|
status = "Press ctrl+s to send"
|
|
}
|
|
|
|
return m.layoutFooter(width, leftSegments, status, rightSegments, segmentStyle, segmentSeparator)
|
|
}
|
|
|
|
func (m *Model) layoutFooter(
|
|
width int,
|
|
leftSegments []string,
|
|
status string,
|
|
rightSegments []string,
|
|
segmentStyle lipgloss.Style,
|
|
segmentSeparator string,
|
|
) string {
|
|
truncate := func(s string, w int) string {
|
|
return tuiutil.TruncateToCellWidth(s, w, "...")
|
|
}
|
|
padWidth := segmentStyle.GetHorizontalPadding()
|
|
|
|
left := strings.Join(leftSegments, segmentSeparator)
|
|
right := strings.Join(rightSegments, segmentSeparator)
|
|
|
|
leftWidth := tuiutil.Width(left)
|
|
rightWidth := tuiutil.Width(right)
|
|
|
|
availableWidth := width - leftWidth - rightWidth - tuiutil.Width(segmentSeparator)
|
|
|
|
statusWidth := tuiutil.Width(status)
|
|
if availableWidth >= statusWidth+padWidth {
|
|
// Everything fits
|
|
availableWidth -= statusWidth + padWidth
|
|
padding := ""
|
|
if availableWidth > 0 {
|
|
padding = strings.Repeat(" ", availableWidth)
|
|
}
|
|
return footerStyle.Render(left + segmentSeparator + segmentStyle.Render(status) + padding + right)
|
|
}
|
|
|
|
if availableWidth > 4 {
|
|
// There is some space left for a truncated status
|
|
truncatedStatus := truncate(status, availableWidth-padWidth)
|
|
return footerStyle.Width(width).Render(left + segmentSeparator + segmentStyle.Render(truncatedStatus) + right)
|
|
}
|
|
|
|
if availableWidth >= 0 {
|
|
// Draw some dots...
|
|
dots := ""
|
|
if availableWidth == 1 {
|
|
dots = " "
|
|
} else if availableWidth > 1 {
|
|
dots = " " + strings.Repeat(".", availableWidth-1)
|
|
dots = lipgloss.NewStyle().Faint(true).Render(dots)
|
|
}
|
|
|
|
return footerStyle.Width(width).Render(left + segmentSeparator + dots + right)
|
|
}
|
|
|
|
// Trucate right segment so it fits
|
|
right = truncate(right, tuiutil.Width(right)+availableWidth-1)
|
|
padding := ""
|
|
return footerStyle.Width(width).Render(left + segmentSeparator + padding + right)
|
|
}
|