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 { m.messageOffsets = make([]int, len(m.App.Messages)) lineCnt := 1 sb := strings.Builder{} 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 := "|" // 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) }