Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/agent/cli-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 13 additions & 4 deletions src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
35 changes: 18 additions & 17 deletions src/agent/markdown-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
});

/**
Expand All @@ -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<string> depending on
// localMarked.parse() can return string | Promise<string> 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+$/, "");
}
Expand All @@ -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)
);
}

Expand Down
3 changes: 3 additions & 0 deletions src/agent/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)")}`,
);
Expand Down
Loading