Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4140bce67 | ||
|
|
381cd3e9fb | ||
|
|
99aa96ce0e |
@@ -16,6 +16,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -154,6 +155,416 @@ func col(cfg Cfg) bool {
|
|||||||
return isTerminal(int(os.Stdout.Fd()))
|
return isTerminal(int(os.Stdout.Fd()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mdState struct {
|
||||||
|
inCode bool
|
||||||
|
lang string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
reCode = regexp.MustCompile("`([^`]+)`")
|
||||||
|
reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
|
||||||
|
reBI1 = regexp.MustCompile(`\*\*\*(.*?)\*\*\*`)
|
||||||
|
reBI2 = regexp.MustCompile(`___(.*?)___`)
|
||||||
|
reB1 = regexp.MustCompile(`\*\*(.*?)\*\*`)
|
||||||
|
reB2 = regexp.MustCompile(`__(.*?)__`)
|
||||||
|
reI1 = regexp.MustCompile(`\*(.*?)\*`)
|
||||||
|
reI2 = regexp.MustCompile(`_(.*?)_`)
|
||||||
|
reS = regexp.MustCompile(`~~(.*?)~~`)
|
||||||
|
reOrd = regexp.MustCompile(`^(\d+\.)\s+(.*)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func renderInline(s string) string {
|
||||||
|
if !COL { return s }
|
||||||
|
var codes []string
|
||||||
|
s = reCode.ReplaceAllStringFunc(s, func(m string) string {
|
||||||
|
codes = append(codes, c(m[1:len(m)-1], 33))
|
||||||
|
return fmt.Sprintf("\x00CD%d\x00", len(codes)-1)
|
||||||
|
})
|
||||||
|
s = reLink.ReplaceAllStringFunc(s, func(m string) string {
|
||||||
|
sm := reLink.FindStringSubmatch(m)
|
||||||
|
if len(sm) == 3 { return c(sm[1], 4, 36) + " " + c("("+sm[2]+")", 2) }
|
||||||
|
return m
|
||||||
|
})
|
||||||
|
s = reBI1.ReplaceAllStringFunc(s, func(m string) string { return c(m[3:len(m)-3], 1, 3) })
|
||||||
|
s = reBI2.ReplaceAllStringFunc(s, func(m string) string { return c(m[3:len(m)-3], 1, 3) })
|
||||||
|
s = reB1.ReplaceAllStringFunc(s, func(m string) string { return c(m[2:len(m)-2], 1) })
|
||||||
|
s = reB2.ReplaceAllStringFunc(s, func(m string) string { return c(m[2:len(m)-2], 1) })
|
||||||
|
s = reI1.ReplaceAllStringFunc(s, func(m string) string { return c(m[1:len(m)-1], 3) })
|
||||||
|
s = reI2.ReplaceAllStringFunc(s, func(m string) string { return c(m[1:len(m)-1], 3) })
|
||||||
|
s = reS.ReplaceAllStringFunc(s, func(m string) string { return c(m[2:len(m)-2], 9) })
|
||||||
|
for i, code := range codes {
|
||||||
|
s = strings.ReplaceAll(s, fmt.Sprintf("\x00CD%d\x00", i), code)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderMDLine(line string, st *mdState) string {
|
||||||
|
if !COL { return line }
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(trimmed, "```") {
|
||||||
|
if !st.inCode {
|
||||||
|
st.inCode = true
|
||||||
|
st.lang = strings.TrimSpace(strings.TrimPrefix(trimmed, "```"))
|
||||||
|
title := ""
|
||||||
|
if st.lang != "" { title = " [ " + st.lang + " ]" }
|
||||||
|
return c("───"+title+"──────────────────────────────────────────", 2)
|
||||||
|
}
|
||||||
|
st.inCode = false
|
||||||
|
st.lang = ""
|
||||||
|
return c("───────────────────────────────────────────────────", 2)
|
||||||
|
}
|
||||||
|
if st.inCode {
|
||||||
|
return c(" ", 2) + c(line, 32)
|
||||||
|
}
|
||||||
|
if trimmed == "---" || trimmed == "***" || trimmed == "___" || trimmed == "----" || trimmed == "------" {
|
||||||
|
return c("───────────────────────────────────────────────────", 2)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, "#") {
|
||||||
|
lvl := 0
|
||||||
|
for lvl < len(trimmed) && trimmed[lvl] == '#' { lvl++ }
|
||||||
|
if lvl < len(trimmed) && trimmed[lvl] == ' ' {
|
||||||
|
htext := strings.TrimSpace(trimmed[lvl:])
|
||||||
|
switch lvl {
|
||||||
|
case 1: return c("■ ", 35) + c(htext, 1, 37)
|
||||||
|
case 2: return c("▲ ", 34) + c(htext, 1, 36)
|
||||||
|
case 3: return c("● ", 32) + c(htext, 1, 32)
|
||||||
|
case 4: return c("◆ ", 33) + c(htext, 1, 33)
|
||||||
|
default: return c(htext, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, ">") {
|
||||||
|
qtext := strings.TrimSpace(strings.TrimPrefix(trimmed, ">"))
|
||||||
|
return c("▎ ", 34) + c(renderInline(qtext), 3)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, "- [ ] ") || strings.HasPrefix(trimmed, "* [ ] ") {
|
||||||
|
return strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " "))) + c("☐ ", 33) + renderInline(trimmed[6:])
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, "- [x] ") || strings.HasPrefix(trimmed, "* [x] ") || strings.HasPrefix(trimmed, "- [X] ") || strings.HasPrefix(trimmed, "* [X] ") {
|
||||||
|
return strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " "))) + c("☑ ", 32) + renderInline(trimmed[6:])
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") || strings.HasPrefix(trimmed, "+ ") {
|
||||||
|
indent := strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " ")))
|
||||||
|
return indent + c("• ", 36) + renderInline(trimmed[2:])
|
||||||
|
}
|
||||||
|
if m := reOrd.FindStringSubmatch(trimmed); len(m) == 3 {
|
||||||
|
indent := strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " ")))
|
||||||
|
return indent + c(m[1]+" ", 33) + renderInline(m[2])
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, "|") && strings.HasSuffix(trimmed, "|") {
|
||||||
|
return renderInline(line)
|
||||||
|
}
|
||||||
|
return renderInline(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTableSep(cells []string) bool {
|
||||||
|
if len(cells) == 0 { return false }
|
||||||
|
for _, cell := range cells {
|
||||||
|
c := strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(cell), "-", ""), ":", "")
|
||||||
|
if c != "" { return false }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTableCells(line string) []string {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(trimmed, "|") { trimmed = trimmed[1:] }
|
||||||
|
if strings.HasSuffix(trimmed, "|") { trimmed = trimmed[:len(trimmed)-1] }
|
||||||
|
parts := strings.Split(trimmed, "|")
|
||||||
|
cells := make([]string, len(parts))
|
||||||
|
for i, p := range parts { cells[i] = strings.TrimSpace(p) }
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTableLine(line string) bool {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if !strings.Contains(trimmed, "|") { return false }
|
||||||
|
return strings.HasPrefix(trimmed, "|") || strings.HasSuffix(trimmed, "|")
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapCell(s string, width int) []string {
|
||||||
|
if width <= 0 { return []string{""} }
|
||||||
|
s = strings.ReplaceAll(s, "<br>", "\n")
|
||||||
|
s = strings.ReplaceAll(s, "<br/>", "\n")
|
||||||
|
s = strings.ReplaceAll(s, "<br />", "\n")
|
||||||
|
var lines []string
|
||||||
|
for _, p := range strings.Split(s, "\n") {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
lines = append(lines, "")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if visibleLen(p) <= width {
|
||||||
|
lines = append(lines, p)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
words := strings.Fields(p)
|
||||||
|
if len(words) == 0 {
|
||||||
|
lines = append(lines, "")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var curLine string
|
||||||
|
var activeEsc string
|
||||||
|
updateActiveEsc := func(token string) {
|
||||||
|
for i := 0; i < len(token); {
|
||||||
|
if token[i] == 0x1b {
|
||||||
|
j := i + 1
|
||||||
|
if j < len(token) && token[j] == '[' {
|
||||||
|
j++
|
||||||
|
for j < len(token) && !(token[j] >= 0x40 && token[j] <= 0x7e) { j++ }
|
||||||
|
if j < len(token) { j++ }
|
||||||
|
}
|
||||||
|
esc := token[i:j]
|
||||||
|
if esc == "\033[0m" || esc == "\033[m" {
|
||||||
|
activeEsc = ""
|
||||||
|
} else {
|
||||||
|
activeEsc = esc
|
||||||
|
}
|
||||||
|
i = j
|
||||||
|
} else {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushLine := func() {
|
||||||
|
if curLine != "" {
|
||||||
|
out := curLine
|
||||||
|
if COL && activeEsc != "" && !strings.HasSuffix(out, "\033[0m") {
|
||||||
|
out += "\033[0m"
|
||||||
|
}
|
||||||
|
lines = append(lines, out)
|
||||||
|
curLine = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, word := range words {
|
||||||
|
wLen := visibleLen(word)
|
||||||
|
if wLen > width {
|
||||||
|
flushLine()
|
||||||
|
var chunk strings.Builder
|
||||||
|
cLen := 0
|
||||||
|
for i := 0; i < len(word); {
|
||||||
|
if word[i] == 0x1b {
|
||||||
|
j := i + 1
|
||||||
|
if j < len(word) && word[j] == '[' {
|
||||||
|
j++
|
||||||
|
for j < len(word) && !(word[j] >= 0x40 && word[j] <= 0x7e) { j++ }
|
||||||
|
if j < len(word) { j++ }
|
||||||
|
}
|
||||||
|
chunk.WriteString(word[i:j])
|
||||||
|
updateActiveEsc(word[i:j])
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r, size := utf8.DecodeRuneInString(word[i:])
|
||||||
|
if cLen >= width {
|
||||||
|
if COL && activeEsc != "" { chunk.WriteString("\033[0m") }
|
||||||
|
lines = append(lines, chunk.String())
|
||||||
|
chunk.Reset()
|
||||||
|
if COL && activeEsc != "" { chunk.WriteString(activeEsc) }
|
||||||
|
cLen = 0
|
||||||
|
}
|
||||||
|
chunk.WriteRune(r)
|
||||||
|
cLen++
|
||||||
|
i += size
|
||||||
|
}
|
||||||
|
if chunk.Len() > 0 { curLine = chunk.String() }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cLen := visibleLen(curLine)
|
||||||
|
if curLine == "" {
|
||||||
|
curLine = word
|
||||||
|
updateActiveEsc(word)
|
||||||
|
} else if cLen+1+wLen <= width {
|
||||||
|
curLine += " " + word
|
||||||
|
updateActiveEsc(word)
|
||||||
|
} else {
|
||||||
|
flushLine()
|
||||||
|
if COL && activeEsc != "" {
|
||||||
|
curLine = activeEsc + word
|
||||||
|
} else {
|
||||||
|
curLine = word
|
||||||
|
}
|
||||||
|
updateActiveEsc(word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushLine()
|
||||||
|
}
|
||||||
|
if len(lines) == 0 { lines = []string{""} }
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderTable(lines []string) []string {
|
||||||
|
if len(lines) == 0 { return nil }
|
||||||
|
var rows [][]string
|
||||||
|
var headerRow []string
|
||||||
|
hasHeader := false
|
||||||
|
|
||||||
|
for _, ln := range lines {
|
||||||
|
cells := parseTableCells(ln)
|
||||||
|
if isTableSep(cells) {
|
||||||
|
if len(rows) > 0 && !hasHeader {
|
||||||
|
headerRow = rows[len(rows)-1]
|
||||||
|
rows = rows[:len(rows)-1]
|
||||||
|
hasHeader = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rows = append(rows, cells)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasHeader && len(lines) < 2 {
|
||||||
|
var out []string
|
||||||
|
var st mdState
|
||||||
|
for _, ln := range lines { out = append(out, renderMDLine(ln, &st)) }
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
numCols := len(headerRow)
|
||||||
|
for _, r := range rows {
|
||||||
|
if len(r) > numCols { numCols = len(r) }
|
||||||
|
}
|
||||||
|
if numCols == 0 { return nil }
|
||||||
|
|
||||||
|
if hasHeader {
|
||||||
|
for len(headerRow) < numCols { headerRow = append(headerRow, "") }
|
||||||
|
}
|
||||||
|
for i := range rows {
|
||||||
|
for len(rows[i]) < numCols { rows[i] = append(rows[i], "") }
|
||||||
|
}
|
||||||
|
|
||||||
|
colWidths := make([]int, numCols)
|
||||||
|
for i := 0; i < numCols; i++ {
|
||||||
|
if hasHeader {
|
||||||
|
vl := visibleLen(headerRow[i])
|
||||||
|
if vl > colWidths[i] { colWidths[i] = vl }
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
vl := visibleLen(renderInline(r[i]))
|
||||||
|
if vl > colWidths[i] { colWidths[i] = vl }
|
||||||
|
}
|
||||||
|
if colWidths[i] < 3 { colWidths[i] = 3 }
|
||||||
|
}
|
||||||
|
|
||||||
|
maxTableWidth := termWidth()
|
||||||
|
if maxTableWidth < 20 { maxTableWidth = 80 }
|
||||||
|
overhead := 3*numCols + 1
|
||||||
|
availContent := maxTableWidth - overhead
|
||||||
|
if availContent < numCols*3 { availContent = numCols * 3 }
|
||||||
|
|
||||||
|
tot := 0
|
||||||
|
for _, w := range colWidths { tot += w }
|
||||||
|
|
||||||
|
for tot > availContent {
|
||||||
|
maxIdx := 0
|
||||||
|
maxVal := colWidths[0]
|
||||||
|
for i := 1; i < numCols; i++ {
|
||||||
|
if colWidths[i] > maxVal {
|
||||||
|
maxVal = colWidths[i]
|
||||||
|
maxIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maxVal <= 3 { break }
|
||||||
|
colWidths[maxIdx]--
|
||||||
|
tot--
|
||||||
|
}
|
||||||
|
|
||||||
|
var res []string
|
||||||
|
|
||||||
|
var topParts []string
|
||||||
|
for _, w := range colWidths { topParts = append(topParts, strings.Repeat("─", w+2)) }
|
||||||
|
res = append(res, c("┌"+strings.Join(topParts, "┬")+"┐", 2))
|
||||||
|
|
||||||
|
if hasHeader {
|
||||||
|
headerCols := make([][]string, numCols)
|
||||||
|
maxHeaderLines := 1
|
||||||
|
for i, h := range headerRow {
|
||||||
|
wrapped := wrapCell(h, colWidths[i])
|
||||||
|
if len(wrapped) > maxHeaderLines { maxHeaderLines = len(wrapped) }
|
||||||
|
headerCols[i] = wrapped
|
||||||
|
}
|
||||||
|
|
||||||
|
for lineIdx := 0; lineIdx < maxHeaderLines; lineIdx++ {
|
||||||
|
var hCells []string
|
||||||
|
for i := 0; i < numCols; i++ {
|
||||||
|
txt := ""
|
||||||
|
if lineIdx < len(headerCols[i]) { txt = headerCols[i][lineIdx] }
|
||||||
|
rh := c(txt, 1, 36)
|
||||||
|
pad := strings.Repeat(" ", colWidths[i]-visibleLen(txt))
|
||||||
|
hCells = append(hCells, " "+rh+pad+" ")
|
||||||
|
}
|
||||||
|
res = append(res, c("│", 2)+strings.Join(hCells, c("│", 2))+c("│", 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
var midParts []string
|
||||||
|
for _, w := range colWidths { midParts = append(midParts, strings.Repeat("─", w+2)) }
|
||||||
|
res = append(res, c("├"+strings.Join(midParts, "┼")+"┤", 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range rows {
|
||||||
|
rowCols := make([][]string, numCols)
|
||||||
|
maxRowLines := 1
|
||||||
|
for i, cell := range r {
|
||||||
|
rc := renderInline(cell)
|
||||||
|
wrapped := wrapCell(rc, colWidths[i])
|
||||||
|
if len(wrapped) > maxRowLines { maxRowLines = len(wrapped) }
|
||||||
|
rowCols[i] = wrapped
|
||||||
|
}
|
||||||
|
|
||||||
|
for lineIdx := 0; lineIdx < maxRowLines; lineIdx++ {
|
||||||
|
var rCells []string
|
||||||
|
for i := 0; i < numCols; i++ {
|
||||||
|
txt := ""
|
||||||
|
if lineIdx < len(rowCols[i]) { txt = rowCols[i][lineIdx] }
|
||||||
|
pad := strings.Repeat(" ", colWidths[i]-visibleLen(txt))
|
||||||
|
rCells = append(rCells, " "+txt+pad+" ")
|
||||||
|
}
|
||||||
|
res = append(res, c("│", 2)+strings.Join(rCells, c("│", 2))+c("│", 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var botParts []string
|
||||||
|
for _, w := range colWidths { botParts = append(botParts, strings.Repeat("─", w+2)) }
|
||||||
|
res = append(res, c("└"+strings.Join(botParts, "┴")+"┘", 2))
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderMD(text string) string {
|
||||||
|
if !COL { return text }
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
var out []string
|
||||||
|
var st mdState
|
||||||
|
var tbl []string
|
||||||
|
|
||||||
|
flushTable := func() {
|
||||||
|
if len(tbl) > 0 {
|
||||||
|
out = append(out, renderTable(tbl)...)
|
||||||
|
tbl = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ln := range lines {
|
||||||
|
trimmed := strings.TrimSpace(ln)
|
||||||
|
if strings.HasPrefix(trimmed, "```") {
|
||||||
|
flushTable()
|
||||||
|
out = append(out, renderMDLine(ln, &st))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if st.inCode {
|
||||||
|
out = append(out, renderMDLine(ln, &st))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isTableLine(ln) {
|
||||||
|
tbl = append(tbl, ln)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
flushTable()
|
||||||
|
out = append(out, renderMDLine(ln, &st))
|
||||||
|
}
|
||||||
|
flushTable()
|
||||||
|
return strings.Join(out, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content *string `json:"content"`
|
Content *string `json:"content"`
|
||||||
@@ -297,8 +708,19 @@ func llm(cfg *Cfg, msgs []Message, tools []map[string]any) (Message, error) {
|
|||||||
func parseStream(r io.Reader) (Message, error) {
|
func parseStream(r io.Reader) (Message, error) {
|
||||||
var content, reas string
|
var content, reas string
|
||||||
var rh, ch bool
|
var rh, ch bool
|
||||||
|
var lineBuf string
|
||||||
|
var mdSt mdState
|
||||||
|
var tblBuf []string
|
||||||
tcs := map[int]*ToolCall{}
|
tcs := map[int]*ToolCall{}
|
||||||
var order []int
|
var order []int
|
||||||
|
|
||||||
|
flushTable := func() {
|
||||||
|
if len(tblBuf) > 0 {
|
||||||
|
for _, tln := range renderTable(tblBuf) { fmt.Println(tln) }
|
||||||
|
tblBuf = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sc := bufio.NewScanner(r)
|
sc := bufio.NewScanner(r)
|
||||||
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||||
for sc.Scan() {
|
for sc.Scan() {
|
||||||
@@ -319,8 +741,26 @@ func parseStream(r io.Reader) (Message, error) {
|
|||||||
if dl.Content != "" {
|
if dl.Content != "" {
|
||||||
if rh && !ch { fmt.Print("\n" + c("--- reasoning end ---", 36) + "\n\n") }
|
if rh && !ch { fmt.Print("\n" + c("--- reasoning end ---", 36) + "\n\n") }
|
||||||
ch = true
|
ch = true
|
||||||
fmt.Print(dl.Content)
|
|
||||||
content += dl.Content
|
content += dl.Content
|
||||||
|
lineBuf += dl.Content
|
||||||
|
for {
|
||||||
|
idx := strings.IndexByte(lineBuf, '\n')
|
||||||
|
if idx == -1 { break }
|
||||||
|
curLine := lineBuf[:idx]
|
||||||
|
lineBuf = lineBuf[idx+1:]
|
||||||
|
trimmed := strings.TrimSpace(curLine)
|
||||||
|
if strings.HasPrefix(trimmed, "```") {
|
||||||
|
flushTable()
|
||||||
|
fmt.Println(renderMDLine(curLine, &mdSt))
|
||||||
|
} else if mdSt.inCode {
|
||||||
|
fmt.Println(renderMDLine(curLine, &mdSt))
|
||||||
|
} else if isTableLine(curLine) {
|
||||||
|
tblBuf = append(tblBuf, curLine)
|
||||||
|
} else {
|
||||||
|
flushTable()
|
||||||
|
fmt.Println(renderMDLine(curLine, &mdSt))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, tc := range dl.ToolCalls {
|
for _, tc := range dl.ToolCalls {
|
||||||
t, ok := tcs[tc.Index]
|
t, ok := tcs[tc.Index]
|
||||||
@@ -334,10 +774,17 @@ func parseStream(r io.Reader) (Message, error) {
|
|||||||
if tc.Function.Arguments != "" { t.Function.Arguments += tc.Function.Arguments }
|
if tc.Function.Arguments != "" { t.Function.Arguments += tc.Function.Arguments }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
flushTable()
|
||||||
|
if lineBuf != "" {
|
||||||
|
if !mdSt.inCode && isTableLine(lineBuf) {
|
||||||
|
tblBuf = append(tblBuf, lineBuf)
|
||||||
|
flushTable()
|
||||||
|
} else {
|
||||||
|
fmt.Println(renderMDLine(lineBuf, &mdSt))
|
||||||
|
}
|
||||||
|
}
|
||||||
if rh && !ch {
|
if rh && !ch {
|
||||||
fmt.Print("\n" + c("--- reasoning end ---", 36) + "\n")
|
fmt.Print("\n" + c("--- reasoning end ---", 36) + "\n")
|
||||||
} else if ch {
|
|
||||||
fmt.Print("\n")
|
|
||||||
}
|
}
|
||||||
m := Message{Role: "assistant"}
|
m := Message{Role: "assistant"}
|
||||||
if content != "" { m.Content = strp(content) }
|
if content != "" { m.Content = strp(content) }
|
||||||
@@ -410,7 +857,7 @@ func AL(cfg *Cfg, msgs []Message, sp string, depth int) ([]Message, error) {
|
|||||||
if m.ReasoningContent != "" {
|
if m.ReasoningContent != "" {
|
||||||
fmt.Println(c("--- reasoning start ---", 36) + "\n" + c(m.ReasoningContent, 2) + "\n" + c("--- reasoning end ---", 36))
|
fmt.Println(c("--- reasoning start ---", 36) + "\n" + c(m.ReasoningContent, 2) + "\n" + c("--- reasoning end ---", 36))
|
||||||
}
|
}
|
||||||
if m.Content != nil { fmt.Println(*m.Content) }
|
if m.Content != nil { fmt.Println(renderMD(*m.Content)) }
|
||||||
}
|
}
|
||||||
if len(m.ToolCalls) == 0 { done = true; break }
|
if len(m.ToolCalls) == 0 { done = true; break }
|
||||||
for _, tc := range m.ToolCalls {
|
for _, tc := range m.ToolCalls {
|
||||||
|
|||||||
+218
@@ -1373,3 +1373,221 @@ func TestLLMForwardsRelevantParameters(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Markdown rendering tests ----------
|
||||||
|
|
||||||
|
func TestRenderInline(t *testing.T) {
|
||||||
|
COL = true
|
||||||
|
defer func() { COL = false }()
|
||||||
|
|
||||||
|
// Code
|
||||||
|
out := renderInline("Use `go test -v` command")
|
||||||
|
if !strings.Contains(out, "\033[33mgo test -v\033[0m") {
|
||||||
|
t.Errorf("renderInline code = %q", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bold
|
||||||
|
out = renderInline("This is **bold** text")
|
||||||
|
if !strings.Contains(out, "\033[1mbold\033[0m") {
|
||||||
|
t.Errorf("renderInline bold = %q", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Italic
|
||||||
|
out = renderInline("This is *italic* text")
|
||||||
|
if !strings.Contains(out, "\033[3mitalic\033[0m") {
|
||||||
|
t.Errorf("renderInline italic = %q", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bold + Italic
|
||||||
|
out = renderInline("This is ***important*** text")
|
||||||
|
if !strings.Contains(out, "\033[1;3mimportant\033[0m") {
|
||||||
|
t.Errorf("renderInline bold+italic = %q", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strikethrough
|
||||||
|
out = renderInline("This is ~~deleted~~ text")
|
||||||
|
if !strings.Contains(out, "\033[9mdeleted\033[0m") {
|
||||||
|
t.Errorf("renderInline strikethrough = %q", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link
|
||||||
|
out = renderInline("Visit [Go](https://go.dev) site")
|
||||||
|
if !strings.Contains(out, "\033[4;36mGo\033[0m") || !strings.Contains(out, "https://go.dev") {
|
||||||
|
t.Errorf("renderInline link = %q", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code shielding (asterisks inside code should not become italic)
|
||||||
|
out = renderInline("Run `foo * bar` now")
|
||||||
|
if !strings.Contains(out, "\033[33mfoo * bar\033[0m") {
|
||||||
|
t.Errorf("renderInline code shield = %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMDBlocks(t *testing.T) {
|
||||||
|
COL = true
|
||||||
|
defer func() { COL = false }()
|
||||||
|
|
||||||
|
// Headings
|
||||||
|
h1 := renderMD("# Title One")
|
||||||
|
if !strings.Contains(h1, "Title One") || !strings.Contains(h1, "\033[35m■ \033[0m") {
|
||||||
|
t.Errorf("renderMD H1 = %q", h1)
|
||||||
|
}
|
||||||
|
h2 := renderMD("## Subtitle")
|
||||||
|
if !strings.Contains(h2, "Subtitle") || !strings.Contains(h2, "\033[34m▲ \033[0m") {
|
||||||
|
t.Errorf("renderMD H2 = %q", h2)
|
||||||
|
}
|
||||||
|
h3 := renderMD("### Section")
|
||||||
|
if !strings.Contains(h3, "Section") || !strings.Contains(h3, "\033[32m● \033[0m") {
|
||||||
|
t.Errorf("renderMD H3 = %q", h3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code block
|
||||||
|
codeMD := "```go\nfunc main() {\n println(1)\n}\n```"
|
||||||
|
renderedCode := renderMD(codeMD)
|
||||||
|
if !strings.Contains(renderedCode, "[ go ]") || !strings.Contains(renderedCode, "println(1)") {
|
||||||
|
t.Errorf("renderMD code block = %q", renderedCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lists
|
||||||
|
ul := renderMD("- Item A\n- Item B")
|
||||||
|
if !strings.Contains(ul, "• ") || !strings.Contains(ul, "Item A") {
|
||||||
|
t.Errorf("renderMD unordered list = %q", ul)
|
||||||
|
}
|
||||||
|
|
||||||
|
ol := renderMD("1. Step 1\n2. Step 2")
|
||||||
|
if !strings.Contains(ol, "1. ") || !strings.Contains(ol, "Step 1") {
|
||||||
|
t.Errorf("renderMD ordered list = %q", ol)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks := renderMD("- [ ] Pending\n- [x] Finished")
|
||||||
|
if !strings.Contains(tasks, "☐ ") || !strings.Contains(tasks, "☑ ") {
|
||||||
|
t.Errorf("renderMD task list = %q", tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blockquote
|
||||||
|
bq := renderMD("> Important quote")
|
||||||
|
if !strings.Contains(bq, "▎ ") || !strings.Contains(bq, "Important quote") {
|
||||||
|
t.Errorf("renderMD blockquote = %q", bq)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal rule
|
||||||
|
hr := renderMD("---")
|
||||||
|
if !strings.Contains(hr, "────") {
|
||||||
|
t.Errorf("renderMD hr = %q", hr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table
|
||||||
|
tbl := renderMD("| Col A | Col B |\n|---|---|\n| Val 1 | Val 2 |")
|
||||||
|
if !strings.Contains(tbl, "Col A") || !strings.Contains(tbl, "Val 1") {
|
||||||
|
t.Errorf("renderMD table = %q", tbl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderTableFormatting(t *testing.T) {
|
||||||
|
COL = true
|
||||||
|
defer func() { COL = false }()
|
||||||
|
|
||||||
|
raw := strings.Join([]string{
|
||||||
|
"| Name | Role | Location |",
|
||||||
|
"| :--- | :---: | ---: |",
|
||||||
|
"| Alice | `Lead` | New York |",
|
||||||
|
"| Bob | Developer | London |",
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
rendered := renderMD(raw)
|
||||||
|
lines := strings.Split(rendered, "\n")
|
||||||
|
if len(lines) != 6 {
|
||||||
|
t.Fatalf("expected 6 table lines (top, header, mid, row1, row2, bot), got %d:\n%s", len(lines), rendered)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(lines[0], "\033[2m┌") || !strings.HasSuffix(lines[0], "┐\033[0m") {
|
||||||
|
t.Errorf("top border = %q", lines[0])
|
||||||
|
}
|
||||||
|
if !strings.Contains(lines[1], "Name") || !strings.Contains(lines[1], "Role") || !strings.Contains(lines[1], "Location") {
|
||||||
|
t.Errorf("header row = %q", lines[1])
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(lines[2], "\033[2m├") || !strings.HasSuffix(lines[2], "┤\033[0m") {
|
||||||
|
t.Errorf("mid border = %q", lines[2])
|
||||||
|
}
|
||||||
|
if !strings.Contains(lines[3], "Alice") || !strings.Contains(lines[3], "New York") {
|
||||||
|
t.Errorf("row 1 = %q", lines[3])
|
||||||
|
}
|
||||||
|
if !strings.Contains(lines[4], "Bob") || !strings.Contains(lines[4], "London") {
|
||||||
|
t.Errorf("row 2 = %q", lines[4])
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(lines[5], "\033[2m└") || !strings.HasSuffix(lines[5], "┘\033[0m") {
|
||||||
|
t.Errorf("bot border = %q", lines[5])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrapCell(t *testing.T) {
|
||||||
|
COL = true
|
||||||
|
defer func() { COL = false }()
|
||||||
|
|
||||||
|
lines := wrapCell("Short text", 20)
|
||||||
|
if len(lines) != 1 || lines[0] != "Short text" {
|
||||||
|
t.Errorf("wrapCell short = %v", lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = wrapCell("The quick brown fox jumps over the lazy dog", 15)
|
||||||
|
if len(lines) < 3 {
|
||||||
|
t.Errorf("wrapCell long = %v", lines)
|
||||||
|
}
|
||||||
|
for _, l := range lines {
|
||||||
|
if visibleLen(l) > 15 {
|
||||||
|
t.Errorf("line %q visible length = %d > 15", l, visibleLen(l))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With ANSI escape codes
|
||||||
|
styled := "\033[1;36mAlice In Wonderland\033[0m"
|
||||||
|
lines = wrapCell(styled, 10)
|
||||||
|
if len(lines) != 2 {
|
||||||
|
t.Errorf("wrapCell styled count = %d, lines = %v", len(lines), lines)
|
||||||
|
}
|
||||||
|
for _, l := range lines {
|
||||||
|
if visibleLen(l) > 10 {
|
||||||
|
t.Errorf("styled line %q length = %d > 10", l, visibleLen(l))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderTableWidthConstraint(t *testing.T) {
|
||||||
|
COL = true
|
||||||
|
defer func() { COL = false }()
|
||||||
|
|
||||||
|
// Create an extra-wide table with long text
|
||||||
|
raw := strings.Join([]string{
|
||||||
|
"| Long Column Header One | Extremely Long Column Header Two That Would Definitely Overflow | Another Very Wide Column Header Three |",
|
||||||
|
"|---|---|---|",
|
||||||
|
"| Some detailed explanation that is very long and has lots of words in it | Another paragraph of text that continues on and on without stopping | Final column with even more long descriptions |",
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
rendered := renderMD(raw)
|
||||||
|
lines := strings.Split(rendered, "\n")
|
||||||
|
maxW := termWidth()
|
||||||
|
if maxW < 20 { maxW = 80 }
|
||||||
|
|
||||||
|
for i, ln := range lines {
|
||||||
|
vl := visibleLen(ln)
|
||||||
|
if vl > maxW {
|
||||||
|
t.Errorf("table line %d visible length = %d, exceeds max width %d:\n%s", i, vl, maxW, ln)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure content was wrapped rather than dropped
|
||||||
|
if !strings.Contains(rendered, "detailed") || !strings.Contains(rendered, "explanation") || !strings.Contains(rendered, "paragraph") {
|
||||||
|
t.Errorf("rendered table should contain all words wrapped across lines:\n%s", rendered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMDNoColorFallback(t *testing.T) {
|
||||||
|
COL = false
|
||||||
|
raw := "# Heading\n**bold** and `code`\n- list item"
|
||||||
|
out := renderMD(raw)
|
||||||
|
if out != raw {
|
||||||
|
t.Errorf("renderMD with COL=false should return raw text, got %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user