From 268f3de34eea826dcbe8e4b3006479f1bd5fece4 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Thu, 26 Feb 2026 18:23:50 +0300 Subject: [PATCH] add tool-specific body rendering with syntax highlighting and truncation Implement iteratr-style rich tool blocks: side-by-side diffs for Edit (via go-udiff), syntax-highlighted code blocks for Read (via chroma), green-tinted write blocks with line numbers and footer, and background- styled bash output with stderr detection. All renderers enforce centralized max-line limits (Edit: 20, Read: 20, Write: 10, Bash: 10) with muted truncation hints. --- AGENTS.md | 6 + go.mod | 1 + internal/ui/compact_renderer.go | 13 +- internal/ui/enhanced_styles.go | 22 + internal/ui/messages.go | 7 +- internal/ui/tool_renderers.go | 696 ++++++++++++++++++++++++++++++++ 6 files changed, 739 insertions(+), 6 deletions(-) create mode 100644 internal/ui/tool_renderers.go diff --git a/AGENTS.md b/AGENTS.md index c8d1306d..20975144 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,3 +39,9 @@ Keep this managed block so 'openspec update' can refresh the instructions. - Multi-provider LLM support via `llm.Provider` interface - MCP client-server for tool integration - Builtin servers: bash, fetch, todo, fs + +## External Repo Research +- **ALWAYS use `btca`** to search external repos (e.g. iteratr, other reference codebases) +- Never guess or manually search the filesystem for external projects +- Example: `btca ask -r https://github.com/user/repo -q "How does X work?"` +- See `.agents/skills/btca-cli/SKILL.md` for full btca usage diff --git a/go.mod b/go.mod index 0e2f35a1..861f57da 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect github.com/aws/smithy-go v1.24.1 // indirect + github.com/aymanbagabas/go-udiff v0.4.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect diff --git a/internal/ui/compact_renderer.go b/internal/ui/compact_renderer.go index 733e7a90..15f3f119 100644 --- a/internal/ui/compact_renderer.go +++ b/internal/ui/compact_renderer.go @@ -168,14 +168,19 @@ func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin header += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params) } - // Format body + // Format body: try tool-specific renderer, then fall back to default var body string if isError { body = lipgloss.NewStyle().Foreground(theme.Error).Render(r.formatToolResult(toolResult)) } else { - body = lipgloss.NewStyle().Foreground(theme.Muted).Render(r.formatToolResult(toolResult)) - if r.formatToolResult(toolResult) == "" { - body = lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render("(no output)") + body = renderToolBody(toolName, toolArgs, toolResult, r.width-4) + if body == "" { + formatted := r.formatToolResult(toolResult) + if formatted == "" { + body = lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render("(no output)") + } else { + body = lipgloss.NewStyle().Foreground(theme.Muted).Render(formatted) + } } } diff --git a/internal/ui/enhanced_styles.go b/internal/ui/enhanced_styles.go index 8d0249e1..525e0602 100644 --- a/internal/ui/enhanced_styles.go +++ b/internal/ui/enhanced_styles.go @@ -66,6 +66,17 @@ type Theme struct { Tool color.Color Accent color.Color Highlight color.Color + + // Diff block backgrounds + DiffInsertBg color.Color // Green-tinted bg for added lines + DiffDeleteBg color.Color // Red-tinted bg for removed lines + DiffEqualBg color.Color // Neutral bg for context lines + DiffMissingBg color.Color // Empty-cell bg when sides are uneven + + // Code/output block backgrounds + CodeBg color.Color // Background for code blocks (Read tool) + GutterBg color.Color // Line-number gutter background + WriteBg color.Color // Green-tinted bg for Write tool content } // DefaultTheme creates and returns the default KIT theme based on the Catppuccin @@ -89,6 +100,17 @@ func DefaultTheme() Theme { Tool: AdaptiveColor("#fe640b", "#fab387"), // Latte/Mocha Peach Accent: AdaptiveColor("#ea76cb", "#f5c2e7"), // Latte/Mocha Pink Highlight: AdaptiveColor("#e6e9ef", "#181825"), // Latte Mantle / Mocha Mantle + + // Diff backgrounds — subtle tinted variants of the base palette + DiffInsertBg: AdaptiveColor("#d5f0d5", "#1a3a2a"), // Green tint + DiffDeleteBg: AdaptiveColor("#f5d5d5", "#3a1a2a"), // Red tint + DiffEqualBg: AdaptiveColor("#eceef3", "#232336"), // Neutral + DiffMissingBg: AdaptiveColor("#e4e6eb", "#1a1a2e"), // Darker neutral + + // Code & output backgrounds + CodeBg: AdaptiveColor("#eceef3", "#232336"), // Matches DiffEqualBg + GutterBg: AdaptiveColor("#e4e6eb", "#1a1a2e"), // Slightly darker + WriteBg: AdaptiveColor("#d5f0d5", "#1a3a2a"), // Matches DiffInsertBg (green tint) } } diff --git a/internal/ui/messages.go b/internal/ui/messages.go index f15b174e..3c3e0443 100644 --- a/internal/ui/messages.go +++ b/internal/ui/messages.go @@ -555,14 +555,17 @@ func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult strin header += " " + lipgloss.NewStyle().Foreground(theme.Muted).Render(params) } - // --- Body --- + // --- Body: try tool-specific renderer first, then fall back --- var body string if isError { body = lipgloss.NewStyle(). Foreground(theme.Error). Render(toolResult) } else { - body = r.formatToolResult(toolName, toolResult, r.width-8) + body = renderToolBody(toolName, toolArgs, toolResult, r.width-8) + if body == "" { + body = r.formatToolResult(toolName, toolResult, r.width-8) + } } if strings.TrimSpace(body) == "" { diff --git a/internal/ui/tool_renderers.go b/internal/ui/tool_renderers.go new file mode 100644 index 00000000..c40fa815 --- /dev/null +++ b/internal/ui/tool_renderers.go @@ -0,0 +1,696 @@ +package ui + +import ( + "bytes" + "encoding/json" + "fmt" + "path/filepath" + "regexp" + "strconv" + "strings" + + "charm.land/lipgloss/v2" + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/formatters" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" + udiff "github.com/aymanbagabas/go-udiff" +) + +// Maximum visible lines per tool type before truncation. +const ( + maxDiffLines = 20 // side-by-side rows for Edit + maxCodeLines = 20 // lines for Read / code blocks + maxWriteLines = 10 // lines for Write blocks + maxBashLines = 10 // lines for Bash output +) + +// renderToolBody dispatches to tool-specific body renderers based on tool name. +// Returns the styled body string, or empty string to fall back to default rendering. +func renderToolBody(toolName, toolArgs, toolResult string, width int) string { + switch { + case toolName == "edit": + if body := renderEditBody(toolArgs, toolResult, width); body != "" { + return body + } + case toolName == "read" || toolName == "ls": + if body := renderReadBody(toolArgs, toolResult, width); body != "" { + return body + } + case toolName == "write": + if body := renderWriteBody(toolArgs, toolResult, width); body != "" { + return body + } + case toolName == "bash" || toolName == "run_shell_cmd" || + strings.Contains(toolName, "shell") || strings.Contains(toolName, "command"): + if body := renderBashBody(toolResult, width); body != "" { + return body + } + } + return "" // fall back to default +} + +// --------------------------------------------------------------------------- +// Edit tool — side-by-side diff +// --------------------------------------------------------------------------- + +// renderEditBody renders a side-by-side diff from old_text/new_text in toolArgs. +func renderEditBody(toolArgs, toolResult string, width int) string { + var args map[string]any + if err := json.Unmarshal([]byte(toolArgs), &args); err != nil { + return "" + } + + oldText, _ := args["old_text"].(string) + newText, _ := args["new_text"].(string) + if oldText == "" && newText == "" { + return "" + } + + // Try to extract the starting line number from the unified diff in the result + startLine := extractDiffStartLine(toolResult) + + return renderDiffBlock(oldText, newText, startLine, width) +} + +// extractDiffStartLine parses the first @@ hunk header from a unified diff +// result to find the starting line number. Returns 1 if not found. +func extractDiffStartLine(result string) int { + re := regexp.MustCompile(`@@ -(\d+)`) + matches := re.FindStringSubmatch(result) + if len(matches) >= 2 { + if n, err := strconv.Atoi(matches[1]); err == nil && n > 0 { + return n + } + } + return 1 +} + +// splitLine holds one row of a side-by-side diff. +type splitLine struct { + beforeNum int + afterNum int + beforeText string + afterText string + beforeKind udiff.OpKind + afterKind udiff.OpKind +} + +// renderDiffBlock renders old→new as a side-by-side diff with colored backgrounds. +func renderDiffBlock(before, after string, startLine int, width int) string { + // Normalise tabs and ensure trailing newlines + before = strings.ReplaceAll(before, "\t", " ") + after = strings.ReplaceAll(after, "\t", " ") + if before != "" && !strings.HasSuffix(before, "\n") { + before += "\n" + } + if after != "" && !strings.HasSuffix(after, "\n") { + after += "\n" + } + + edits := udiff.Strings(before, after) + if len(edits) == 0 { + return "" // no changes + } + + unified, err := udiff.ToUnifiedDiff("a", "b", before, edits, 3) + if err != nil || len(unified.Hunks) == 0 { + return "" + } + + // Convert hunks to paired split-lines for side-by-side rendering. + var lines []splitLine + for hi, h := range unified.Hunks { + beforeLine := h.FromLine + startLine - 1 + afterLine := h.ToLine + startLine - 1 + + // Hunk separator between hunks + if hi > 0 { + lines = append(lines, splitLine{beforeKind: -1, afterKind: -1}) + } + + i := 0 + for i < len(h.Lines) { + l := h.Lines[i] + switch l.Kind { + case udiff.Equal: + lines = append(lines, splitLine{ + beforeNum: beforeLine, afterNum: afterLine, + beforeText: l.Content, afterText: l.Content, + beforeKind: udiff.Equal, afterKind: udiff.Equal, + }) + beforeLine++ + afterLine++ + i++ + + case udiff.Delete: + // Collect consecutive deletes then inserts and pair them. + var deletes, inserts []udiff.Line + for i < len(h.Lines) && h.Lines[i].Kind == udiff.Delete { + deletes = append(deletes, h.Lines[i]) + i++ + } + for i < len(h.Lines) && h.Lines[i].Kind == udiff.Insert { + inserts = append(inserts, h.Lines[i]) + i++ + } + maxPairs := len(deletes) + if len(inserts) > maxPairs { + maxPairs = len(inserts) + } + for j := 0; j < maxPairs; j++ { + sl := splitLine{} + if j < len(deletes) { + sl.beforeNum = beforeLine + sl.beforeText = deletes[j].Content + sl.beforeKind = udiff.Delete + beforeLine++ + } + if j < len(inserts) { + sl.afterNum = afterLine + sl.afterText = inserts[j].Content + sl.afterKind = udiff.Insert + afterLine++ + } + lines = append(lines, sl) + } + + case udiff.Insert: + lines = append(lines, splitLine{ + afterNum: afterLine, afterText: l.Content, + afterKind: udiff.Insert, + }) + afterLine++ + i++ + } + } + } + + if len(lines) == 0 { + return "" + } + + // Truncate to maxDiffLines visible rows + var diffHiddenCount int + if len(lines) > maxDiffLines { + diffHiddenCount = len(lines) - maxDiffLines + lines = lines[:maxDiffLines] + } + + // Layout calculations + const indent = " " + availableWidth := width - len(indent) + panelWidth := (availableWidth - 3) / 2 // " │ " divider + if panelWidth < 20 { + panelWidth = 20 + } + + // Gutter width from max line number + maxLineNum := 1 + for _, l := range lines { + if l.beforeNum > maxLineNum { + maxLineNum = l.beforeNum + } + if l.afterNum > maxLineNum { + maxLineNum = l.afterNum + } + } + gutterWidth := len(fmt.Sprintf("%d", maxLineNum)) + if gutterWidth < 3 { + gutterWidth = 3 + } + contentWidth := panelWidth - gutterWidth - 4 // gutter + " - " or " + " + if contentWidth < 10 { + contentWidth = 10 + } + + theme := getTheme() + + // Styles for each cell type + gutterInsert := lipgloss.NewStyle().Foreground(theme.Muted).Background(theme.DiffInsertBg) + gutterDelete := lipgloss.NewStyle().Foreground(theme.Muted).Background(theme.DiffDeleteBg) + gutterEqual := lipgloss.NewStyle().Foreground(theme.VeryMuted).Background(theme.DiffEqualBg) + gutterMissing := lipgloss.NewStyle().Background(theme.DiffMissingBg) + + contentInsert := lipgloss.NewStyle().Background(theme.DiffInsertBg) + contentDelete := lipgloss.NewStyle().Background(theme.DiffDeleteBg).Strikethrough(true) + contentEqual := lipgloss.NewStyle().Foreground(theme.Muted).Background(theme.DiffEqualBg) + contentMissing := lipgloss.NewStyle().Background(theme.DiffMissingBg) + + dividerStyle := lipgloss.NewStyle().Foreground(theme.MutedBorder) + + var result []string + for _, sl := range lines { + // Hunk separator + if sl.beforeKind == -1 { + sep := indent + + dividerStyle.Render(padRight("···", panelWidth)) + " " + + dividerStyle.Render("│") + " " + + dividerStyle.Render(padRight("···", panelWidth)) + result = append(result, sep) + continue + } + + beforeText := strings.TrimRight(sl.beforeText, "\n") + afterText := strings.TrimRight(sl.afterText, "\n") + + // Left panel (before) + var left string + switch { + case sl.beforeNum > 0 && sl.beforeKind == udiff.Delete: + gutter := fmt.Sprintf(" %*d", gutterWidth, sl.beforeNum) + code := padRight(truncateLine(beforeText, contentWidth), contentWidth) + left = gutterDelete.Render(gutter) + contentDelete.Render(" - "+code) + case sl.beforeNum > 0 && sl.beforeKind == udiff.Equal: + gutter := fmt.Sprintf(" %*d", gutterWidth, sl.beforeNum) + code := padRight(truncateLine(beforeText, contentWidth), contentWidth) + left = gutterEqual.Render(gutter) + contentEqual.Render(" "+code) + default: + left = gutterMissing.Render(padRight("", gutterWidth+1)) + + contentMissing.Render(padRight("", contentWidth+3)) + } + + // Right panel (after) + var right string + switch { + case sl.afterNum > 0 && sl.afterKind == udiff.Insert: + gutter := fmt.Sprintf(" %*d", gutterWidth, sl.afterNum) + code := padRight(truncateLine(afterText, contentWidth), contentWidth) + right = gutterInsert.Render(gutter) + contentInsert.Render(" + "+code) + case sl.afterNum > 0 && sl.afterKind == udiff.Equal: + gutter := fmt.Sprintf(" %*d", gutterWidth, sl.afterNum) + code := padRight(truncateLine(afterText, contentWidth), contentWidth) + right = gutterEqual.Render(gutter) + contentEqual.Render(" "+code) + default: + right = gutterMissing.Render(padRight("", gutterWidth+1)) + + contentMissing.Render(padRight("", contentWidth+3)) + } + + row := indent + left + " " + dividerStyle.Render("│") + " " + right + result = append(result, row) + } + + // Truncation hint spanning both panels + if diffHiddenCount > 0 { + hint := fmt.Sprintf("...(%d more lines)", diffHiddenCount) + hintStyle := lipgloss.NewStyle(). + Foreground(theme.Muted). + Background(theme.DiffEqualBg). + Italic(true) + fullWidth := panelWidth*2 + 3 // both panels + divider + hintRow := indent + hintStyle.Width(fullWidth).Render(hint) + result = append(result, hintRow) + } + + return strings.Join(result, "\n") +} + +// --------------------------------------------------------------------------- +// Read tool — code block with line numbers + syntax highlighting +// --------------------------------------------------------------------------- + +// renderReadBody renders Read tool output with styled line numbers and optional +// syntax highlighting based on file extension. +func renderReadBody(toolArgs, toolResult string, width int) string { + if strings.TrimSpace(toolResult) == "" { + return "" + } + + // Extract file path for syntax highlighting + var fileName string + var args map[string]any + if err := json.Unmarshal([]byte(toolArgs), &args); err == nil { + if p, ok := args["path"].(string); ok { + fileName = p + } + } + + return renderCodeBlock(toolResult, fileName, width) +} + +// codeLine holds a parsed line with optional line number. +type codeLine struct { + lineNum string + code string +} + +// renderCodeBlock renders content with a styled gutter (line numbers) and +// optional syntax highlighting. +func renderCodeBlock(content, fileName string, width int) string { + rawLines := strings.Split(content, "\n") + + // Parse lines: detect "N: content" format from Read tool + var parsed []codeLine + maxNumWidth := 0 + var codeOnly []string + + for _, line := range rawLines { + if idx := strings.Index(line, ": "); idx > 0 && idx <= 7 { + numPart := line[:idx] + if _, err := strconv.Atoi(strings.TrimSpace(numPart)); err == nil { + parsed = append(parsed, codeLine{lineNum: numPart, code: line[idx+2:]}) + if len(numPart) > maxNumWidth { + maxNumWidth = len(numPart) + } + codeOnly = append(codeOnly, line[idx+2:]) + continue + } + } + // No line number — treat as metadata/footer + parsed = append(parsed, codeLine{code: line}) + codeOnly = append(codeOnly, line) + } + + if len(parsed) == 0 { + return "" + } + + // Truncate to maxCodeLines visible lines (preserve footer/metadata lines) + var codeHiddenCount int + totalParsed := len(parsed) + if totalParsed > maxCodeLines { + // Check if last line is a footer (no line number) — keep it + var footerLines []codeLine + for totalParsed > 0 && parsed[totalParsed-1].lineNum == "" { + footerLines = append([]codeLine{parsed[totalParsed-1]}, footerLines...) + totalParsed-- + } + if totalParsed > maxCodeLines { + codeHiddenCount = totalParsed - maxCodeLines + parsed = append(parsed[:maxCodeLines], footerLines...) + codeOnly = codeOnly[:maxCodeLines] + for _, fl := range footerLines { + codeOnly = append(codeOnly, fl.code) + } + } else { + // Restore — footer trimming was enough + parsed = parsed[:totalParsed] + parsed = append(parsed, footerLines...) + } + } + + // Syntax highlight the code portion + highlighted := syntaxHighlight(strings.Join(codeOnly, "\n"), fileName) + highlightedLines := strings.Split(highlighted, "\n") + + // Layout + const codeIndent = " " + gutterWidth := maxNumWidth + 2 + if gutterWidth < 5 { + gutterWidth = 5 + } + codeWidth := width - gutterWidth - len(codeIndent) + if codeWidth < 20 { + codeWidth = 20 + } + + theme := getTheme() + gutterStyle := lipgloss.NewStyle().Foreground(theme.Muted).Background(theme.GutterBg).PaddingRight(1) + codeStyle := lipgloss.NewStyle().Background(theme.CodeBg).PaddingLeft(1) + + var result []string + for i, p := range parsed { + // If this line has no line number, it's a metadata/footer line (e.g. truncation notice). + if p.lineNum == "" { + // Render footer lines with code background but no gutter + footer := codeStyle.Width(codeWidth).Render(p.code) + emptyGutter := gutterStyle.Width(gutterWidth).Render("") + result = append(result, codeIndent+lipgloss.JoinHorizontal(lipgloss.Top, emptyGutter, footer)) + continue + } + + gutter := gutterStyle.Width(gutterWidth).Render(p.lineNum) + + var codePart string + if i < len(highlightedLines) { + codePart = highlightedLines[i] + } else { + codePart = p.code + } + styledCode := codeStyle.Width(codeWidth).Render(codePart) + + result = append(result, codeIndent+lipgloss.JoinHorizontal(lipgloss.Top, gutter, styledCode)) + } + + // Truncation hint + if codeHiddenCount > 0 { + hint := fmt.Sprintf("...(%d more lines)", codeHiddenCount) + emptyGutter := gutterStyle.Width(gutterWidth).Render("") + hintContent := codeStyle.Width(codeWidth). + Foreground(theme.Muted).Italic(true).Render(hint) + result = append(result, codeIndent+lipgloss.JoinHorizontal(lipgloss.Top, emptyGutter, hintContent)) + } + + return strings.Join(result, "\n") +} + +// --------------------------------------------------------------------------- +// Write tool — green-tinted block with line numbers and "End of file" footer +// --------------------------------------------------------------------------- + +// renderWriteBody extracts content from toolArgs and renders it as a green-tinted +// code block with line numbers and an "End of file" footer. +func renderWriteBody(toolArgs, toolResult string, width int) string { + var args map[string]any + if err := json.Unmarshal([]byte(toolArgs), &args); err != nil { + return "" + } + + content, _ := args["content"].(string) + if content == "" { + return "" // fall back to default + } + + var fileName string + if p, ok := args["path"].(string); ok { + fileName = p + } + + return renderWriteBlock(content, fileName, width) +} + +// renderWriteBlock renders file content with green-tinted background, line numbers, +// and a footer showing the total line count. +func renderWriteBlock(content, fileName string, width int) string { + lines := strings.Split(content, "\n") + totalLines := len(lines) + + // Truncate to maxWriteLines for display + var hiddenCount int + if totalLines > maxWriteLines { + hiddenCount = totalLines - maxWriteLines + lines = lines[:maxWriteLines] + } + + // Line number width + numDigits := len(fmt.Sprintf("%d", totalLines)) + if numDigits < 3 { + numDigits = 3 + } + + // Syntax highlight + displayContent := strings.Join(lines, "\n") + highlighted := syntaxHighlight(displayContent, fileName) + highlightedLines := strings.Split(highlighted, "\n") + + // Layout + const codeIndent = " " + gutterWidth := numDigits + 2 + codeWidth := width - gutterWidth - len(codeIndent) + if codeWidth < 20 { + codeWidth = 20 + } + + theme := getTheme() + gutterStyle := lipgloss.NewStyle().Foreground(theme.Muted).Background(theme.GutterBg).PaddingRight(1) + writeStyle := lipgloss.NewStyle().Background(theme.WriteBg).PaddingLeft(1) + + var result []string + for i, line := range lines { + numStr := fmt.Sprintf("%*d", numDigits, i+1) + gutter := gutterStyle.Width(gutterWidth).Render(numStr) + + var codePart string + if i < len(highlightedLines) { + codePart = highlightedLines[i] + } else { + codePart = line + } + styledCode := writeStyle.Width(codeWidth).Render(codePart) + + result = append(result, codeIndent+lipgloss.JoinHorizontal(lipgloss.Top, gutter, styledCode)) + } + + // Footer + var footer string + if hiddenCount > 0 { + footer = fmt.Sprintf("...(%d more lines, %d total)", hiddenCount, totalLines) + } else { + footer = fmt.Sprintf("(End of file \u2014 total %d lines)", totalLines) + } + + emptyGutter := gutterStyle.Width(gutterWidth).Render("") + footerContent := writeStyle.Width(codeWidth). + Foreground(theme.Muted). + Italic(true). + Render(footer) + result = append(result, codeIndent+lipgloss.JoinHorizontal(lipgloss.Top, emptyGutter, footerContent)) + + return strings.Join(result, "\n") +} + +// --------------------------------------------------------------------------- +// Bash tool — output with background styling +// --------------------------------------------------------------------------- + +// renderBashBody renders bash output with per-line background and stderr +// in error color. +func renderBashBody(toolResult string, width int) string { + if strings.TrimSpace(toolResult) == "" { + return "" + } + + theme := getTheme() + outputStyle := lipgloss.NewStyle().Background(theme.CodeBg).PaddingLeft(1) + stderrStyle := lipgloss.NewStyle().Foreground(theme.Error).Background(theme.CodeBg).PaddingLeft(1) + + // Parse stdout/stderr sections (if tagged) or STDERR: label + result := toolResult + + // Truncate to maxBashLines for display + lines := strings.Split(result, "\n") + var hiddenCount int + if len(lines) > maxBashLines { + hiddenCount = len(lines) - maxBashLines + lines = lines[:maxBashLines] + } + + const lineIndent = " " + var rendered []string + inStderr := false + for _, line := range lines { + // Detect the STDERR: label that Kit's bash tool emits + if strings.TrimSpace(line) == "STDERR:" { + inStderr = true + continue + } + // Exit code line + if strings.HasPrefix(line, "Exit code:") { + styled := stderrStyle.Width(width - len(lineIndent)).Render(line) + rendered = append(rendered, lineIndent+styled) + continue + } + + if inStderr { + styled := stderrStyle.Width(width - len(lineIndent)).Render(line) + rendered = append(rendered, lineIndent+styled) + } else { + styled := outputStyle.Width(width - len(lineIndent)).Render(line) + rendered = append(rendered, lineIndent+styled) + } + } + + if hiddenCount > 0 { + truncMsg := fmt.Sprintf("...(%d more lines)", hiddenCount) + hint := outputStyle.Width(width - len(lineIndent)). + Foreground(theme.Muted).Italic(true).Render(truncMsg) + rendered = append(rendered, lineIndent+hint) + } + + return strings.Join(rendered, "\n") +} + +// --------------------------------------------------------------------------- +// Syntax highlighting via Chroma +// --------------------------------------------------------------------------- + +// syntaxHighlight applies syntax highlighting to source code using chroma. +// Uses the catppuccin-mocha style for dark terminals, catppuccin-latte for light. +// Returns the source unchanged if highlighting fails. +func syntaxHighlight(source, fileName string) string { + if source == "" { + return source + } + + // Detect lexer from filename + lexer := lexers.Match(fileName) + if lexer == nil { + // Try content-based detection + lexer = lexers.Analyse(source) + } + if lexer == nil { + return source // no highlighting + } + + // Use true-color formatter + formatter := formatters.Get("terminal16m") + if formatter == nil { + formatter = formatters.Get("terminal256") + } + if formatter == nil { + return source + } + + // Pick style matching our UI theme + styleName := "catppuccin-mocha" + if !IsDarkBackground() { + styleName = "catppuccin-latte" + } + baseStyle := styles.Get(styleName) + if baseStyle == nil { + baseStyle = styles.Fallback + } + + // Clear token backgrounds so the containing lipgloss style controls bg. + style, err := baseStyle.Builder().Transform(func(entry chroma.StyleEntry) chroma.StyleEntry { + entry.Background = 0 + return entry + }).Build() + if err != nil { + style = baseStyle + } + + iterator, err := lexer.Tokenise(nil, source) + if err != nil { + return source + } + + var buf bytes.Buffer + if err := formatter.Format(&buf, style, iterator); err != nil { + return source + } + + // Replace full ANSI resets with fg-only resets so they don't clear + // the background set by lipgloss. + result := strings.ReplaceAll(buf.String(), "\x1b[0m", "\x1b[39;22;23;24m") + return strings.TrimRight(result, "\n") +} + +// fileExtension returns the file extension (with dot) for a path, used to +// help chroma pick the right lexer. +func fileExtension(path string) string { + return filepath.Ext(path) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// padRight pads s with spaces to exactly width characters. +func padRight(s string, width int) string { + if len(s) >= width { + return s[:width] + } + return s + strings.Repeat(" ", width-len(s)) +} + +// truncateLine truncates a line to maxWidth, adding "…" if truncated. +func truncateLine(s string, maxWidth int) string { + if len(s) <= maxWidth { + return s + } + if maxWidth < 2 { + return s[:maxWidth] + } + return s[:maxWidth-1] + "…" +}