From a6f0e40ece6710db17ac4f66775096e3e0095c8b Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Thu, 14 May 2026 20:41:30 +0100 Subject: [PATCH] fix: address remaining PR #135 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /markdown toggle: set sessionNeedsRebuild so system prompt updates (#1) - CLI help: document --[no-]markdown, --md/--no-md aliases, HYPERAGENT_MARKDOWN (#6) - Streamed output: gate renderMarkdown on looksLikeMarkdown consistently (#7) - markdown-renderer: use local Marked instance instead of global setOptions (#9) - looksLikeMarkdown: remove over-eager bold and unordered-list patterns (#10) - unescape: verified valid marked-terminal option (comment was wrong) (#8) - linkifyFiles order: verified safe — [[file:]] not a markdown token (#16) Verified: diff matches this message. 40 test files, 2350 tests pass. Signed-off-by: Simon Davies --- src/agent/cli-parser.ts | 3 ++- src/agent/index.ts | 17 +++++++++++++---- src/agent/markdown-renderer.ts | 35 +++++++++++++++++----------------- src/agent/slash-commands.ts | 3 +++ 4 files changed, 36 insertions(+), 22 deletions(-) diff --git a/src/agent/cli-parser.ts b/src/agent/cli-parser.ts index 82f4535..c1e2eaa 100644 --- a/src/agent/cli-parser.ts +++ b/src/agent/cli-parser.ts @@ -104,7 +104,8 @@ Options: --show-timing Log timing breakdown to ~/.hyperagent/logs/ --show-reasoning [level] Set reasoning effort (low|medium|high|xhigh, default: high) --verbose Verbose output mode (scrolling reasoning, turn details) - --no-markdown Disable markdown rendering (use raw streaming instead) + --[no-]markdown Toggle markdown rendering (default: on, env: HYPERAGENT_MARKDOWN) + Aliases: --md, --no-md --transcript Record session transcript to ~/.hyperagent/logs/ --list-models List available models and exit --resume [id] Resume previous session (last if no ID given) diff --git a/src/agent/index.ts b/src/agent/index.ts index a45319a..aef788b 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -5993,10 +5993,19 @@ async function processMessage( }; if (state.markdownEnabled && state.streamedText) { - // Markdown mode: output was buffered (not streamed). Render now. - let rendered = renderMarkdown(state.streamedText); - rendered = linkifyFiles(rendered, fsWriteBase, trackFile); - console.log(rendered); + // Markdown mode: output was buffered (not streamed). Render now + // if it looks like markdown; otherwise print as-is to avoid + // mangling plain prose with ANSI escapes. + let output: string; + if (looksLikeMarkdown(state.streamedText)) { + output = renderMarkdown(state.streamedText); + } else { + output = state.streamedText; + } + // Replace [[file:path]] markers before printing so ANSI codes + // from renderMarkdown don't split the markers. + output = linkifyFiles(output, fsWriteBase, trackFile); + console.log(output); } else if (!state.streamedContent && content) { // Non-streamed fallback (rare) — render through markdown if enabled if (state.markdownEnabled && looksLikeMarkdown(content)) { diff --git a/src/agent/markdown-renderer.ts b/src/agent/markdown-renderer.ts index 43b4e84..2a330ee 100644 --- a/src/agent/markdown-renderer.ts +++ b/src/agent/markdown-renderer.ts @@ -8,25 +8,26 @@ // import { renderMarkdown } from "./markdown-renderer.js"; // console.log(renderMarkdown("# Hello\n**bold** and `code`")); -import { marked, type MarkedOptions } from "marked"; +import { Marked, type MarkedOptions } from "marked"; import TerminalRenderer from "marked-terminal"; import { resolve } from "node:path"; -// Configure marked with the terminal renderer once at import time. -// marked-terminal handles: headings, bold/italic, code blocks with -// syntax highlighting, lists, tables, links, blockquotes, and hr. -const renderer = new TerminalRenderer({ +// Use a local Marked instance so we don't mutate the global marked +// singleton — other code importing marked won't accidentally get +// terminal-rendered output instead of HTML. +const terminalRenderer = new TerminalRenderer({ // Indent code blocks for visual separation tab: 2, // Show URLs inline rather than as footnotes showSectionPrefix: true, - // Use unicode bullets + // Convert HTML entities back to characters unescape: true, }); + // marked-terminal's renderer type doesn't match marked v15's _Renderer // exactly, but it works at runtime. Cast to satisfy the type checker. -marked.setOptions({ - renderer: renderer as unknown as MarkedOptions["renderer"], +const localMarked = new Marked({ + renderer: terminalRenderer as unknown as MarkedOptions["renderer"], }); /** @@ -41,9 +42,9 @@ marked.setOptions({ * @returns ANSI-formatted string ready for console output */ export function renderMarkdown(text: string): string { - // marked.parse() can return string | Promise depending on + // localMarked.parse() can return string | Promise depending on // config. With our sync renderer it always returns string. - const rendered = marked.parse(text) as string; + const rendered = localMarked.parse(text) as string; // Trim trailing newlines that marked adds (avoids double-spacing) return rendered.replace(/\n+$/, ""); } @@ -54,14 +55,14 @@ export function renderMarkdown(text: string): string { * doesn't benefit from being passed through the renderer. */ export function looksLikeMarkdown(text: string): boolean { - // Quick heuristics — check for common markdown patterns + // Require a strong signal that this is markdown rather than plain text. + // Weak patterns like bold (**word**) or list bullets (- item) match + // too many false positives (git branch output, log lines, etc.). return ( - /^#{1,6}\s/m.test(text) || // headings - /\*\*[^*]+\*\*/m.test(text) || // bold - /```[\s\S]*?```/m.test(text) || // code blocks - /^\s*[-*+]\s/m.test(text) || // unordered lists - /^\s*\d+\.\s/m.test(text) || // ordered lists - /\|.*\|.*\|/m.test(text) // tables + /^#{1,6}\s/m.test(text) || // headings (strong signal) + /```[\s\S]*?```/m.test(text) || // code fences (strong signal) + /^\|\s*.+\s*\|\s*.+\s*\|/m.test(text) || // table rows (strong signal) + /^\s*\d+\.\s/m.test(text) // ordered lists (moderate signal) ); } diff --git a/src/agent/slash-commands.ts b/src/agent/slash-commands.ts index 218fd7b..f70967d 100644 --- a/src/agent/slash-commands.ts +++ b/src/agent/slash-commands.ts @@ -298,6 +298,9 @@ export async function handleSlashCommand( // Toggle markdown rendering — buffers output instead of streaming // and renders through marked-terminal for proper formatting. state.markdownEnabled = !state.markdownEnabled; + // System prompt includes markdown-specific instructions (OUTPUT mode, + // FILE REFERENCES). Rebuild the session so the LLM gets the update. + state.sessionNeedsRebuild = true; console.log( ` 📝 Markdown rendering: ${state.markdownEnabled ? C.ok("ON") + C.dim(" (output buffered, not streamed)") : C.err("OFF") + C.dim(" (raw streaming)")}`, );