2024-06-02 16:40:46 -06:00
|
|
|
package chat
|
|
|
|
|
|
|
|
import (
|
|
|
|
"encoding/json"
|
|
|
|
"fmt"
|
|
|
|
"strings"
|
|
|
|
|
2024-06-12 02:35:07 -06:00
|
|
|
"git.mlow.ca/mlow/lmcli/pkg/api"
|
2024-06-02 16:40:46 -06:00
|
|
|
"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"
|
2024-06-22 22:00:55 -06:00
|
|
|
"gopkg.in/yaml.v3"
|
2024-06-02 16:40:46 -06:00
|
|
|
)
|
|
|
|
|
|
|
|
// 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()
|
|
|
|
)
|
|
|
|
|
2024-06-12 02:35:07 -06:00
|
|
|
func (m *Model) renderMessageHeading(i int, message *api.Message) string {
|
2024-06-02 16:40:46 -06:00
|
|
|
icon := ""
|
|
|
|
friendly := message.Role.FriendlyRole()
|
|
|
|
style := lipgloss.NewStyle().Faint(true).Bold(true)
|
|
|
|
|
|
|
|
switch message.Role {
|
2024-06-12 02:35:07 -06:00
|
|
|
case api.MessageRoleSystem:
|
2024-06-02 16:40:46 -06:00
|
|
|
icon = "⚙️"
|
2024-06-12 02:35:07 -06:00
|
|
|
case api.MessageRoleUser:
|
2024-06-02 16:40:46 -06:00
|
|
|
style = userStyle
|
2024-06-12 02:35:07 -06:00
|
|
|
case api.MessageRoleAssistant:
|
2024-06-02 16:40:46 -06:00
|
|
|
style = assistantStyle
|
2024-06-12 02:35:07 -06:00
|
|
|
case api.MessageRoleToolCall:
|
2024-06-02 16:40:46 -06:00
|
|
|
style = assistantStyle
|
2024-06-12 02:35:07 -06:00
|
|
|
friendly = api.MessageRoleAssistant.FriendlyRole()
|
|
|
|
case api.MessageRoleToolResult:
|
2024-06-02 16:40:46 -06:00
|
|
|
icon = "🔧"
|
|
|
|
}
|
|
|
|
|
|
|
|
user := style.Render(icon + friendly)
|
|
|
|
|
|
|
|
var prefix string
|
|
|
|
var suffix string
|
|
|
|
|
|
|
|
faint := lipgloss.NewStyle().Faint(true)
|
|
|
|
|
2024-09-15 18:48:45 -06:00
|
|
|
if i == 0 && len(m.App.RootMessages) > 1 && m.App.Conversation.SelectedRootID != nil {
|
2024-06-02 16:40:46 -06:00
|
|
|
selectedRootIndex := 0
|
2024-09-15 18:48:45 -06:00
|
|
|
for j, reply := range m.App.RootMessages {
|
|
|
|
if reply.ID == *m.App.Conversation.SelectedRootID {
|
2024-06-02 16:40:46 -06:00
|
|
|
selectedRootIndex = j
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
2024-09-15 18:48:45 -06:00
|
|
|
suffix += faint.Render(fmt.Sprintf(" <%d/%d>", selectedRootIndex+1, len(m.App.RootMessages)))
|
2024-06-02 16:40:46 -06:00
|
|
|
}
|
2024-09-15 18:48:45 -06:00
|
|
|
if i > 0 && len(m.App.Messages[i-1].Replies) > 1 {
|
2024-06-02 16:40:46 -06:00
|
|
|
// Find the selected reply index
|
|
|
|
selectedReplyIndex := 0
|
2024-09-15 18:48:45 -06:00
|
|
|
for j, reply := range m.App.Messages[i-1].Replies {
|
|
|
|
if reply.ID == *m.App.Messages[i-1].SelectedReplyID {
|
2024-06-02 16:40:46 -06:00
|
|
|
selectedReplyIndex = j
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
2024-09-15 18:48:45 -06:00
|
|
|
suffix += faint.Render(fmt.Sprintf(" <%d/%d>", selectedReplyIndex+1, len(m.App.Messages[i-1].Replies)))
|
2024-06-02 16:40:46 -06:00
|
|
|
}
|
|
|
|
|
2024-07-09 19:21:06 -06:00
|
|
|
if i == m.selectedMessage {
|
|
|
|
prefix = "> "
|
|
|
|
} else {
|
|
|
|
prefix = " "
|
2024-06-02 16:40:46 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
if message.ID == 0 {
|
|
|
|
suffix += faint.Render(" (not saved)")
|
|
|
|
}
|
|
|
|
|
|
|
|
return messageHeadingStyle.Render(prefix + user + suffix)
|
|
|
|
}
|
|
|
|
|
2024-06-20 23:52:59 -06:00
|
|
|
// 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).
|
2024-06-02 16:40:46 -06:00
|
|
|
func (m *Model) renderMessage(i int) string {
|
2024-09-15 18:48:45 -06:00
|
|
|
msg := &m.App.Messages[i]
|
2024-06-02 16:40:46 -06:00
|
|
|
|
|
|
|
// Write message contents
|
|
|
|
sb := &strings.Builder{}
|
|
|
|
sb.Grow(len(msg.Content) * 2)
|
|
|
|
if msg.Content != "" {
|
2024-09-15 18:48:45 -06:00
|
|
|
err := m.App.Ctx.Chroma.Highlight(sb, msg.Content)
|
2024-06-02 16:40:46 -06:00
|
|
|
if err != nil {
|
|
|
|
sb.Reset()
|
|
|
|
sb.WriteString(msg.Content)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-09-15 18:48:45 -06:00
|
|
|
isLast := i == len(m.App.Messages)-1
|
2024-06-20 23:52:59 -06:00
|
|
|
isAssistant := msg.Role == api.MessageRoleAssistant
|
|
|
|
|
|
|
|
if m.state == pendingResponse && isLast && isAssistant {
|
|
|
|
// Show the assistant's cursor
|
2024-06-02 16:40:46 -06:00
|
|
|
sb.WriteString(m.replyCursor.View())
|
|
|
|
}
|
|
|
|
|
|
|
|
// Write tool call info
|
|
|
|
var toolString string
|
|
|
|
switch msg.Role {
|
2024-06-12 02:35:07 -06:00
|
|
|
case api.MessageRoleToolCall:
|
2024-06-02 16:40:46 -06:00
|
|
|
bytes, err := yaml.Marshal(msg.ToolCalls)
|
|
|
|
if err != nil {
|
|
|
|
toolString = "Could not serialize ToolCalls"
|
|
|
|
} else {
|
|
|
|
toolString = "tool_calls:\n" + string(bytes)
|
|
|
|
}
|
2024-06-12 02:35:07 -06:00
|
|
|
case api.MessageRoleToolResult:
|
2024-06-02 16:40:46 -06:00
|
|
|
type renderedResult struct {
|
|
|
|
ToolName string `yaml:"tool"`
|
2024-06-21 00:05:00 -06:00
|
|
|
Result any `yaml:"result,omitempty"`
|
2024-06-02 16:40:46 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
var toolResults []renderedResult
|
|
|
|
for _, result := range msg.ToolResults {
|
2024-06-21 00:05:00 -06:00
|
|
|
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,
|
|
|
|
})
|
|
|
|
}
|
2024-06-02 16:40:46 -06:00
|
|
|
} else {
|
2024-06-21 00:05:00 -06:00
|
|
|
// Only show the tool name when results are hidden
|
2024-06-02 16:40:46 -06:00
|
|
|
toolResults = append(toolResults, renderedResult{
|
|
|
|
ToolName: result.ToolName,
|
2024-07-09 19:21:06 -06:00
|
|
|
Result: "(hidden, press ctrl+t to view)",
|
2024-06-02 16:40:46 -06:00
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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")
|
|
|
|
}
|
2024-09-15 18:48:45 -06:00
|
|
|
_ = m.App.Ctx.Chroma.HighlightLang(sb, toolString, "yaml")
|
2024-06-02 16:40:46 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
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{}
|
|
|
|
|
2024-09-15 18:48:45 -06:00
|
|
|
m.messageOffsets = make([]int, len(m.App.Messages))
|
2024-06-02 16:40:46 -06:00
|
|
|
lineCnt := 1
|
2024-09-15 18:48:45 -06:00
|
|
|
for i, message := range m.App.Messages {
|
2024-06-02 16:40:46 -06:00
|
|
|
m.messageOffsets[i] = lineCnt
|
|
|
|
|
|
|
|
heading := m.renderMessageHeading(i, &message)
|
|
|
|
sb.WriteString(heading)
|
|
|
|
sb.WriteString("\n")
|
|
|
|
lineCnt += lipgloss.Height(heading)
|
|
|
|
|
2024-06-20 23:52:59 -06:00
|
|
|
rendered := m.messageCache[i]
|
2024-06-02 16:40:46 -06:00
|
|
|
sb.WriteString(rendered)
|
|
|
|
sb.WriteString("\n")
|
|
|
|
lineCnt += lipgloss.Height(rendered)
|
|
|
|
}
|
|
|
|
|
2024-06-08 15:28:29 -06:00
|
|
|
// Render a placeholder for the incoming assistant reply
|
2024-09-15 18:48:45 -06:00
|
|
|
if m.state == pendingResponse && m.App.Messages[len(m.App.Messages)-1].Role != api.MessageRoleAssistant {
|
2024-06-12 02:35:07 -06:00
|
|
|
heading := m.renderMessageHeading(-1, &api.Message{
|
|
|
|
Role: api.MessageRoleAssistant,
|
2024-06-08 15:28:29 -06:00
|
|
|
})
|
|
|
|
sb.WriteString(heading)
|
|
|
|
sb.WriteString("\n")
|
|
|
|
sb.WriteString(messageStyle.Width(0).Render(m.replyCursor.View()))
|
|
|
|
sb.WriteString("\n")
|
|
|
|
}
|
|
|
|
|
2024-06-02 16:40:46 -06:00
|
|
|
return sb.String()
|
|
|
|
}
|
|
|
|
|
2024-09-22 17:34:53 -06:00
|
|
|
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
|
2024-09-26 12:31:04 -06:00
|
|
|
m.content.Width, m.content.Height = width, height-tuiutil.Height(input)
|
2024-09-22 17:34:53 -06:00
|
|
|
content := m.content.View()
|
|
|
|
return lipgloss.JoinVertical(lipgloss.Left, content, input)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m *Model) Header(width int) string {
|
2024-06-02 16:40:46 -06:00
|
|
|
titleStyle := lipgloss.NewStyle().Bold(true)
|
|
|
|
var title string
|
2024-09-15 18:48:45 -06:00
|
|
|
if m.App.Conversation != nil && m.App.Conversation.Title != "" {
|
|
|
|
title = m.App.Conversation.Title
|
2024-06-02 16:40:46 -06:00
|
|
|
} else {
|
|
|
|
title = "Untitled"
|
|
|
|
}
|
2024-09-22 17:34:53 -06:00
|
|
|
title = tuiutil.TruncateToCellWidth(title, width-styles.Header.GetHorizontalPadding(), "...")
|
2024-06-02 16:40:46 -06:00
|
|
|
header := titleStyle.Render(title)
|
2024-09-22 17:34:53 -06:00
|
|
|
return styles.Header.Width(width).Render(header)
|
2024-06-02 16:40:46 -06:00
|
|
|
}
|
|
|
|
|
2024-09-22 17:34:53 -06:00
|
|
|
func (m *Model) Footer(width int) string {
|
2024-06-02 16:40:46 -06:00
|
|
|
segmentStyle := lipgloss.NewStyle().PaddingLeft(1).PaddingRight(1).Faint(true)
|
|
|
|
segmentSeparator := "|"
|
|
|
|
|
2024-09-26 12:31:04 -06:00
|
|
|
// Left segments
|
|
|
|
|
|
|
|
leftSegments := []string{}
|
|
|
|
|
2024-09-20 20:46:51 -06:00
|
|
|
savingStyle := segmentStyle.Bold(true)
|
2024-06-02 16:40:46 -06:00
|
|
|
saving := ""
|
|
|
|
if m.persistence {
|
|
|
|
saving = savingStyle.Foreground(lipgloss.Color("2")).Render("✅💾")
|
|
|
|
} else {
|
|
|
|
saving = savingStyle.Foreground(lipgloss.Color("1")).Render("❌💾")
|
|
|
|
}
|
2024-09-26 12:31:04 -06:00
|
|
|
leftSegments = append(leftSegments, saving)
|
2024-06-02 16:40:46 -06:00
|
|
|
|
2024-09-26 12:31:04 -06:00
|
|
|
// Right segments
|
2024-06-02 16:40:46 -06:00
|
|
|
|
|
|
|
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))
|
|
|
|
}
|
2024-09-26 12:31:04 -06:00
|
|
|
model := segmentStyle.Render(m.App.ActiveModel(lipgloss.NewStyle()))
|
|
|
|
rightSegments = append(rightSegments, model)
|
|
|
|
|
|
|
|
// Status
|
2024-06-02 16:40:46 -06:00
|
|
|
|
2024-09-26 12:31:04 -06:00
|
|
|
var status string
|
|
|
|
switch m.state {
|
|
|
|
case pendingResponse:
|
|
|
|
status = "Press ctrl+c to cancel" + m.spinner.View()
|
|
|
|
default:
|
|
|
|
status = "Press ctrl+s to send"
|
2024-09-25 09:49:45 -06:00
|
|
|
}
|
|
|
|
|
2024-09-26 12:31:04 -06:00
|
|
|
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()
|
2024-06-02 16:40:46 -06:00
|
|
|
|
|
|
|
left := strings.Join(leftSegments, segmentSeparator)
|
|
|
|
right := strings.Join(rightSegments, segmentSeparator)
|
|
|
|
|
2024-09-26 12:31:04 -06:00
|
|
|
leftWidth := tuiutil.Width(left)
|
|
|
|
rightWidth := tuiutil.Width(right)
|
|
|
|
|
|
|
|
availableWidth := width - leftWidth - rightWidth - tuiutil.Width(segmentSeparator)
|
2024-06-02 16:40:46 -06:00
|
|
|
|
2024-09-26 12:31:04 -06:00
|
|
|
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)
|
2024-06-02 16:40:46 -06:00
|
|
|
}
|
|
|
|
|
2024-09-26 12:31:04 -06:00
|
|
|
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)
|
2024-06-02 16:40:46 -06:00
|
|
|
}
|
2024-09-26 12:31:04 -06:00
|
|
|
|
|
|
|
// Trucate right segment so it fits
|
|
|
|
right = truncate(right, tuiutil.Width(right)+availableWidth-1)
|
|
|
|
padding := ""
|
|
|
|
return footerStyle.Width(width).Render(left + segmentSeparator + padding + right)
|
2024-06-02 16:40:46 -06:00
|
|
|
}
|