mirror of
https://github.com/usestrix/strix.git
synced 2026-08-23 19:32:37 +02:00
feat(tui): replace Textual with a Go/Bubble Tea interface (#941)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user