Three compounding bugs prevented tool calling with llama.cpp:
- Stream parser set argsComplete on partial JSON (e.g. "{"), dropping
subsequent argument deltas — fix: use json.Valid to detect completeness
- Missing tool_choice default — llama.cpp needs explicit "auto" to
activate its GBNF grammar constraint; now set when tools are present
- Tool names in history used internal format (fs.ls) while definitions
used API format (fs_ls) — now re-sanitized in translateMessage
Additional changes:
- Disable SDK retries for local providers (500s are deterministic)
- Dynamic capability probing via /props (llama.cpp) and /api/show
(Ollama), replacing hardcoded model prefix list
- Engine respects forced arm ToolUse capability when router is active
- Bundled /init skill with Go template blocks, context-aware for local
vs cloud models, deduplication rules against CLAUDE.md
- Tool result compaction for local models — previous round results
replaced with size markers to stay within small context windows
- Text-only fallback when tool-parse errors occur on local models
- "text-only" TUI indicator when model lacks tool support
- Session ResetError for retry after stream failures
- AllowedTools per-turn filtering in engine buildRequest
624 lines
18 KiB
Go
624 lines
18 KiB
Go
package tui
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
xansi "github.com/charmbracelet/x/ansi"
|
||
|
||
tea "charm.land/bubbletea/v2"
|
||
"charm.land/lipgloss/v2"
|
||
"somegit.dev/Owlibou/gnoma/internal/message"
|
||
"somegit.dev/Owlibou/gnoma/internal/session"
|
||
)
|
||
|
||
func (m Model) View() tea.View {
|
||
if m.width == 0 {
|
||
return tea.NewView("")
|
||
}
|
||
|
||
status := m.renderStatus()
|
||
input := m.renderInput()
|
||
topLine, bottomLine := m.renderSeparators()
|
||
|
||
// Fixed: status bar + separator + input + separator = bottom area
|
||
statusH := lipgloss.Height(status)
|
||
inputH := lipgloss.Height(input)
|
||
chatH := m.height - statusH - inputH - 2
|
||
|
||
chat := m.renderChat(chatH)
|
||
|
||
// Show "new content below" indicator when scrolled up during streaming
|
||
if m.scrollOffset > 0 && m.streaming {
|
||
indicator := lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true).Render(" ⬇ new content below")
|
||
topLine = indicator + topLine[len(indicator):]
|
||
}
|
||
|
||
v := tea.NewView(lipgloss.JoinVertical(lipgloss.Left,
|
||
chat,
|
||
topLine,
|
||
input,
|
||
bottomLine,
|
||
status,
|
||
))
|
||
if m.copyMode {
|
||
v.MouseMode = tea.MouseModeNone
|
||
} else {
|
||
v.MouseMode = tea.MouseModeCellMotion
|
||
}
|
||
v.AltScreen = true
|
||
return v
|
||
}
|
||
|
||
func (m Model) shortCwd() string {
|
||
dir := m.cwd
|
||
home, _ := os.UserHomeDir()
|
||
if strings.HasPrefix(dir, home) {
|
||
dir = "~" + dir[len(home):]
|
||
}
|
||
return dir
|
||
}
|
||
|
||
func (m Model) renderChat(height int) string {
|
||
var lines []string
|
||
|
||
// Header info — scrolls with content
|
||
status := m.session.Status()
|
||
lines = append(lines,
|
||
sHeaderBrand.Render(" gnoma ")+" "+sHeaderDim.Render("gnoma "+version),
|
||
" "+sHeaderModel.Render(fmt.Sprintf("%s/%s", status.Provider, status.Model))+
|
||
sHeaderDim.Render(" · ")+sHeaderDim.Render(m.shortCwd()),
|
||
"",
|
||
)
|
||
|
||
if len(m.messages) == 0 && !m.streaming {
|
||
lines = append(lines,
|
||
sHint.Render(" Type a message and press Enter."),
|
||
sHint.Render(" /help for commands, Ctrl+C to cancel or quit."),
|
||
"",
|
||
)
|
||
}
|
||
|
||
for _, msg := range m.messages {
|
||
lines = append(lines, m.renderMessage(msg)...)
|
||
}
|
||
|
||
// Elf tree view — shows active elfs with structured progress
|
||
if m.streaming && len(m.elfStates) > 0 {
|
||
lines = append(lines, m.renderElfTree()...)
|
||
}
|
||
|
||
// Transient: running tools (disappear when tool completes)
|
||
for _, name := range m.runningTools {
|
||
lines = append(lines, " "+sToolOutput.Render(fmt.Sprintf("⚙ [%s] running...", name)))
|
||
}
|
||
|
||
// Transient: permission prompt (disappear when approved/denied)
|
||
if m.permPending {
|
||
lines = append(lines, "")
|
||
lines = append(lines, sSystem.Render("• "+formatPermissionPrompt(m.permToolName, m.permArgs)))
|
||
lines = append(lines, "")
|
||
}
|
||
|
||
// Transient: session resume picker
|
||
if m.resumePending && len(m.resumeSessions) > 0 {
|
||
lines = append(lines, "")
|
||
lines = append(lines, sSystem.Render(" Sessions ↑↓ · Enter to load · Esc to cancel"))
|
||
lines = append(lines, "")
|
||
for i, s := range m.resumeSessions {
|
||
age := time.Since(s.UpdatedAt).Truncate(time.Minute)
|
||
label := s.ID
|
||
if s.Title != "" {
|
||
label = s.Title
|
||
if len(label) > 40 {
|
||
label = label[:40] + "…"
|
||
}
|
||
}
|
||
row := fmt.Sprintf("%-42s %s/%s %d turns %s ago",
|
||
label, s.Provider, s.Model, s.TurnCount, age)
|
||
if i == m.resumeSelected {
|
||
lines = append(lines, sText.Render("→ "+row))
|
||
} else {
|
||
lines = append(lines, sHint.Render(" "+row))
|
||
}
|
||
}
|
||
lines = append(lines, "")
|
||
}
|
||
|
||
// Streaming: show frozen thinking above live text content
|
||
if m.streaming {
|
||
maxWidth := m.width - 2
|
||
if m.thinkingBuf.Len() > 0 {
|
||
// Thinking is frozen once text starts; show dim with hollow diamond.
|
||
// Cap at 3 lines while streaming (ctrl+o expands).
|
||
const liveThinkMax = 3
|
||
thinkLines := strings.Split(wrapText(m.thinkingBuf.String(), maxWidth), "\n")
|
||
showN := len(thinkLines)
|
||
if !m.expandOutput && showN > liveThinkMax {
|
||
showN = liveThinkMax
|
||
}
|
||
for i, line := range thinkLines[:showN] {
|
||
if i == 0 {
|
||
lines = append(lines, sThinkingLabel.Render("◇ ")+sThinkingBody.Render(line))
|
||
} else {
|
||
lines = append(lines, sThinkingBody.Render(" "+line))
|
||
}
|
||
}
|
||
if !m.expandOutput && len(thinkLines) > liveThinkMax {
|
||
lines = append(lines, sHint.Render(fmt.Sprintf(" +%d lines (ctrl+o to expand)", len(thinkLines)-liveThinkMax)))
|
||
}
|
||
}
|
||
if m.streamBuf.Len() > 0 {
|
||
// Regular text content — strip model artifacts before display
|
||
liveText := sanitizeAssistantText(m.streamBuf.String())
|
||
for i, line := range strings.Split(wrapText(liveText, maxWidth), "\n") {
|
||
if i == 0 {
|
||
lines = append(lines, styleAssistantLabel.Render("◆ ")+line)
|
||
} else {
|
||
lines = append(lines, " "+line)
|
||
}
|
||
}
|
||
} else if m.thinkingBuf.Len() == 0 {
|
||
lines = append(lines, styleAssistantLabel.Render("◆ ")+sCursor.Render("█"))
|
||
}
|
||
}
|
||
|
||
// Join all logical lines then split by newlines
|
||
raw := strings.Join(lines, "\n")
|
||
rawLines := strings.Split(raw, "\n")
|
||
|
||
// Hard-wrap any remaining overlong lines to get accurate physical line count
|
||
// for the scroll logic. Content should already be word-wrapped by renderMessage,
|
||
// but ANSI escape overhead can push a styled line past m.width.
|
||
var physLines []string
|
||
for _, line := range rawLines {
|
||
if lipgloss.Width(line) <= m.width {
|
||
physLines = append(physLines, line)
|
||
} else {
|
||
// Actually split the line using ANSI-aware hard wrap so the scroll
|
||
// offset math and the rendered content agree.
|
||
split := strings.Split(xansi.Hardwrap(line, m.width, false), "\n")
|
||
physLines = append(physLines, split...)
|
||
}
|
||
}
|
||
|
||
// Apply scroll: offset from bottom
|
||
if len(physLines) > height && height > 0 {
|
||
maxScroll := len(physLines) - height
|
||
offset := m.scrollOffset
|
||
if offset > maxScroll {
|
||
offset = maxScroll
|
||
}
|
||
end := len(physLines) - offset
|
||
start := end - height
|
||
if start < 0 {
|
||
start = 0
|
||
}
|
||
physLines = physLines[start:end]
|
||
}
|
||
|
||
// Hard truncate to exactly height lines — prevent overflow
|
||
if len(physLines) > height && height > 0 {
|
||
physLines = physLines[:height]
|
||
}
|
||
|
||
content := strings.Join(physLines, "\n")
|
||
|
||
// Pad to fill height if content is shorter
|
||
contentH := strings.Count(content, "\n") + 1
|
||
if contentH < height {
|
||
content += strings.Repeat("\n", height-contentH)
|
||
}
|
||
|
||
return content
|
||
}
|
||
|
||
func (m Model) renderMessage(msg chatMessage) []string {
|
||
var lines []string
|
||
indent := " " // 2-space indent for continuation lines
|
||
|
||
switch msg.role {
|
||
case "user":
|
||
// ❯ first line, indented continuation — word-wrapped to terminal width
|
||
maxWidth := m.width - 2 // 2 for the "❯ " / " " prefix
|
||
msgLines := strings.Split(wrapText(msg.content, maxWidth), "\n")
|
||
for i, line := range msgLines {
|
||
if i == 0 {
|
||
lines = append(lines, sUserLabel.Render("❯ ")+sUserLabel.Render(line))
|
||
} else {
|
||
lines = append(lines, sUserLabel.Render(indent+line))
|
||
}
|
||
}
|
||
lines = append(lines, "")
|
||
|
||
case "thinking":
|
||
// Thinking/reasoning content — dim italic with hollow diamond label.
|
||
// Collapsed to 3 lines by default; ctrl+o expands.
|
||
const thinkingMaxLines = 3
|
||
maxWidth := m.width - 2
|
||
msgLines := strings.Split(wrapText(msg.content, maxWidth), "\n")
|
||
showLines := len(msgLines)
|
||
if !m.expandOutput && showLines > thinkingMaxLines {
|
||
showLines = thinkingMaxLines
|
||
}
|
||
for i, line := range msgLines[:showLines] {
|
||
if i == 0 {
|
||
lines = append(lines, sThinkingLabel.Render("◇ ")+sThinkingBody.Render(line))
|
||
} else {
|
||
lines = append(lines, sThinkingBody.Render(indent+line))
|
||
}
|
||
}
|
||
if !m.expandOutput && len(msgLines) > thinkingMaxLines {
|
||
remaining := len(msgLines) - thinkingMaxLines
|
||
lines = append(lines, sHint.Render(indent+fmt.Sprintf("+%d lines (ctrl+o to expand)", remaining)))
|
||
}
|
||
lines = append(lines, "")
|
||
|
||
case "assistant":
|
||
// Render markdown with glamour; strip model-specific artifacts first.
|
||
clean := sanitizeAssistantText(msg.content)
|
||
rendered := clean
|
||
if m.mdRenderer != nil {
|
||
if md, err := m.mdRenderer.Render(clean); err == nil {
|
||
rendered = strings.TrimSpace(md)
|
||
}
|
||
}
|
||
renderedLines := strings.Split(rendered, "\n")
|
||
for i, line := range renderedLines {
|
||
if i == 0 {
|
||
lines = append(lines, styleAssistantLabel.Render("◆ ")+line)
|
||
} else {
|
||
lines = append(lines, indent+line)
|
||
}
|
||
}
|
||
lines = append(lines, "")
|
||
|
||
case "tool":
|
||
maxW := m.width - len([]rune(indent))
|
||
for _, line := range strings.Split(wrapText(msg.content, maxW), "\n") {
|
||
lines = append(lines, indent+sToolOutput.Render(line))
|
||
}
|
||
|
||
case "toolresult":
|
||
resultLines := strings.Split(msg.content, "\n")
|
||
maxShow := 3 // collapsed by default
|
||
if m.expandOutput {
|
||
maxShow = len(resultLines)
|
||
}
|
||
maxW := m.width - 4 // indent(2) + indent(2)
|
||
for i, line := range resultLines {
|
||
if i >= maxShow {
|
||
remaining := len(resultLines) - maxShow
|
||
lines = append(lines, indent+indent+sHint.Render(
|
||
fmt.Sprintf("+%d lines (ctrl+o to expand)", remaining)))
|
||
break
|
||
}
|
||
// Wrap this logical line into sub-lines, then diff-color each sub-line
|
||
for _, subLine := range strings.Split(wrapText(line, maxW), "\n") {
|
||
trimmed := strings.TrimSpace(subLine)
|
||
if strings.HasPrefix(trimmed, "+") && !strings.HasPrefix(trimmed, "++") && len(trimmed) > 1 {
|
||
lines = append(lines, indent+indent+sDiffAdd.Render(subLine))
|
||
} else if strings.HasPrefix(trimmed, "-") && !strings.HasPrefix(trimmed, "--") && len(trimmed) > 1 {
|
||
lines = append(lines, indent+indent+sDiffRemove.Render(subLine))
|
||
} else {
|
||
lines = append(lines, indent+indent+sToolResult.Render(subLine))
|
||
}
|
||
}
|
||
}
|
||
lines = append(lines, "")
|
||
|
||
case "system":
|
||
maxW := m.width - 4 // "• "(2) + indent(2)
|
||
for i, line := range strings.Split(wrapText(msg.content, maxW), "\n") {
|
||
if i == 0 {
|
||
lines = append(lines, sSystem.Render("• "+line))
|
||
} else {
|
||
lines = append(lines, sSystem.Render(indent+line))
|
||
}
|
||
}
|
||
lines = append(lines, "")
|
||
|
||
case "error":
|
||
maxW := m.width - 2 // "✗ " = 2
|
||
for _, line := range strings.Split(wrapText(msg.content, maxW), "\n") {
|
||
lines = append(lines, sError.Render("✗ "+line))
|
||
}
|
||
lines = append(lines, "")
|
||
|
||
case "cost":
|
||
lines = append(lines, sHint.Render(indent+msg.content))
|
||
}
|
||
|
||
return lines
|
||
}
|
||
|
||
func (m Model) renderElfTree() []string {
|
||
if len(m.elfOrder) == 0 {
|
||
return nil
|
||
}
|
||
|
||
var lines []string
|
||
|
||
// Count running vs done
|
||
running := 0
|
||
for _, id := range m.elfOrder {
|
||
if p, ok := m.elfStates[id]; ok && !p.Done {
|
||
running++
|
||
}
|
||
}
|
||
|
||
// Header
|
||
if running > 0 {
|
||
header := fmt.Sprintf("● Running %d elf", len(m.elfOrder))
|
||
if len(m.elfOrder) != 1 {
|
||
header += "s"
|
||
}
|
||
header += "…"
|
||
lines = append(lines, sStatusStreaming.Render(header))
|
||
} else {
|
||
header := fmt.Sprintf("● %d elf", len(m.elfOrder))
|
||
if len(m.elfOrder) != 1 {
|
||
header += "s"
|
||
}
|
||
header += " completed"
|
||
lines = append(lines, sToolOutput.Render(header))
|
||
}
|
||
|
||
for i, elfID := range m.elfOrder {
|
||
p, ok := m.elfStates[elfID]
|
||
if !ok {
|
||
continue
|
||
}
|
||
|
||
isLast := i == len(m.elfOrder)-1
|
||
|
||
// Branch character
|
||
branch := "├─"
|
||
childPrefix := "│ "
|
||
if isLast {
|
||
branch = "└─"
|
||
childPrefix = " "
|
||
}
|
||
|
||
// Main line: branch + description + stats
|
||
var stats []string
|
||
if p.ToolUses > 0 {
|
||
stats = append(stats, fmt.Sprintf("%d tool uses", p.ToolUses))
|
||
}
|
||
if p.Tokens > 0 {
|
||
stats = append(stats, formatTokens(p.Tokens))
|
||
}
|
||
|
||
statsStr := ""
|
||
if len(stats) > 0 {
|
||
statsStr = " · " + strings.Join(stats, " · ")
|
||
}
|
||
desc := p.Description
|
||
if len(statsStr) > 0 {
|
||
// Truncate description so the combined line fits on one terminal row
|
||
maxDescW := m.width - 4 - len([]rune(branch)) - len([]rune(statsStr))
|
||
if maxDescW > 10 && len([]rune(desc)) > maxDescW {
|
||
desc = string([]rune(desc)[:maxDescW-1]) + "…"
|
||
}
|
||
}
|
||
line := sToolOutput.Render(branch+" ") + sText.Render(desc)
|
||
if len(statsStr) > 0 {
|
||
line += sToolResult.Render(statsStr)
|
||
}
|
||
lines = append(lines, line)
|
||
|
||
// Activity sub-line
|
||
var activity string
|
||
if p.Done {
|
||
if p.Error != "" {
|
||
activity = sError.Render("Error: " + p.Error)
|
||
} else {
|
||
dur := p.Duration.Round(time.Millisecond)
|
||
activity = sToolOutput.Render(fmt.Sprintf("Done (%s)", dur))
|
||
}
|
||
} else {
|
||
activity = p.Activity
|
||
if activity == "" {
|
||
activity = "working…"
|
||
}
|
||
activity = sToolResult.Render(activity)
|
||
}
|
||
// Wrap activity so long error/path strings don't overflow the terminal.
|
||
actPrefix := childPrefix + "└─ "
|
||
actMaxW := m.width - len([]rune(actPrefix))
|
||
actLines := strings.Split(wrapText(activity, actMaxW), "\n")
|
||
for j, al := range actLines {
|
||
if j == 0 {
|
||
lines = append(lines, sToolResult.Render(actPrefix)+al)
|
||
} else {
|
||
lines = append(lines, sToolResult.Render(childPrefix+" ")+al)
|
||
}
|
||
}
|
||
}
|
||
|
||
lines = append(lines, "") // spacing after tree
|
||
return lines
|
||
}
|
||
|
||
func formatTokens(tokens int) string {
|
||
if tokens >= 1_000_000 {
|
||
return fmt.Sprintf("%.1fM tokens", float64(tokens)/1_000_000)
|
||
}
|
||
if tokens >= 1_000 {
|
||
return fmt.Sprintf("%.1fk tokens", float64(tokens)/1_000)
|
||
}
|
||
return fmt.Sprintf("%d tokens", tokens)
|
||
}
|
||
|
||
func (m Model) renderSeparators() (string, string) {
|
||
lineColor := cSurface // default dim
|
||
modeLabel := ""
|
||
|
||
if m.config.Permissions != nil {
|
||
mode := m.config.Permissions.Mode()
|
||
lineColor = ModeColor(mode)
|
||
modeLabel = string(mode)
|
||
}
|
||
|
||
// Incognito adds amber overlay but keeps mode visible
|
||
if m.incognito {
|
||
lineColor = cYellow
|
||
modeLabel = "🔒 " + modeLabel
|
||
}
|
||
|
||
// Permission pending — flash the line with command summary
|
||
if m.permPending {
|
||
lineColor = cRed
|
||
hint := shortPermHint(m.permToolName, m.permArgs)
|
||
modeLabel = "⚠ " + hint + " [y/n]"
|
||
}
|
||
|
||
lineStyle := lipgloss.NewStyle().Foreground(lineColor)
|
||
labelStyle := lipgloss.NewStyle().Foreground(lineColor).Bold(true)
|
||
|
||
// Top line: ─── with mode label on right ─── bypass ───
|
||
label := " " + modeLabel + " "
|
||
labelW := lipgloss.Width(labelStyle.Render(label))
|
||
lineW := m.width - labelW
|
||
if lineW < 4 {
|
||
lineW = 4
|
||
}
|
||
leftW := lineW - 2
|
||
rightW := 2
|
||
|
||
topLine := lineStyle.Render(strings.Repeat("─", leftW)) +
|
||
labelStyle.Render(label) +
|
||
lineStyle.Render(strings.Repeat("─", rightW))
|
||
|
||
// Bottom line: plain colored line
|
||
bottomLine := lineStyle.Render(strings.Repeat("─", m.width))
|
||
|
||
return topLine, bottomLine
|
||
}
|
||
|
||
func (m Model) renderInput() string {
|
||
view := m.input.View()
|
||
if m.suggestion != "" {
|
||
// Show the untyped remainder as dim ghost text.
|
||
rest := strings.TrimPrefix(m.suggestion, m.input.Value())
|
||
if rest != "" {
|
||
ghost := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(rest + " (tab)")
|
||
view += ghost
|
||
}
|
||
}
|
||
return view
|
||
}
|
||
|
||
func (m Model) renderStatus() string {
|
||
status := m.session.Status()
|
||
|
||
// Left: provider + model + incognito
|
||
provModel := fmt.Sprintf(" %s/%s", status.Provider, status.Model)
|
||
if m.incognito {
|
||
provModel += " " + sStatusIncognito.Render("🔒")
|
||
}
|
||
if !status.ToolsAvailable {
|
||
provModel += " " + sStatusDim.Render("text-only")
|
||
}
|
||
left := sStatusHighlight.Render(provModel)
|
||
|
||
// Center: cwd + git branch
|
||
dir := filepath.Base(m.cwd)
|
||
centerParts := []string{"📁 " + dir}
|
||
if m.gitBranch != "" {
|
||
centerParts = append(centerParts, sStatusBranch.Render(" "+m.gitBranch))
|
||
}
|
||
center := sStatusDim.Render(strings.Join(centerParts, ""))
|
||
|
||
// Right: context bar + tokens + turns
|
||
right := renderContextBar(status) + sStatusDim.Render(fmt.Sprintf(" │ turns: %d ", status.TurnCount))
|
||
|
||
if m.quitHint {
|
||
right = lipgloss.NewStyle().Foreground(cRed).Bold(true).Render("ctrl+c to quit ") + sStatusDim.Render("│ ") + right
|
||
}
|
||
if m.copyMode {
|
||
right = lipgloss.NewStyle().Foreground(cYellow).Bold(true).Render("✂ COPY ") + sStatusDim.Render("│ ") + right
|
||
}
|
||
if m.streaming {
|
||
right = sStatusStreaming.Render("● streaming ") + sStatusDim.Render("│ ") + right
|
||
}
|
||
|
||
// Compose with spacing
|
||
leftW := lipgloss.Width(left)
|
||
centerW := lipgloss.Width(center)
|
||
rightW := lipgloss.Width(right)
|
||
|
||
gap1 := (m.width-leftW-centerW-rightW)/2 - 1
|
||
if gap1 < 1 {
|
||
gap1 = 1
|
||
}
|
||
gap2 := m.width - leftW - gap1 - centerW - rightW
|
||
if gap2 < 0 {
|
||
gap2 = 0
|
||
}
|
||
|
||
bar := left + strings.Repeat(" ", gap1) + center + strings.Repeat(" ", gap2) + right
|
||
return sStatusBar.Width(m.width).Render(bar)
|
||
}
|
||
|
||
// renderContextBar draws a compact [████░░░░] 45% progress bar for the context window.
|
||
func renderContextBar(s session.Status) string {
|
||
pct := s.TokenPercent
|
||
if pct <= 0 && s.TokensUsed == 0 {
|
||
return sStatusDim.Render("ctx: —")
|
||
}
|
||
|
||
const barWidth = 8
|
||
filled := (pct * barWidth) / 100
|
||
if filled > barWidth {
|
||
filled = barWidth
|
||
}
|
||
empty := barWidth - filled
|
||
|
||
var barColor lipgloss.Style
|
||
switch s.TokenState {
|
||
case "critical":
|
||
barColor = lipgloss.NewStyle().Foreground(cRed).Bold(true)
|
||
case "warning":
|
||
barColor = lipgloss.NewStyle().Foreground(cYellow)
|
||
default:
|
||
barColor = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) // green
|
||
}
|
||
dimBlock := sStatusDim
|
||
|
||
bar := barColor.Render(strings.Repeat("█", filled)) + dimBlock.Render(strings.Repeat("░", empty))
|
||
label := fmt.Sprintf(" %d%%", pct)
|
||
|
||
var labelStyle lipgloss.Style
|
||
switch s.TokenState {
|
||
case "critical":
|
||
labelStyle = lipgloss.NewStyle().Foreground(cRed).Bold(true)
|
||
case "warning":
|
||
labelStyle = lipgloss.NewStyle().Foreground(cYellow)
|
||
default:
|
||
labelStyle = sStatusDim
|
||
}
|
||
return "[" + bar + "]" + labelStyle.Render(label)
|
||
}
|
||
|
||
// formatTurnUsage produces a compact token summary for a single turn.
|
||
func formatTurnUsage(u message.Usage) string {
|
||
parts := []string{fmt.Sprintf("in: %d", u.InputTokens), fmt.Sprintf("out: %d", u.OutputTokens)}
|
||
if u.CacheReadTokens > 0 {
|
||
parts = append(parts, fmt.Sprintf("cache: %d", u.CacheReadTokens))
|
||
}
|
||
return strings.Join(parts, " · ")
|
||
}
|
||
|
||
// wrapText word-wraps text at word boundaries, preserving existing newlines.
|
||
// Uses ANSI-aware wrapping so lipgloss-styled text is measured correctly.
|
||
func wrapText(text string, width int) string {
|
||
if width <= 0 {
|
||
return text
|
||
}
|
||
return xansi.Wordwrap(text, width, "")
|
||
}
|