feat(tui): replace Textual with a Go/Bubble Tea interface (#941)

This commit is contained in:
oyasumi
2026-08-03 19:23:07 -07:00
committed by GitHub
parent a51ca18666
commit 5bb9fe896b
128 changed files with 16229 additions and 6562 deletions
@@ -0,0 +1,320 @@
package render
import (
"regexp"
"strings"
"github.com/charmbracelet/lipgloss"
)
// ---------------------------------------------------------------------------
// Markdown (agent_message_renderer.py)
// ---------------------------------------------------------------------------
var blankLineRuns = regexp.MustCompile(`\n\s*\n`)
type mdHeader struct {
prefix string
strip int
style lipgloss.Style
}
var mdHeaders = []mdHeader{
{"###### ", 7, Bold(Field)},
{"##### ", 6, Bold(Green)},
{"#### ", 5, Bold(Hdr16a)},
{"### ", 4, Bold(Hdr158)},
{"## ", 3, Bold(Green)},
{"# ", 2, Bold(Field)},
}
// renderAssistantMarkdown ports AgentMessageRenderer.render_simple + helpers.
func renderAssistantMarkdown(content string) string {
if content == "" {
return ""
}
cleaned := strings.TrimSpace(blankLineRuns.ReplaceAllString(content, "\n\n"))
if cleaned == "" {
return ""
}
return applyMarkdownStyles(cleaned)
}
func applyMarkdownStyles(text string) string {
var out strings.Builder
lines := strings.Split(text, "\n")
inCode := false
codeLang := ""
var codeLines []string
flushCode := func() {
if len(codeLines) > 0 {
out.WriteString(HighlightCode(strings.Join(codeLines, "\n"), codeLang))
}
codeLines = nil
codeLang = ""
}
for i := 0; i < len(lines); i++ {
line := lines[i]
if i > 0 && !inCode {
out.WriteString("\n")
}
if !inCode {
if rows := tableRows(lines[i:]); rows > 0 {
out.WriteString(renderMarkdownTable(lines[i : i+rows]))
i += rows - 1
continue
}
}
if strings.HasPrefix(line, "```") {
if !inCode {
inCode = true
codeLines = nil
codeLang = strings.TrimSpace(strings.TrimPrefix(line, "```"))
if i > 0 {
out.WriteString("\n")
}
} else {
inCode = false
flushCode()
}
continue
}
if inCode {
codeLines = append(codeLines, line)
continue
}
if h := tryHeader(line); h != nil {
out.WriteString(h.style.Render(line[h.strip:]))
continue
}
switch {
case strings.HasPrefix(line, "> "):
out.WriteString(Col(Green).Render("┃ ") + inlineFormat(line[2:]))
case strings.HasPrefix(line, "- "), strings.HasPrefix(line, "* "):
out.WriteString(Col(Green).Render("• ") + inlineFormat(line[2:]))
case len(line) > 2 && line[0] >= '0' && line[0] <= '9' && (line[1:3] == ". " || line[1:3] == ") "):
out.WriteString(Col(Green).Render(string(line[0])+". ") + inlineFormat(line[2:]))
case line == "---" || line == "***" || line == "___":
out.WriteString(Col(Green).Render(strings.Repeat("─", 40)))
default:
out.WriteString(inlineFormat(line))
}
}
if inCode && len(codeLines) > 0 {
flushCode()
}
return out.String()
}
func isTableRow(line string) bool {
trimmed := strings.TrimSpace(line)
return strings.HasPrefix(trimmed, "|") && strings.Count(trimmed, "|") >= 2
}
var tableSeparatorCell = regexp.MustCompile(`^:?-+:?$`)
func isTableSeparator(line string) bool {
if !isTableRow(line) {
return false
}
cells := splitTableRow(line)
if len(cells) == 0 {
return false
}
for _, cell := range cells {
if !tableSeparatorCell.MatchString(strings.TrimSpace(cell)) {
return false
}
}
return true
}
// tableRows returns how many leading lines form a markdown table (header,
// separator, then body rows), or 0 when the block is not a table.
func tableRows(lines []string) int {
if len(lines) < 2 || !isTableRow(lines[0]) || !isTableSeparator(lines[1]) {
return 0
}
rows := 2
for rows < len(lines) && isTableRow(lines[rows]) && !isTableSeparator(lines[rows]) {
rows++
}
return rows
}
func splitTableRow(line string) []string {
trimmed := strings.TrimSpace(line)
trimmed = strings.TrimPrefix(trimmed, "|")
trimmed = strings.TrimSuffix(trimmed, "|")
cells := strings.Split(trimmed, "|")
for i := range cells {
cells[i] = strings.TrimSpace(cells[i])
}
return cells
}
// renderMarkdownTable draws a column-aligned table: bold header, a rule under
// it, and inline-formatted body cells.
func renderMarkdownTable(lines []string) string {
headerStyle := func(cell string) string { return Bold(Field).Render(cell) }
rows := make([][]string, 0, len(lines)-1)
styleCells := func(line string, style func(string) string) []string {
cells := splitTableRow(line)
for i := range cells {
cells[i] = style(cells[i])
}
return cells
}
rows = append(rows, styleCells(lines[0], headerStyle))
for _, line := range lines[2:] {
rows = append(rows, styleCells(line, inlineFormat))
}
widths := make([]int, len(rows[0]))
for _, cells := range rows {
for i, cell := range cells {
if i < len(widths) {
widths[i] = max(widths[i], lipgloss.Width(cell))
}
}
}
formatRow := func(cells []string) string {
parts := make([]string, len(widths))
for i := range widths {
cell := ""
if i < len(cells) {
cell = cells[i]
}
parts[i] = cell + strings.Repeat(" ", max(0, widths[i]-lipgloss.Width(cell)))
}
return strings.TrimRight(strings.Join(parts, Dim().Render(" │ ")), " ")
}
out := []string{formatRow(rows[0])}
rule := make([]string, len(widths))
for i, width := range widths {
rule[i] = strings.Repeat("─", width)
}
out = append(out, Dim().Render(strings.Join(rule, "─┼─")))
for _, cells := range rows[1:] {
out = append(out, formatRow(cells))
}
return strings.Join(out, "\n")
}
func tryHeader(line string) *mdHeader {
for i := range mdHeaders {
if strings.HasPrefix(line, mdHeaders[i].prefix) {
return &mdHeaders[i]
}
}
return nil
}
func isWordByte(b byte) bool {
return b == '_' || b >= '0' && b <= '9' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z'
}
// canOpenEmphasis reports whether an emphasis run starting at i (with the
// given marker width) follows CommonMark-style flanking rules: it must not
// sit inside a word and must be followed by a non-space.
func canOpenEmphasis(line string, i, width int) bool {
if i > 0 && isWordByte(line[i-1]) {
return false
}
// Underscores appear inside identifiers far more often than as emphasis,
// so they only open at a word boundary.
if i > 0 && line[i] == '_' && line[i-1] != ' ' && line[i-1] != '\t' {
return false
}
after := i + width
return after < len(line) && line[after] != ' ' && line[after] != '\t'
}
// canCloseEmphasis reports whether an emphasis run ending at end (marker
// starts at end) is preceded by a non-space and not followed by a word.
func canCloseEmphasis(line string, end, width int) bool {
if end > 0 && (line[end-1] == ' ' || line[end-1] == '\t') {
return false
}
after := end + width
return after >= len(line) || !isWordByte(line[after])
}
// findEmphasisEnd locates the closing marker for an emphasis span opened at
// i, honoring the flanking rules; returns -1 when the span should be treated
// as literal text.
func findEmphasisEnd(line string, i int, marker string) int {
from := i + len(marker)
for {
end := strings.Index(line[from:], marker)
if end == -1 {
return -1
}
end += from
if end == i+len(marker) {
return -1
}
if canCloseEmphasis(line, end, len(marker)) {
return end
}
from = end + 1
}
}
// inlineFormat ports _process_inline_formatting.
func inlineFormat(line string) string {
var out strings.Builder
i, n := 0, len(line)
for i < n {
if i+1 < n && (line[i:i+2] == "**" || line[i:i+2] == "__") {
marker := line[i : i+2]
if canOpenEmphasis(line, i, 2) {
if end := findEmphasisEnd(line, i, marker); end != -1 {
out.WriteString(Bold(Field).Render(line[i+2 : end]))
i = end + 2
continue
}
}
}
if i+1 < n && line[i:i+2] == "~~" {
if canOpenEmphasis(line, i, 2) {
if end := findEmphasisEnd(line, i, "~~"); end != -1 {
out.WriteString(lipgloss.NewStyle().Strikethrough(true).Foreground(Strike).Render(line[i+2 : end]))
i = end + 2
continue
}
}
}
if line[i] == '`' {
if end := strings.Index(line[i+1:], "`"); end != -1 {
end += i + 1
out.WriteString(lipgloss.NewStyle().Bold(true).Foreground(Green).Background(CodeBg).Render(line[i+1 : end]))
i = end + 1
continue
}
}
if line[i] == '*' || line[i] == '_' {
marker := string(line[i])
if i+1 < n && line[i+1] != line[i] && canOpenEmphasis(line, i, 1) {
if end := findEmphasisEnd(line, i, marker); end != -1 && (end+1 >= n || line[end+1] != line[i]) {
out.WriteString(lipgloss.NewStyle().Italic(true).Foreground(Mint).Render(line[i+1 : end]))
i = end + 1
continue
}
}
}
out.WriteByte(line[i])
i++
}
return out.String()
}
@@ -0,0 +1,86 @@
package render
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
// ---------------------------------------------------------------------------
// Agents graph (agents_graph_renderer.py)
// ---------------------------------------------------------------------------
func renderAgentGraphTool(name string, args map[string]any, result any) string {
var b strings.Builder
switch name {
case "view_agent_graph":
b.WriteString(Col(Lavender).Render("◇ ") + Dim().Render("viewing agents graph"))
case "create_agent":
agentName := StringValue(args["name"])
if agentName == "" {
agentName = "Agent"
}
b.WriteString(Col(Lavender).Render("◈ ") + Dim().Render("spawning ") + Bold(Lavender).Render(agentName))
if task := StringValue(args["task"]); task != "" {
b.WriteString("\n " + Dim().Render(task))
}
case "send_message_to_agent":
b.WriteString(Col(InfoBlue).Render("→ "))
if target := StringValue(args["target_agent_id"]); target != "" {
b.WriteString(Dim().Render("to " + target))
} else {
b.WriteString(Dim().Render("sending message"))
}
if msg := StringValue(args["message"]); msg != "" {
b.WriteString("\n " + Dim().Render(msg))
}
case "agent_finish":
success := true
if v, ok := args["success"].(bool); ok {
success = v
}
if success {
b.WriteString(Col(Green).Render("◆ ") + Bold(Green).Render("Agent completed"))
} else {
b.WriteString(Col(Red).Render("◆ ") + Bold(Red).Render("Agent failed"))
}
if summary := StringValue(args["result_summary"]); summary != "" {
b.WriteString("\n " + lipgloss.NewStyle().Bold(true).Render(summary))
if findings, ok := args["findings"].([]any); ok {
for _, f := range findings {
b.WriteString("\n • " + Dim().Render(StringValue(f)))
}
}
} else {
b.WriteString("\n " + Dim().Render("Completing task..."))
}
case "wait_for_agents":
b.WriteString(Col(Gray).Render("○ ") + Dim().Render("waiting"))
if reason := StringValue(args["reason"]); reason != "" {
b.WriteString("\n " + Dim().Render(reason))
}
case "stop_agent":
b.WriteString(Col(Red).Render("◼ ") + Dim().Render("stopping"))
if target := StringValue(args["target_agent_id"]); target != "" {
b.WriteString(Bold(Red).Render(" " + target))
}
cascade := true
if v, ok := args["cascade"].(bool); ok {
cascade = v
}
if cascade {
b.WriteString(Dim().Italic(true).Render(" + descendants"))
}
if reason := StringValue(args["reason"]); reason != "" {
b.WriteString("\n " + Dim().Render(reason))
}
if m, ok := result.(map[string]any); ok {
if s, hs := m["success"].(bool); hs && !s {
if e := StringValue(m["error"]); e != "" {
b.WriteString("\n " + Col(Red).Render(e))
}
}
}
}
return b.String()
}
@@ -0,0 +1,32 @@
package render
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
// ---------------------------------------------------------------------------
// Chat messages
// ---------------------------------------------------------------------------
// renderUserMessage ports UserMessageRenderer._format_user_message.
func renderUserMessage(content string) string {
bar := Col(Blue).Render("▍")
var b strings.Builder
b.WriteString(bar + " " + lipgloss.NewStyle().Bold(true).Render("You:"))
for _, line := range strings.Split(content, "\n") {
b.WriteString("\n" + bar + " " + line)
}
return b.String()
}
// renderChat renders a chat event (assistant markdown or user message).
func Chat(data map[string]any) string {
role, _ := data["role"].(string)
content := StripControls(StringValue(data["content"]))
if role == "user" {
return renderUserMessage(content)
}
return renderAssistantMarkdown(content)
}
@@ -0,0 +1,70 @@
package render
import (
"path/filepath"
"strings"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
)
// HighlightCode ports the Python renderers' pygments highlighting: colorize
// code for the terminal using the "native" style, falling back to the plain
// text when the language is unknown or the highlighter fails.
func HighlightCode(code, language string) string {
if strings.TrimSpace(code) == "" {
return code
}
var lexer chroma.Lexer
if language != "" {
lexer = lexers.Get(language)
}
if lexer == nil {
lexer = lexers.Analyse(code)
}
if lexer == nil {
return Col(Text).Render(code)
}
lexer = chroma.Coalesce(lexer)
style := styles.Get("native")
formatter := formatters.Get("terminal256")
iterator, err := lexer.Tokenise(nil, code)
if err != nil {
return Col(Text).Render(code)
}
var out strings.Builder
if err := formatter.Format(&out, style, iterator); err != nil {
return Col(Text).Render(code)
}
return strings.TrimSuffix(out.String(), "\n")
}
// languageForPath resolves a chroma language name from a file path, returning
// "" when the extension is unknown.
func languageForPath(path string) string {
if path == "" {
return ""
}
lexer := lexers.Match(filepath.Base(path))
if lexer == nil {
return ""
}
return lexer.Config().Name
}
// ParseFencedCode ports parse_fenced_code: strip a surrounding ``` fence and
// return the declared language (if any) and the inner code.
func ParseFencedCode(raw string) (language, code string) {
trimmed := strings.TrimSpace(raw)
if !strings.HasPrefix(trimmed, "```") {
return "", raw
}
lines := strings.Split(trimmed, "\n")
if len(lines) < 2 || strings.TrimSpace(lines[len(lines)-1]) != "```" {
return "", raw
}
language = strings.TrimSpace(strings.TrimPrefix(lines[0], "```"))
return language, strings.Join(lines[1:len(lines)-1], "\n")
}
@@ -0,0 +1,99 @@
package render
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
func renderDependencyReport(args map[string]any, result any) string {
resultMap, _ := result.(map[string]any)
// Unsuccessful / not-persisted variants.
if resultMap != nil {
success, hasSuccess := resultMap["success"].(bool)
warning := StringValue(resultMap["warning"])
if (hasSuccess && !success) || warning != "" {
return renderDependencyUnsuccessful(args, resultMap)
}
}
var b strings.Builder
b.WriteString("📦 " + Bold(ReportHdr).Render("Dependency (SCA) Report"))
field := func(label, value string) {
if value != "" {
b.WriteString("\n\n" + Bold(Field).Render(label+": ") + value)
}
}
title := StringValue(args["title"])
field("Title", title)
if sev := StringValue(resultMap["severity"]); sev != "" {
b.WriteString("\n\n" + Bold(Field).Render("Severity: ") +
lipgloss.NewStyle().Bold(true).Foreground(SeverityColor(sev)).Render(strings.ToUpper(sev)))
}
if score, ok := NumericValue(args["advisory_cvss"]); ok {
b.WriteString("\n\n" + Bold(Field).Render("Advisory CVSS: ") +
lipgloss.NewStyle().Bold(true).Foreground(CVSSColor(score)).Render(StringValue(args["advisory_cvss"])))
}
field("CVE", StringValue(args["cve"]))
field("CWE", StringValue(args["cwe"]))
if pkg := StringValue(args["package_name"]); pkg != "" {
b.WriteString("\n\n" + Bold(Field).Render("Package: ") + Bold(InfoBlue).Render(pkg))
if eco := StringValue(args["package_ecosystem"]); eco != "" {
b.WriteString(Dim().Render(" (" + eco + ")"))
}
}
if inst := StringValue(args["installed_version"]); inst != "" {
b.WriteString("\n\n" + Bold(Field).Render("Installed: ") + Col(Red).Render(inst))
if fixed := StringValue(args["fixed_version"]); fixed != "" {
b.WriteString(Dim().Render(" → ") + Bold(Field).Render("Fixed: ") + Col(Green).Render(fixed))
}
}
field("Fix Effort", StringValue(args["fix_effort"]))
field("Target", StringValue(args["target"]))
section := func(label, value string) {
if value != "" {
b.WriteString("\n\n" + Bold(Field).Render(label) + "\n" + value)
}
}
section("Description", StringValue(args["description"]))
section("Impact", StringValue(args["impact"]))
section("Technical Analysis", StringValue(args["technical_analysis"]))
section("Assumptions", StringValue(args["assumptions"]))
section("Remediation", StringValue(args["remediation_steps"]))
if title == "" {
b.WriteString("\n " + Dim().Render("Creating dependency report..."))
}
return "\n\n" + b.String() + "\n\n"
}
func renderDependencyUnsuccessful(args, result map[string]any) string {
var b strings.Builder
b.WriteString("📦 " + Bold(ReportHdr).Render("Dependency (SCA) Report"))
if title := StringValue(args["title"]); title != "" {
b.WriteString("\n\n" + Bold(Field).Render("Title: ") + title)
}
success, hasSuccess := result["success"].(bool)
var label, detail string
var style lipgloss.Style
if hasSuccess && !success {
detail = StringValue(result["error"])
if errs, ok := result["errors"].([]any); ok && len(errs) > 0 {
var parts []string
for _, e := range errs {
parts = append(parts, StringValue(e))
}
detail = strings.Join(parts, "; ")
}
label, style = "✗ Not created: ", Bold(SevCrit)
if detail == "" {
detail = "Report was not created."
}
} else {
detail = StringValue(result["warning"])
label, style = "⚠ Not persisted: ", Bold(SevMed)
if detail == "" {
detail = "Report could not be persisted."
}
}
b.WriteString("\n\n" + style.Render(label) + detail)
return "\n\n" + b.String() + "\n\n"
}
@@ -0,0 +1,143 @@
package render
import (
"strings"
)
// ---------------------------------------------------------------------------
// Filesystem: apply_patch + view_image (filesystem_renderer.py)
// ---------------------------------------------------------------------------
const (
addFilePfx = "*** Add File: "
deleteFilePfx = "*** Delete File: "
updateFilePfx = "*** Update File: "
beginPatch = "*** Begin Patch"
endPatch = "*** End Patch"
)
type patchOp struct {
kind string
path string
old []string
new []string
}
func extractPatchText(args map[string]any) string {
if raw, ok := args["patch"].(string); ok {
return raw
}
if raw, ok := args["patch"].(map[string]any); ok {
if inner, ok := raw["patch"].(string); ok {
return inner
}
}
if fb, ok := args["input"].(string); ok {
return fb
}
return ""
}
func parsePatchOperations(patch string) []patchOp {
var ops []patchOp
var cur *patchOp
flush := func() {
if cur != nil && cur.kind != "" {
ops = append(ops, *cur)
}
cur = nil
}
for _, line := range strings.Split(patch, "\n") {
switch {
case line == beginPatch || line == endPatch:
continue
case strings.HasPrefix(line, addFilePfx):
flush()
cur = &patchOp{kind: "add", path: strings.TrimSpace(line[len(addFilePfx):])}
case strings.HasPrefix(line, updateFilePfx):
flush()
cur = &patchOp{kind: "update", path: strings.TrimSpace(line[len(updateFilePfx):])}
case strings.HasPrefix(line, deleteFilePfx):
flush()
cur = &patchOp{kind: "delete", path: strings.TrimSpace(line[len(deleteFilePfx):])}
case cur != nil && cur.kind == "update":
if strings.HasPrefix(line, "@@") {
continue
}
if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") {
cur.old = append(cur.old, line[1:])
} else if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") {
cur.new = append(cur.new, line[1:])
}
case cur != nil && cur.kind == "add":
if strings.HasPrefix(line, "+") {
cur.new = append(cur.new, line[1:])
} else if strings.TrimSpace(line) != "" {
cur.new = append(cur.new, line)
}
}
}
flush()
return ops
}
var opLabel = map[string]string{"add": "create", "update": "edit", "delete": "delete"}
func renderPatchOperation(b *strings.Builder, op patchOp) {
label := opLabel[op.kind]
if label == "" {
label = "file"
}
b.WriteString(Col(Emerald).Render("◇ ") + Dim().Render(label))
if op.path != "" {
p := op.path
if len(p) > 60 {
p = p[len(p)-60:]
}
b.WriteString(" " + Dim().Render(p))
}
lang := languageForPath(op.path)
if op.kind == "update" {
for _, line := range highlightLines(op.old, lang) {
b.WriteString("\n" + Col(Red).Render("-") + " " + line)
}
for _, line := range highlightLines(op.new, lang) {
b.WriteString("\n" + Col(Green).Render("+") + " " + line)
}
} else if op.kind == "add" && len(op.new) > 0 {
b.WriteString("\n" + HighlightCode(strings.Join(op.new, "\n"), lang))
}
}
func highlightLines(lines []string, lang string) []string {
if len(lines) == 0 || lang == "" {
return lines
}
return strings.Split(HighlightCode(strings.Join(lines, "\n"), lang), "\n")
}
func renderApplyPatch(args map[string]any, result any, status string) string {
ops := parsePatchOperations(extractPatchText(args))
var b strings.Builder
if len(ops) == 0 {
b.WriteString(Col(Emerald).Render("◇ ") + Dim().Render("patch"))
if s, ok := result.(string); ok && strings.TrimSpace(s) != "" {
b.WriteString("\n " + Dim().Render(strings.TrimSpace(s)))
} else if result == nil {
b.WriteString(" " + Dim().Render("Processing..."))
}
return b.String()
}
for i, op := range ops {
if i > 0 {
b.WriteString("\n")
}
renderPatchOperation(&b, op)
}
if status == "failed" {
if s, ok := result.(string); ok && strings.TrimSpace(s) != "" {
b.WriteString("\n " + Col(Red).Render(strings.TrimSpace(s)))
}
}
return b.String()
}
@@ -0,0 +1,112 @@
package render
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
// ---------------------------------------------------------------------------
// small helpers
// ---------------------------------------------------------------------------
func truthy(v any) bool {
switch x := v.(type) {
case bool:
return x
case string:
return x != ""
case float64:
return x != 0
case nil:
return false
}
return v != nil
}
func NumericValue(v any) (float64, bool) {
switch x := v.(type) {
case float64:
return x, true
case int:
return float64(x), true
case int64:
return float64(x), true
}
return 0, false
}
func truncStr(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
func lastN(s string, n int) string {
if len(s) > n {
return s[len(s)-n:]
}
return s
}
func firstN(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
func joinTrunc(items []any, max, limit int) string {
shown := items
if len(shown) > limit {
shown = shown[:limit]
}
var parts []string
for _, it := range shown {
parts = append(parts, ptrunc(StringValue(it), max))
}
return strings.Join(parts, ", ")
}
// stripControlsKeepTabs drops control bytes except \t and \n (shell cleaning).
func stripControlsKeepTabs(s string) string {
return strings.Map(func(r rune) rune {
if r == '\n' || r == '\t' || r >= 32 {
return r
}
return -1
}, s)
}
func StringValue(value any) string {
if value == nil {
return ""
}
if text, ok := value.(string); ok {
return text
}
raw, err := json.Marshal(value)
if err == nil {
return string(raw)
}
return fmt.Sprint(value)
}
func StripControls(value string) string {
return strings.Map(func(r rune) rune {
if r == '\n' || r == '\t' || r >= 32 {
return r
}
return -1
}, value)
}
func SortedKeys(values map[string]any) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
@@ -0,0 +1,106 @@
package render
import (
"strings"
)
func renderViewImage(args map[string]any, result any) string {
path := strings.TrimSpace(StringValue(args["path"]))
var b strings.Builder
b.WriteString(Col(Emerald).Render("◇ ") + Dim().Render("view image"))
if path != "" {
if len(path) > 60 {
path = path[len(path)-60:]
}
b.WriteString(" " + Dim().Render(path))
}
if s, ok := result.(string); ok {
low := strings.ToLower(strings.TrimSpace(s))
if strings.HasPrefix(low, "image path ") || strings.HasPrefix(low, "unable to read image") ||
strings.HasPrefix(low, "manifest path") || strings.HasPrefix(low, "exceeded the allowed size") ||
strings.Contains(low, "not a supported image") {
b.WriteString("\n " + Col(Red).Render(strings.TrimSpace(s)))
return b.String()
}
}
if isImageSuccess(result) {
b.WriteString(" " + Col(Green).Render("✓"))
if KittyGraphicsSupported() {
if mime, payload := extractImageDataURI(result); mime != "" {
if block := kittyImageBlock(mime, payload); block != "" {
b.WriteString("\n" + block)
}
}
}
}
return b.String()
}
var imageMimes = []string{"png", "jpeg", "jpg", "gif", "webp"}
func isBase64Byte(b byte) bool {
return b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z' || b >= '0' && b <= '9' ||
b == '+' || b == '/' || b == '='
}
// parseImageDataURI scans a data URI without a regexp: payloads run to
// megabytes and the regexp engine is far too slow to walk them per frame.
func parseImageDataURI(s string) (mime, payload string) {
start := strings.Index(s, "data:image/")
if start < 0 {
return "", ""
}
rest := s[start+len("data:image/"):]
for _, candidate := range imageMimes {
if !strings.HasPrefix(rest, candidate+";base64,") {
continue
}
data := rest[len(candidate)+len(";base64,"):]
end := len(data)
for i := range len(data) {
if !isBase64Byte(data[i]) {
end = i
break
}
}
if candidate == "jpg" {
candidate = "jpeg"
}
return candidate, data[:end]
}
return "", ""
}
// extractImageDataURI pulls a base64 image payload out of a view_image tool
// result: a raw data URI or a structured map with an image_url/url field.
func extractImageDataURI(result any) (mime, payload string) {
var s string
switch v := result.(type) {
case string:
s = v
case map[string]any:
if u := StringValue(v["image_url"]); u != "" {
s = u
} else if u := StringValue(v["url"]); u != "" {
s = u
}
}
if s == "" {
return "", ""
}
mime, payload = parseImageDataURI(s)
if mime == "" || len(payload) < 100 || len(payload)%4 != 0 {
return "", ""
}
return mime, payload
}
func isImageSuccess(result any) bool {
if m, ok := result.(map[string]any); ok {
return StringValue(m["type"]) == "image"
}
if s, ok := result.(string); ok {
return strings.HasPrefix(strings.TrimLeft(s, " \t\n"), "data:image/")
}
return false
}
@@ -0,0 +1,139 @@
package render
import (
"bytes"
"os"
"time"
"github.com/charmbracelet/x/term"
)
// queryBudget bounds the whole capability exchange. A terminal answers in
// microseconds; anything this slow is not going to answer at all.
const queryBudget = 500 * time.Millisecond
// drainBudget is the grace period spent collecting whatever else the terminal
// sent after the answer we were looking for.
const drainBudget = 50 * time.Millisecond
// etx is what ctrl-c delivers while ISIG is cleared.
const etx = 0x03
// DetectKittyGraphics asks the terminal whether it supports the kitty
// graphics protocol, the way kitty's own tooling does: send a 1x1 query
// (a=q) followed by a Primary Device Attributes request, then read until the
// DA1 response arrives. A graphics-capable terminal answers the query with an
// APC "OK" response before the DA1; anything else ignores it. Must run before
// Bubble Tea takes over stdin.
func DetectKittyGraphics() {
supported, interrupted := queryKittyGraphics(os.Stdin, os.Stdout)
KittyGraphicsSupported = func() bool { return supported }
if interrupted {
// The query runs with ISIG cleared, so ctrl-c arrives as a byte instead
// of a signal. Raise it now that the terminal is restored, so a ctrl-c
// during startup quits rather than being swallowed.
interruptSelf()
}
}
func queryKittyGraphics(in, out *os.File) (supported, interrupted bool) {
fd := int(in.Fd())
if !term.IsTerminal(uintptr(fd)) {
return false, false
}
oldState, err := term.MakeRaw(uintptr(fd))
if err != nil {
return false, false
}
// Everything the terminal sends must be consumed before the terminal echoes
// it: once cooked mode is back, a reply still in flight is printed to the
// screen as mojibake like "^[[?62;52;c".
defer term.Restore(uintptr(fd), oldState) //nolint:errcheck
// The same 1x1 RGB query used by viuer and yazi; DA1 (CSI c) is answered
// by every terminal and bounds the read.
if _, err := out.WriteString("\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\\x1b[c"); err != nil {
return false, false
}
reply := readCapabilityReply(in, queryBudget)
if reply.answered {
// The kitty answer arrives before the DA1, so the DA1 is still on its
// way. Take it now rather than leaving it for the shell to echo.
drainInput(in, drainBudget)
}
return reply.supported, reply.interrupted
}
// capabilityReply is what the terminal told us: whether it supports the
// protocol, whether it answered at all, and whether the user pressed ctrl-c
// while we were waiting.
type capabilityReply struct {
supported bool
answered bool
interrupted bool
}
// readCapabilityReply reads until the kitty answer or the DA1 that follows it,
// whichever comes first.
func readCapabilityReply(in *os.File, budget time.Duration) capabilityReply {
deadline := time.Now().Add(budget)
var buf bytes.Buffer
chunk := make([]byte, 256)
for {
remaining := time.Until(deadline)
if remaining <= 0 {
return capabilityReply{}
}
// The read itself has to be bounded. os.File deadlines do not work on a
// terminal - the fd is blocking, so it is never registered with the
// runtime poller and SetReadDeadline fails with "file type does not
// support deadline" - which would leave this read hanging until the
// terminal happened to send something.
ready, err := waitReadable(in, remaining)
if err != nil || !ready {
return capabilityReply{}
}
n, err := in.Read(chunk)
if n > 0 {
buf.Write(chunk[:n])
// ctrl-c is ETX here rather than a signal. Stop waiting on the
// terminal the moment the user asks to leave.
if bytes.IndexByte(buf.Bytes(), etx) >= 0 {
return capabilityReply{interrupted: true}
}
if apc := bytes.Index(buf.Bytes(), []byte("\x1b_G")); apc >= 0 &&
bytes.Contains(buf.Bytes()[apc:], []byte(";OK")) {
return capabilityReply{supported: true, answered: true}
}
// DA1 response: ESC [ ? ... c
if idx := bytes.Index(buf.Bytes(), []byte("\x1b[?")); idx >= 0 &&
bytes.IndexByte(buf.Bytes()[idx:], 'c') >= 0 {
return capabilityReply{answered: true}
}
}
if err != nil {
return capabilityReply{}
}
}
}
// drainInput consumes whatever is already readable, so no part of the terminal's
// answer survives into cooked mode.
func drainInput(in *os.File, budget time.Duration) {
deadline := time.Now().Add(budget)
chunk := make([]byte, 256)
for {
remaining := time.Until(deadline)
if remaining <= 0 {
return
}
ready, err := waitReadable(in, remaining)
if err != nil || !ready {
return
}
if _, err := in.Read(chunk); err != nil {
return
}
}
}
@@ -0,0 +1,133 @@
//go:build !windows
package render
import (
"os"
"testing"
"time"
)
// A terminal that ignores the query must not stall startup. This is the bound
// that os.File read deadlines could not provide: a tty descriptor is blocking,
// so it is never registered with the runtime poller and SetReadDeadline fails
// with "file type does not support deadline", leaving the read to hang until the
// terminal happened to send something.
func TestCapabilityReadGivesUpOnASilentTerminal(t *testing.T) {
reader, writer := pipePair(t)
defer writer.Close()
start := time.Now()
reply := readCapabilityReply(reader, 150*time.Millisecond)
if reply.supported || reply.answered {
t.Fatalf("silence reported an answer: %+v", reply)
}
if elapsed := time.Since(start); elapsed > 3*time.Second {
t.Fatalf("the read was not bounded: %s", elapsed)
}
}
func TestCapabilityReadClassifiesTheReply(t *testing.T) {
for _, testCase := range []struct {
name string
reply string
want bool
}{
{"DA1 alone means no kitty support", "\x1b[?62;52;c", false},
{"a kitty answer means support", "\x1b_Gi=31;OK\x1b\\\x1b[?62;52;c", true},
} {
t.Run(testCase.name, func(t *testing.T) {
reader, writer := pipePair(t)
defer writer.Close()
if _, err := writer.WriteString(testCase.reply); err != nil {
t.Fatalf("write reply: %v", err)
}
reply := readCapabilityReply(reader, time.Second)
if !reply.answered {
t.Fatal("a reply was sent but not seen")
}
if reply.supported != testCase.want {
t.Fatalf("support = %v, want %v", reply.supported, testCase.want)
}
})
}
}
// The DA1 trails a kitty answer, so it is still arriving when the answer is
// recognized. Anything left unread is echoed to the screen once cooked mode
// returns, which is where "^[[?62;52;c" came from.
func TestDrainClearsWhatFollowsTheAnswer(t *testing.T) {
reader, writer := pipePair(t)
defer writer.Close()
if _, err := writer.WriteString("\x1b_Gi=31;OK\x1b\\\x1b[?62;52;c"); err != nil {
t.Fatalf("write reply: %v", err)
}
reply := readCapabilityReply(reader, time.Second)
if !reply.supported || !reply.answered {
t.Fatalf("kitty answer not recognized: %+v", reply)
}
drainInput(reader, drainBudget)
leftover, err := waitReadable(reader, 100*time.Millisecond)
if err != nil {
t.Fatalf("leftover check failed: %v", err)
}
if leftover {
t.Fatal("part of the reply survived the drain and would be echoed")
}
}
// The query clears ISIG, so ctrl-c arrives as ETX rather than a signal. It has to
// end the wait instead of being swallowed as terminal noise, which is what left a
// hung startup unresponsive to ctrl-c.
func TestCtrlCEndsTheWait(t *testing.T) {
reader, writer := pipePair(t)
defer writer.Close()
if _, err := writer.Write([]byte{etx}); err != nil {
t.Fatalf("write ctrl-c: %v", err)
}
start := time.Now()
reply := readCapabilityReply(reader, 10*time.Second)
if !reply.interrupted {
t.Fatalf("ctrl-c was not recognized: %+v", reply)
}
if reply.answered || reply.supported {
t.Fatalf("ctrl-c must not be read as a terminal answer: %+v", reply)
}
if elapsed := time.Since(start); elapsed > 2*time.Second {
t.Fatalf("ctrl-c did not end the wait promptly: %s", elapsed)
}
}
// waitReadable must report readiness without waiting out the whole timeout.
func TestWaitReadableSeesAvailableInput(t *testing.T) {
reader, writer := pipePair(t)
defer writer.Close()
if _, err := writer.WriteString("x"); err != nil {
t.Fatalf("write: %v", err)
}
ready, err := waitReadable(reader, time.Second)
if err != nil {
t.Fatalf("waitReadable failed: %v", err)
}
if !ready {
t.Fatal("input was available but waitReadable reported none")
}
}
func pipePair(t *testing.T) (reader, writer *os.File) {
t.Helper()
reader, writer, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
t.Cleanup(func() { reader.Close() })
return reader, writer
}
@@ -0,0 +1,37 @@
//go:build !windows
package render
import (
"os"
"time"
"golang.org/x/sys/unix"
)
// waitReadable reports whether the descriptor has input available within the
// timeout. poll(2) works on a blocking terminal descriptor, which is what a tty
// is and why os.File read deadlines cannot be used here.
func waitReadable(in *os.File, timeout time.Duration) (bool, error) {
fds := []unix.PollFd{{Fd: int32(in.Fd()), Events: unix.POLLIN}}
milliseconds := int(timeout.Milliseconds())
if milliseconds <= 0 {
milliseconds = 1
}
for {
n, err := unix.Poll(fds, milliseconds)
if err == unix.EINTR {
continue
}
if err != nil {
return false, err
}
return n > 0, nil
}
}
// interruptSelf raises the interrupt the terminal could not deliver while the
// capability query held the terminal with signals disabled.
func interruptSelf() {
_ = unix.Kill(os.Getpid(), unix.SIGINT)
}
@@ -0,0 +1,19 @@
//go:build windows
package render
import (
"os"
"time"
)
// waitReadable has no console equivalent worth carrying: no Windows terminal
// implements the kitty graphics protocol, so detection reports no support rather
// than blocking on a reply that never comes.
func waitReadable(_ *os.File, _ time.Duration) (bool, error) {
return false, nil
}
// interruptSelf has nothing to do: detection never reads on this platform, so
// ctrl-c is never withheld from the console.
func interruptSelf() {}
@@ -0,0 +1,202 @@
package render
import (
"bytes"
"encoding/base64"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
"image/png"
"strings"
"sync"
)
// Native inline images via the kitty graphics protocol with Unicode
// placeholders (https://sw.kovidgoyal.net/kitty/graphics-protocol/): the image
// is transmitted once out of band with a virtual placement, and the chat trace
// renders placeholder cells that the terminal replaces with real pixels. The
// placeholder rows are plain styled text, so they scroll and diff like any
// other Bubble Tea content. Terminals without the protocol show no preview.
const (
imageMinCols = 20
imageMaxCols = 100
imageDefaultCols = 72
imageMaxRows = 28
kittyChunkSize = 4096
)
var imageCols = imageDefaultCols
// SetImageWidth sizes inline image placements to the chat content width in cells.
func SetImageWidth(cells int) {
imageCols = min(max(cells, imageMinCols), imageMaxCols)
}
// KittyGraphicsSupported reports whether the terminal supports the kitty
// graphics protocol; set at startup by DetectKittyGraphics via a live
// terminal query.
var KittyGraphicsSupported = func() bool { return false }
type kittyPlacement struct {
id uint32
cols int
rows int
placeholder string
}
var (
kittyMu sync.Mutex
kittyByHash = map[string]kittyPlacement{}
kittyQueue []string
kittyNextID uint32 = 1
)
// DrainImageTransmissions returns queued kitty transmit/placement sequences,
// to be written directly to the terminal exactly once per image.
func DrainImageTransmissions() []string {
kittyMu.Lock()
defer kittyMu.Unlock()
out := kittyQueue
kittyQueue = nil
return out
}
// payloadKey identifies an image payload without hashing megabytes of base64
// on every frame: its length plus both ends are enough to tell distinct
// images apart.
func payloadKey(payload string) string {
const edge = 64
if len(payload) <= 2*edge {
return payload
}
return fmt.Sprintf("%d:%s:%s", len(payload), payload[:edge], payload[len(payload)-edge:])
}
// kittyImageBlock registers the image payload (queueing its transmission on
// first sight) and returns the styled placeholder block for the chat trace.
func kittyImageBlock(mime, payload string) string {
kittyMu.Lock()
defer kittyMu.Unlock()
key := payloadKey(payload)
placement, ok := kittyByHash[key]
if !ok {
pngData, w, h := payloadToPNG(mime, payload)
if pngData == nil {
return ""
}
cols := min(imageCols, w)
rows := (h*cols + w - 1) / (w * 2)
rows = min(max(1, rows), imageMaxRows)
placement = kittyPlacement{id: kittyNextID, cols: cols, rows: rows}
placement.placeholder = kittyPlaceholder(placement)
kittyNextID++
kittyByHash[key] = placement
kittyQueue = append(kittyQueue, kittyTransmit(placement, pngData))
}
return placement.placeholder
}
func payloadToPNG(mime, payload string) (data []byte, w, h int) {
raw, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
return nil, 0, 0
}
img, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
return nil, 0, 0
}
bounds := img.Bounds()
if bounds.Dx() <= 0 || bounds.Dy() <= 0 {
return nil, 0, 0
}
if mime == "png" {
return raw, bounds.Dx(), bounds.Dy()
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, 0, 0
}
return buf.Bytes(), bounds.Dx(), bounds.Dy()
}
// kittyTransmit builds the chunked APC sequences transmitting the PNG and
// creating a virtual (U=1) placement for Unicode placeholders.
func kittyTransmit(p kittyPlacement, pngData []byte) string {
encoded := base64.StdEncoding.EncodeToString(pngData)
var b strings.Builder
first := true
for len(encoded) > 0 {
chunk := encoded
if len(chunk) > kittyChunkSize {
chunk = chunk[:kittyChunkSize]
}
encoded = encoded[len(chunk):]
more := 0
if len(encoded) > 0 {
more = 1
}
if first {
fmt.Fprintf(&b, "\x1b_Ga=t,q=2,f=100,i=%d,m=%d;%s\x1b\\", p.id, more, chunk)
first = false
} else {
fmt.Fprintf(&b, "\x1b_Gm=%d;%s\x1b\\", more, chunk)
}
}
fmt.Fprintf(&b, "\x1b_Ga=p,q=2,U=1,i=%d,c=%d,r=%d\x1b\\", p.id, p.cols, p.rows)
return b.String()
}
// kittyPlaceholder renders the rows x cols grid of U+10EEEE placeholder cells
// carrying the image id in the foreground color and the cell position in
// row/column diacritics. The id must reach the terminal as an exact truecolor
// value, so the SGR sequence is emitted directly rather than through lipgloss
// (whose profile detection may downsample it).
func kittyPlaceholder(p kittyPlacement) string {
id := p.id & 0xffffff
var b strings.Builder
for row := range p.rows {
if row > 0 {
b.WriteString("\n")
}
fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm", id>>16&0xff, id>>8&0xff, id&0xff)
for col := range p.cols {
b.WriteRune(0x10eeee)
b.WriteRune(rowColumnDiacritics[row])
b.WriteRune(rowColumnDiacritics[col])
}
b.WriteString("\x1b[39m")
}
return b.String()
}
// rowColumnDiacritics is kitty's canonical placeholder diacritic table
// (gen/rowcolumn-diacritics.txt); index n encodes row/column number n.
var rowColumnDiacritics = []rune{
0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, 0x0346, 0x034A, 0x034B, 0x034C,
0x0350, 0x0351, 0x0352, 0x0357, 0x035B, 0x0363, 0x0364, 0x0365, 0x0366, 0x0367, 0x0368, 0x0369,
0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F, 0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592,
0x0593, 0x0594, 0x0595, 0x0597, 0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1,
0x05A8, 0x05A9, 0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615,
0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6, 0x06D7, 0x06D8,
0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2, 0x06E4, 0x06E7, 0x06E8, 0x06EB,
0x06EC, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736, 0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743,
0x0745, 0x0747, 0x0749, 0x074A, 0x07EB, 0x07EC, 0x07ED, 0x07EE, 0x07EF, 0x07F0, 0x07F1, 0x07F3,
0x0816, 0x0817, 0x0818, 0x0819, 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822,
0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C, 0x082D, 0x0951, 0x0953, 0x0954,
0x0F82, 0x0F83, 0x0F86, 0x0F87, 0x135D, 0x135E, 0x135F, 0x17DD, 0x193A, 0x1A17, 0x1A75, 0x1A76,
0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C, 0x1B6B, 0x1B6D, 0x1B6E, 0x1B6F, 0x1B70, 0x1B71,
0x1B72, 0x1B73, 0x1CD0, 0x1CD1, 0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4,
0x1DC5, 0x1DC6, 0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5,
0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF, 0x1DE0, 0x1DE1,
0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1, 0x20D4, 0x20D5, 0x20D6, 0x20D7,
0x20DB, 0x20DC, 0x20E1, 0x20E7, 0x20E9, 0x20F0, 0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2,
0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6, 0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE,
0x2DEF, 0x2DF0, 0x2DF1, 0x2DF2, 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA,
0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1, 0xA8E0, 0xA8E1,
0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9, 0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED,
0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2, 0xAAB3, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1,
0xFE20, 0xFE21, 0xFE22, 0xFE23, 0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186,
0x1D187, 0x1D188, 0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, 0x1D244,
}
@@ -0,0 +1,103 @@
package render
import (
"bytes"
"encoding/base64"
"image"
"image/color"
"image/png"
"strings"
"testing"
)
func testImageDataURI(t *testing.T, w, h int) string {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := range h {
for x := range w {
img.Set(x, y, color.RGBA{R: uint8(255 * x / w), G: uint8(255 * y / h), B: 128, A: 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatal(err)
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
}
func withKittySupport(t *testing.T, supported bool) {
t.Helper()
previous := KittyGraphicsSupported
KittyGraphicsSupported = func() bool { return supported }
t.Cleanup(func() { KittyGraphicsSupported = previous })
}
func TestViewImageRendersKittyPlaceholders(t *testing.T) {
withKittySupport(t, true)
uri := testImageDataURI(t, 120, 80)
out := Tool(tool("view_image", map[string]any{"path": "/tmp/shot.png"}, uri, "completed"))
if !strings.ContainsRune(out, 0x10eeee) {
t.Fatalf("expected kitty placeholder cells in render:\n%s", out)
}
transmissions := DrainImageTransmissions()
if len(transmissions) != 1 {
t.Fatalf("expected one queued transmission, got %d", len(transmissions))
}
seq := transmissions[0]
if !strings.Contains(seq, "\x1b_Ga=t,q=2,f=100,") {
t.Fatalf("missing transmit sequence: %.80s", seq)
}
if !strings.Contains(seq, "a=p,q=2,U=1,") {
t.Fatalf("missing virtual placement: %.80s", seq)
}
// Re-rendering the same image must not queue a second transmission.
Tool(tool("view_image", map[string]any{"path": "/tmp/shot.png"}, uri, "completed"))
if again := DrainImageTransmissions(); len(again) != 0 {
t.Fatalf("image retransmitted: %d", len(again))
}
}
func TestViewImageWithoutKittySupportShowsNoPreview(t *testing.T) {
withKittySupport(t, false)
uri := testImageDataURI(t, 60, 40)
out := Tool(tool("view_image", map[string]any{"path": "/tmp/shot.png"}, uri, "completed"))
if !strings.Contains(out, "✓") {
t.Fatalf("expected success check:\n%s", out)
}
if strings.ContainsRune(out, 0x10eeee) {
t.Fatal("placeholder cells must not render without kitty graphics support")
}
if len(DrainImageTransmissions()) != 0 {
t.Fatal("no transmissions expected without kitty graphics support")
}
}
func TestExtractImageDataURI(t *testing.T) {
uri := testImageDataURI(t, 8, 8)
if mime, payload := extractImageDataURI(uri); mime != "png" || payload == "" {
t.Fatal("raw data URI should extract")
}
if mime, _ := extractImageDataURI(map[string]any{"image_url": uri}); mime != "png" {
t.Fatal("structured result should extract")
}
if mime, _ := extractImageDataURI("data:image/png;base64,short"); mime != "" {
t.Fatal("tiny payload must be rejected")
}
}
func TestKittyPlaceholderGrid(t *testing.T) {
p := kittyPlacement{id: 3, cols: 4, rows: 2}
out := kittyPlaceholder(p)
lines := strings.Split(out, "\n")
if len(lines) != 2 {
t.Fatalf("expected 2 rows, got %d", len(lines))
}
if got := strings.Count(out, string(rune(0x10eeee))); got != 8 {
t.Fatalf("expected 8 placeholder cells, got %d", got)
}
if !strings.Contains(out, "\x1b[38;2;0;0;3m") {
t.Fatalf("placeholder must carry the image id in the foreground color:\n%q", out)
}
}
@@ -0,0 +1,103 @@
package render
import (
"strings"
"testing"
"github.com/charmbracelet/x/ansi"
)
func TestHighlightCodeColorsKnownLanguage(t *testing.T) {
out := HighlightCode("def main():\n return 1", "python")
if !strings.Contains(out, "\x1b[") {
t.Fatal("python code was not colorized")
}
if ansi.Strip(out) != "def main():\n return 1" {
t.Fatalf("highlighting changed the code text: %q", ansi.Strip(out))
}
}
func TestMarkdownCodeFenceIsHighlighted(t *testing.T) {
out := renderAssistantMarkdown("intro\n```python\nimport os\n```\ndone")
plain := ansi.Strip(out)
if !strings.Contains(plain, "import os") {
t.Fatalf("code fence content missing: %q", plain)
}
if strings.Contains(plain, "```") {
t.Fatalf("fence markers leaked into output: %q", plain)
}
}
func TestParseFencedCode(t *testing.T) {
lang, code := ParseFencedCode("```python\nprint(1)\n```")
if lang != "python" || code != "print(1)" {
t.Fatalf("got lang=%q code=%q", lang, code)
}
lang, code = ParseFencedCode("plain text")
if lang != "" || code != "plain text" {
t.Fatalf("unfenced text mangled: lang=%q code=%q", lang, code)
}
}
func TestMarkdownTableIsAligned(t *testing.T) {
out := renderAssistantMarkdown(strings.Join([]string{
"| Name | Severity |",
"| --- | --- |",
"| SQLi | **high** |",
"| XSS | low |",
}, "\n"))
plain := ansi.Strip(out)
lines := strings.Split(plain, "\n")
if len(lines) != 4 {
t.Fatalf("expected 4 table rows, got %d: %q", len(lines), plain)
}
if !strings.Contains(lines[0], "Name") || !strings.Contains(lines[0], "│") {
t.Fatalf("header row not formatted: %q", lines[0])
}
if !strings.Contains(lines[1], "─┼─") {
t.Fatalf("separator rule missing: %q", lines[1])
}
if !strings.Contains(lines[2], "high") || strings.Contains(lines[2], "**") {
t.Fatalf("body cell not inline-formatted: %q", lines[2])
}
if strings.Index(lines[2], "│") != strings.Index(lines[3], "│") {
t.Fatalf("columns misaligned:\n%q\n%q", lines[2], lines[3])
}
}
func TestNonTablePipeLinesAreLeftAlone(t *testing.T) {
out := renderAssistantMarkdown("a | b\nplain line")
if !strings.Contains(ansi.Strip(out), "a | b") {
t.Fatalf("pipe text mangled: %q", ansi.Strip(out))
}
}
func TestInlineFormatKeepsNonEmphasisMarkers(t *testing.T) {
literal := []string{
"ls *.py *.go",
"snake_case_name and other_var_here",
"a * b * c",
"call obj.__init__ now",
"rm -rf /tmp/* /var/*",
"5 * 3 = 15",
}
for _, line := range literal {
if got := ansi.Strip(inlineFormat(line)); got != line {
t.Fatalf("%q was treated as emphasis: %q", line, got)
}
}
}
func TestInlineFormatStillStylesRealEmphasis(t *testing.T) {
cases := map[string]string{
"this is *italic* text": "this is italic text",
"this is **bold** text": "this is bold text",
"gone ~~away~~ now": "gone away now",
"use `code` here": "use code here",
}
for line, want := range cases {
if got := ansi.Strip(inlineFormat(line)); got != want {
t.Fatalf("%q: got %q want %q", line, got, want)
}
}
}
@@ -0,0 +1,111 @@
package render
import (
"strings"
)
// ---------------------------------------------------------------------------
// Notes (notes_renderer.py)
// ---------------------------------------------------------------------------
func renderNote(name string, args map[string]any, result any) string {
var b strings.Builder
icon := Col(Gold).Render("◇ ")
switch name {
case "create_note":
category := StringValue(args["category"])
if category == "" {
category = "general"
}
title, content := strings.TrimSpace(StringValue(args["title"])), strings.TrimSpace(StringValue(args["content"]))
b.WriteString(icon + Dim().Render("note") + " " + Dim().Render("("+category+")"))
if title != "" {
b.WriteString("\n " + title)
}
if content != "" {
b.WriteString("\n " + Dim().Render(content))
}
if title == "" && content == "" {
b.WriteString("\n " + Dim().Render("Capturing..."))
}
case "delete_note":
b.WriteString(icon + Dim().Render("note removed"))
case "update_note":
title, content := StringValue(args["title"]), strings.TrimSpace(StringValue(args["content"]))
b.WriteString(icon + Dim().Render("note updated"))
if title != "" {
b.WriteString("\n " + title)
}
if content != "" {
b.WriteString("\n " + Dim().Render(content))
}
if title == "" && content == "" {
b.WriteString("\n " + Dim().Render("Updating..."))
}
case "list_notes":
b.WriteString(icon + Dim().Render("notes"))
b.WriteString(noteListBody(result))
case "get_note":
b.WriteString(icon + Dim().Render("note read"))
if m, ok := result.(map[string]any); ok && truthy(m["success"]) {
note, _ := m["note"].(map[string]any)
renderSingleNote(&b, note)
} else {
b.WriteString("\n " + Dim().Render("Loading..."))
}
default:
b.WriteString(icon + Dim().Render(strings.ReplaceAll(name, "_", " ")))
}
return b.String()
}
func noteListBody(result any) string {
var b strings.Builder
if s, ok := result.(string); ok && strings.TrimSpace(s) != "" {
return "\n " + Dim().Render(strings.TrimSpace(s))
}
m, ok := result.(map[string]any)
if !ok || !truthy(m["success"]) {
return "\n " + Dim().Render("Loading...")
}
notes, _ := m["notes"].([]any)
count, _ := NumericValue(m["total_count"])
if int(count) == 0 || len(notes) == 0 {
return "\n " + Dim().Render("No notes")
}
for _, n := range notes {
note, _ := n.(map[string]any)
title := strings.TrimSpace(StringValue(note["title"]))
if title == "" {
title = "(untitled)"
}
category := StringValue(note["category"])
if category == "" {
category = "general"
}
content := strings.TrimSpace(StringValue(note["content"]))
if content == "" {
content = strings.TrimSpace(StringValue(note["content_preview"]))
}
b.WriteString("\n - " + title + Dim().Render(" ("+category+")"))
if content != "" {
b.WriteString("\n " + Dim().Render(content))
}
}
return b.String()
}
func renderSingleNote(b *strings.Builder, note map[string]any) {
title := strings.TrimSpace(StringValue(note["title"]))
if title == "" {
title = "(untitled)"
}
category := StringValue(note["category"])
if category == "" {
category = "general"
}
b.WriteString("\n " + title + Dim().Render(" ("+category+")"))
if content := strings.TrimSpace(StringValue(note["content"])); content != "" {
b.WriteString("\n " + Dim().Render(content))
}
}
@@ -0,0 +1,577 @@
package render
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
// ---------------------------------------------------------------------------
// Proxy (proxy_renderer.py)
// ---------------------------------------------------------------------------
const proxyIcon = "<~>"
func proxyStatusStyle(code int) lipgloss.Style {
switch {
case code >= 200 && code < 300:
return Col(Green)
case code >= 300 && code < 400:
return Col(Status3xx)
case code >= 400 && code < 500:
return Col(Status4xx)
case code >= 500:
return Col(Red)
}
return Dim()
}
func ptrunc(s string, max int) string {
if len(s) > max {
return s[:max-3] + "..."
}
return s
}
func psanitize(s string, max int) string {
clean := strings.NewReplacer("\n", " ", "\r", "", "\t", " ").Replace(s)
return ptrunc(clean, max)
}
func renderProxyTool(name string, args map[string]any, result any, status string) string {
switch name {
case "list_requests":
return renderListRequests(args, result, status)
case "view_request":
return renderViewRequest(args, result, status)
case "repeat_request":
return renderRepeatRequest(args, result, status)
case "list_sitemap":
return renderListSitemap(args, result, status)
case "view_sitemap_entry":
return renderViewSitemapEntry(args, result, status)
case "scope_rules":
return renderScopeRules(args, result, status)
}
return ""
}
func resultMapOf(result any) (map[string]any, bool) {
m, ok := result.(map[string]any)
return m, ok
}
func renderListRequests(args map[string]any, result any, status string) string {
var b strings.Builder
b.WriteString(Dim().Render(proxyIcon) + Col(Cyan).Render(" listing requests"))
if f := StringValue(args["httpql_filter"]); f != "" {
b.WriteString(Dim().Italic(true).Render(" where " + ptrunc(f, 150)))
}
var meta []string
if s := StringValue(args["sort_by"]); s != "" && s != "timestamp" {
meta = append(meta, "by:"+s)
}
if s := StringValue(args["sort_order"]); s != "" && s != "desc" {
meta = append(meta, s)
}
if s := StringValue(args["scope_id"]); s != "" {
meta = append(meta, "scope:"+truncStr(s, 8))
}
if len(meta) > 0 {
b.WriteString(Dim().Render(" (" + strings.Join(meta, ", ") + ")"))
}
if status == "completed" {
if m, ok := resultMapOf(result); ok {
if e, has := m["error"]; has {
b.WriteString(Col(Red).Render(" error: " + psanitize(StringValue(e), 150)))
} else {
entries, _ := m["entries"].([]any)
suffix := ""
if pi, ok := m["page_info"].(map[string]any); ok && truthy(pi["has_next_page"]) {
suffix = "+"
}
b.WriteString(Dim().Render(fmt.Sprintf(" [%d%s found]", len(entries), suffix)))
renderRequestEntries(&b, entries)
}
}
}
return b.String()
}
func renderRequestEntries(b *strings.Builder, entries []any) {
if len(entries) == 0 {
return
}
b.WriteString("\n")
limit := len(entries)
if limit > 20 {
limit = 20
}
for i := 0; i < limit; i++ {
entry, ok := entries[i].(map[string]any)
if !ok {
continue
}
req, _ := entry["request"].(map[string]any)
resp, _ := entry["response"].(map[string]any)
method := StringValue(req["method"])
if method == "" {
method = "?"
}
host := StringValue(req["host"])
path := StringValue(req["path"])
if path == "" {
path = "/"
}
b.WriteString(" " + Col(Lavender).Render(fmt.Sprintf("%-6s", method)))
b.WriteString(Dim().Render(" " + ptrunc(host+path, 180)))
if code, ok := NumericValue(resp["status_code"]); ok && code != 0 {
b.WriteString(proxyStatusStyle(int(code)).Render(fmt.Sprintf(" %d", int(code))))
}
if i < limit-1 {
b.WriteString("\n")
}
}
if len(entries) > 20 {
b.WriteString("\n" + Dim().Italic(true).Render(fmt.Sprintf(" ... +%d more", len(entries)-20)))
}
}
func renderViewRequest(args map[string]any, result any, status string) string {
var b strings.Builder
b.WriteString(Dim().Render(proxyIcon))
part := StringValue(args["part"])
if part == "" {
part = "request"
}
action := "viewing"
search := StringValue(args["search_pattern"])
if search != "" {
action = "searching"
}
b.WriteString(Col(Cyan).Render(" " + action + " " + part))
if rid := StringValue(args["request_id"]); rid != "" {
b.WriteString(Dim().Render(" #" + rid))
}
if search != "" {
b.WriteString(Dim().Italic(true).Render(" /" + ptrunc(search, 100) + "/"))
}
if status == "completed" {
if m, ok := resultMapOf(result); ok {
if e, has := m["error"]; has {
b.WriteString(Col(Red).Render(" error: " + psanitize(StringValue(e), 150)))
} else if hits, has := m["hits"].([]any); has {
total := len(hits)
if t, ok := NumericValue(m["total_hits"]); ok {
total = int(t)
}
b.WriteString(Dim().Render(fmt.Sprintf(" [%d matches]", total)))
renderSearchHits(&b, hits)
} else if content, has := m["content"]; has {
page := 1
if p, ok := NumericValue(m["page"]); ok {
page = int(p)
}
tl := 0
if t, ok := NumericValue(m["total_lines"]); ok {
tl = int(t)
}
b.WriteString(Dim().Render(fmt.Sprintf(" [page %d, %d lines]", page, tl)))
renderContentLines(&b, StringValue(content), truthy(m["has_more"]))
}
}
}
return b.String()
}
func renderSearchHits(b *strings.Builder, hits []any) {
if len(hits) == 0 {
return
}
b.WriteString("\n")
limit := len(hits)
if limit > 5 {
limit = 5
}
for i := 0; i < limit; i++ {
m, ok := hits[i].(map[string]any)
if !ok {
continue
}
before := lastN(strings.NewReplacer("\n", " ", "\r", "").Replace(StringValue(m["before"])), 100)
after := firstN(strings.NewReplacer("\n", " ", "\r", "").Replace(StringValue(m["after"])), 100)
b.WriteString(" ")
if before != "" {
b.WriteString(Dim().Render("..." + before))
}
b.WriteString(Bold(Green).Render(StringValue(m["match"])))
if after != "" {
b.WriteString(Dim().Render(after + "..."))
}
if i < limit-1 {
b.WriteString("\n")
}
}
if len(hits) > 5 {
b.WriteString("\n" + Dim().Italic(true).Render(fmt.Sprintf(" ... +%d more matches", len(hits)-5)))
}
}
func renderContentLines(b *strings.Builder, content string, hasMore bool) {
if content == "" {
return
}
allLines := strings.Split(content, "\n")
lines := allLines
if len(lines) > 15 {
lines = lines[:15]
}
b.WriteString("\n")
for i, line := range lines {
b.WriteString(" " + Dim().Render(ptrunc(line, maxLineLength)))
if i < len(lines)-1 {
b.WriteString("\n")
}
}
if hasMore || len(allLines) > 15 {
b.WriteString("\n" + Dim().Italic(true).Render(" ... more content available"))
}
}
func renderRepeatRequest(args map[string]any, result any, status string) string {
var b strings.Builder
b.WriteString(Dim().Render(proxyIcon) + Col(Cyan).Render(" repeating request"))
if rid := StringValue(args["request_id"]); rid != "" {
b.WriteString(Dim().Render(" #" + rid))
}
if mods, ok := args["modifications"].(map[string]any); ok {
b.WriteString(Dim().Italic(true).Render("\n modifications:"))
arrow := Col(Blue).Render(" >> ")
if url, ok := mods["url"]; ok {
b.WriteString("\n" + arrow + Dim().Render("url: "+ptrunc(StringValue(url), 180)))
}
writeKV := func(key, prefix string, valMax int) {
if kv, ok := mods[key].(map[string]any); ok {
n := 0
for k, v := range kv {
if n >= 5 {
break
}
b.WriteString("\n" + arrow + Dim().Render(fmt.Sprintf(prefix, k, psanitize(StringValue(v), valMax))))
n++
}
}
}
writeKV("headers", "%s: %s", 150)
writeKV("cookies", "cookie %s=%s", 100)
writeKV("params", "param %s=%s", 100)
if body, ok := mods["body"].(string); ok {
b.WriteString("\n" + arrow)
bodyLines := strings.Split(body, "\n")
shown := bodyLines
if len(shown) > 4 {
shown = shown[:4]
}
for i, line := range shown {
if i > 0 {
b.WriteString("\n" + Dim().Render(" "))
}
b.WriteString(Dim().Render(ptrunc(line, maxLineLength)))
}
if len(bodyLines) > 4 {
b.WriteString(Dim().Italic(true).Render(" ..."))
}
}
} else if mods, ok := args["modifications"].(string); ok && mods != "" {
b.WriteString(Dim().Italic(true).Render("\n " + ptrunc(mods, 200)))
}
if status == "completed" {
if m, ok := resultMapOf(result); ok {
success, hasSuccess := m["success"].(bool)
if hasSuccess && !success && StringValue(m["error"]) != "" {
b.WriteString(Col(Red).Render("\n error: " + psanitize(StringValue(m["error"]), 150)))
} else {
resp, _ := m["response"].(map[string]any)
b.WriteString("\n" + Col(Green).Render(" << "))
if code, ok := NumericValue(resp["status_code"]); ok && code != 0 {
b.WriteString(proxyStatusStyle(int(code)).Render(fmt.Sprintf("%d", int(code))))
} else {
b.WriteString(Dim().Render("(no response)"))
}
if ms, ok := NumericValue(m["elapsed_ms"]); ok && ms != 0 {
b.WriteString(Dim().Render(fmt.Sprintf(" (%dms)", int(ms))))
}
body := StringValue(resp["body"])
if body != "" {
allLines := strings.Split(body, "\n")
lines := allLines
if len(lines) > 5 {
lines = lines[:5]
}
for _, line := range lines {
b.WriteString("\n" + Col(Green).Render(" << ") + Dim().Render(ptrunc(line, maxLineLength-5)))
}
if truthy(resp["body_truncated"]) || len(allLines) > 5 {
b.WriteString("\n" + Dim().Italic(true).Render(" ..."))
}
}
}
}
}
return b.String()
}
func renderListSitemap(args map[string]any, result any, status string) string {
var b strings.Builder
b.WriteString(Dim().Render(proxyIcon) + Col(Cyan).Render(" listing sitemap"))
if pid := StringValue(args["parent_id"]); pid != "" {
b.WriteString(Dim().Render(" under #" + ptrunc(pid, 20)))
}
var meta []string
if s := StringValue(args["scope_id"]); s != "" {
meta = append(meta, "scope:"+truncStr(s, 8))
}
if d := StringValue(args["depth"]); d != "" && d != "DIRECT" {
meta = append(meta, strings.ToLower(d))
}
if len(meta) > 0 {
b.WriteString(Dim().Render(" (" + strings.Join(meta, ", ") + ")"))
}
if status == "completed" {
if m, ok := resultMapOf(result); ok {
if e, has := m["error"]; has {
b.WriteString(Col(Red).Render(" error: " + psanitize(StringValue(e), 150)))
} else {
total := 0
if t, ok := NumericValue(m["total_count"]); ok {
total = int(t)
}
entries, _ := m["entries"].([]any)
b.WriteString(Dim().Render(fmt.Sprintf(" [%d entries]", total)))
renderSitemapEntries(&b, entries)
}
}
}
return b.String()
}
var sitemapKindColors = map[string]lipgloss.Color{
"DOMAIN": AmberY, "DIRECTORY": Blue, "REQUEST": Green,
}
func renderSitemapEntries(b *strings.Builder, entries []any) {
if len(entries) == 0 {
return
}
b.WriteString("\n")
limit := len(entries)
if limit > 20 {
limit = 20
}
for i := 0; i < limit; i++ {
entry, ok := entries[i].(map[string]any)
if !ok {
continue
}
kind := StringValue(entry["kind"])
if kind == "" {
kind = "?"
}
label := StringValue(entry["label"])
if label == "" {
label = "?"
}
kindStyle, ok := sitemapKindColors[kind]
style := Dim()
if ok {
style = Col(kindStyle)
}
abbr := kind
if len(abbr) > 3 {
abbr = abbr[:3]
}
b.WriteString(" " + style.Render(fmt.Sprintf("%-3s", abbr)) + Dim().Render(" "+ptrunc(label, 150)))
if req, ok := entry["request"].(map[string]any); ok {
if method := StringValue(req["method"]); method != "" {
b.WriteString(Col(Lavender).Render(" " + method))
}
if code, ok := NumericValue(req["status_code"]); ok && code != 0 {
b.WriteString(proxyStatusStyle(int(code)).Render(fmt.Sprintf(" %d", int(code))))
}
}
if truthy(entry["has_descendants"]) {
b.WriteString(Dim().Italic(true).Render(" +"))
}
if i < limit-1 {
b.WriteString("\n")
}
}
if len(entries) > 20 {
b.WriteString("\n" + Dim().Italic(true).Render(fmt.Sprintf(" ... +%d more", len(entries)-20)))
}
}
func renderViewSitemapEntry(args map[string]any, result any, status string) string {
var b strings.Builder
b.WriteString(Dim().Render(proxyIcon) + Col(Cyan).Render(" viewing sitemap"))
if eid := StringValue(args["entry_id"]); eid != "" {
b.WriteString(Dim().Render(" #" + ptrunc(eid, 20)))
}
if status == "completed" {
if m, ok := resultMapOf(result); ok {
if e, has := m["error"]; has {
b.WriteString(Col(Red).Render(" error: " + psanitize(StringValue(e), 150)))
} else if entry, ok := m["entry"].(map[string]any); ok {
kind, label := StringValue(entry["kind"]), StringValue(entry["label"])
related, _ := entry["related_requests"].(map[string]any)
if kind != "" && label != "" {
b.WriteString(Dim().Render(fmt.Sprintf(" %s: %s", kind, ptrunc(label, 120))))
}
total := 0
if t, ok := NumericValue(related["total_count"]); ok {
total = int(t)
}
if total != 0 {
b.WriteString(Dim().Render(fmt.Sprintf(" [%d requests]", total)))
}
reqs, _ := related["requests"].([]any)
renderRelatedRequests(&b, reqs)
}
}
}
return b.String()
}
func renderRelatedRequests(b *strings.Builder, reqs []any) {
if len(reqs) == 0 {
return
}
b.WriteString("\n")
limit := len(reqs)
if limit > 10 {
limit = 10
}
for i := 0; i < limit; i++ {
req, ok := reqs[i].(map[string]any)
if !ok {
continue
}
method := StringValue(req["method"])
if method == "" {
method = "?"
}
path := StringValue(req["path"])
if path == "" {
path = "/"
}
b.WriteString(" " + Col(Lavender).Render(fmt.Sprintf("%-6s", method)) + Dim().Render(" "+ptrunc(path, 180)))
if code, ok := NumericValue(req["status_code"]); ok && code != 0 {
b.WriteString(proxyStatusStyle(int(code)).Render(fmt.Sprintf(" %d", int(code))))
}
if i < limit-1 {
b.WriteString("\n")
}
}
if len(reqs) > 10 {
b.WriteString("\n" + Dim().Italic(true).Render(fmt.Sprintf(" ... +%d more", len(reqs)-10)))
}
}
var scopeActionMap = map[string]string{
"get": "getting", "list": "listing", "create": "creating", "update": "updating", "delete": "deleting",
}
func renderScopeRules(args map[string]any, result any, status string) string {
var b strings.Builder
b.WriteString(Dim().Render(proxyIcon))
action := StringValue(args["action"])
actionText, ok := scopeActionMap[action]
if !ok {
if action != "" {
actionText = action + "ing"
} else {
actionText = "managing"
}
}
b.WriteString(Col(Cyan).Render(" " + actionText + " proxy scope"))
if sn := StringValue(args["scope_name"]); sn != "" {
b.WriteString(Dim().Italic(true).Render(" '" + ptrunc(sn, 50) + "'"))
}
if sid := StringValue(args["scope_id"]); sid != "" {
b.WriteString(Dim().Render(" #" + truncStr(sid, 8)))
}
writeList := func(key, label string) {
if items, ok := args[key].([]any); ok && len(items) > 0 {
shown := items
if len(shown) > 4 {
shown = shown[:4]
}
var parts []string
for _, it := range shown {
parts = append(parts, ptrunc(StringValue(it), 40))
}
b.WriteString("\n " + Dim().Render(label+": "+strings.Join(parts, ", ")))
if len(items) > 4 {
b.WriteString(Dim().Italic(true).Render(fmt.Sprintf(" +%d", len(items)-4)))
}
}
}
writeList("allowlist", "allow")
writeList("denylist", "deny")
if status == "completed" {
if m, ok := resultMapOf(result); ok {
switch {
case m["error"] != nil:
b.WriteString(Col(Red).Render(" error: " + psanitize(StringValue(m["error"]), 150)))
case m["scopes"] != nil:
scopes, _ := m["scopes"].([]any)
b.WriteString(Dim().Render(fmt.Sprintf(" [%d scopes]", len(scopes))))
renderScopeList(&b, scopes)
case m["scope"] != nil:
if scope, ok := m["scope"].(map[string]any); ok {
if allow, ok := scope["allowlist"].([]any); ok && len(allow) > 0 {
b.WriteString("\n " + Dim().Render("allow: "+joinTrunc(allow, 40, 5)))
}
if deny, ok := scope["denylist"].([]any); ok && len(deny) > 0 {
b.WriteString("\n " + Dim().Render("deny: "+joinTrunc(deny, 40, 5)))
}
}
case m["message"] != nil:
b.WriteString(Col(Green).Render(" " + StringValue(m["message"])))
}
}
}
return b.String()
}
func renderScopeList(b *strings.Builder, scopes []any) {
if len(scopes) == 0 {
return
}
b.WriteString("\n")
limit := len(scopes)
if limit > 5 {
limit = 5
}
for i := 0; i < limit; i++ {
scope, ok := scopes[i].(map[string]any)
if !ok {
continue
}
name := StringValue(scope["name"])
if name == "" {
name = "?"
}
b.WriteString(" " + Col(Green).Render(ptrunc(name, 40)))
if allow, ok := scope["allowlist"].([]any); ok && len(allow) > 0 {
b.WriteString(Dim().Render(" " + joinTrunc(allow, 30, 3)))
if len(allow) > 3 {
b.WriteString(Dim().Italic(true).Render(fmt.Sprintf(" +%d", len(allow)-3)))
}
}
if i < limit-1 {
b.WriteString("\n")
}
}
}
@@ -0,0 +1,135 @@
package render
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
// statusIcon ports BaseToolRenderer.status_icon.
func statusIcon(status string) (string, lipgloss.Style) {
switch status {
case "running":
return "● In progress...", Col(AmberY)
case "completed":
return "✓ Done", Col(Green)
case "failed":
return "✗ Failed", Col(SevCrit)
case "error":
return "✗ Error", Col(SevCrit)
}
return "○ Unknown", Dim()
}
// renderGenericTool ports registry._render_default_tool_widget.
func renderGenericTool(name string, args map[string]any, result any, status string) string {
var b strings.Builder
b.WriteString(Dim().Render("→ Using tool ") + Bold(Blue).Render(name) + "\n")
for _, k := range SortedKeys(args) {
b.WriteString(" " + Dim().Render(k) + ": " + StringValue(args[k]) + "\n")
}
if (status == "completed" || status == "failed" || status == "error") && result != nil {
b.WriteString(lipgloss.NewStyle().Bold(true).Render("Result: ") + StringValue(result))
} else {
icon, style := statusIcon(status)
b.WriteString(style.Render(icon))
}
return b.String()
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
func Tool(data map[string]any) string {
name := StringValue(data["tool_name"])
status := StringValue(data["status"])
args, _ := data["args"].(map[string]any)
if args == nil {
args = map[string]any{}
}
result := data["result"]
switch name {
case "exec_command":
return renderExecCommand(args, result, status)
case "write_stdin":
return renderWriteStdin(args, result, status)
case "apply_patch":
return renderApplyPatch(args, result, status)
case "view_image":
return renderViewImage(args, result)
case "create_vulnerability_report":
return renderVulnerabilityReport(args, result)
case "create_dependency_report":
return renderDependencyReport(args, result)
case "list_reports":
return renderListReports(result)
case "get_report":
return renderGetReport(result)
case "respond_to_user":
return renderRespondToUser(args)
case "finish_scan":
return renderFinishScan(args)
case "think":
return renderThink(args)
case "web_search":
return renderWebSearch(args)
case "load_skill":
return renderLoadSkill(args, result)
case "create_note", "delete_note", "update_note", "list_notes", "get_note":
return renderNote(name, args, result)
case "create_todo", "list_todos", "update_todo", "mark_todo_done", "mark_todo_pending", "delete_todo":
return renderTodo(name, result)
case "view_agent_graph", "create_agent", "send_message_to_agent", "agent_finish", "wait_for_agents", "stop_agent":
return renderAgentGraphTool(name, args, result)
case "list_requests", "view_request", "repeat_request", "list_sitemap", "view_sitemap_entry", "scope_rules":
return renderProxyTool(name, args, result, status)
}
return renderGenericTool(name, args, result, status)
}
// ---------------------------------------------------------------------------
// Collapsing: output-heavy tools (terminal, proxy) render as short block
// previews; clicking a tool in the trace expands it to the full render.
// ---------------------------------------------------------------------------
const outputPreviewLines = 10
// ToolPreviewLines returns how many lines of a tool's render are shown before
// it is collapsed; 0 means the tool is never collapsed. Only tools whose
// output can grow unbounded (terminal, patches, proxy) collapse.
func ToolPreviewLines(name string) int {
switch name {
case "exec_command", "write_stdin", "apply_patch",
"view_request", "repeat_request", "view_sitemap_entry":
return outputPreviewLines
}
return 0
}
// CollapseTool clips a full tool render to its preview size, appending a
// click-to-expand/collapse hint. It reports whether the tool has more content
// than the preview (i.e. whether it is expandable).
func CollapseTool(full, name string, expanded bool) (string, bool) {
maxLines := ToolPreviewLines(name)
if maxLines <= 0 {
return full, false
}
lines := strings.Split(full, "\n")
if len(lines) <= maxLines {
return full, false
}
if expanded {
return full + "\n" + Dim().Italic(true).Render(" ▲ click to collapse"), true
}
preview := strings.Join(lines[:maxLines], "\n")
hidden := len(lines) - maxLines
plural := "s"
if hidden == 1 {
plural = ""
}
hint := Dim().Italic(true).Render(fmt.Sprintf(" … +%d line%s — click to expand", hidden, plural))
return preview + "\n" + hint, true
}
@@ -0,0 +1,251 @@
package render
import (
"fmt"
"strings"
"testing"
"github.com/charmbracelet/x/ansi"
)
func tool(name string, args map[string]any, result any, status string) map[string]any {
data := map[string]any{"tool_name": name, "status": status}
if args != nil {
data["args"] = args
}
if result != nil {
data["result"] = result
}
return data
}
func requireContains(t *testing.T, output string, wants ...string) {
t.Helper()
for _, want := range wants {
if !strings.Contains(output, want) {
t.Fatalf("output missing %q:\n%s", want, output)
}
}
}
func TestChatUserMessage(t *testing.T) {
out := Chat(map[string]any{"role": "user", "content": "hello\nworld"})
requireContains(t, out, "You:", "hello", "world")
}
func TestChatAssistantMarkdown(t *testing.T) {
out := Chat(map[string]any{"role": "assistant", "content": "# Heading\n\nSome **bold** text"})
requireContains(t, out, "Heading", "bold")
}
func TestExecCommandHighlightsCommand(t *testing.T) {
out := Tool(tool("exec_command", map[string]any{"cmd": "for f in *.py; do echo \"$f\"; done"}, nil, "running"))
if !strings.Contains(out, "\x1b[38;5;") {
t.Fatalf("expected syntax-highlighted command:\n%q", out)
}
}
func TestApplyPatchHighlightsCode(t *testing.T) {
out := Tool(tool("apply_patch", map[string]any{
"patch": "*** Update File: src/app.py\n-import os\n+import sys\n+def main():\n+ return sys.argv",
}, nil, "completed"))
if !strings.Contains(out, "\x1b[38;5;") {
t.Fatalf("expected syntax-highlighted patch lines:\n%q", out)
}
lines := strings.Split(out, "\n")
if len(lines) != 5 {
t.Fatalf("diff line structure must survive highlighting, got %d lines:\n%q", len(lines), out)
}
}
func TestToolDispatchCoversKnownTools(t *testing.T) {
cases := []struct {
name string
data map[string]any
wants []string
}{
{
"exec_command",
tool("exec_command", map[string]any{"cmd": "ls -la"}, nil, "running"),
[]string{"ls -la"},
},
{
"write_stdin",
tool("write_stdin", map[string]any{"chars": "y", "session_id": 3}, nil, "completed"),
[]string{"y", "session #3"},
},
{
"apply_patch",
tool("apply_patch", map[string]any{
"file_path": "src/app.py",
"patch": "*** Update File: src/app.py\n+new line",
}, nil, "completed"),
[]string{"src/app.py"},
},
{
"view_image",
tool("view_image", map[string]any{"path": "shot.png"}, nil, "completed"),
[]string{"shot.png"},
},
{
"create_vulnerability_report",
tool("create_vulnerability_report",
map[string]any{"title": "SQL injection in login", "target": "https://x.test"},
map[string]any{"severity": "critical", "cvss_score": 9.8},
"completed"),
[]string{"Vulnerability Report", "SQL injection in login", "CRITICAL", "9.8"},
},
{
"create_dependency_report",
tool("create_dependency_report",
map[string]any{"package_name": "requests", "installed_version": "2.0.0"},
nil, "completed"),
[]string{"requests"},
},
{
"list_reports",
tool("list_reports", nil, map[string]any{
"success": true,
"total_count": 2,
"severity_counts": map[string]any{"critical": 1, "low": 1},
"reports": []any{
map[string]any{"id": "VULN-1", "title": "SQLi", "severity": "critical", "by_you": true},
map[string]any{"id": "VULN-2", "title": "Weak header", "severity": "low", "agent_name": "recon"},
},
}, "completed"),
[]string{"reports", "(2)", "CRITICAL", "VULN-1", "SQLi", "(you)", "LOW", "VULN-2", "(recon)"},
},
{
"list_reports empty",
tool("list_reports", nil, map[string]any{"success": true, "total_count": 0}, "completed"),
[]string{"reports", "(0)", "No reports filed yet"},
},
{
"get_report",
tool("get_report", nil, map[string]any{
"success": true,
"report": map[string]any{
"id": "VULN-1", "title": "SQLi", "severity": "high", "target": "https://x.test",
},
}, "completed"),
[]string{"report read", "HIGH", "VULN-1", "SQLi", "https://x.test"},
},
{
"get_report error",
tool("get_report", nil, map[string]any{"success": false, "error": "not found"}, "failed"),
[]string{"report read", "not found"},
},
{
"respond_to_user",
tool("respond_to_user", map[string]any{"message": "Here is the answer"}, nil, "completed"),
[]string{"Here is the answer", "waiting for your reply"},
},
{
"finish_scan",
tool("finish_scan", map[string]any{"executive_summary": "All done"}, nil, "completed"),
[]string{"Penetration test completed", "All done"},
},
{
"think",
tool("think", map[string]any{"thought": "checking auth flow"}, nil, "running"),
[]string{"Thinking", "checking auth flow"},
},
{
"web_search",
tool("web_search", map[string]any{"query": "CVE-2024-1234"}, nil, "running"),
[]string{"Searching the web", "CVE-2024-1234"},
},
{
"load_skill",
tool("load_skill", map[string]any{"skills": []any{"sqli"}}, nil, "completed"),
[]string{"sqli"},
},
{
"create_note",
tool("create_note", map[string]any{"title": "Recon findings"}, nil, "completed"),
[]string{"Recon findings"},
},
{
"create_todo",
tool("create_todo", nil, map[string]any{
"success": true,
"todos": []any{
map[string]any{"id": 1, "title": "Check login", "status": "pending"},
},
}, "completed"),
[]string{"Check login"},
},
{
"create_agent",
tool("create_agent", map[string]any{"name": "ReconAgent", "task": "map the site"}, nil, "running"),
[]string{"spawning", "ReconAgent", "map the site"},
},
{
"wait_for_agents",
tool("wait_for_agents", map[string]any{"reason": "results needed"}, nil, "running"),
[]string{"waiting", "results needed"},
},
{
"stop_agent",
tool("stop_agent", map[string]any{"target_agent_id": "agent-2"}, nil, "completed"),
[]string{"stopping", "agent-2"},
},
{
"view_agent_graph",
tool("view_agent_graph", nil, nil, "completed"),
[]string{"viewing agents graph"},
},
{
"list_requests",
tool("list_requests", map[string]any{"httpql_filter": "host:example.com"}, nil, "completed"),
[]string{"host:example.com"},
},
{
"unknown tool falls back to generic",
tool("brand_new_tool", map[string]any{"alpha": "1"}, "done", "completed"),
[]string{"brand_new_tool", "alpha", "Result:", "done"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
requireContains(t, Tool(tc.data), tc.wants...)
})
}
}
func TestCollapseToolShellPreviewAndExpand(t *testing.T) {
lines := make([]string, 16)
for i := range lines {
lines[i] = fmt.Sprintf("line %d", i)
}
full := strings.Join(lines, "\n")
collapsed, expandable := CollapseTool(full, "exec_command", false)
if !expandable {
t.Fatal("long shell output should be expandable")
}
got := strings.Split(ansi.Strip(collapsed), "\n")
if len(got) != 11 || !strings.Contains(got[10], "+6 lines — click to expand") {
t.Fatalf("collapsed shell preview wrong: %q", got)
}
expanded, expandable := CollapseTool(full, "exec_command", true)
if !expandable || !strings.Contains(ansi.Strip(expanded), full) ||
!strings.Contains(ansi.Strip(expanded), "click to collapse") {
t.Fatalf("expanded render wrong: %q", expanded)
}
}
func TestCollapseToolOnlyOutputHeavyTools(t *testing.T) {
full := "🧠 Thinking\n a long private thought\n spanning lines"
if out, expandable := CollapseTool(full, "think", false); expandable || out != full {
t.Fatal("think must never collapse")
}
if _, expandable := CollapseTool("short", "exec_command", false); expandable {
t.Fatal("short output must not be expandable")
}
if out, expandable := CollapseTool(full, "respond_to_user", false); expandable || out != full {
t.Fatal("respond_to_user must never collapse")
}
}
@@ -0,0 +1,126 @@
package render
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
// ---------------------------------------------------------------------------
// Reporting (reporting_renderer.py)
// ---------------------------------------------------------------------------
func renderVulnerabilityReport(args map[string]any, result any) string {
resultMap, _ := result.(map[string]any)
var b strings.Builder
b.WriteString("🐞 " + Bold(ReportHdr).Render("Vulnerability Report"))
field := func(label, value string) {
if value != "" {
b.WriteString("\n\n" + Bold(Field).Render(label+": ") + value)
}
}
title := StringValue(args["title"])
field("Title", title)
if sev := StringValue(resultMap["severity"]); sev != "" {
b.WriteString("\n\n" + Bold(Field).Render("Severity: ") +
lipgloss.NewStyle().Bold(true).Foreground(SeverityColor(sev)).Render(strings.ToUpper(sev)))
}
if score, ok := NumericValue(resultMap["cvss_score"]); ok {
b.WriteString("\n\n" + Bold(Field).Render("CVSS Score: ") +
lipgloss.NewStyle().Bold(true).Foreground(CVSSColor(score)).Render(StringValue(resultMap["cvss_score"])))
}
field("Target", StringValue(args["target"]))
field("Endpoint", StringValue(args["endpoint"]))
field("Method", StringValue(args["method"]))
field("CVE", StringValue(args["cve"]))
field("CWE", StringValue(args["cwe"]))
if bd, ok := args["cvss_breakdown"].(map[string]any); ok && len(bd) > 0 {
parts := CVSSVectorParts(bd)
if len(parts) > 0 {
b.WriteString("\n\n" + Bold(Field).Render("CVSS Vector: ") + Dim().Render(strings.Join(parts, "/")))
}
}
section := func(label, value string) {
if value != "" {
b.WriteString("\n\n" + Bold(Field).Render(label) + "\n" + value)
}
}
section("Description", StringValue(args["description"]))
section("Impact", StringValue(args["impact"]))
section("Technical Analysis", StringValue(args["technical_analysis"]))
renderCodeLocations(&b, args["code_locations"])
section("PoC Description", StringValue(args["poc_description"]))
if poc := StringValue(args["poc_script_code"]); poc != "" {
b.WriteString("\n\n" + Bold(Field).Render("PoC Code") + "\n" + Col(Text).Render(poc))
}
section("Remediation", StringValue(args["remediation_steps"]))
if title == "" {
b.WriteString("\n " + Dim().Render("Creating report..."))
}
return "\n\n" + b.String() + "\n\n"
}
var cvssKeys = [][2]string{
{"attack_vector", "AV"}, {"attack_complexity", "AC"}, {"privileges_required", "PR"},
{"user_interaction", "UI"}, {"scope", "S"}, {"confidentiality", "C"},
{"integrity", "I"}, {"availability", "A"},
}
func CVSSVectorParts(bd map[string]any) []string {
var parts []string
for _, kp := range cvssKeys {
if v := StringValue(bd[kp[0]]); v != "" {
parts = append(parts, kp[1]+":"+v)
}
}
return parts
}
func renderCodeLocations(b *strings.Builder, raw any) {
locs, ok := raw.([]any)
if !ok || len(locs) == 0 {
return
}
b.WriteString("\n\n" + Bold(Field).Render("Code Locations"))
for i, l := range locs {
loc, ok := l.(map[string]any)
if !ok {
continue
}
b.WriteString("\n\n" + Dim().Render(fmt.Sprintf(" Location %d: ", i+1)))
file := StringValue(loc["file"])
if file == "" {
file = "unknown"
}
b.WriteString(Bold(InfoBlue).Render(file))
if start, ok := NumericValue(loc["start_line"]); ok {
if end, ok := NumericValue(loc["end_line"]); ok && end != start {
b.WriteString(Col(LineNum).Render(fmt.Sprintf(":%d-%d", int(start), int(end))))
} else {
b.WriteString(Col(LineNum).Render(fmt.Sprintf(":%d", int(start))))
}
}
if label := StringValue(loc["label"]); label != "" {
b.WriteString(lipgloss.NewStyle().Italic(true).Foreground(Label).Render("\n " + label))
}
if snip := StringValue(loc["snippet"]); snip != "" {
b.WriteString("\n " + Col(Snippet).Render(snip))
}
before, after := StringValue(loc["fix_before"]), StringValue(loc["fix_after"])
if before != "" || after != "" {
b.WriteString("\n " + Dim().Render("Fix:"))
if before != "" {
b.WriteString("\n " + Col(Red).Render("- ") + Col(Red).Render(before))
}
if after != "" {
b.WriteString("\n " + Col(Green).Render("+ ") + Col(Green).Render(after))
}
}
}
}
@@ -0,0 +1,129 @@
package render
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
// ---------------------------------------------------------------------------
// Report browsing (reporting_renderer.py: ListReportsRenderer, GetReportRenderer)
// ---------------------------------------------------------------------------
// listSeverityColor mirrors reporting_renderer._severity_style, whose fallback
// is medium rather than the vulnerability report's neutral gray.
func listSeverityColor(severity string) lipgloss.Color {
switch strings.ToLower(severity) {
case "critical":
return SevCrit
case "high":
return SevHigh
case "medium":
return SevMed
case "low":
return SevLow
case "info":
return SevInfo
case "none":
return Gray
}
return SevMed
}
// authorLabel ports reporting_renderer._author_label.
func authorLabel(report map[string]any) string {
if by, ok := report["by_you"].(bool); ok && by {
return "you"
}
return strings.TrimSpace(StringValue(report["agent_name"]))
}
func reportSummaryLine(b *strings.Builder, report map[string]any, prefix string) {
id := strings.TrimSpace(StringValue(report["id"]))
title := strings.TrimSpace(StringValue(report["title"]))
if title == "" {
title = "(untitled)"
}
severity := strings.TrimSpace(StringValue(report["severity"]))
b.WriteString(prefix)
if severity != "" {
b.WriteString(Bold(listSeverityColor(severity)).Render(strings.ToUpper(severity)) + " ")
}
if id != "" {
b.WriteString(Dim().Render(id + " "))
}
b.WriteString(title)
if author := authorLabel(report); author != "" {
b.WriteString(Dim().Render(" (" + author + ")"))
}
}
func renderListReports(result any) string {
var b strings.Builder
b.WriteString(Col(Red).Render("◆ ") + Dim().Render("reports"))
if text, ok := result.(string); ok && strings.TrimSpace(text) != "" {
b.WriteString("\n " + Dim().Render(strings.TrimSpace(text)))
return b.String()
}
resultMap, _ := result.(map[string]any)
success, _ := resultMap["success"].(bool)
if !success {
b.WriteString("\n " + Dim().Render("Loading..."))
return b.String()
}
if total, ok := NumericValue(resultMap["total_count"]); ok {
b.WriteString(Dim().Render(fmt.Sprintf(" (%d)", int(total))))
} else {
b.WriteString(Dim().Render(" (0)"))
}
if counts, ok := resultMap["severity_counts"].(map[string]any); ok {
for _, severity := range SortedKeys(counts) {
b.WriteString(" " + Col(listSeverityColor(severity)).Render(
severity+" "+StringValue(counts[severity])))
}
}
reports, _ := resultMap["reports"].([]any)
if len(reports) == 0 {
b.WriteString("\n " + Dim().Render("No reports filed yet"))
return b.String()
}
for _, raw := range reports {
report, ok := raw.(map[string]any)
if !ok {
continue
}
reportSummaryLine(&b, report, "\n - ")
}
return b.String()
}
func renderGetReport(result any) string {
var b strings.Builder
b.WriteString(Col(Red).Render("◆ ") + Dim().Render("report read"))
resultMap, _ := result.(map[string]any)
success, _ := resultMap["success"].(bool)
report, _ := resultMap["report"].(map[string]any)
if !success || len(report) == 0 {
detail := ""
if hasSuccess, ok := resultMap["success"].(bool); ok && !hasSuccess {
detail = StringValue(resultMap["error"])
}
if detail == "" {
detail = "Loading..."
}
b.WriteString("\n " + Dim().Render(detail))
return b.String()
}
reportSummaryLine(&b, report, "\n ")
if target := strings.TrimSpace(StringValue(report["target"])); target != "" {
b.WriteString("\n " + Dim().Render(target))
}
return b.String()
}
@@ -0,0 +1,18 @@
package render
import "strings"
// ---------------------------------------------------------------------------
// Direct replies (respond_renderer.py)
// ---------------------------------------------------------------------------
// renderRespondToUser shows the reply as the agent's own prose, since
// respond_to_user carries the message the user is meant to read.
func renderRespondToUser(args map[string]any) string {
var b strings.Builder
if message := StringValue(args["message"]); message != "" {
b.WriteString(renderAssistantMarkdown(message) + "\n\n")
}
b.WriteString(Col(Gray).Render("○ ") + Dim().Render("waiting for your reply"))
return b.String()
}
@@ -0,0 +1,31 @@
package render
import (
"strings"
)
// ---------------------------------------------------------------------------
// Finish scan (finish_renderer.py)
// ---------------------------------------------------------------------------
func renderFinishScan(args map[string]any) string {
var b strings.Builder
b.WriteString(Col(Green).Render("◆ ") + Bold(Green).Render("Penetration test completed"))
section := func(label, value string) {
if value != "" {
b.WriteString("\n\n" + Bold(Field).Render(label) + "\n" + value)
}
}
es := StringValue(args["executive_summary"])
me := StringValue(args["methodology"])
ta := StringValue(args["technical_analysis"])
re := StringValue(args["recommendations"])
section("Executive Summary", es)
section("Methodology", me)
section("Technical Analysis", ta)
section("Recommendations", re)
if es == "" && me == "" && ta == "" && re == "" {
b.WriteString("\n " + Dim().Render("Generating final report..."))
}
return "\n\n" + b.String() + "\n\n"
}
@@ -0,0 +1,52 @@
package render
import (
"strings"
)
// ---------------------------------------------------------------------------
// Simple tools (think, web_search, load_skill) + generic fallback
// ---------------------------------------------------------------------------
func renderThink(args map[string]any) string {
thought := StringValue(args["thought"])
var b strings.Builder
b.WriteString("🧠 " + Bold(Purple).Render("Thinking") + "\n ")
if thought != "" {
b.WriteString(Dim().Italic(true).Render(thought))
} else {
b.WriteString(Dim().Italic(true).Render("Thinking..."))
}
return b.String()
}
func renderWebSearch(args map[string]any) string {
query := StringValue(args["query"])
var b strings.Builder
b.WriteString("🌐 " + Bold(InfoBlue).Render("Searching the web..."))
if query != "" {
b.WriteString("\n " + Dim().Render(query))
}
return b.String()
}
func renderLoadSkill(args map[string]any, result any) string {
var requested string
if list, ok := args["skills"].([]any); ok {
var parts []string
for _, s := range list {
parts = append(parts, StringValue(s))
}
requested = strings.Join(parts, ", ")
} else {
requested = StringValue(args["skills"])
}
var b strings.Builder
b.WriteString(Col(Emerald).Render("◇ ") + Dim().Render("loading skill"))
if requested != "" {
b.WriteString(" " + Col(Emerald).Render(requested))
} else if result == nil {
b.WriteString("\n " + Dim().Render("Loading..."))
}
return b.String()
}
@@ -0,0 +1,82 @@
// Package render turns chat and tool events into styled terminal output,
// with one file per tool renderer.
package render
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
// Colors and shared lipgloss style helpers used across the renderers.
// Rich's "dim" attribute maps to lipgloss Faint.
var (
Green = lipgloss.Color("#22c55e")
Blue = lipgloss.Color("#3b82f6")
Red = lipgloss.Color("#ef4444")
Text = lipgloss.Color("#d4d4d4")
Field = lipgloss.Color("#4ade80") // FIELD_STYLE base (bold)
ReportHdr = lipgloss.Color("#ea580c") // report title / orange
SevCrit = lipgloss.Color("#dc2626")
SevHigh = lipgloss.Color("#ea580c")
SevMed = lipgloss.Color("#d97706")
SevLow = lipgloss.Color("#65a30d")
SevInfo = lipgloss.Color("#0284c7")
Gray = lipgloss.Color("#6b7280")
Purple = lipgloss.Color("#a855f7") // thinking
Lavender = lipgloss.Color("#a78bfa") // todos / agent graph
Emerald = lipgloss.Color("#10b981") // skills / patch ops
Gold = lipgloss.Color("#fbbf24") // notes
AmberY = lipgloss.Color("#f59e0b") // running icon / reopened
LineNum = lipgloss.Color("#facc15")
Label = lipgloss.Color("#a1a1aa")
Snippet = lipgloss.Color("#e2e8f0")
Slate = lipgloss.Color("#94a3b8")
Cyan = lipgloss.Color("#06b6d4") // proxy
Status3xx = lipgloss.Color("#eab308")
Status4xx = lipgloss.Color("#f97316")
Hdr16a = lipgloss.Color("#16a34a")
Hdr158 = lipgloss.Color("#15803d")
Mint = lipgloss.Color("#86efac")
Strike = lipgloss.Color("#525252")
CodeBg = lipgloss.Color("#0a0a0a")
InfoBlue = lipgloss.Color("#60a5fa")
)
// Style helpers. Col() foreground; Dim() Rich "dim" (faint attribute).
func Col(c lipgloss.Color) lipgloss.Style { return lipgloss.NewStyle().Foreground(c) }
func Dim() lipgloss.Style { return lipgloss.NewStyle().Faint(true) }
func Bold(c lipgloss.Color) lipgloss.Style {
return lipgloss.NewStyle().Bold(true).Foreground(c)
}
// severityColor maps a severity string to the report renderer's color.
func SeverityColor(sev string) lipgloss.Color {
switch strings.ToLower(sev) {
case "critical":
return SevCrit
case "high":
return SevHigh
case "medium":
return SevMed
case "low":
return SevLow
case "info":
return SevInfo
}
return Gray
}
func CVSSColor(score float64) lipgloss.Color {
switch {
case score >= 9.0:
return SevCrit
case score >= 7.0:
return SevHigh
case score >= 4.0:
return SevMed
case score >= 0.1:
return SevLow
}
return Gray
}
@@ -0,0 +1,183 @@
package render
import (
"fmt"
"regexp"
"strings"
"github.com/charmbracelet/lipgloss"
)
// ---------------------------------------------------------------------------
// Shell renderer (shell_renderer.py)
// ---------------------------------------------------------------------------
const (
maxOutputLines = 50
maxLineLength = 200
)
var (
exitRE = regexp.MustCompile(`Process exited with code (-?\d+)`)
sessionRE = regexp.MustCompile(`Process running with session ID (\d+)`)
stripRE = regexp.MustCompile(`(?m)^(Chunk ID: [0-9a-f]+|Wall time: [\d.]+ seconds|Process exited with code -?\d+|Process running with session ID \d+|Original token count: \d+)\s*$`)
)
const outputHeader = "\nOutput:\n"
type shellParsed struct {
content string
exitCode int
hasExitCode bool
}
func parseShellResult(result any) shellParsed {
if m, ok := result.(map[string]any); ok {
p := shellParsed{content: StringValue(m["content"])}
if code, ok := NumericValue(m["exit_code"]); ok {
p.exitCode, p.hasExitCode = int(code), true
}
return p
}
s, ok := result.(string)
if !ok {
if result == nil {
return shellParsed{}
}
return shellParsed{content: StringValue(result)}
}
p := shellParsed{}
if m := exitRE.FindStringSubmatch(s); m != nil {
fmt.Sscanf(m[1], "%d", &p.exitCode)
p.hasExitCode = true
}
if idx := strings.Index(s, outputHeader); idx >= 0 {
p.content = s[idx+len(outputHeader):]
} else {
p.content = s
}
return p
}
func cleanShellOutput(output string) string {
cleaned := stripControlsKeepTabs(output)
cleaned = stripRE.ReplaceAllString(cleaned, "")
if strings.TrimSpace(cleaned) == "" {
return ""
}
lines := strings.Split(cleaned, "\n")
var filtered []string
for _, line := range lines {
if len(filtered) == 0 && strings.TrimSpace(line) == "" {
continue
}
if strings.TrimSpace(line) == "Output:" {
continue
}
filtered = append(filtered, line)
}
for len(filtered) > 0 && strings.TrimSpace(filtered[len(filtered)-1]) == "" {
filtered = filtered[:len(filtered)-1]
}
return strings.TrimSpace(strings.Join(filtered, "\n"))
}
func truncateShellLine(line string) string {
if len(line) > maxLineLength {
return line[:maxLineLength-3] + "..."
}
return line
}
// formatShellOutput ports _format_output (head/tail truncation with a middle marker).
func formatShellOutput(output string) string {
lines := strings.Split(output, "\n")
total := len(lines)
head := maxOutputLines / 2
tail := maxOutputLines - head - 1
var b strings.Builder
if total <= maxOutputLines {
for i, line := range lines {
b.WriteString(" " + Dim().Render(truncateShellLine(line)))
if i < len(lines)-1 {
b.WriteString("\n")
}
}
return b.String()
}
display := lines[:head]
hidden := total - head - tail
for _, line := range display {
b.WriteString(" " + Dim().Render(truncateShellLine(line)) + "\n")
}
b.WriteString(Dim().Italic(true).Render(fmt.Sprintf(" ... %d lines truncated ...", hidden)) + "\n")
tailLines := lines[total-tail:]
for i, line := range tailLines {
b.WriteString(" " + Dim().Render(truncateShellLine(line)))
if i < len(tailLines)-1 {
b.WriteString("\n")
}
}
return b.String()
}
func appendShellOutput(b *strings.Builder, p shellParsed, status string) {
output := cleanShellOutput(p.content)
if status == "running" {
if output != "" {
b.WriteString("\n" + formatShellOutput(output))
}
return
}
if output == "" {
if p.hasExitCode && p.exitCode != 0 {
b.WriteString("\n" + Col(Red).Faint(true).Render(fmt.Sprintf(" exit %d", p.exitCode)))
}
return
}
b.WriteString("\n" + formatShellOutput(output))
if p.hasExitCode && p.exitCode != 0 {
b.WriteString("\n" + Col(Red).Faint(true).Render(fmt.Sprintf(" exit %d", p.exitCode)))
}
}
func renderTerminal(prompt string, promptColor lipgloss.Color, command string, result any, status, meta string) string {
var b strings.Builder
b.WriteString(Dim().Render(">_") + " ")
if strings.TrimSpace(command) == "" {
b.WriteString(Dim().Render("getting logs..."))
} else {
b.WriteString(Col(promptColor).Render(prompt) + " " + command)
}
if meta != "" {
b.WriteString(Dim().Render(" " + meta))
}
if result != nil {
appendShellOutput(&b, parseShellResult(result), status)
}
return b.String()
}
func renderExecCommand(args map[string]any, result any, status string) string {
cmd := StringValue(args["cmd"])
var metaParts []string
if wd := StringValue(args["workdir"]); wd != "" {
metaParts = append(metaParts, "cwd:"+wd)
}
if b, ok := args["tty"].(bool); ok && b {
metaParts = append(metaParts, "tty")
}
meta := strings.Join(metaParts, ", ")
return renderTerminal("$", Green, HighlightCode(cmd, "bash"), result, status, meta)
}
func renderWriteStdin(args map[string]any, result any, status string) string {
chars := StringValue(args["chars"])
meta := ""
if sid, ok := args["session_id"]; ok && sid != nil {
meta = "session #" + StringValue(sid)
}
return renderTerminal(">>>", Blue, chars, result, status, meta)
}
@@ -0,0 +1,80 @@
package render
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
// ---------------------------------------------------------------------------
// Todos (todo_renderer.py)
// ---------------------------------------------------------------------------
var todoMarkers = map[string]string{"pending": "[ ]", "in_progress": "[~]", "done": "[•]"}
var todoTitles = map[string]struct {
title string
color lipgloss.Color
loading string
errMsg string
}{
"create_todo": {"Todo", Lavender, "Creating...", "Failed to create todo"},
"list_todos": {"Todos", Lavender, "Loading...", "Unable to list todos"},
"update_todo": {"Todo Updated", Lavender, "Updating...", "Failed to update todo"},
"mark_todo_done": {"Todo Completed", Lavender, "Marking done...", "Failed to mark todo done"},
"mark_todo_pending": {"Todo Reopened", AmberY, "Reopening...", "Failed to reopen todo"},
"delete_todo": {"Todo Removed", Slate, "Removing...", "Failed to remove todo"},
}
func renderTodo(name string, result any) string {
meta := todoTitles[name]
var b strings.Builder
b.WriteString("📋 " + Bold(meta.color).Render(meta.title))
if s, ok := result.(string); ok && strings.TrimSpace(s) != "" {
b.WriteString("\n " + Dim().Render(strings.TrimSpace(s)))
return b.String()
}
if m, ok := result.(map[string]any); ok {
if truthy(m["success"]) {
formatTodoLines(&b, m)
} else {
errMsg := StringValue(m["error"])
if errMsg == "" {
errMsg = meta.errMsg
}
b.WriteString("\n " + Col(Red).Render(errMsg))
}
} else {
b.WriteString("\n " + Dim().Render(meta.loading))
}
return b.String()
}
func formatTodoLines(b *strings.Builder, result map[string]any) {
todos, ok := result["todos"].([]any)
if !ok || len(todos) == 0 {
b.WriteString("\n " + Dim().Render("No todos"))
return
}
for _, t := range todos {
todo, _ := t.(map[string]any)
status := StringValue(todo["status"])
marker := todoMarkers[status]
if marker == "" {
marker = todoMarkers["pending"]
}
title := strings.TrimSpace(StringValue(todo["title"]))
if title == "" {
title = "(untitled)"
}
b.WriteString("\n " + marker + " ")
switch status {
case "done":
b.WriteString(Dim().Strikethrough(true).Render(title))
case "in_progress":
b.WriteString(lipgloss.NewStyle().Italic(true).Render(title))
default:
b.WriteString(title)
}
}
}