From eef660ca9260823ec6ad04b6a8e16374ba46bb46 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 7 Sep 2026 13:09:32 +0530 Subject: [PATCH] feat: render --print output as styled markdown with --markdown Add a --markdown root flag that renders the --print text-mode response through the existing markdown renderer (teal headers, bold, inline code, fenced code blocks, lists). Rendering only happens when color is enabled (ShouldColor), so piped/NO_COLOR output stays raw and machine-parseable. When --markdown is set, the response is buffered and rendered at the end instead of streamed raw, since partial markdown cannot be styled. Default streaming behavior is unchanged; json/stream-json output is untouched. Extract renderPrintResponse for testability; unit-test the raw/render and color gating. Update the root-help golden file for the new flag. --- cmd/chat_print.go | 34 +++++++++++++++++++++++++++------- cmd/print_markdown_test.go | 31 +++++++++++++++++++++++++++++++ cmd/root.go | 2 ++ testdata/golden/help_root.txt | 1 + 4 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 cmd/print_markdown_test.go diff --git a/cmd/chat_print.go b/cmd/chat_print.go index e26c4337..0e34ddfc 100644 --- a/cmd/chat_print.go +++ b/cmd/chat_print.go @@ -88,7 +88,7 @@ func runPrint(text string) error { for ev := range ch { switch ev.Type { case "content": - if outputFormat == "text" { + if outputFormat == "text" && !printMarkdown { fmt.Print(ev.Content) } else if outputFormat == "stream-json" { writePrintEvent(sessionID, "content", ev.Content, "") @@ -134,9 +134,7 @@ func runPrint(text string) error { case "done": switch outputFormat { case "text": - if !strings.HasSuffix(printed.String(), "\n") { - fmt.Println() - } + printTextResponse(printed.String()) printTextUsageFooter(lastUsage, started) case "json": writePrintResult(printed.String(), sessionID, sess, false, nil) @@ -151,9 +149,7 @@ func runPrint(text string) error { } switch outputFormat { case "text": - if !strings.HasSuffix(printed.String(), "\n") { - fmt.Println() - } + printTextResponse(printed.String()) printTextUsageFooter(lastUsage, started) case "json": writePrintResult(printed.String(), sessionID, sess, false, nil) @@ -202,6 +198,30 @@ func printTextUsageFooter(usage *engine.StreamUsage, started time.Time) { _, _ = fmt.Fprintf(os.Stderr, "%s\n", auditTint("tokens: "+strings.Join(parts, " ยท "), textMuted)) } +// printTextResponse emits the final text-mode response, rendering markdown to +// styled ANSI when --markdown is set and color is enabled. Raw markdown is +// preserved for piped/NO_COLOR output so scripts stay machine-parseable. +func printTextResponse(s string) { + s = renderPrintResponse(s, printMarkdown, ShouldColor()) + fmt.Print(s) + if !strings.HasSuffix(s, "\n") { + fmt.Println() + } +} + +// renderPrintResponse applies markdown rendering when both markdown and color +// are requested; otherwise it returns the raw response unchanged. +func renderPrintResponse(s string, markdown, color bool) string { + if !markdown || !color || s == "" { + return s + } + w, _ := TermSize() + if w <= 0 { + w = 80 + } + return renderMarkdown(s, w) +} + func writePrintResult(result, sessionID string, sess *engine.Session, isError bool, errors []string) { event := map[string]interface{}{ "type": "result", diff --git a/cmd/print_markdown_test.go b/cmd/print_markdown_test.go new file mode 100644 index 00000000..7f58b654 --- /dev/null +++ b/cmd/print_markdown_test.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "strings" + "testing" +) + +func TestRenderPrintResponse(t *testing.T) { + const md = "## Hello **world**\n\n- one\n- two\n" + + // markdown off -> raw, regardless of color + if got := renderPrintResponse(md, false, true); got != md { + t.Errorf("markdown=false: got %q, want raw %q", got, md) + } + // color off -> raw, regardless of markdown + if got := renderPrintResponse(md, true, false); got != md { + t.Errorf("color=false: got %q, want raw %q", got, md) + } + // empty stays empty + if got := renderPrintResponse("", true, true); got != "" { + t.Errorf("empty: got %q, want empty", got) + } + // markdown + color -> styled ANSI, raw markers stripped + got := renderPrintResponse(md, true, true) + if !strings.Contains(got, "\x1b[") { + t.Errorf("markdown+color: no ANSI in %q", got) + } + if strings.Contains(got, "**world**") { + t.Errorf("markdown+color: raw ** still present in %q", got) + } +} diff --git a/cmd/root.go b/cmd/root.go index 33cd4c98..a5a6f285 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -30,6 +30,7 @@ var ( printMode bool versionFlag bool outputFormat string + printMarkdown bool outputFields string inputFormat string noSessionPersistence bool @@ -222,6 +223,7 @@ func init() { rootCmd.Flags().BoolVarP(&printMode, "print", "p", false, "print response and exit") rootCmd.Flags().StringVar(&promptFlag, "prompt", "", "send a single prompt and exit (legacy alias for --print)") rootCmd.Flags().StringVar(&outputFormat, "output-format", "text", `output format for --print: "text", "json", or "stream-json"`) + rootCmd.Flags().BoolVar(&printMarkdown, "markdown", false, `render --print text output as styled markdown (needs color; ignored for json/stream-json)`) rootCmd.Flags().StringVar(&outputFields, "output-fields", "", `comma-separated field whitelist for --output-format json (e.g. "result,session_id")`) rootCmd.Flags().StringVar(&inputFormat, "input-format", "text", `input format for --print: "text" or "stream-json"`) rootCmd.Flags().BoolVar(&noSessionPersistence, "no-session-persistence", false, "disable session persistence in print mode") diff --git a/testdata/golden/help_root.txt b/testdata/golden/help_root.txt index 47e72d48..cc284b79 100644 --- a/testdata/golden/help_root.txt +++ b/testdata/golden/help_root.txt @@ -110,6 +110,7 @@ Flags: -h, --help help for graycode --input-format string input format for --print: "text" or "stream-json" (default "text") --map-tokens int token budget for the --repo-map overview (default 1024) + --markdown render --print text output as styled markdown (needs color; ignored for json/stream-json) --max-budget-usd float maximum estimated API spend in USD --max-turns int maximum number of agentic turns in non-interactive mode --mcp stringArray MCP server command