From 99aa96ce0e8ebcb44b3381c6607571fb87f42055 Mon Sep 17 00:00:00 2001 From: Luxferre Date: Sat, 15 Aug 2026 08:50:39 +0300 Subject: [PATCH] added markdown support for go port --- main.go | 139 +++++++++++++++++++++++++++++++++++++++++++++++++-- main_test.go | 119 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 254 insertions(+), 4 deletions(-) diff --git a/main.go b/main.go index 538c60d..92bd59e 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "sort" "strconv" "strings" @@ -154,6 +155,126 @@ func col(cfg Cfg) bool { 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, "|") { + isSep := true + for _, ch := range strings.ReplaceAll(strings.ReplaceAll(trimmed, "|", ""), ":", "") { + if ch != '-' && ch != ' ' { isSep = false; break } + } + if isSep { return c(trimmed, 2) } + cells := strings.Split(trimmed[1:len(trimmed)-1], "|") + var rcells []string + for _, cell := range cells { rcells = append(rcells, " "+renderInline(strings.TrimSpace(cell))+" ") } + return c("│", 2) + strings.Join(rcells, c("│", 2)) + c("│", 2) + } + return renderInline(line) +} + +func renderMD(text string) string { + lines := strings.Split(text, "\n") + var out []string + var st mdState + for _, ln := range lines { + out = append(out, renderMDLine(ln, &st)) + } + return strings.Join(out, "\n") +} + type Message struct { Role string `json:"role"` Content *string `json:"content"` @@ -297,6 +418,8 @@ func llm(cfg *Cfg, msgs []Message, tools []map[string]any) (Message, error) { func parseStream(r io.Reader) (Message, error) { var content, reas string var rh, ch bool + var lineBuf string + var mdSt mdState tcs := map[int]*ToolCall{} var order []int sc := bufio.NewScanner(r) @@ -319,8 +442,15 @@ func parseStream(r io.Reader) (Message, error) { if dl.Content != "" { if rh && !ch { fmt.Print("\n" + c("--- reasoning end ---", 36) + "\n\n") } ch = true - fmt.Print(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:] + fmt.Println(renderMDLine(curLine, &mdSt)) + } } for _, tc := range dl.ToolCalls { t, ok := tcs[tc.Index] @@ -334,10 +464,11 @@ func parseStream(r io.Reader) (Message, error) { if tc.Function.Arguments != "" { t.Function.Arguments += tc.Function.Arguments } } } + if lineBuf != "" { + fmt.Println(renderMDLine(lineBuf, &mdSt)) + } if rh && !ch { fmt.Print("\n" + c("--- reasoning end ---", 36) + "\n") - } else if ch { - fmt.Print("\n") } m := Message{Role: "assistant"} if content != "" { m.Content = strp(content) } @@ -410,7 +541,7 @@ func AL(cfg *Cfg, msgs []Message, sp string, depth int) ([]Message, error) { if m.ReasoningContent != "" { 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 } for _, tc := range m.ToolCalls { diff --git a/main_test.go b/main_test.go index c768ebd..44fdfe7 100644 --- a/main_test.go +++ b/main_test.go @@ -1373,3 +1373,122 @@ 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 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) + } +} + +