mirror of
https://github.com/ollama/ollama.git
synced 2026-07-15 20:54:54 -05:00
2170 lines
54 KiB
Go
2170 lines
54 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
|
|
}
|
|
|
|
const (
|
|
chatMessageIndent = " "
|
|
chatUserMessagePrefix = ""
|
|
maxCtrlOToolOutputRunes = 400
|
|
|
|
defaultViewWidth = 80
|
|
defaultViewHeight = 24
|
|
)
|
|
|
|
type chatEntryRenderKey struct {
|
|
width int
|
|
version int
|
|
}
|
|
|
|
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() {
|
|
if m.hasPendingDetectedToolCalls() {
|
|
m.applyToolOutputMode()
|
|
return
|
|
}
|
|
m.entries = groupCompletedToolEntries(m.entries, m.detectedToolCalls...)
|
|
m.applyToolOutputMode()
|
|
}
|
|
|
|
func (m chatModel) hasPendingDetectedToolCalls() bool {
|
|
if len(m.detectedToolCalls) == 0 {
|
|
return false
|
|
}
|
|
results := map[string]struct{}{}
|
|
for _, tool := range flattenToolHistory(m.entries) {
|
|
if tool.toolID == "" || !isToolResultStatus(tool.status) {
|
|
continue
|
|
}
|
|
results[tool.toolID] = struct{}{}
|
|
}
|
|
for _, tool := range m.detectedToolCalls {
|
|
if tool.toolID == "" {
|
|
continue
|
|
}
|
|
if _, ok := results[tool.toolID]; !ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
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
|
|
first := true
|
|
for index, entry := range m.entries {
|
|
prefix, body := m.renderEntry(entry)
|
|
prefixWidth := lipgloss.Width(prefix)
|
|
continuation := ""
|
|
if prefixWidth > 0 {
|
|
continuation = strings.Repeat(" ", prefixWidth)
|
|
}
|
|
lines := m.renderEntryLinesCached(index, entry, body, width-prefixWidth)
|
|
if len(lines) == 0 {
|
|
continue
|
|
}
|
|
if !first {
|
|
b.WriteByte('\n')
|
|
}
|
|
first = false
|
|
for i, line := range lines {
|
|
if i == 0 {
|
|
b.WriteString(prefix)
|
|
b.WriteString(line)
|
|
} else {
|
|
b.WriteString(continuation)
|
|
b.WriteString(line)
|
|
}
|
|
b.WriteByte('\n')
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// renderEntryLinesCached renders (and memoizes) the wrapped lines for an entry.
|
|
// Despite the value receiver, the cache write below mutates the caller's entries
|
|
// in place: m.entries is a slice, so the value-receiver copy shares its backing
|
|
// array with the caller. markEntryDirty invalidates a cached entry by bumping
|
|
// its version, which entryRenderKey compares against to decide whether to reuse
|
|
// or re-render.
|
|
func (m chatModel) renderEntryLinesCached(index int, entry chatEntry, body string, width int) []string {
|
|
key := entryRenderKey(entry, 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) 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 {
|
|
width, _ := m.viewSize()
|
|
return width
|
|
}
|
|
|
|
// defaultSize clamps caller-supplied dimensions to the standard fallbacks
|
|
// (80x24) when unset. Free functions that receive width/height as arguments
|
|
// (rather than reading them off the model) use this directly; methods use
|
|
// viewSize, which reads m.width/m.height first.
|
|
func defaultSize(width, height int) (int, int) {
|
|
if width <= 0 {
|
|
width = defaultViewWidth
|
|
}
|
|
if height <= 0 {
|
|
height = defaultViewHeight
|
|
}
|
|
return width, height
|
|
}
|
|
|
|
func (m chatModel) viewSize() (int, int) {
|
|
return defaultSize(m.width, m.height)
|
|
}
|
|
|
|
func (m chatModel) transcriptHeight() int {
|
|
width, height := m.viewSize()
|
|
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, height := m.viewSize()
|
|
headerHeight := len(m.headerLines())
|
|
bottomLines := m.bottomLines(width, height-headerHeight)
|
|
transcriptLineCount := len(m.transcriptLines(width))
|
|
return headerHeight, max(0, height-headerHeight-len(bottomLines)-transcriptInputGap(height-headerHeight, len(bottomLines), transcriptLineCount))
|
|
}
|
|
|
|
func (m chatModel) maxScroll() int {
|
|
width, _ := m.viewSize()
|
|
return max(0, len(m.transcriptLines(width))-m.transcriptHeight())
|
|
}
|
|
|
|
func (m chatModel) bottomLines(width, maxHeight int) []string {
|
|
var lines []string
|
|
if m.modelPicker != nil {
|
|
lines = append(lines, m.renderInlineModelPicker(width)...)
|
|
} else {
|
|
lines = append(lines, m.completionLines(width)...)
|
|
}
|
|
|
|
actionStatusLines := m.renderActionStatusLines(width)
|
|
approvalLines := m.renderApprovalPromptLines(width)
|
|
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) + 2
|
|
if len(modelLines) > 0 {
|
|
fixedLines += len(modelLines)
|
|
}
|
|
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())...)
|
|
if len(modelLines) > 0 {
|
|
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
|
|
}
|
|
indent := inputBoxTextIndent()
|
|
lines := wrapChatText(strings.Join(parts, " "), max(20, width-lipgloss.Width(indent)))
|
|
for i := range lines {
|
|
lines[i] = renderFooterPlainLine(indent + lines[i])
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func (m chatModel) renderActionStatusLines(width int) []string {
|
|
if activity := m.activityLine(); activity != "" {
|
|
return []string{chatMetaStyle.Render(inputBoxTextIndent() + activity)}
|
|
}
|
|
if notificationLines := m.renderNotificationLines(width); len(notificationLines) > 0 {
|
|
return notificationLines
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func transcriptInputGap(maxHeight, bottomLineCount, transcriptLineCount int) int {
|
|
const desiredGap = 1
|
|
if transcriptLineCount == 0 {
|
|
return 0
|
|
}
|
|
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 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 := transcriptLineTextForSelection(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 transcriptLineTextForSelection(text string) string {
|
|
firstPrefix := chatMessageIndent + chatUserMessagePrefix
|
|
if strings.HasPrefix(text, firstPrefix) {
|
|
return chatMessageIndent + strings.TrimPrefix(text, firstPrefix)
|
|
}
|
|
continuationPrefix := chatMessageIndent + strings.Repeat(" ", lipgloss.Width(chatUserMessagePrefix))
|
|
if strings.HasPrefix(text, continuationPrefix) {
|
|
return chatMessageIndent + strings.TrimPrefix(text, continuationPrefix)
|
|
}
|
|
return text
|
|
}
|
|
|
|
func (m chatModel) renderEntry(entry chatEntry) (string, string) {
|
|
switch entry.role {
|
|
case "user":
|
|
return "", entry.content
|
|
case "assistant":
|
|
return "", entry.content
|
|
case "thinking":
|
|
return chatMetaStyle.Render("•") + " ", thinkingStatusLine(entry)
|
|
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":
|
|
innerWidth := max(1, width-lipgloss.Width(chatMessageIndent))
|
|
lines := indentLines(splitRenderedBody(renderMarkdownForView(body, innerWidth)), chatMessageIndent)
|
|
lines = append(lines, indentLines(renderMetricsLines(entry.metrics, innerWidth), chatMessageIndent)...)
|
|
return lines
|
|
case "thinking":
|
|
return renderThinkingLines(entry, width)
|
|
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 {
|
|
if width < 20 {
|
|
width = 20
|
|
}
|
|
firstPrefix := chatMessageIndent + chatUserMessagePrefix
|
|
continuationPrefix := chatMessageIndent + strings.Repeat(" ", lipgloss.Width(chatUserMessagePrefix))
|
|
innerWidth := max(1, width-lipgloss.Width(firstPrefix))
|
|
lines := wrapChatText(content, innerWidth)
|
|
for i, line := range lines {
|
|
prefix := continuationPrefix
|
|
if i == 0 {
|
|
prefix = firstPrefix
|
|
}
|
|
lines[i] = chatUserBlockStyle.Render(padRenderedLine(prefix+line, width))
|
|
}
|
|
return lines
|
|
}
|
|
|
|
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 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 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 renderThinkingLines(entry chatEntry, width int) []string {
|
|
if !entry.expanded || strings.TrimSpace(entry.content) == "" {
|
|
return nil
|
|
}
|
|
lines := wrapChatText(thinkingStatusLine(entry), width)
|
|
lines = append(lines, "")
|
|
lines = append(lines, indentLines(splitRenderedBody(renderMarkdownForView(entry.content, width-2)), " ")...)
|
|
return lines
|
|
}
|
|
|
|
func thinkingStatusLine(entry chatEntry) string {
|
|
if strings.TrimSpace(entry.label) != "" {
|
|
return entry.label
|
|
}
|
|
return "Thinking"
|
|
}
|
|
|
|
func (m chatModel) thinkingLabel() string {
|
|
return thinkingActivityLabel(m.thinkingTokens)
|
|
}
|
|
|
|
func thinkingActivityLabel(tokens int) string {
|
|
if tokens > 0 {
|
|
return "Thinking ↓ " + formatTokenCount(tokens)
|
|
}
|
|
return "Thinking"
|
|
}
|
|
|
|
func (m *chatModel) syncThinkingEntry() {
|
|
if strings.TrimSpace(m.latestLiveThinking()) == "" {
|
|
return
|
|
}
|
|
idx := -1
|
|
if len(m.entries) > 0 && m.entries[len(m.entries)-1].role == "thinking" && m.entries[len(m.entries)-1].status == "running" {
|
|
idx = len(m.entries) - 1
|
|
}
|
|
if idx < 0 {
|
|
m.entries = append(m.entries, newChatEntry(chatEntry{role: "thinking", status: "running"}))
|
|
idx = len(m.entries) - 1
|
|
}
|
|
m.entries[idx].content = m.latestLiveThinking()
|
|
m.entries[idx].label = m.thinkingLabel()
|
|
m.entries[idx].status = "running"
|
|
m.entries[idx].expanded = false
|
|
m.markEntryDirty(idx)
|
|
}
|
|
|
|
func (m chatModel) latestLiveThinking() string {
|
|
for i := len(m.liveMessages) - 1; i >= 0; i-- {
|
|
if m.liveMessages[i].Role == "assistant" && strings.TrimSpace(m.liveMessages[i].Thinking) != "" {
|
|
return m.liveMessages[i].Thinking
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (m *chatModel) finishThinkingEntry() {
|
|
if len(m.entries) == 0 {
|
|
return
|
|
}
|
|
idx := len(m.entries) - 1
|
|
if m.entries[idx].role != "thinking" || m.entries[idx].status != "running" {
|
|
return
|
|
}
|
|
m.entries[idx].status = "done"
|
|
m.entries[idx].label = m.thinkingLabel()
|
|
m.markEntryDirty(idx)
|
|
}
|
|
|
|
func toolGroupChildStatusLine(entry chatEntry) string {
|
|
label := toolGroupChildStatusLabel(entry)
|
|
|
|
segment := renderToolStatusSegment(entry)
|
|
if segment == "" {
|
|
return boldToolInvocationName(label)
|
|
}
|
|
return fmt.Sprintf("%s %s", boldToolInvocationName(label), segment)
|
|
}
|
|
|
|
func toolGroupChildStatusLabel(entry chatEntry) string {
|
|
if strings.TrimSpace(entry.label) != "" {
|
|
return entry.label
|
|
}
|
|
if strings.TrimSpace(entry.detail) != "" {
|
|
return toolInvocationLabel(entry.detail, entry.args)
|
|
}
|
|
return toolEntryStatusLabel(entry)
|
|
}
|
|
|
|
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)
|
|
output = truncateCtrlOToolOutput(output)
|
|
if looksLikeUnifiedDiff(output) {
|
|
return splitRenderedBody(renderDiffForView(output, width))
|
|
}
|
|
if toolOutputUsesMarkdown(entry.detail) {
|
|
return styleLines(splitRenderedBody(renderMarkdownForView(output, width)), chatToolOutputStyle)
|
|
}
|
|
return styleLines(wrapChatText(output, width), chatToolOutputStyle)
|
|
}
|
|
|
|
func styleLines(lines []string, style lipgloss.Style) []string {
|
|
for i := range lines {
|
|
lines[i] = style.Render(lines[i])
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func truncateCtrlOToolOutput(output string) string {
|
|
runes := []rune(output)
|
|
if len(runes) <= maxCtrlOToolOutputRunes {
|
|
return output
|
|
}
|
|
return string(runes[:maxCtrlOToolOutputRunes-3]) + "..."
|
|
}
|
|
|
|
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" || status == "denied" || status == "disabled"
|
|
}
|
|
|
|
// 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 chatMetaStyle.Render(s)
|
|
}
|
|
|
|
func toolStatusLine(entry chatEntry) string {
|
|
label := toolEntryStatusLabel(entry)
|
|
|
|
segment := renderToolStatusSegment(entry)
|
|
if segment == "" {
|
|
return label
|
|
}
|
|
return fmt.Sprintf("%s %s", label, segment)
|
|
}
|
|
|
|
func toolEntryStatusLabel(entry chatEntry) string {
|
|
if isShellToolName(entry.detail) {
|
|
switch entry.status {
|
|
case "approval":
|
|
if entry.label != "" {
|
|
return entry.label
|
|
}
|
|
return toolInvocationLabel(entry.detail, entry.args)
|
|
case "denied":
|
|
if entry.label != "" {
|
|
return entry.label + " denied"
|
|
}
|
|
return toolInvocationLabel(entry.detail, entry.args) + " denied"
|
|
case "queued", "running", "done", "error":
|
|
if entry.label != "" {
|
|
return entry.label
|
|
}
|
|
return toolInvocationLabel(entry.detail, entry.args)
|
|
}
|
|
}
|
|
if entry.status == "denied" {
|
|
if entry.label != "" {
|
|
return entry.label + " denied"
|
|
}
|
|
return toolDisplayName(entry.detail) + " denied"
|
|
}
|
|
if entry.label != "" {
|
|
return entry.label
|
|
}
|
|
return toolDisplayName(entry.detail)
|
|
}
|
|
|
|
func toolGroupStatusLine(entry chatEntry) string {
|
|
label := entry.label
|
|
if label == "" || strings.HasPrefix(label, "Tool calls (") {
|
|
label = toolGroupSummary(entry.tools)
|
|
}
|
|
|
|
segment := renderToolStatusSegment(entry)
|
|
if segment == "" {
|
|
return label
|
|
}
|
|
return fmt.Sprintf("%s %s", label, segment)
|
|
}
|
|
|
|
func toolGroupSummary(tools []chatEntry) string {
|
|
if len(tools) == 0 {
|
|
return "Used tools"
|
|
}
|
|
|
|
type actionCount struct {
|
|
action string
|
|
count int
|
|
}
|
|
|
|
var counts []actionCount
|
|
indexes := map[string]int{}
|
|
for _, tool := range tools {
|
|
action := toolActionForEntry(tool)
|
|
if index, ok := indexes[action]; ok {
|
|
counts[index].count++
|
|
continue
|
|
}
|
|
indexes[action] = len(counts)
|
|
counts = append(counts, actionCount{action: action, count: 1})
|
|
}
|
|
|
|
phrases := make([]string, 0, len(counts))
|
|
for _, count := range counts {
|
|
phrases = append(phrases, toolActionPhrase(count.action, count.count))
|
|
}
|
|
return joinToolActionPhrases(phrases)
|
|
}
|
|
|
|
func toolActionForEntry(tool chatEntry) string {
|
|
if tool.status == "denied" {
|
|
if isShellToolName(tool.detail) || strings.Contains(strings.ToLower(tool.label), "bash(") || strings.Contains(strings.ToLower(tool.label), "powershell(") {
|
|
return "denied_command"
|
|
}
|
|
return "denied_tool"
|
|
}
|
|
if tool.status == "disabled" {
|
|
if isShellToolName(tool.detail) || strings.Contains(strings.ToLower(tool.label), "bash(") || strings.Contains(strings.ToLower(tool.label), "powershell(") {
|
|
return "disabled_command"
|
|
}
|
|
return "disabled_tool"
|
|
}
|
|
action := toolAction(tool.detail)
|
|
if action == "" {
|
|
action = toolAction(tool.label)
|
|
}
|
|
if action == "" {
|
|
action = "tool"
|
|
}
|
|
return action
|
|
}
|
|
|
|
func toolAction(name string) string {
|
|
name = strings.TrimSpace(strings.ToLower(name))
|
|
switch {
|
|
case isShellToolName(name):
|
|
return "command"
|
|
case strings.Contains(name, "bash") || strings.Contains(name, "powershell"):
|
|
return "command"
|
|
case strings.HasPrefix(name, "edit("):
|
|
return "edit"
|
|
case strings.HasPrefix(name, "read("):
|
|
return "read"
|
|
case strings.HasPrefix(name, "list("):
|
|
return "list"
|
|
case strings.HasPrefix(name, "web search("):
|
|
return "search"
|
|
case strings.HasPrefix(name, "web fetch("):
|
|
return "fetch"
|
|
case strings.HasPrefix(name, "skill("):
|
|
return "skill"
|
|
}
|
|
switch name {
|
|
case "edit":
|
|
return "edit"
|
|
case "read":
|
|
return "read"
|
|
case "list":
|
|
return "list"
|
|
case "web_search":
|
|
return "search"
|
|
case "web_fetch":
|
|
return "fetch"
|
|
case "skill":
|
|
return "skill"
|
|
default:
|
|
return "tool"
|
|
}
|
|
}
|
|
|
|
func toolActionPhrase(action string, count int) string {
|
|
plural := count != 1
|
|
switch action {
|
|
case "denied_command":
|
|
if plural {
|
|
return fmt.Sprintf("Denied %d commands", count)
|
|
}
|
|
return "Denied a command"
|
|
case "denied_tool":
|
|
if plural {
|
|
return fmt.Sprintf("Denied %d tools", count)
|
|
}
|
|
return "Denied a tool"
|
|
case "disabled_command":
|
|
if plural {
|
|
return fmt.Sprintf("Skipped %d commands", count)
|
|
}
|
|
return "Skipped a command"
|
|
case "disabled_tool":
|
|
if plural {
|
|
return fmt.Sprintf("Skipped %d tools", count)
|
|
}
|
|
return "Skipped a tool"
|
|
case "command":
|
|
if plural {
|
|
return fmt.Sprintf("Ran %d commands", count)
|
|
}
|
|
return "Ran 1 command"
|
|
case "edit":
|
|
if plural {
|
|
return fmt.Sprintf("Edited %d files", count)
|
|
}
|
|
return "Edited a file"
|
|
case "read":
|
|
if plural {
|
|
return fmt.Sprintf("Read %d files", count)
|
|
}
|
|
return "Read a file"
|
|
case "list":
|
|
if plural {
|
|
return fmt.Sprintf("Listed files %d times", count)
|
|
}
|
|
return "Listed files"
|
|
case "search":
|
|
if plural {
|
|
return fmt.Sprintf("Searched the web %d times", count)
|
|
}
|
|
return "Searched the web"
|
|
case "fetch":
|
|
if plural {
|
|
return fmt.Sprintf("Fetched %d URLs", count)
|
|
}
|
|
return "Fetched a URL"
|
|
case "skill":
|
|
if plural {
|
|
return fmt.Sprintf("Ran %d skills", count)
|
|
}
|
|
return "Ran a skill"
|
|
default:
|
|
if plural {
|
|
return fmt.Sprintf("Used %d tools", count)
|
|
}
|
|
return "Used a tool"
|
|
}
|
|
}
|
|
|
|
func joinToolActionPhrases(phrases []string) string {
|
|
switch len(phrases) {
|
|
case 0:
|
|
return "Used tools"
|
|
case 1:
|
|
return phrases[0]
|
|
case 2:
|
|
return phrases[0] + " and " + lowerInitial(phrases[1])
|
|
default:
|
|
for i := 1; i < len(phrases); i++ {
|
|
phrases[i] = lowerInitial(phrases[i])
|
|
}
|
|
return strings.Join(phrases[:len(phrases)-1], ", ") + ", and " + phrases[len(phrases)-1]
|
|
}
|
|
}
|
|
|
|
func lowerInitial(s string) string {
|
|
if s == "" {
|
|
return s
|
|
}
|
|
runes := []rune(s)
|
|
runes[0] = []rune(strings.ToLower(string(runes[0])))[0]
|
|
return string(runes)
|
|
}
|
|
|
|
func toolGroupPrefixStyle(entry chatEntry) lipgloss.Style {
|
|
succeeded, failed, denied := toolGroupResultCounts(entry.tools)
|
|
switch {
|
|
case succeeded > 0 && (failed > 0 || denied > 0):
|
|
return chatToolMixedStyle
|
|
case succeeded > 0:
|
|
return chatToolDoneStyle
|
|
case denied > 0 && failed == 0:
|
|
return toolStatusStyle("denied")
|
|
default:
|
|
return toolStatusStyle(entry.status)
|
|
}
|
|
}
|
|
|
|
func toolGroupResultCounts(tools []chatEntry) (succeeded int, failed int, denied int) {
|
|
for _, tool := range tools {
|
|
if tool.status == "denied" || tool.status == "disabled" {
|
|
denied++
|
|
continue
|
|
}
|
|
if tool.err != "" || tool.status == "error" {
|
|
failed++
|
|
continue
|
|
}
|
|
if tool.status == "done" {
|
|
succeeded++
|
|
}
|
|
}
|
|
return succeeded, failed, denied
|
|
}
|
|
|
|
func toolStatusLabel(entry chatEntry) string {
|
|
if entry.status == "approval" {
|
|
return "needs approval"
|
|
}
|
|
if entry.status == "running" || entry.status == "queued" {
|
|
return ""
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func toolStatusStyle(status string) lipgloss.Style {
|
|
switch status {
|
|
case "queued", "running", "approval":
|
|
return chatToolRunningStyle
|
|
case "done":
|
|
return chatToolDoneStyle
|
|
case "error":
|
|
return chatErrorStyle
|
|
case "denied", "disabled":
|
|
return chatToolMixedStyle
|
|
default:
|
|
return chatMetaStyle
|
|
}
|
|
}
|
|
|
|
func toolInvocationLabel(name string, args map[string]any) string {
|
|
displayName := toolDisplayName(name)
|
|
for _, key := range []string{"query", "url", "command", "path", "name"} {
|
|
if value, ok := rawStringArg(args, key); ok {
|
|
if isShellToolName(name) && key == "command" {
|
|
value = truncateRunes(value, 100)
|
|
}
|
|
return fmt.Sprintf("%s(%s)", displayName, strconv.Quote(value))
|
|
}
|
|
}
|
|
if len(args) == 0 {
|
|
return displayName
|
|
}
|
|
return fmt.Sprintf("%s(%s)", displayName, formatDisplayArgs(args))
|
|
}
|
|
|
|
func toolDisplayName(name string) string {
|
|
switch name {
|
|
case "web_search":
|
|
return "Web Search"
|
|
case "web_fetch":
|
|
return "Web Fetch"
|
|
case "bash":
|
|
return "Bash"
|
|
case "powershell":
|
|
return "PowerShell"
|
|
case "read":
|
|
return "Read"
|
|
case "list":
|
|
return "List"
|
|
case "edit":
|
|
return "Edit"
|
|
default:
|
|
if name == "" {
|
|
return "Tool"
|
|
}
|
|
return 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 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 isShellToolName(name string) bool {
|
|
return name == "bash" || name == "powershell"
|
|
}
|
|
|
|
func formatDisplayArgs(args map[string]any) string {
|
|
keys := make([]string, 0, len(args))
|
|
for key := range args {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
parts := make([]string, 0, len(keys))
|
|
for _, key := range keys {
|
|
value := truncateRunes(fmt.Sprintf("%v", args[key]), 100)
|
|
parts = append(parts, fmt.Sprintf("%s=%s", key, strconv.Quote(value)))
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
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 isDeniedToolResult(value string) bool {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
return strings.Contains(value, "tool execution denied") ||
|
|
strings.Contains(value, "tool approval canceled")
|
|
}
|
|
|
|
func isDisabledToolResult(value string) bool {
|
|
return strings.Contains(strings.ToLower(strings.TrimSpace(value)), "tool execution disabled")
|
|
}
|
|
|
|
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 "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
|
|
}
|
|
indent := inputBoxTextIndent()
|
|
lines := wrapChatText(line, max(20, width-lipgloss.Width(indent)))
|
|
for i, wrapped := range lines {
|
|
lines[i] = chatNotificationStyle.Render(indent + wrapped)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func inputBoxTextIndent() string {
|
|
return strings.Repeat(" ", inputBoxHorizontalPadding+1)
|
|
}
|
|
|
|
func renderFooterPlainLine(line string) string {
|
|
const fullAccess = "full access"
|
|
if !strings.Contains(line, fullAccess) {
|
|
return chatFooterStyle.Render(line)
|
|
}
|
|
|
|
var b strings.Builder
|
|
for {
|
|
before, after, ok := strings.Cut(line, fullAccess)
|
|
if before != "" {
|
|
b.WriteString(chatFooterStyle.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.allowAllToolsEnabled() && 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.awaitingToolStart() {
|
|
return statusWithSpinner(m.spinnerFrame(), "Working")
|
|
}
|
|
if m.awaitingModel {
|
|
return statusWithSpinner(m.spinnerFrame(), "Working")
|
|
}
|
|
if !m.waitingForModel() || m.spinner < idleWorkingDelayTicks {
|
|
return ""
|
|
}
|
|
if m.preloadingModel != "" {
|
|
return statusWithSpinner(m.spinnerFrame(), "Working")
|
|
}
|
|
return statusWithSpinner(m.spinnerFrame(), "Working")
|
|
}
|
|
if m.thinking {
|
|
return label
|
|
}
|
|
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 {
|
|
return thinkingActivityLabel(m.thinkingTokens)
|
|
}
|
|
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 "tool_group":
|
|
if entryHasActiveTool(entry) {
|
|
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 "tool_group":
|
|
if entryHasActiveTool(entry) {
|
|
return false
|
|
}
|
|
case "assistant":
|
|
return true
|
|
}
|
|
}
|
|
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) 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 {
|
|
total := approximateTokenCount(systemPrompt)
|
|
for _, msg := range messages {
|
|
total += approximateTokenCount(msg.Role)
|
|
total += approximateTokenCount(msg.Content)
|
|
total += approximateTokenCount(msg.Thinking)
|
|
total += approximateTokenCount(msg.ToolName)
|
|
total += approximateTokenCount(msg.ToolCallID)
|
|
for _, call := range msg.ToolCalls {
|
|
total += approximateTokenCount(call.Function.Name)
|
|
total += approximateTokenCount(call.Function.Arguments.String())
|
|
}
|
|
}
|
|
total += approximateTokenCount(tools.String())
|
|
total += approximateTokenCount(format)
|
|
return total
|
|
}
|
|
|
|
func approximateTokenCount(text string) int {
|
|
n := len([]rune(text))
|
|
if n <= 0 {
|
|
return 0
|
|
}
|
|
return max(1, (n+3)/4)
|
|
}
|
|
|
|
func formatTokenCount(count int) string {
|
|
if count == 1 {
|
|
return "1 token"
|
|
}
|
|
return fmt.Sprintf("%d tokens", count)
|
|
}
|
|
|
|
func (m chatModel) contextStatus() string {
|
|
window := m.displayContextWindowTokens()
|
|
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%% used)", 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%% used)", prefix, formatContextTokenCount(used), formatContextTokenCount(window), percent)
|
|
}
|
|
|
|
if percent > 60 {
|
|
return fmt.Sprintf("ctx %s%s / %s (%d%% used)", prefix, formatContextTokenCount(used), formatContextTokenCount(window), percent)
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func (m chatModel) displayContextWindowTokens() int {
|
|
if n := chatIntOption(m.opts.Options, "num_ctx"); n > 0 {
|
|
return n
|
|
}
|
|
return max(0, m.opts.ContextWindowTokens)
|
|
}
|
|
|
|
func chatIntOption(options map[string]any, key string) int {
|
|
if options == nil {
|
|
return 0
|
|
}
|
|
switch v := options[key].(type) {
|
|
case int:
|
|
return max(0, v)
|
|
case int32:
|
|
return max(0, int(v))
|
|
case int64:
|
|
return max(0, int(v))
|
|
case uint:
|
|
return int(v)
|
|
case uint32:
|
|
return int(v)
|
|
case uint64:
|
|
return int(v)
|
|
case float64:
|
|
if v == float64(int(v)) {
|
|
return max(0, int(v))
|
|
}
|
|
case string:
|
|
n, err := strconv.Atoi(strings.TrimSpace(v))
|
|
if err == nil {
|
|
return max(0, n)
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
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 {
|
|
width, height = defaultSize(width, height)
|
|
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 {
|
|
width, height = defaultSize(width, height)
|
|
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, width int) chatEntryRenderKey {
|
|
return chatEntryRenderKey{width: width, version: entry.version}
|
|
}
|
|
|
|
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":
|
|
if strings.TrimSpace(msg.Thinking) != "" {
|
|
entries = append(entries, newChatEntry(chatEntry{
|
|
role: "thinking",
|
|
content: msg.Thinking,
|
|
label: "Thinking",
|
|
status: "done",
|
|
}))
|
|
}
|
|
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()
|
|
}
|
|
status := "done"
|
|
if isDisabledToolResult(msg.Content) {
|
|
status = "disabled"
|
|
} else if isDeniedToolResult(msg.Content) {
|
|
status = "denied"
|
|
}
|
|
entries = append(entries, newChatEntry(chatEntry{
|
|
role: "tool",
|
|
content: msg.Content,
|
|
label: toolInvocationLabel(toolName, args),
|
|
detail: toolName,
|
|
status: status,
|
|
toolID: msg.ToolCallID,
|
|
args: args,
|
|
}))
|
|
}
|
|
}
|
|
return groupCompletedToolEntries(entries)
|
|
}
|
|
|
|
func compactionSummaryContent(msg api.Message) (string, bool) {
|
|
return coreagent.CompactionSummaryContent(msg)
|
|
}
|
|
|
|
func groupCompletedToolEntries(entries []chatEntry, detected ...chatEntry) []chatEntry {
|
|
grouped := make([]chatEntry, 0, len(entries))
|
|
visibleToolIDs := visibleToolIDs(entries)
|
|
for i := 0; i < len(entries); {
|
|
if !isGroupableToolHistoryEntry(entries[i]) {
|
|
grouped = append(grouped, entries[i])
|
|
i++
|
|
continue
|
|
}
|
|
|
|
start := i
|
|
var tools []chatEntry
|
|
for i < len(entries) {
|
|
if isGroupableToolHistoryEntry(entries[i]) {
|
|
tools = append(tools, flattenToolHistory([]chatEntry{entries[i]})...)
|
|
i++
|
|
continue
|
|
}
|
|
if isInvisibleToolGroupingBoundary(entries[i]) && nextGroupableToolHistoryIndex(entries, i+1) >= 0 {
|
|
i++
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
|
|
summaryTools := toolSummaryEntries(tools, detected, visibleToolIDs)
|
|
if len(summaryTools) <= 1 {
|
|
grouped = append(grouped, flattenToolHistory(entries[start:i])...)
|
|
continue
|
|
}
|
|
|
|
group := chatEntry{
|
|
role: "tool_group",
|
|
label: toolGroupSummary(summaryTools),
|
|
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 isInvisibleToolGroupingBoundary(entry chatEntry) bool {
|
|
switch entry.role {
|
|
case "assistant":
|
|
return strings.TrimSpace(entry.content) == "" &&
|
|
strings.TrimSpace(entry.label) == "" &&
|
|
strings.TrimSpace(entry.detail) == "" &&
|
|
entry.metrics == nil
|
|
case "thinking":
|
|
return !entry.expanded
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func nextGroupableToolHistoryIndex(entries []chatEntry, index int) int {
|
|
for index < len(entries) && isInvisibleToolGroupingBoundary(entries[index]) {
|
|
index++
|
|
}
|
|
if index < len(entries) && isGroupableToolHistoryEntry(entries[index]) {
|
|
return index
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func anyToolExpanded(tools []chatEntry) bool {
|
|
for _, tool := range tools {
|
|
if tool.expanded {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isGroupableToolHistoryEntry(entry chatEntry) bool {
|
|
return (entry.role == "tool" && isToolResultStatus(entry.status)) ||
|
|
(entry.role == "tool_group" && len(entry.tools) > 0)
|
|
}
|
|
|
|
func visibleToolIDs(entries []chatEntry) map[string]struct{} {
|
|
ids := map[string]struct{}{}
|
|
for _, tool := range flattenToolHistory(entries) {
|
|
if tool.toolID != "" {
|
|
ids[tool.toolID] = struct{}{}
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func toolSummaryEntries(tools []chatEntry, detected []chatEntry, visible map[string]struct{}) []chatEntry {
|
|
if len(detected) == 0 {
|
|
return tools
|
|
}
|
|
seen := make(map[string]struct{}, len(visible)+len(tools))
|
|
for id := range visible {
|
|
seen[id] = struct{}{}
|
|
}
|
|
summary := slices.Clone(tools)
|
|
for _, tool := range detected {
|
|
if tool.toolID != "" {
|
|
if _, ok := seen[tool.toolID]; ok {
|
|
continue
|
|
}
|
|
seen[tool.toolID] = struct{}{}
|
|
}
|
|
summary = append(summary, tool)
|
|
}
|
|
return summary
|
|
}
|
|
|
|
func entryHasActiveTool(entry chatEntry) bool {
|
|
switch entry.role {
|
|
case "tool":
|
|
return isToolActiveStatus(entry.status)
|
|
case "tool_group":
|
|
for _, tool := range entry.tools {
|
|
if isToolActiveStatus(tool.status) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
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 {
|
|
denied := false
|
|
active := false
|
|
for _, tool := range tools {
|
|
if tool.err != "" || tool.status == "error" {
|
|
return "error"
|
|
}
|
|
if tool.status == "denied" {
|
|
denied = true
|
|
}
|
|
if isToolActiveStatus(tool.status) {
|
|
active = true
|
|
}
|
|
}
|
|
if denied {
|
|
return "denied"
|
|
}
|
|
if active {
|
|
return "running"
|
|
}
|
|
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
|
|
}
|