Skip to content

Commit c01ab93

Browse files
authored
Feat markdown rendering (#135)
* feat: terminal markdown rendering for LLM output (--markdown / /md) - Add marked + marked-terminal for ANSI-formatted markdown output - New --markdown CLI flag and /markdown (/md) toggle command - When enabled: LLM output is buffered (not streamed) and rendered with proper headings, bold, code blocks, lists, tables, links - When disabled (default): raw streaming as before - markdownEnabled state field, wired through event handler to suppress character-by-character streaming in markdown mode - processMessage renders buffered output through marked-terminal before displaying to user - All 2342 tests pass Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com> * fix: wrap full error messages in C.err() for red styling All error messages previously had only the label (e.g. '❌ Error:') styled red, while the actual error text appeared unstyled after the ANSI reset. Now the entire message including the error detail is wrapped in C.err(). - event-handler.ts: SDK-level tool failure error display - slash-commands.ts: 11 error paths (models, sessions, history, audit, modules) all consistently fully red Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com> * feat: extend markdown rendering to tool results and show-code blocks - Tool result strings: render via marked-terminal when markdown mode is enabled and text contains markdown patterns (headings, code blocks, etc.) - register_handler show-code: wrap in ```javascript fence for syntax hl - execute_bash show-code: wrap in ```bash fence for syntax hl - Errors/warnings left untouched — keep C.err()/C.warn() ANSI coloring - JSON objects left untouched — dim pretty-print is fine for structured data - Bash stdout left untouched — too risky for false-positive markdown matches Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com> * fix: bash bundle node:module stub for just-bash 3.0.1 Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com> * feat: SDK large output handling + markdown config tables - Add wrapToolResult() helper returning proper ToolResultObject with skipLargeOutputProcessing to bypass SDK VB() /tmp truncation - Restructure execute_javascript/execute_bash thresholds: disk save (20KB) and LLM context limit (50K chars) as independent concerns - Add 50K char guards to read_input/read_output with sandbox guidance - Render plugin config and startup/slash-command config as markdown tables when markdown mode is enabled - Render tool result strings with markdown patterns via renderMarkdown Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com> * feat: markdown default, verbose gating, /files + /open commands, file tracking - Markdown rendering enabled by default (--no-markdown to disable) - HYPERAGENT_MARKDOWN env default flipped (disable with =0) - Startup config banner as markdown tables - Plugin and sandbox config confirmations as markdown tables - Tool execution: show tool name for all tools, not just sandbox - Tool results gated behind verbose mode (non-verbose shows ✅ Done) - Errors always shown regardless of verbose mode - [plugins] and [mcp] discovery output gated behind --verbose - File tracking: write_output and auto-save register produced files - [[file:path]] markers in LLM output resolved via linkifyFiles() - /files command: lists all produced files with numbered refs - /open command: opens file by number (WSL/macOS/Linux) - Dedup produced files by absPath to avoid double entries - System message: conditional markdown/plain output instructions - C.fileLink() returns raw paths for terminal auto-detection Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com> * fix: address PR #135 review feedback - /markdown toggle: set sessionNeedsRebuild so system prompt updates - Help text: fix wrong '(default)' label (ON is default, not OFF) - /open: use spawnSync with argv arrays instead of shell interpolation (prevents shell injection with special characters in paths) - /open: validate input with /^\d+$/ regex (reject '1abc' etc.) - Spinner: restart after tool name display so user sees activity - Non-verbose errors: always show parsed.error even in non-verbose mode (previously hidden behind '✅ Done' — regression) - Gate 1 config: restore requested config display before plugin approval - Truncated preview: skip markdown rendering on 300-char truncated content (truncation can break mid-token producing garbled output) - _userDisplayed: verified path through wrapToolResult JSON serialisation Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com> --------- Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
1 parent 4131af4 commit c01ab93

16 files changed

Lines changed: 1744 additions & 552 deletions

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,5 @@ plugins/shared/*.js
5252
plugins/plugin-schema-types.d.ts
5353
plugins/plugin-schema-types.js
5454
plugins/host-modules.d.ts
55-
output-hyperagent**/**scripts/bash-bundle/_tmp_bundle.js
5655
output-hyperagent-*/
5756
scripts/bash-bundle/_tmp_bundle.js

builtin-modules/bash.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"author": "system",
55
"mutable": false,
66
"type": "script",
7-
"sourceHash": "sha256:31219cbf85cf26d1",
7+
"sourceHash": "sha256:a86d1d576100eb10",
88
"hints": {
99
"overview": "Pure-JS bash interpreter for the sandbox. Used internally by the execute_bash tool — not intended for direct import in handlers."
1010
}

package-lock.json

Lines changed: 746 additions & 159 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,12 @@
3636
"@modelcontextprotocol/sdk": "^1.29.0",
3737
"boxen": "^8.0.1",
3838
"hyperlight-analysis": "file:src/code-validator/guest",
39+
"marked": "^15.0.12",
40+
"marked-terminal": "^7.3.0",
3941
"zod": "^4.3.6"
4042
},
4143
"devDependencies": {
44+
"@types/marked-terminal": "^6.1.1",
4245
"@types/node": "^25.3.3",
4346
"@types/pngjs": "^6.0.5",
4447
"@xarsh/ooxml-validator": "^0.1.10",

scripts/bash-bundle/build.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ if (!existsSync(join(repoRoot, "node_modules", "just-bash"))) {
3333
}
3434

3535
const aliasArgs = [
36+
`--alias:node:module=${join(stubDir, "module-stub.mjs")}`,
3637
`--alias:node:zlib=${join(stubDir, "zlib-stub.mjs")}`,
3738
`--alias:node:worker_threads=${join(stubDir, "worker-stub.mjs")}`,
3839
`--alias:node:path=${join(stubDir, "node-path-stub.mjs")}`,
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Stub for node:module — used by just-bash for createRequire()
2+
// In the Hyperlight sandbox there's no native require(); return a
3+
// function that throws so callers fall back to ESM imports.
4+
export function createRequire() {
5+
return function fakeRequire(id) {
6+
throw new Error("require() not available in sandbox: " + id);
7+
};
8+
}
9+
export default { createRequire };

scripts/build-modules.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,13 @@ try {
8787
});
8888
}
8989
} catch (e) {
90-
console.error(" \u26a0\ufe0f bash bundle build failed:", e.message);
91-
// Non-fatal — bash is optional, core agent works without it
90+
if (process.env.CI) {
91+
// Fail hard in CI — stale bash bundles must not slip through
92+
console.error(" ❌ bash bundle build failed (fatal in CI):", e.message);
93+
process.exit(1);
94+
}
95+
console.error(" ⚠️ bash bundle build failed:", e.message);
96+
// Non-fatal locally — bash is optional, core agent works without it
9297
}
9398

9499
// Step 5b: Auto-update hashes in .json metadata files

src/agent/ansi.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,21 @@ export const C = {
4848
on ? `${ANSI.green}ON${ANSI.reset}` : `${ANSI.red}OFF${ANSI.reset}`,
4949
/** Dim italic — model reasoning text (ephemeral inner monologue). */
5050
reasoning: (s: string) => `${ANSI.dim}${ANSI.italic}${s}${ANSI.reset}`,
51+
/**
52+
* OSC 8 terminal hyperlink — makes `label` clickable, opening `uri`.
53+
* Falls back to plain underlined cyan text if the terminal doesn't
54+
* support OSC 8 (the escape is harmless in those terminals).
55+
*
56+
* Usage: C.link('https://example.com', 'click here')
57+
*/
58+
link: (uri: string, label?: string) =>
59+
`\x1b]8;;${uri}\x07${ANSI.underline}${ANSI.cyan}${label ?? uri}${ANSI.reset}\x1b]8;;\x07`,
60+
/**
61+
* Format a file path for terminal output.
62+
* Outputs the raw absolute path with NO ANSI styling so that
63+
* terminal emulators (VS Code, Windows Terminal, iTerm2) can
64+
* auto-detect it and make it ctrl-clickable natively.
65+
* Adding ANSI escapes breaks the terminal's path detection regex.
66+
*/
67+
fileLink: (absPath: string, _label?: string) => absPath,
5168
} as const;

src/agent/cli-parser.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export interface CliConfig {
2121
showTiming: boolean;
2222
showReasoning: string;
2323
verbose: boolean;
24+
/** Render LLM markdown output with ANSI formatting (headings, code, lists). */
25+
markdown: boolean;
2426
transcript: boolean;
2527
listModels: boolean;
2628
resumeSession: string;
@@ -102,6 +104,7 @@ Options:
102104
--show-timing Log timing breakdown to ~/.hyperagent/logs/
103105
--show-reasoning [level] Set reasoning effort (low|medium|high|xhigh, default: high)
104106
--verbose Verbose output mode (scrolling reasoning, turn details)
107+
--no-markdown Disable markdown rendering (use raw streaming instead)
105108
--transcript Record session transcript to ~/.hyperagent/logs/
106109
--list-models List available models and exit
107110
--resume [id] Resume previous session (last if no ID given)
@@ -177,6 +180,7 @@ export function parseCliArgs(
177180
showTiming: false,
178181
showReasoning: process.env.HYPERAGENT_SHOW_REASONING || "",
179182
verbose: process.env.HYPERAGENT_VERBOSE === "1",
183+
markdown: process.env.HYPERAGENT_MARKDOWN !== "0",
180184
transcript: process.env.HYPERAGENT_TRANSCRIPT === "1",
181185
listModels: process.env.HYPERAGENT_LIST_MODELS === "1",
182186
resumeSession: process.env.HYPERAGENT_RESUME_SESSION || "",
@@ -261,6 +265,14 @@ export function parseCliArgs(
261265
case "--verbose":
262266
config.verbose = true;
263267
break;
268+
case "--no-markdown":
269+
case "--no-md":
270+
config.markdown = false;
271+
break;
272+
case "--markdown":
273+
case "--md":
274+
config.markdown = true;
275+
break;
264276
case "--transcript":
265277
config.transcript = true;
266278
break;

src/agent/commands.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,23 @@ const COMMANDS: readonly CommandEntry[] = Object.freeze([
110110
"\n" +
111111
"Also: --verbose CLI flag or HYPERAGENT_VERBOSE=1.",
112112
},
113+
{
114+
completion: "/markdown",
115+
help: "Toggle markdown rendering for LLM output",
116+
detail:
117+
"Toggles terminal markdown rendering on/off.\n" +
118+
"\n" +
119+
"When ON (default):\n" +
120+
" • LLM output is buffered (not streamed character-by-character)\n" +
121+
" • Rendered with ANSI colours: headings, bold, code blocks, lists, tables\n" +
122+
" • Much more readable for structured responses\n" +
123+
"\n" +
124+
"When OFF:\n" +
125+
" • Raw text streamed in real-time (faster perceived response)\n" +
126+
" • Markdown syntax shown as-is (# headings, **bold**, ```code```)\n" +
127+
"\n" +
128+
"Enabled by default. Disable: --no-markdown or HYPERAGENT_MARKDOWN=0.",
129+
},
113130

114131
// ── Timeouts & Buffers ───────────────────────────────────
115132
{
@@ -242,6 +259,23 @@ const COMMANDS: readonly CommandEntry[] = Object.freeze([
242259
"Pass a full or partial session ID (prefix optional).\n" +
243260
"Example: /resume abc123",
244261
},
262+
{
263+
completion: "/files",
264+
help: "List all files produced in this session",
265+
detail:
266+
"Shows numbered references for all files created during\n" +
267+
"this session (via write_output, handlers, or auto-save).\n" +
268+
"Use /open <n> to open a file by its number.",
269+
},
270+
{
271+
completion: "/open",
272+
help: "Open a produced file by number (/open <n>)",
273+
detail:
274+
"Opens a file from the /files list using the system default\n" +
275+
"application. Works on WSL (converts to Windows path),\n" +
276+
"macOS (open), and Linux (xdg-open).\n" +
277+
"Example: /open 1",
278+
},
245279
{
246280
completion: "/history",
247281
help: "Show recent conversation messages (/history [n])",

0 commit comments

Comments
 (0)