mirror of
https://github.com/ollama/ollama.git
synced 2026-07-08 08:53:11 +00:00
1864 lines
47 KiB
Go
1864 lines
47 KiB
Go
package chat
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/mattn/go-runewidth"
|
|
|
|
coreagent "github.com/ollama/ollama/agent"
|
|
"github.com/ollama/ollama/api"
|
|
)
|
|
|
|
type chatEntry struct {
|
|
role string
|
|
content string
|
|
label string
|
|
detail string
|
|
status string
|
|
err string
|
|
toolID string
|
|
args map[string]any
|
|
expanded bool
|
|
startedAt time.Time
|
|
finishedAt time.Time
|
|
tools []chatEntry
|
|
metrics *api.Metrics
|
|
|
|
version int
|
|
renderKey chatEntryRenderKey
|
|
renderLines []string
|
|
}
|
|
|
|
type chatEntryRenderKey struct {
|
|
width int
|
|
version int
|
|
hash string
|
|
}
|
|
|
|
func (m chatModel) findToolEntry(toolID string) int {
|
|
if toolID == "" {
|
|
return -1
|
|
}
|
|
for i := len(m.entries) - 1; i >= 0; i-- {
|
|
if m.entries[i].role == "tool" && m.entries[i].toolID == toolID {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func (m chatModel) findActiveToolEntry(toolID string) int {
|
|
idx := m.findToolEntry(toolID)
|
|
if idx < 0 || !isToolActiveStatus(m.entries[idx].status) {
|
|
return -1
|
|
}
|
|
return idx
|
|
}
|
|
|
|
func (m chatModel) toolStartedAt(toolID string) time.Time {
|
|
idx := m.findToolEntry(toolID)
|
|
if idx < 0 {
|
|
return time.Time{}
|
|
}
|
|
return m.entries[idx].startedAt
|
|
}
|
|
|
|
func entryHasExpandableOutput(entry chatEntry) bool {
|
|
return (entry.role == "tool" && (len(entry.args) > 0 || strings.TrimSpace(entry.content) != "")) ||
|
|
(entry.role == "tool_group" && len(entry.tools) > 0) ||
|
|
(entry.role == "compaction_summary" && strings.TrimSpace(entry.content) != "")
|
|
}
|
|
|
|
func entryHasToolOutputMode(entry chatEntry) bool {
|
|
return (entry.role == "tool" && (isToolActiveStatus(entry.status) || isToolResultStatus(entry.status) || entry.content != "")) ||
|
|
(entry.role == "tool_group" && len(entry.tools) > 0) ||
|
|
(entry.role == "compaction_summary" && strings.TrimSpace(entry.content) != "")
|
|
}
|
|
|
|
func (m *chatModel) applyToolOutputMode() {
|
|
if !m.toolOutputMode {
|
|
return
|
|
}
|
|
for i := range m.entries {
|
|
m.applyToolOutputModeTo(i)
|
|
}
|
|
}
|
|
|
|
func (m *chatModel) applyToolOutputModeTo(index int) {
|
|
if !m.toolOutputMode || index < 0 || index >= len(m.entries) {
|
|
return
|
|
}
|
|
if !entryHasToolOutputMode(m.entries[index]) {
|
|
return
|
|
}
|
|
if m.entries[index].expanded == m.toolOutputOpen {
|
|
return
|
|
}
|
|
m.entries[index].expanded = m.toolOutputOpen
|
|
m.markEntryDirty(index)
|
|
}
|
|
|
|
func (m *chatModel) groupCompletedToolHistory() {
|
|
m.entries = groupCompletedToolEntries(m.entries)
|
|
m.applyToolOutputMode()
|
|
}
|
|
|
|
func (m *chatModel) ensureAssistantEntry() int {
|
|
if len(m.entries) > 0 && m.entries[len(m.entries)-1].role == "assistant" {
|
|
return len(m.entries) - 1
|
|
}
|
|
m.entries = append(m.entries, newChatEntry(chatEntry{role: "assistant"}))
|
|
return len(m.entries) - 1
|
|
}
|
|
|
|
func (m chatModel) renderTranscript(width int) string {
|
|
var b strings.Builder
|
|
for index, entry := range m.entries {
|
|
if b.Len() > 0 {
|
|
b.WriteByte('\n')
|
|
}
|
|
prefix, body := m.renderEntry(entry)
|
|
prefixWidth := lipgloss.Width(prefix)
|
|
continuation := ""
|
|
if prefixWidth > 0 {
|
|
continuation = strings.Repeat(" ", prefixWidth)
|
|
}
|
|
for i, line := range m.renderEntryLinesCached(index, entry, body, width-prefixWidth) {
|
|
if i == 0 {
|
|
b.WriteString(prefix)
|
|
b.WriteString(line)
|
|
} else {
|
|
b.WriteString(continuation)
|
|
b.WriteString(line)
|
|
}
|
|
b.WriteByte('\n')
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (m chatModel) renderEntryLinesCached(index int, entry chatEntry, body string, width int) []string {
|
|
key := entryRenderKey(entry, body, width)
|
|
if index >= 0 && index < len(m.entries) {
|
|
cached := m.entries[index]
|
|
if cached.renderKey == key && cached.renderLines != nil {
|
|
return cached.renderLines
|
|
}
|
|
}
|
|
|
|
lines := m.renderEntryLines(entry, body, width)
|
|
if index >= 0 && index < len(m.entries) {
|
|
m.entries[index].renderKey = key
|
|
m.entries[index].renderLines = slices.Clone(lines)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func (m chatModel) transcriptLines(width int) []string {
|
|
transcript := m.renderTranscript(width)
|
|
transcript = strings.TrimRight(transcript, "\n")
|
|
if transcript == "" {
|
|
return nil
|
|
}
|
|
return strings.Split(transcript, "\n")
|
|
}
|
|
|
|
func (m chatModel) visibleTranscriptLinesForLines(lines []string, available int) []string {
|
|
if available <= 0 {
|
|
return nil
|
|
}
|
|
start := 0
|
|
if len(lines) > available {
|
|
start = m.visibleTranscriptStartLineForLines(len(lines), available)
|
|
lines = lines[start : start+available]
|
|
}
|
|
lines = m.applyTranscriptSelection(lines, start)
|
|
return lines
|
|
}
|
|
|
|
func (m chatModel) visibleTranscriptStartLine(width, available int) int {
|
|
return m.visibleTranscriptStartLineForLines(len(m.transcriptLines(width)), available)
|
|
}
|
|
|
|
func (m chatModel) visibleTranscriptStartLineForLines(total, available int) int {
|
|
if total <= available || available <= 0 {
|
|
return 0
|
|
}
|
|
maxScroll := total - available
|
|
scroll := clamp(m.scroll, 0, maxScroll)
|
|
return maxScroll - scroll
|
|
}
|
|
|
|
func (m chatModel) viewWidth() int {
|
|
if m.width > 0 {
|
|
return m.width
|
|
}
|
|
return 80
|
|
}
|
|
|
|
func (m chatModel) transcriptHeight() int {
|
|
width := m.width
|
|
if width <= 0 {
|
|
width = 80
|
|
}
|
|
if !m.boundedFrame {
|
|
return len(m.transcriptLines(width))
|
|
}
|
|
height := m.height
|
|
if height <= 0 {
|
|
height = 24
|
|
}
|
|
lineCount := len(m.transcriptLines(width))
|
|
baseHeaderHeight := 2
|
|
baseMaxHeight := height - baseHeaderHeight
|
|
baseBottomLines := m.bottomLines(width, baseMaxHeight)
|
|
baseHeight := max(0, baseMaxHeight-len(baseBottomLines)-transcriptInputGap(baseMaxHeight, len(baseBottomLines), lineCount))
|
|
if lineCount <= baseHeight {
|
|
return lineCount
|
|
}
|
|
statusHeaderHeight := 3
|
|
statusMaxHeight := height - statusHeaderHeight
|
|
statusBottomLines := m.bottomLines(width, statusMaxHeight)
|
|
return max(0, statusMaxHeight-len(statusBottomLines)-transcriptInputGap(statusMaxHeight, len(statusBottomLines), lineCount))
|
|
}
|
|
|
|
func (m chatModel) transcriptLayout() (top, height int) {
|
|
width := m.width
|
|
if width <= 0 {
|
|
width = 80
|
|
}
|
|
if !m.boundedFrame {
|
|
return 0, len(m.transcriptLines(width))
|
|
}
|
|
viewHeight := m.height
|
|
if viewHeight <= 0 {
|
|
viewHeight = 24
|
|
}
|
|
headerHeight := len(m.headerLines())
|
|
bottomLines := m.bottomLines(width, viewHeight-headerHeight)
|
|
transcriptLineCount := len(m.transcriptLines(width))
|
|
return headerHeight, max(0, viewHeight-headerHeight-len(bottomLines)-transcriptInputGap(viewHeight-headerHeight, len(bottomLines), transcriptLineCount))
|
|
}
|
|
|
|
func (m chatModel) maxScroll() int {
|
|
if !m.boundedFrame {
|
|
return 0
|
|
}
|
|
width := m.width
|
|
if width <= 0 {
|
|
width = 80
|
|
}
|
|
return max(0, len(m.transcriptLines(width))-m.transcriptHeight())
|
|
}
|
|
|
|
func (m chatModel) bottomLines(width, maxHeight int) []string {
|
|
var lines []string
|
|
if m.resumePicker != nil {
|
|
lines = append(lines, m.renderInlineResumePicker(width)...)
|
|
} else if m.modelPicker != nil {
|
|
lines = append(lines, m.renderInlineModelPicker(width)...)
|
|
} else {
|
|
lines = append(lines, m.completionLines(width)...)
|
|
}
|
|
lines = append(lines, m.queuedLines(width)...)
|
|
|
|
actionStatusLines := m.renderActionStatusLines(width)
|
|
approvalLines := m.renderApprovalPromptLines(width)
|
|
if len(actionStatusLines) == 0 {
|
|
actionStatusLines = []string{""}
|
|
}
|
|
if maxHeight > 0 {
|
|
maxApprovalLines := max(0, maxHeight-len(lines)-len(actionStatusLines)-3)
|
|
if len(approvalLines) > maxApprovalLines {
|
|
approvalLines = approvalLines[:maxApprovalLines]
|
|
}
|
|
maxActionStatusLines := max(1, maxHeight-len(lines)-len(approvalLines)-3)
|
|
if len(actionStatusLines) > maxActionStatusLines {
|
|
actionStatusLines = actionStatusLines[:maxActionStatusLines]
|
|
}
|
|
}
|
|
|
|
lines = append(lines, actionStatusLines...)
|
|
lines = append(lines, approvalLines...)
|
|
modelLines := m.renderModelStatusLines(width)
|
|
fixedLines := len(lines) + len(modelLines) + 2
|
|
inputBodyLines := maxInputBoxBodyLines
|
|
if maxHeight > 0 {
|
|
inputBodyLines = min(inputBodyLines, max(1, maxHeight-fixedLines))
|
|
}
|
|
inputCursor := m.normalizedInputCursor()
|
|
if m.approvalPrompt != nil || m.cloudAuthPrompt != nil {
|
|
inputCursor = -1
|
|
}
|
|
lines = append(lines, renderInputBoxLines(string(m.input), inputCursor, width, inputBodyLines, m.emptyInputPlaceholder())...)
|
|
lines = append(lines, modelLines...)
|
|
return lines
|
|
}
|
|
|
|
func (m chatModel) renderModelStatusLines(width int) []string {
|
|
if m.modelPicker != nil {
|
|
return nil
|
|
}
|
|
var parts []string
|
|
if model := strings.TrimSpace(m.opts.Model); model != "" {
|
|
parts = append(parts, model)
|
|
}
|
|
if contextStatus := m.contextStatus(); contextStatus != "" {
|
|
parts = append(parts, contextStatus)
|
|
}
|
|
if notice := m.permissionModeNotice(); notice != "" {
|
|
parts = append(parts, notice)
|
|
}
|
|
if len(parts) == 0 {
|
|
return nil
|
|
}
|
|
lines := wrapChatText(strings.Join(parts, " "), width)
|
|
for i := range lines {
|
|
lines[i] = renderFooterPlainLine(lines[i])
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func (m chatModel) renderActionStatusLines(width int) []string {
|
|
if activity := m.activityLine(); activity != "" {
|
|
return []string{chatMetaStyle.Render(activity)}
|
|
}
|
|
if notificationLines := m.renderNotificationLines(width); len(notificationLines) > 0 {
|
|
return notificationLines
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func transcriptInputGap(maxHeight, bottomLineCount, _ int) int {
|
|
const desiredGap = 1
|
|
if maxHeight <= 0 {
|
|
return desiredGap
|
|
}
|
|
available := maxHeight - bottomLineCount
|
|
if available <= 1 {
|
|
return 0
|
|
}
|
|
return min(desiredGap, available-1)
|
|
}
|
|
|
|
func (m *chatModel) scrollBy(lines int) {
|
|
if lines == 0 {
|
|
return
|
|
}
|
|
m.scroll = clamp(m.scroll+lines, 0, m.maxScroll())
|
|
}
|
|
|
|
var chatANSISequencePattern = regexp.MustCompile(`\x1b\[[0-9;:]*[A-Za-z]`)
|
|
|
|
func stripChatANSI(s string) string {
|
|
return chatANSISequencePattern.ReplaceAllString(s, "")
|
|
}
|
|
|
|
func (m chatModel) normalizedSelectionRange() (chatSelectionPoint, chatSelectionPoint, bool) {
|
|
return normalizedSelectionRangeFor(m.selection)
|
|
}
|
|
|
|
func normalizedSelectionRangeFor(selection chatSelection) (chatSelectionPoint, chatSelectionPoint, bool) {
|
|
if !selection.active {
|
|
return chatSelectionPoint{}, chatSelectionPoint{}, false
|
|
}
|
|
start, end := selection.anchor, selection.cursor
|
|
if start.line > end.line || (start.line == end.line && start.col > end.col) {
|
|
start, end = end, start
|
|
}
|
|
if start.line == end.line && start.col == end.col {
|
|
return chatSelectionPoint{}, chatSelectionPoint{}, false
|
|
}
|
|
return start, end, true
|
|
}
|
|
|
|
func (m chatModel) applyTranscriptSelection(lines []string, offset int) []string {
|
|
start, end, ok := m.normalizedSelectionRange()
|
|
if !ok || len(lines) == 0 {
|
|
return lines
|
|
}
|
|
out := slices.Clone(lines)
|
|
for i, line := range out {
|
|
lineIndex := offset + i
|
|
if lineIndex < start.line || lineIndex > end.line {
|
|
continue
|
|
}
|
|
text := stripChatANSI(line)
|
|
startCol, endCol := 0, len([]rune(text))
|
|
if lineIndex == start.line {
|
|
startCol = displayColumnToRuneIndex(text, start.col)
|
|
}
|
|
if lineIndex == end.line {
|
|
endCol = displayColumnToRuneIndex(text, end.col)
|
|
}
|
|
if startCol > endCol {
|
|
startCol, endCol = endCol, startCol
|
|
}
|
|
out[i] = renderSelectedTranscriptLine(text, startCol, endCol)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func renderSelectedTranscriptLine(line string, startCol, endCol int) string {
|
|
runes := []rune(line)
|
|
startCol = clamp(startCol, 0, len(runes))
|
|
endCol = clamp(endCol, 0, len(runes))
|
|
if startCol == endCol {
|
|
return line
|
|
}
|
|
return string(runes[:startCol]) + chatSelectionStyle.Render(string(runes[startCol:endCol])) + string(runes[endCol:])
|
|
}
|
|
|
|
func displayColumnToRuneIndex(line string, col int) int {
|
|
if col <= 0 {
|
|
return 0
|
|
}
|
|
width := 0
|
|
for i, r := range []rune(line) {
|
|
next := width + runewidth.RuneWidth(r)
|
|
if col < next {
|
|
return i
|
|
}
|
|
width = next
|
|
}
|
|
return len([]rune(line))
|
|
}
|
|
|
|
func (m chatModel) selectedTranscriptText(width int) string {
|
|
start, end, ok := m.normalizedSelectionRange()
|
|
if !ok {
|
|
return ""
|
|
}
|
|
lines := m.transcriptLines(width)
|
|
if len(lines) == 0 {
|
|
return ""
|
|
}
|
|
start.line = clamp(start.line, 0, len(lines)-1)
|
|
end.line = clamp(end.line, 0, len(lines)-1)
|
|
var selected []string
|
|
for lineIndex := start.line; lineIndex <= end.line; lineIndex++ {
|
|
text := stripChatANSI(lines[lineIndex])
|
|
runes := []rune(text)
|
|
startCol, endCol := 0, len(runes)
|
|
if lineIndex == start.line {
|
|
startCol = displayColumnToRuneIndex(text, start.col)
|
|
}
|
|
if lineIndex == end.line {
|
|
endCol = displayColumnToRuneIndex(text, end.col)
|
|
}
|
|
if startCol > endCol {
|
|
startCol, endCol = endCol, startCol
|
|
}
|
|
selected = append(selected, string(runes[startCol:endCol]))
|
|
}
|
|
return strings.TrimRight(strings.Join(selected, "\n"), "\n")
|
|
}
|
|
|
|
func (m chatModel) renderEntry(entry chatEntry) (string, string) {
|
|
switch entry.role {
|
|
case "user":
|
|
return "", entry.content
|
|
case "assistant":
|
|
return chatAssistantStyle.Render("•") + " ", entry.content
|
|
case "slash":
|
|
return chatMetaStyle.Render("•") + " ", entry.content
|
|
case "compaction_summary":
|
|
prefix := toolStatusStyle(entry.status).Render("•") + " "
|
|
return prefix, compactionSummaryStatusLine(entry)
|
|
case "tool":
|
|
prefix := toolStatusStyle(entry.status).Render("•") + " "
|
|
return prefix, toolStatusLine(entry)
|
|
case "tool_group":
|
|
prefix := toolGroupPrefixStyle(entry).Render("•") + " "
|
|
return prefix, toolGroupStatusLine(entry)
|
|
case "error":
|
|
return chatErrorStyle.Render("err ") + " ", entry.content
|
|
case "system":
|
|
return "", entry.content
|
|
default:
|
|
return "", entry.content
|
|
}
|
|
}
|
|
|
|
func (m chatModel) renderEntryLines(entry chatEntry, body string, width int) []string {
|
|
if width < 20 {
|
|
width = 20
|
|
}
|
|
switch entry.role {
|
|
case "assistant":
|
|
lines := splitRenderedBody(renderMarkdownForView(body, width))
|
|
lines = append(lines, renderMetricsLines(entry.metrics, width)...)
|
|
return lines
|
|
case "system", "slash":
|
|
return splitRenderedBody(renderMarkdownForView(body, width))
|
|
case "user":
|
|
return renderUserMessageLines(body, width)
|
|
case "compaction_summary":
|
|
return renderCompactionSummaryLines(entry, width)
|
|
case "tool":
|
|
if entryHasExpandableOutput(entry) {
|
|
return renderToolResultLines(entry, width)
|
|
}
|
|
if isToolResultStatus(entry.status) {
|
|
return renderToolResultLines(entry, width)
|
|
}
|
|
return wrapChatText(body, width)
|
|
case "tool_group":
|
|
return renderToolGroupLines(entry, width)
|
|
default:
|
|
return wrapChatText(body, width)
|
|
}
|
|
}
|
|
|
|
func renderUserMessageLines(content string, width int) []string {
|
|
return renderPromptRow(chatPromptPrefix+content, width)
|
|
}
|
|
|
|
func renderMetricsLines(metrics *api.Metrics, width int) []string {
|
|
summary := metricsSummaryLines(metrics)
|
|
if len(summary) == 0 {
|
|
return nil
|
|
}
|
|
var lines []string
|
|
for _, line := range summary {
|
|
for _, wrapped := range wrapChatText(line, width) {
|
|
lines = append(lines, chatMetaStyle.Render(wrapped))
|
|
}
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func metricsSummaryLines(metrics *api.Metrics) []string {
|
|
if metrics == nil || metricsEmpty(*metrics) {
|
|
return nil
|
|
}
|
|
var lines []string
|
|
if metrics.TotalDuration > 0 {
|
|
lines = append(lines, fmt.Sprintf("total duration: %v", metrics.TotalDuration))
|
|
}
|
|
if metrics.LoadDuration > 0 {
|
|
lines = append(lines, fmt.Sprintf("load duration: %v", metrics.LoadDuration))
|
|
}
|
|
if metrics.PromptEvalCount > 0 {
|
|
lines = append(lines, fmt.Sprintf("prompt eval count: %d token(s)", metrics.PromptEvalCount))
|
|
}
|
|
if metrics.PromptEvalDuration > 0 {
|
|
lines = append(lines, fmt.Sprintf("prompt eval duration: %s", metrics.PromptEvalDuration))
|
|
lines = append(lines, fmt.Sprintf("prompt eval rate: %.2f tokens/s", float64(metrics.PromptEvalCount)/metrics.PromptEvalDuration.Seconds()))
|
|
}
|
|
if metrics.EvalCount > 0 {
|
|
lines = append(lines, fmt.Sprintf("eval count: %d token(s)", metrics.EvalCount))
|
|
}
|
|
if metrics.EvalDuration > 0 {
|
|
lines = append(lines, fmt.Sprintf("eval duration: %s", metrics.EvalDuration))
|
|
lines = append(lines, fmt.Sprintf("eval rate: %.2f tokens/s", float64(metrics.EvalCount)/metrics.EvalDuration.Seconds()))
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func metricsEmpty(metrics api.Metrics) bool {
|
|
return metrics.TotalDuration <= 0 &&
|
|
metrics.LoadDuration <= 0 &&
|
|
metrics.PromptEvalCount <= 0 &&
|
|
metrics.PromptEvalDuration <= 0 &&
|
|
metrics.EvalCount <= 0 &&
|
|
metrics.EvalDuration <= 0
|
|
}
|
|
|
|
func renderHistoryMessages(messages []api.Message, width int) []string {
|
|
if width < 20 {
|
|
width = 20
|
|
}
|
|
var lines []string
|
|
for _, msg := range messages {
|
|
role := strings.TrimSpace(msg.Role)
|
|
if role == "" {
|
|
role = "message"
|
|
}
|
|
lines = append(lines, renderHistoryStyledLine("", role, width, historyRoleStyle(role))...)
|
|
|
|
var meta []string
|
|
if msg.ToolName != "" {
|
|
meta = append(meta, "tool: `"+msg.ToolName+"`")
|
|
}
|
|
if msg.ToolCallID != "" {
|
|
meta = append(meta, "tool call: `"+msg.ToolCallID+"`")
|
|
}
|
|
if len(meta) > 0 {
|
|
lines = append(lines, " "+renderHistoryMetaLine(strings.Join(meta, " · ")))
|
|
}
|
|
|
|
if strings.TrimSpace(msg.Thinking) != "" {
|
|
lines = append(lines, renderHistoryFieldLines(" ", "thinking", msg.Thinking, width)...)
|
|
}
|
|
if len(msg.ToolCalls) > 0 {
|
|
lines = append(lines, " "+chatHistoryLabelStyle.Render("tool calls:"))
|
|
for _, call := range msg.ToolCalls {
|
|
lines = append(lines, renderHistoryToolCallLines(call, width)...)
|
|
}
|
|
}
|
|
if strings.TrimSpace(msg.Content) != "" {
|
|
content := msg.Content
|
|
if msg.Role == "tool" {
|
|
content = stripInternalToolTruncationMarkers(content)
|
|
}
|
|
lines = append(lines, renderHistoryFieldLines(" ", "content", content, width)...)
|
|
}
|
|
if strings.TrimSpace(msg.Thinking) == "" && len(msg.ToolCalls) == 0 && strings.TrimSpace(msg.Content) == "" {
|
|
lines = append(lines, " "+chatHistoryTextStyle.Render("_empty_"))
|
|
}
|
|
lines = append(lines, "")
|
|
}
|
|
return stringsTrimTrailingEmptyLines(lines)
|
|
}
|
|
|
|
func renderHistoryFieldLines(indent, label, content string, width int) []string {
|
|
content = strings.TrimRight(content, "\n")
|
|
if content == "" {
|
|
return nil
|
|
}
|
|
if !strings.Contains(content, "\n") {
|
|
return renderHistoryLabelValue(indent, label, content, width)
|
|
}
|
|
lines := []string{indent + chatHistoryLabelStyle.Render(label+":")}
|
|
for _, line := range strings.Split(content, "\n") {
|
|
lines = append(lines, renderHistoryCodeLine(indent, line, width)...)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func renderHistoryToolCallLines(call api.ToolCall, width int) []string {
|
|
name := call.Function.Name
|
|
if name == "" {
|
|
name = "tool"
|
|
}
|
|
line := " - "
|
|
if call.ID != "" {
|
|
line += "`" + call.ID + "` "
|
|
}
|
|
line += toolDisplayName(name)
|
|
lines := renderHistoryStyledLine("", line, width, chatHistoryTextStyle)
|
|
|
|
args := call.Function.Arguments.ToMap()
|
|
if len(args) == 0 {
|
|
return lines
|
|
}
|
|
lines = append(lines, " "+chatHistoryLabelStyle.Render("args:"))
|
|
data, err := json.MarshalIndent(args, "", " ")
|
|
if err != nil {
|
|
lines = append(lines, renderHistoryCodeLine(" ", fmt.Sprint(args), width)...)
|
|
return lines
|
|
}
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
lines = append(lines, renderHistoryCodeLine(" ", line, width)...)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func stringsTrimTrailingEmptyLines(lines []string) []string {
|
|
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
|
|
lines = lines[:len(lines)-1]
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func renderHistoryLabelValue(indent, label, value string, width int) []string {
|
|
labelText := label + ":"
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return []string{indent + chatHistoryLabelStyle.Render(labelText)}
|
|
}
|
|
|
|
prefixWidth := lipgloss.Width(indent) + lipgloss.Width(labelText) + 1
|
|
wrapped := wrapChatText(value, max(10, width-prefixWidth))
|
|
lines := make([]string, 0, len(wrapped))
|
|
for i, line := range wrapped {
|
|
if i == 0 {
|
|
lines = append(lines, indent+chatHistoryLabelStyle.Render(labelText)+" "+renderHistoryInline(line, chatHistoryTextStyle))
|
|
continue
|
|
}
|
|
lines = append(lines, indent+strings.Repeat(" ", lipgloss.Width(labelText)+1)+renderHistoryInline(line, chatHistoryTextStyle))
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func renderHistoryStyledLine(indent, text string, width int, style lipgloss.Style) []string {
|
|
wrapped := wrapChatText(text, max(10, width-lipgloss.Width(indent)))
|
|
for i, line := range wrapped {
|
|
wrapped[i] = indent + renderHistoryInline(line, style)
|
|
}
|
|
return wrapped
|
|
}
|
|
|
|
func renderHistoryCodeLine(indent, code string, width int) []string {
|
|
codeIndent := indent + " "
|
|
wrapped := wrapChatText(code, max(10, width-lipgloss.Width(codeIndent)))
|
|
for i, line := range wrapped {
|
|
wrapped[i] = codeIndent + chatHistoryCodeStyle.Render(line)
|
|
}
|
|
return wrapped
|
|
}
|
|
|
|
func renderHistoryMetaLine(text string) string {
|
|
parts := strings.Split(text, " · ")
|
|
for i, part := range parts {
|
|
if label, value, ok := historyLabelValue(part); ok {
|
|
parts[i] = chatHistoryLabelStyle.Render(label+":") + " " + renderHistoryInline(value, chatHistoryTextStyle)
|
|
continue
|
|
}
|
|
parts[i] = renderHistoryInline(part, chatHistoryTextStyle)
|
|
}
|
|
return strings.Join(parts, chatHistoryLabelStyle.Render(" · "))
|
|
}
|
|
|
|
func renderHistoryInline(text string, style lipgloss.Style) string {
|
|
text = strings.ReplaceAll(text, "**", "")
|
|
var b strings.Builder
|
|
for {
|
|
before, rest, ok := strings.Cut(text, "`")
|
|
b.WriteString(style.Render(before))
|
|
if !ok {
|
|
break
|
|
}
|
|
code, after, ok := strings.Cut(rest, "`")
|
|
if !ok {
|
|
b.WriteString(style.Render("`" + rest))
|
|
break
|
|
}
|
|
b.WriteString(chatHistoryCodeStyle.Render(code))
|
|
text = after
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func historyRoleStyle(role string) lipgloss.Style {
|
|
switch role {
|
|
case "system":
|
|
return chatHistorySystemRoleStyle
|
|
case "user":
|
|
return chatHistoryUserRoleStyle
|
|
case "assistant":
|
|
return chatHistoryAssistantRoleStyle
|
|
case "tool":
|
|
return chatHistoryToolRoleStyle
|
|
default:
|
|
return chatHistoryTitleStyle
|
|
}
|
|
}
|
|
|
|
func historyLabelValue(text string) (string, string, bool) {
|
|
label, value, ok := strings.Cut(text, ":")
|
|
if !ok {
|
|
return "", "", false
|
|
}
|
|
label = strings.TrimSpace(label)
|
|
if !historyLabel(label) {
|
|
return "", "", false
|
|
}
|
|
return label, strings.TrimSpace(value), true
|
|
}
|
|
|
|
func historyLabel(label string) bool {
|
|
switch label {
|
|
case "args", "content", "thinking", "tool", "tool call", "tool calls":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (m chatModel) queuedLines(width int) []string {
|
|
if len(m.queued) == 0 {
|
|
return nil
|
|
}
|
|
limit := 2
|
|
if width < 40 {
|
|
limit = 1
|
|
}
|
|
lines := make([]string, 0, min(len(m.queued), limit)+1)
|
|
for i, queued := range m.queued {
|
|
if i >= limit {
|
|
lines = append(lines, chatMetaStyle.Render(fmt.Sprintf("queued +%d more", len(m.queued)-i)))
|
|
break
|
|
}
|
|
label := fmt.Sprintf("queued %d: %s", i+1, queued)
|
|
lines = append(lines, chatMetaStyle.Render(truncateRunes(label, max(20, width))))
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func renderToolResultLines(entry chatEntry, width int) []string {
|
|
lines := wrapChatText(toolStatusLine(entry), width)
|
|
if !entry.expanded {
|
|
return lines
|
|
}
|
|
|
|
detailLines := renderToolCallDetailLines(entry, width)
|
|
if len(detailLines) > 0 {
|
|
lines = append(lines, "")
|
|
lines = append(lines, detailLines...)
|
|
}
|
|
if strings.TrimSpace(entry.content) != "" {
|
|
lines = append(lines, "")
|
|
lines = append(lines, renderToolOutputLines(entry, entry.content, width)...)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func renderToolGroupLines(entry chatEntry, width int) []string {
|
|
lines := wrapChatText(toolGroupStatusLine(entry), width)
|
|
if !entry.expanded {
|
|
return lines
|
|
}
|
|
|
|
for i, tool := range entry.tools {
|
|
if i > 0 {
|
|
lines = append(lines, "")
|
|
}
|
|
lines = append(lines, " "+toolGroupChildStatusLine(tool))
|
|
if detailLines := renderToolCallDetailLines(tool, width-4); len(detailLines) > 0 {
|
|
lines = append(lines, indentLines(detailLines, " ")...)
|
|
}
|
|
if strings.TrimSpace(tool.content) == "" {
|
|
continue
|
|
}
|
|
lines = append(lines, indentLines(renderToolOutputLines(tool, tool.content, width-4), " ")...)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func renderCompactionSummaryLines(entry chatEntry, width int) []string {
|
|
lines := wrapChatText(compactionSummaryStatusLine(entry), width)
|
|
if !entry.expanded || strings.TrimSpace(entry.content) == "" {
|
|
return lines
|
|
}
|
|
lines = append(lines, "")
|
|
lines = append(lines, indentLines(splitRenderedBody(renderMarkdownForView(entry.content, width-2)), " ")...)
|
|
return lines
|
|
}
|
|
|
|
func compactionSummaryStatusLine(entry chatEntry) string {
|
|
segment := toolStatusStyle(entry.status).Render(toolStatusLabel(entry))
|
|
if segment == "" {
|
|
return "Compacted summary"
|
|
}
|
|
return fmt.Sprintf("Compacted summary %s", segment)
|
|
}
|
|
|
|
func toolGroupChildStatusLine(entry chatEntry) string {
|
|
label := entry.label
|
|
if label == "" {
|
|
label = toolDisplayName(entry.detail)
|
|
}
|
|
|
|
segment := renderToolStatusSegment(entry)
|
|
if segment == "" {
|
|
return boldToolInvocationName(label)
|
|
}
|
|
return fmt.Sprintf("%s %s", boldToolInvocationName(label), segment)
|
|
}
|
|
|
|
func boldToolInvocationName(label string) string {
|
|
name, rest, ok := strings.Cut(label, "(")
|
|
if !ok || name == "" {
|
|
return chatHeaderStyle.Render(label)
|
|
}
|
|
return chatHeaderStyle.Render(name) + "(" + rest
|
|
}
|
|
|
|
func renderToolOutputLines(entry chatEntry, output string, width int) []string {
|
|
output = stripInternalToolTruncationMarkers(output)
|
|
if looksLikeUnifiedDiff(output) {
|
|
return splitRenderedBody(renderDiffForView(output, width))
|
|
}
|
|
if toolOutputUsesMarkdown(entry.detail) {
|
|
return splitRenderedBody(renderMarkdownForView(output, width))
|
|
}
|
|
return wrapChatText(output, width)
|
|
}
|
|
|
|
func stripInternalToolTruncationMarkers(output string) string {
|
|
lines := strings.Split(output, "\n")
|
|
filtered := lines[:0]
|
|
for _, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "[tool output truncated: ") && strings.HasSuffix(trimmed, "]") {
|
|
continue
|
|
}
|
|
filtered = append(filtered, line)
|
|
}
|
|
return strings.TrimSpace(strings.Join(filtered, "\n"))
|
|
}
|
|
|
|
func looksLikeUnifiedDiff(output string) bool {
|
|
lines := strings.Split(output, "\n")
|
|
hasOldFile := false
|
|
hasNewFile := false
|
|
hasHunk := false
|
|
for _, line := range lines {
|
|
switch {
|
|
case strings.HasPrefix(line, "diff --git "):
|
|
return true
|
|
case strings.HasPrefix(line, "--- "):
|
|
hasOldFile = true
|
|
case strings.HasPrefix(line, "+++ "):
|
|
hasNewFile = true
|
|
case strings.HasPrefix(line, "@@ "):
|
|
hasHunk = true
|
|
}
|
|
}
|
|
return hasHunk || (hasOldFile && hasNewFile)
|
|
}
|
|
|
|
func renderDiffForView(diff string, width int) string {
|
|
if width < 20 {
|
|
width = 20
|
|
}
|
|
lines := strings.Split(strings.TrimRight(diff, "\n"), "\n")
|
|
rendered := make([]string, 0, len(lines))
|
|
for _, line := range lines {
|
|
style := diffLineStyle(line)
|
|
for _, wrapped := range wrapChatText(line, width) {
|
|
rendered = append(rendered, style.Render(wrapped))
|
|
}
|
|
}
|
|
return strings.Join(rendered, "\n")
|
|
}
|
|
|
|
func diffLineStyle(line string) lipgloss.Style {
|
|
switch {
|
|
case strings.HasPrefix(line, "diff --git "),
|
|
strings.HasPrefix(line, "--- "),
|
|
strings.HasPrefix(line, "+++ "):
|
|
return chatDiffFileStyle
|
|
case strings.HasPrefix(line, "@@ "):
|
|
return chatDiffHunkStyle
|
|
case strings.HasPrefix(line, "+"):
|
|
return chatDiffAddStyle
|
|
case strings.HasPrefix(line, "-"):
|
|
return chatDiffDeleteStyle
|
|
case strings.HasPrefix(line, "index "),
|
|
strings.HasPrefix(line, "new file "),
|
|
strings.HasPrefix(line, "deleted file "),
|
|
strings.HasPrefix(line, "similarity index "),
|
|
strings.HasPrefix(line, "rename from "),
|
|
strings.HasPrefix(line, "rename to "),
|
|
strings.HasPrefix(line, "\\ "):
|
|
return chatDiffMetaStyle
|
|
default:
|
|
return chatToolStyle
|
|
}
|
|
}
|
|
|
|
func isToolActiveStatus(status string) bool {
|
|
return status == "queued" || status == "running" || status == "approval"
|
|
}
|
|
|
|
func isToolResultStatus(status string) bool {
|
|
return status == "done" || status == "error"
|
|
}
|
|
|
|
// renderToolStatusSegment returns the styled status text for a tool entry,
|
|
// or "" when the entry has no status word to show (e.g. a completed or failed
|
|
// tool, where the colored dot alone conveys the result).
|
|
func renderToolStatusSegment(entry chatEntry) string {
|
|
s := toolStatusLabel(entry)
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
return toolStatusStyle(entry.status).Render(s)
|
|
}
|
|
|
|
func toolStatusLine(entry chatEntry) string {
|
|
label := entry.label
|
|
if label == "" {
|
|
label = toolDisplayName(entry.detail)
|
|
}
|
|
|
|
segment := renderToolStatusSegment(entry)
|
|
if segment == "" {
|
|
return label
|
|
}
|
|
return fmt.Sprintf("%s %s", label, segment)
|
|
}
|
|
|
|
func toolGroupStatusLine(entry chatEntry) string {
|
|
label := entry.label
|
|
if label == "" {
|
|
label = fmt.Sprintf("Tool calls (%d)", len(entry.tools))
|
|
}
|
|
|
|
// Color the label with the group's outcome color (green/red/amber/yellow);
|
|
// the small dot prefix already carries the same color, so the label
|
|
// reinforces which outcome the group had without adding a status word.
|
|
styledLabel := toolGroupPrefixStyle(entry).Render(label)
|
|
segment := renderToolStatusSegment(entry)
|
|
if segment == "" {
|
|
return styledLabel
|
|
}
|
|
return fmt.Sprintf("%s %s", styledLabel, segment)
|
|
}
|
|
|
|
func toolGroupPrefixStyle(entry chatEntry) lipgloss.Style {
|
|
succeeded, failed := toolGroupResultCounts(entry.tools)
|
|
switch {
|
|
case succeeded > 0 && failed > 0:
|
|
return chatToolMixedStyle
|
|
case succeeded > 0:
|
|
return chatToolDoneStyle
|
|
default:
|
|
return toolStatusStyle(entry.status)
|
|
}
|
|
}
|
|
|
|
func toolGroupResultCounts(tools []chatEntry) (succeeded int, failed int) {
|
|
for _, tool := range tools {
|
|
if tool.err != "" || tool.status == "error" {
|
|
failed++
|
|
continue
|
|
}
|
|
if tool.status == "done" {
|
|
succeeded++
|
|
}
|
|
}
|
|
return succeeded, failed
|
|
}
|
|
|
|
func toolStatusLabel(entry chatEntry) string {
|
|
if entry.status == "approval" {
|
|
return "needs approval"
|
|
}
|
|
if entry.status == "running" || entry.status == "queued" {
|
|
return "in progress"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func toolStatusStyle(status string) lipgloss.Style {
|
|
switch status {
|
|
case "done":
|
|
return chatToolDoneStyle
|
|
case "error":
|
|
return chatErrorStyle
|
|
default:
|
|
return chatToolRunningStyle
|
|
}
|
|
}
|
|
|
|
func toolInvocationLabel(name string, args map[string]any) string {
|
|
return coreagent.ToolInvocationLabel(name, args)
|
|
}
|
|
|
|
func toolDisplayName(name string) string {
|
|
return coreagent.ToolDisplayName(name)
|
|
}
|
|
|
|
func toolOutputUsesMarkdown(name string) bool {
|
|
switch name {
|
|
case "read", "skill", "web_search", "web_fetch":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func renderToolCallDetailLines(entry chatEntry, width int) []string {
|
|
if len(entry.args) == 0 {
|
|
return nil
|
|
}
|
|
if coreagent.IsShellToolName(entry.detail) {
|
|
if command, ok := rawStringArg(entry.args, "command"); ok {
|
|
return renderToolCallArgLine(shellPromptPrefix(entry.detail)+command, width)
|
|
}
|
|
}
|
|
switch entry.detail {
|
|
case "web_search":
|
|
if query, ok := rawStringArg(entry.args, "query"); ok {
|
|
return renderToolCallArgLine("query: "+query, width)
|
|
}
|
|
case "web_fetch":
|
|
if targetURL, ok := rawStringArg(entry.args, "url"); ok {
|
|
return renderToolCallArgLine("url: "+targetURL, width)
|
|
}
|
|
}
|
|
return renderToolCallArgs(entry.args, width)
|
|
}
|
|
|
|
func shellPromptPrefix(toolName string) string {
|
|
if toolName == "powershell" {
|
|
return "PS> "
|
|
}
|
|
return "$ "
|
|
}
|
|
|
|
func renderToolCallArgs(args map[string]any, width int) []string {
|
|
keys := make([]string, 0, len(args))
|
|
for key := range args {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
var lines []string
|
|
for _, key := range keys {
|
|
value := toolArgDisplayValue(args[key])
|
|
if strings.Contains(value, "\n") {
|
|
lines = append(lines, chatMetaStyle.Render(key+":"))
|
|
lines = append(lines, indentLines(renderToolCallArgLine(value, max(20, width-2)), " ")...)
|
|
continue
|
|
}
|
|
lines = append(lines, renderToolCallArgLine(key+": "+value, width)...)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func renderToolCallArgLine(line string, width int) []string {
|
|
wrapped := wrapChatText(line, width)
|
|
for i := range wrapped {
|
|
wrapped[i] = chatMetaStyle.Render(wrapped[i])
|
|
}
|
|
return wrapped
|
|
}
|
|
|
|
func toolArgDisplayValue(value any) string {
|
|
if value == nil {
|
|
return "null"
|
|
}
|
|
if text, ok := value.(string); ok {
|
|
return text
|
|
}
|
|
data, err := json.MarshalIndent(value, "", " ")
|
|
if err == nil {
|
|
return string(data)
|
|
}
|
|
return fmt.Sprint(value)
|
|
}
|
|
|
|
func rawStringArg(args map[string]any, key string) (string, bool) {
|
|
value, ok := args[key].(string)
|
|
if !ok || strings.TrimSpace(value) == "" {
|
|
return "", false
|
|
}
|
|
return value, true
|
|
}
|
|
|
|
func truncateRunes(value string, limit int) string {
|
|
runes := []rune(value)
|
|
if len(runes) <= limit {
|
|
return value
|
|
}
|
|
return string(runes[:limit]) + "..."
|
|
}
|
|
|
|
func (m chatModel) notificationLine() string {
|
|
status := strings.TrimSpace(m.status)
|
|
if status == "" || status == "ready" {
|
|
return ""
|
|
}
|
|
if m.running || m.compacting || m.approvalPrompt != nil {
|
|
return ""
|
|
}
|
|
switch status {
|
|
case "queued", "running", "compacting", "approval required", "full access enabled", "review mode enabled":
|
|
return ""
|
|
default:
|
|
return status
|
|
}
|
|
}
|
|
|
|
func (m chatModel) renderNotificationLines(width int) []string {
|
|
line := m.notificationLine()
|
|
if line == "" {
|
|
return nil
|
|
}
|
|
lines := wrapChatText(line, width)
|
|
for i, wrapped := range lines {
|
|
lines[i] = chatNotificationStyle.Render(wrapped)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func renderFooterPlainLine(line string) string {
|
|
const fullAccess = "full access"
|
|
if !strings.Contains(line, fullAccess) {
|
|
return chatMetaStyle.Render(line)
|
|
}
|
|
|
|
var b strings.Builder
|
|
for {
|
|
before, after, ok := strings.Cut(line, fullAccess)
|
|
if before != "" {
|
|
b.WriteString(chatMetaStyle.Render(before))
|
|
}
|
|
if !ok {
|
|
break
|
|
}
|
|
b.WriteString(chatFullAccessStyle.Render(fullAccess))
|
|
line = after
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (m chatModel) permissionModeNotice() string {
|
|
if notice := strings.TrimSpace(m.permissionNotice); notice != "" {
|
|
return notice
|
|
}
|
|
switch strings.TrimSpace(m.status) {
|
|
case "full access enabled", "review mode enabled":
|
|
return strings.TrimSpace(m.status)
|
|
}
|
|
if m.currentPolicy().ToolMode == coreagent.ToolModeFullAccess && m.notificationLine() == "" {
|
|
return "full access enabled"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (m *chatModel) refreshContextWindowTokens(modelName string) {
|
|
if m == nil || m.opts.ContextWindowTokensForModel == nil {
|
|
return
|
|
}
|
|
modelName = strings.TrimSpace(modelName)
|
|
if modelName == "" {
|
|
return
|
|
}
|
|
ctx := m.ctx
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
tokens := m.opts.ContextWindowTokensForModel(ctx, modelName, m.opts.ContextWindowTokens)
|
|
m.updateContextWindowTokens(tokens)
|
|
}
|
|
|
|
func (m *chatModel) updateContextWindowTokens(tokens int) {
|
|
if tokens <= 0 || tokens == m.opts.ContextWindowTokens {
|
|
return
|
|
}
|
|
m.opts.ContextWindowTokens = tokens
|
|
if compactor, ok := m.opts.Compactor.(*coreagent.SimpleCompactor); ok && compactor != nil {
|
|
compactor.Options.ContextWindowTokens = tokens
|
|
}
|
|
}
|
|
|
|
func (m chatModel) responseModelName(response *api.ChatResponse) string {
|
|
if response != nil {
|
|
if strings.TrimSpace(response.Model) != "" {
|
|
return response.Model
|
|
}
|
|
if strings.TrimSpace(response.RemoteModel) != "" {
|
|
return response.RemoteModel
|
|
}
|
|
}
|
|
return m.opts.Model
|
|
}
|
|
|
|
func (m chatModel) currentWorkingDir() string {
|
|
if strings.TrimSpace(m.workingDir) != "" {
|
|
return m.workingDir
|
|
}
|
|
return m.opts.WorkingDir
|
|
}
|
|
|
|
func (m chatModel) activityLine() string {
|
|
if m.approvalPrompt != nil {
|
|
return ""
|
|
}
|
|
if m.preloadingModel != "" && !m.running && !m.compacting {
|
|
return ""
|
|
}
|
|
if !m.running && !m.compacting && m.preloadingModel == "" && m.approvalPrompt == nil {
|
|
return ""
|
|
}
|
|
label := m.activityLabel()
|
|
if label == "" {
|
|
if !m.waitingForModel() || m.spinner < waitingSpinnerTicks {
|
|
return ""
|
|
}
|
|
if m.preloadingModel != "" {
|
|
return statusWithSpinner(m.spinnerFrame(), "Working")
|
|
}
|
|
return statusWithSpinner(m.spinnerFrame(), "Working")
|
|
}
|
|
return statusWithSpinner(m.spinnerFrame(), label)
|
|
}
|
|
|
|
func (m chatModel) activityLabel() string {
|
|
if m.status == "canceling" {
|
|
return "canceling"
|
|
}
|
|
if m.compacting {
|
|
if m.compactingTokens > 0 {
|
|
return "Compacting " + formatTokenCount(m.compactingTokens)
|
|
}
|
|
return "Compacting"
|
|
}
|
|
if m.thinking {
|
|
if m.thinkingTokens > 0 {
|
|
return "Thinking " + formatTokenCount(m.thinkingTokens)
|
|
}
|
|
return "Thinking"
|
|
}
|
|
start := m.currentTurnEntryStart()
|
|
for i := len(m.entries) - 1; i >= start; i-- {
|
|
entry := m.entries[i]
|
|
switch entry.role {
|
|
case "tool":
|
|
if isToolActiveStatus(entry.status) {
|
|
return ""
|
|
}
|
|
case "assistant":
|
|
if entry.content != "" {
|
|
return ""
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (m chatModel) waitingForModel() bool {
|
|
if m.preloadingModel != "" && !m.compacting && m.approvalPrompt == nil && m.status != "canceling" {
|
|
return true
|
|
}
|
|
if !m.running || m.compacting || m.approvalPrompt != nil || m.thinking || m.status == "canceling" {
|
|
return false
|
|
}
|
|
if m.awaitingModel {
|
|
return true
|
|
}
|
|
start := m.currentTurnEntryStart()
|
|
for i := len(m.entries) - 1; i >= start; i-- {
|
|
entry := m.entries[i]
|
|
switch entry.role {
|
|
case "tool":
|
|
if isToolActiveStatus(entry.status) {
|
|
return false
|
|
}
|
|
case "assistant":
|
|
return entry.content == ""
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (m chatModel) currentTurnEntryStart() int {
|
|
for i := len(m.entries) - 1; i >= 0; i-- {
|
|
if m.entries[i].role == "user" {
|
|
return i + 1
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (m *chatModel) applyResponseMetrics(response *api.ChatResponse) {
|
|
if response == nil {
|
|
return
|
|
}
|
|
tokens := 0
|
|
if response.PromptEvalCount > 0 {
|
|
tokens = response.PromptEvalCount
|
|
}
|
|
if response.EvalCount > 0 {
|
|
tokens += response.EvalCount
|
|
}
|
|
if tokens <= 0 {
|
|
return
|
|
}
|
|
if m.running {
|
|
if tokens > m.contextTokens {
|
|
m.contextTokens = tokens
|
|
}
|
|
return
|
|
}
|
|
if tokens > 0 {
|
|
m.contextTokens = tokens
|
|
m.contextEstimate = false
|
|
}
|
|
}
|
|
|
|
func (m *chatModel) attachVerboseMetrics(metrics api.Metrics) {
|
|
if !m.opts.Verbose || metricsEmpty(metrics) {
|
|
return
|
|
}
|
|
if len(m.entries) == 0 || m.entries[len(m.entries)-1].role != "assistant" {
|
|
return
|
|
}
|
|
copied := metrics
|
|
idx := len(m.entries) - 1
|
|
m.entries[idx].metrics = &copied
|
|
m.markEntryDirty(idx)
|
|
}
|
|
|
|
func eventEvalCount(event coreagent.Event) int {
|
|
if event.Response == nil {
|
|
return 0
|
|
}
|
|
return event.Response.EvalCount
|
|
}
|
|
|
|
func (m chatModel) estimatePromptTokens(messages []api.Message, systemPrompt string) int {
|
|
if strings.TrimSpace(systemPrompt) == "" {
|
|
systemPrompt = m.systemPrompt("")
|
|
}
|
|
var tools api.Tools
|
|
if m.opts.Tools != nil {
|
|
tools = m.opts.Tools.Tools()
|
|
}
|
|
return estimatePromptTokenCount(systemPrompt, messages, tools, m.opts.Format)
|
|
}
|
|
|
|
func estimatePromptTokenCount(systemPrompt string, messages []api.Message, tools api.Tools, format string) int {
|
|
return coreagent.EstimatePromptTokens(systemPrompt, messages, tools, format)
|
|
}
|
|
|
|
func formatTokenCount(count int) string {
|
|
if count == 1 {
|
|
return "1 token"
|
|
}
|
|
return fmt.Sprintf("%d tokens", count)
|
|
}
|
|
|
|
func (m chatModel) contextStatus() string {
|
|
window := coreagent.ResolveContextWindowTokens(m.opts.Options, m.opts.ContextWindowTokens)
|
|
if window <= 0 {
|
|
return ""
|
|
}
|
|
used := max(m.contextTokens, 0)
|
|
percent := 0
|
|
if window > 0 {
|
|
percent = (used*100 + window/2) / window
|
|
}
|
|
|
|
prefix := ""
|
|
if m.contextEstimate {
|
|
prefix = "~"
|
|
}
|
|
|
|
threshold := coreagent.ResolveCompactionThreshold(m.opts.CompactionThreshold)
|
|
compactAt := int(float64(window)*threshold + 0.999999)
|
|
if compactAt <= 0 || compactAt > window {
|
|
compactAt = window
|
|
}
|
|
|
|
if used >= compactAt {
|
|
return fmt.Sprintf("ctx %s%s/%s (%d%%)", prefix, formatContextTokenCount(used), formatContextTokenCount(window), percent)
|
|
}
|
|
|
|
noticeDistance := int(float64(window)*0.1 + 0.999999)
|
|
if noticeDistance < 1 {
|
|
noticeDistance = 1
|
|
}
|
|
if compactAt-used <= noticeDistance {
|
|
return fmt.Sprintf("ctx %s%s/%s (%d%%)", prefix, formatContextTokenCount(used), formatContextTokenCount(window), percent)
|
|
}
|
|
|
|
if percent > 60 {
|
|
return fmt.Sprintf("ctx %s%s/%s (%d%%)", prefix, formatContextTokenCount(used), formatContextTokenCount(window), percent)
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func formatContextTokenCount(value int) string {
|
|
sign := ""
|
|
if value < 0 {
|
|
sign = "-"
|
|
value = -value
|
|
}
|
|
if value >= 950_000 {
|
|
return fmt.Sprintf("%s%dM", sign, int(float64(value)/1_000_000+0.5))
|
|
}
|
|
if value >= 10_240 {
|
|
return fmt.Sprintf("%s%dk", sign, int(float64(value)/1024+0.5))
|
|
}
|
|
return sign + formatInteger(value)
|
|
}
|
|
|
|
func formatInteger(value int) string {
|
|
sign := ""
|
|
if value < 0 {
|
|
sign = "-"
|
|
value = -value
|
|
}
|
|
s := strconv.Itoa(value)
|
|
if len(s) <= 3 {
|
|
return sign + s
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString(sign)
|
|
first := len(s) % 3
|
|
if first == 0 {
|
|
first = 3
|
|
}
|
|
b.WriteString(s[:first])
|
|
for i := first; i < len(s); i += 3 {
|
|
b.WriteByte(',')
|
|
b.WriteString(s[i : i+3])
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (m chatModel) spinnerFrame() string {
|
|
if len(chatSpinnerFrames) == 0 {
|
|
return ""
|
|
}
|
|
return chatSpinnerFrames[m.spinner%len(chatSpinnerFrames)]
|
|
}
|
|
|
|
func statusWithSpinner(frame, label string) string {
|
|
label = strings.TrimSpace(label)
|
|
if label == "" {
|
|
return strings.TrimSpace(frame)
|
|
}
|
|
return label + frame
|
|
}
|
|
|
|
func renderFullFrame(content string, width, height int) string {
|
|
if width <= 0 {
|
|
width = 80
|
|
}
|
|
if height <= 0 {
|
|
height = 24
|
|
}
|
|
rendered := lipgloss.NewStyle().MaxWidth(width).Render(content)
|
|
lines := strings.Split(strings.TrimRight(rendered, "\n"), "\n")
|
|
if len(lines) > height {
|
|
lines = lines[:height]
|
|
}
|
|
for len(lines) < height {
|
|
lines = append(lines, "")
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func renderFrameLines(lines []string, width, height int) string {
|
|
if width <= 0 {
|
|
width = 80
|
|
}
|
|
if height <= 0 {
|
|
height = 24
|
|
}
|
|
if len(lines) > height {
|
|
lines = lines[:height]
|
|
}
|
|
out := make([]string, 0, height)
|
|
for _, line := range lines {
|
|
out = append(out, padRenderedLine(clipRenderedLine(line, width), width))
|
|
}
|
|
for len(out) < height {
|
|
out = append(out, strings.Repeat(" ", width))
|
|
}
|
|
return strings.Join(out, "\n")
|
|
}
|
|
|
|
func truncateRenderedLine(line string, width int) string {
|
|
if width <= 0 || lipgloss.Width(line) <= width {
|
|
return line
|
|
}
|
|
return lipgloss.NewStyle().MaxWidth(width).Render(line)
|
|
}
|
|
|
|
func clipRenderedLine(line string, width int) string {
|
|
if width <= 0 {
|
|
return ""
|
|
}
|
|
line, _, _ = strings.Cut(line, "\n")
|
|
if lipgloss.Width(line) <= width {
|
|
return line
|
|
}
|
|
clipped := lipgloss.NewStyle().MaxWidth(width).Render(line)
|
|
clipped, _, _ = strings.Cut(clipped, "\n")
|
|
return clipped
|
|
}
|
|
|
|
func clamp(value, minValue, maxValue int) int {
|
|
if value < minValue {
|
|
return minValue
|
|
}
|
|
if value > maxValue {
|
|
return maxValue
|
|
}
|
|
return value
|
|
}
|
|
|
|
func newChatEntry(entry chatEntry) chatEntry {
|
|
if entry.version <= 0 {
|
|
entry.version = 1
|
|
}
|
|
return entry
|
|
}
|
|
|
|
func newSlashEntry(content string) chatEntry {
|
|
return newChatEntry(chatEntry{role: "slash", content: content})
|
|
}
|
|
|
|
func (m *chatModel) markEntryDirty(index int) {
|
|
if index < 0 || index >= len(m.entries) {
|
|
return
|
|
}
|
|
entry := &m.entries[index]
|
|
entry.version++
|
|
if entry.version <= 0 {
|
|
entry.version = 1
|
|
}
|
|
entry.renderKey = chatEntryRenderKey{}
|
|
entry.renderLines = nil
|
|
}
|
|
|
|
func entryRenderKey(entry chatEntry, body string, width int) chatEntryRenderKey {
|
|
if entry.version > 0 {
|
|
return chatEntryRenderKey{width: width, version: entry.version}
|
|
}
|
|
return chatEntryRenderKey{width: width, hash: fallbackEntryRenderHash(entry, body)}
|
|
}
|
|
|
|
func fallbackEntryRenderHash(entry chatEntry, body string) string {
|
|
var b strings.Builder
|
|
writeEntryRenderHash(&b, entry)
|
|
b.WriteString("\x00body\x00")
|
|
b.WriteString(body)
|
|
return b.String()
|
|
}
|
|
|
|
func writeEntryRenderHash(b *strings.Builder, entry chatEntry) {
|
|
for _, value := range []string{
|
|
entry.role,
|
|
entry.content,
|
|
entry.label,
|
|
entry.detail,
|
|
entry.status,
|
|
entry.err,
|
|
entry.toolID,
|
|
strconv.FormatBool(entry.expanded),
|
|
strconv.FormatInt(entry.startedAt.UnixNano(), 10),
|
|
strconv.FormatInt(entry.finishedAt.UnixNano(), 10),
|
|
strings.Join(metricsSummaryLines(entry.metrics), "\n"),
|
|
} {
|
|
b.WriteString(value)
|
|
b.WriteByte(0)
|
|
}
|
|
for _, tool := range entry.tools {
|
|
writeEntryRenderHash(b, tool)
|
|
}
|
|
}
|
|
|
|
func entriesFromMessages(messages []api.Message) []chatEntry {
|
|
entries := make([]chatEntry, 0, len(messages))
|
|
toolCalls := make(map[string]api.ToolCall)
|
|
for _, msg := range messages {
|
|
switch msg.Role {
|
|
case "user", "system":
|
|
if summary, ok := compactionSummaryContent(msg); ok {
|
|
entries = append(entries, newChatEntry(chatEntry{
|
|
role: "compaction_summary",
|
|
content: summary,
|
|
status: "done",
|
|
}))
|
|
continue
|
|
}
|
|
entries = append(entries, newChatEntry(chatEntry{role: msg.Role, content: msg.Content}))
|
|
case "assistant":
|
|
for _, call := range msg.ToolCalls {
|
|
if call.ID != "" {
|
|
toolCalls[call.ID] = call
|
|
}
|
|
}
|
|
if strings.TrimSpace(msg.Content) != "" {
|
|
entries = append(entries, newChatEntry(chatEntry{role: "assistant", content: msg.Content}))
|
|
}
|
|
case "tool":
|
|
if summary, ok := compactionSummaryContent(msg); ok {
|
|
entries = append(entries, newChatEntry(chatEntry{
|
|
role: "compaction_summary",
|
|
content: summary,
|
|
status: "done",
|
|
}))
|
|
continue
|
|
}
|
|
toolName := msg.ToolName
|
|
var args map[string]any
|
|
if call, ok := toolCalls[msg.ToolCallID]; ok {
|
|
if toolName == "" {
|
|
toolName = call.Function.Name
|
|
}
|
|
args = call.Function.Arguments.ToMap()
|
|
}
|
|
entries = append(entries, newChatEntry(chatEntry{
|
|
role: "tool",
|
|
content: msg.Content,
|
|
label: toolInvocationLabel(toolName, args),
|
|
detail: toolName,
|
|
status: "done",
|
|
toolID: msg.ToolCallID,
|
|
args: args,
|
|
}))
|
|
}
|
|
}
|
|
return groupCompletedToolEntries(entries)
|
|
}
|
|
|
|
func compactionSummaryContent(msg api.Message) (string, bool) {
|
|
return coreagent.CompactionSummaryContent(msg)
|
|
}
|
|
|
|
func groupCompletedToolEntries(entries []chatEntry) []chatEntry {
|
|
grouped := make([]chatEntry, 0, len(entries))
|
|
for i := 0; i < len(entries); {
|
|
if !isCompletedToolHistoryEntry(entries[i]) {
|
|
grouped = append(grouped, entries[i])
|
|
i++
|
|
continue
|
|
}
|
|
|
|
start := i
|
|
for i < len(entries) && isCompletedToolHistoryEntry(entries[i]) {
|
|
i++
|
|
}
|
|
|
|
tools := flattenToolHistory(entries[start:i])
|
|
if len(tools) <= 1 {
|
|
grouped = append(grouped, entries[start:i]...)
|
|
continue
|
|
}
|
|
|
|
group := chatEntry{
|
|
role: "tool_group",
|
|
label: fmt.Sprintf("Tool calls (%d)", len(tools)),
|
|
status: aggregateToolStatus(tools),
|
|
expanded: anyToolExpanded(tools),
|
|
startedAt: firstToolStartedAt(tools),
|
|
finishedAt: lastToolFinishedAt(tools),
|
|
tools: tools,
|
|
}
|
|
if group.status == "error" {
|
|
group.err = "one or more tool calls failed"
|
|
}
|
|
grouped = append(grouped, newChatEntry(group))
|
|
}
|
|
return grouped
|
|
}
|
|
|
|
func anyToolExpanded(tools []chatEntry) bool {
|
|
for _, tool := range tools {
|
|
if tool.expanded {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isCompletedToolHistoryEntry(entry chatEntry) bool {
|
|
return (entry.role == "tool" && isToolResultStatus(entry.status)) ||
|
|
(entry.role == "tool_group" && len(entry.tools) > 0)
|
|
}
|
|
|
|
func flattenToolHistory(entries []chatEntry) []chatEntry {
|
|
var tools []chatEntry
|
|
for _, entry := range entries {
|
|
switch entry.role {
|
|
case "tool":
|
|
tools = append(tools, entry)
|
|
case "tool_group":
|
|
tools = append(tools, entry.tools...)
|
|
}
|
|
}
|
|
return tools
|
|
}
|
|
|
|
func aggregateToolStatus(tools []chatEntry) string {
|
|
for _, tool := range tools {
|
|
if tool.err != "" || tool.status == "error" {
|
|
return "error"
|
|
}
|
|
}
|
|
return "done"
|
|
}
|
|
|
|
func firstToolStartedAt(tools []chatEntry) time.Time {
|
|
for _, tool := range tools {
|
|
if !tool.startedAt.IsZero() {
|
|
return tool.startedAt
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
func lastToolFinishedAt(tools []chatEntry) time.Time {
|
|
for i := len(tools) - 1; i >= 0; i-- {
|
|
if !tools[i].finishedAt.IsZero() {
|
|
return tools[i].finishedAt
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
func indentLines(lines []string, prefix string) []string {
|
|
if len(lines) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, len(lines))
|
|
for i, line := range lines {
|
|
if line == "" {
|
|
out[i] = prefix
|
|
} else {
|
|
out[i] = prefix + line
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func wrapChatText(text string, width int) []string {
|
|
if width < 20 {
|
|
width = 20
|
|
}
|
|
var out []string
|
|
for _, rawLine := range strings.Split(text, "\n") {
|
|
line := strings.TrimRight(rawLine, "\r")
|
|
for runewidth.StringWidth(line) > width {
|
|
cut := chatDisplayWidthCut(line, width)
|
|
out = append(out, strings.TrimSpace(line[:cut]))
|
|
line = strings.TrimSpace(line[cut:])
|
|
}
|
|
out = append(out, line)
|
|
}
|
|
if len(out) == 0 {
|
|
return []string{""}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func chatDisplayWidthCut(line string, width int) int {
|
|
hardCut := 0
|
|
currentWidth := 0
|
|
spaceCut := 0
|
|
spaceWidth := 0
|
|
for i := 0; i < len(line); {
|
|
r, size := utf8.DecodeRuneInString(line[i:])
|
|
nextWidth := currentWidth + runewidth.RuneWidth(r)
|
|
if nextWidth > width {
|
|
break
|
|
}
|
|
currentWidth = nextWidth
|
|
hardCut = i + size
|
|
if (r == ' ' || r == '\t') && currentWidth > width/2 {
|
|
spaceCut = i
|
|
spaceWidth = currentWidth
|
|
}
|
|
i += size
|
|
}
|
|
if spaceCut > 0 && spaceWidth > 0 {
|
|
return spaceCut
|
|
}
|
|
if hardCut > 0 {
|
|
return hardCut
|
|
}
|
|
_, size := utf8.DecodeRuneInString(line)
|
|
return size
|
|
}
|