From 855d89cc5eab2607428293926adead3bc438f4aa Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 22 Mar 2026 01:53:06 +0100 Subject: [PATCH] feat(coding-agent): added inline markdown rendering with theme-aware styling - Added renderInlineMarkdown() utility function to support inline markdown rendering with optional base color styling. - Refactored ask tool to render questions and option labels with markdown formatting for improved text styling. - Updated hook-input and hook-selector components to render titles as markdown with theme-aware styling. - Implemented recursive token processing for nested markdown elements including bold, italic, code, links, and strikethrough. Fixes #491 --- packages/coding-agent/CHANGELOG.md | 3 + .../src/modes/components/hook-input.ts | 12 +-- .../src/modes/components/hook-selector.ts | 22 +++-- packages/coding-agent/src/tools/ask.ts | 86 ++++++++++++------- packages/tui/CHANGELOG.md | 5 +- packages/tui/src/components/markdown.ts | 67 ++++++++++++++- packages/tui/test/markdown.test.ts | 11 ++- 7 files changed, 158 insertions(+), 48 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d52f924bc..955fe5505 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] + ### Added - Added ACP (Agent Client Protocol) mode for headless agent operation via `--mode acp` @@ -9,6 +10,8 @@ ### Changed +- Updated ask tool rendering to support markdown formatting in questions and option labels +- Refactored hook input and selector components to render titles as markdown for richer text formatting - Changed session collection to include sessions with zero messages, enabling ACP mode to create discoverable sessions immediately - Changed session persistence logic to use atomic file rewrite when flushing unflushed sessions to prevent duplication diff --git a/packages/coding-agent/src/modes/components/hook-input.ts b/packages/coding-agent/src/modes/components/hook-input.ts index 06a192457..eb4d01f66 100644 --- a/packages/coding-agent/src/modes/components/hook-input.ts +++ b/packages/coding-agent/src/modes/components/hook-input.ts @@ -1,8 +1,8 @@ /** * Simple text input component for hooks. */ -import { Container, Input, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; -import { theme } from "../../modes/theme/theme"; +import { Container, Input, Markdown, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; +import { getMarkdownTheme, theme } from "../../modes/theme/theme"; import { CountdownTimer } from "./countdown-timer"; import { DynamicBorder } from "./dynamic-border"; @@ -16,7 +16,7 @@ export class HookInputComponent extends Container { #input: Input; #onSubmitCallback: (value: string) => void; #onCancelCallback: () => void; - #titleText: Text; + #titleComponent: Markdown; #baseTitle: string; #countdown: CountdownTimer | undefined; @@ -36,15 +36,15 @@ export class HookInputComponent extends Container { this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); - this.#titleText = new Text(theme.fg("accent", title), 1, 0); - this.addChild(this.#titleText); + this.#titleComponent = new Markdown(title, 1, 0, getMarkdownTheme(), { color: t => theme.fg("accent", t) }); + this.addChild(this.#titleComponent); this.addChild(new Spacer(1)); if (opts?.timeout && opts.timeout > 0 && opts.tui) { this.#countdown = new CountdownTimer( opts.timeout, opts.tui, - s => this.#titleText.setText(theme.fg("accent", `${this.#baseTitle} (${s}s)`)), + s => this.#titleComponent.setText(`${this.#baseTitle} (${s}s)`), () => { opts.onTimeout?.(); this.#onCancelCallback(); diff --git a/packages/coding-agent/src/modes/components/hook-selector.ts b/packages/coding-agent/src/modes/components/hook-selector.ts index 5c2dec8ec..c00aea1d1 100644 --- a/packages/coding-agent/src/modes/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/components/hook-selector.ts @@ -4,8 +4,10 @@ */ import { Container, + Markdown, matchesKey, padding, + renderInlineMarkdown, replaceTabs, Spacer, Text, @@ -13,7 +15,7 @@ import { truncateToWidth, visibleWidth, } from "@oh-my-pi/pi-tui"; -import { theme } from "../../modes/theme/theme"; +import { getMarkdownTheme, theme } from "../../modes/theme/theme"; import { CountdownTimer } from "./countdown-timer"; import { DynamicBorder } from "./dynamic-border"; @@ -59,7 +61,7 @@ export class HookSelectorComponent extends Container { #outlinedList: OutlinedList | undefined; #onSelectCallback: (option: string) => void; #onCancelCallback: () => void; - #titleText: Text; + #titleComponent: Markdown; #baseTitle: string; #countdown: CountdownTimer | undefined; #onLeftCallback: (() => void) | undefined; @@ -85,15 +87,15 @@ export class HookSelectorComponent extends Container { this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); - this.#titleText = new Text(theme.fg("accent", title), 1, 0); - this.addChild(this.#titleText); + this.#titleComponent = new Markdown(title, 1, 0, getMarkdownTheme(), { color: t => theme.fg("accent", t) }); + this.addChild(this.#titleComponent); this.addChild(new Spacer(1)); if (opts?.timeout && opts.timeout > 0 && opts.tui) { this.#countdown = new CountdownTimer( opts.timeout, opts.tui, - s => this.#titleText.setText(theme.fg("accent", `${this.#baseTitle} (${s}s)`)), + s => this.#titleComponent.setText(`${this.#baseTitle} (${s}s)`), () => { opts?.onTimeout?.(); // Auto-select current option on timeout (typically the first/recommended option) @@ -131,12 +133,14 @@ export class HookSelectorComponent extends Container { ); const endIndex = Math.min(startIndex + this.#maxVisible, this.#options.length); + const mdTheme = getMarkdownTheme(); for (let i = startIndex; i < endIndex; i++) { const isSelected = i === this.#selectedIndex; - const text = isSelected - ? theme.fg("accent", `${theme.nav.cursor} `) + theme.fg("accent", this.#options[i]) - : ` ${theme.fg("text", this.#options[i])}`; - lines.push(text); + const label = isSelected + ? renderInlineMarkdown(this.#options[i], mdTheme, t => theme.fg("accent", t)) + : renderInlineMarkdown(this.#options[i], mdTheme, t => theme.fg("text", t)); + const prefix = isSelected ? theme.fg("accent", `${theme.nav.cursor} `) : " "; + lines.push(prefix + label); } if (startIndex > 0 || endIndex < this.#options.length) { diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index f611bba7c..c2f2fc7f1 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -16,13 +16,12 @@ */ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; -import type { Component } from "@oh-my-pi/pi-tui"; -import { TERMINAL, Text } from "@oh-my-pi/pi-tui"; +import { type Component, Container, Markdown, renderInlineMarkdown, TERMINAL, Text } from "@oh-my-pi/pi-tui"; import { untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import { renderPromptTemplate } from "../config/prompt-templates"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; -import { type Theme, theme } from "../modes/theme/theme"; +import { getMarkdownTheme, type Theme, theme } from "../modes/theme/theme"; import askDescription from "../prompts/tools/ask.md" with { type: "text" }; import { renderStatusLine } from "../tui"; import type { ToolSession } from "."; @@ -574,10 +573,13 @@ interface AskRenderArgs { export const askToolRenderer = { renderCall(args: AskRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component { const label = formatTitle("Ask", uiTheme); + const mdTheme = getMarkdownTheme(); + const accentStyle = { color: (t: string) => uiTheme.fg("accent", t) }; // Multi-part questions if (args.questions && args.questions.length > 0) { - let text = `${label} ${uiTheme.fg("muted", `${args.questions.length} questions`)}`; + const container = new Container(); + container.addChild(new Text(`${label} ${uiTheme.fg("muted", `${args.questions.length} questions`)}`, 0, 0)); for (let i = 0; i < args.questions.length; i++) { const q = args.questions[i]; @@ -585,25 +587,29 @@ export const askToolRenderer = { const qBranch = isLastQ ? uiTheme.tree.last : uiTheme.tree.branch; const continuation = isLastQ ? " " : uiTheme.tree.vertical; - // Question line with metadata const meta: string[] = []; if (q.multi) meta.push("multi"); if (q.options?.length) meta.push(`options:${q.options.length}`); const metaStr = meta.length > 0 ? uiTheme.fg("dim", ` · ${meta.join(" · ")}`) : ""; - text += `\n ${uiTheme.fg("dim", qBranch)} ${uiTheme.fg("dim", `[${q.id}]`)} ${uiTheme.fg("accent", q.question)}${metaStr}`; + container.addChild( + new Text(` ${uiTheme.fg("dim", qBranch)} ${uiTheme.fg("dim", `[${q.id}]`)}${metaStr}`, 0, 0), + ); + container.addChild(new Markdown(q.question, 3, 0, mdTheme, accentStyle)); - // Options under question if (q.options?.length) { + let optText = ""; for (let j = 0; j < q.options.length; j++) { const opt = q.options[j]; const isLastOpt = j === q.options.length - 1; const optBranch = isLastOpt ? uiTheme.tree.last : uiTheme.tree.branch; - text += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${uiTheme.fg("muted", opt.label)}`; + const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t)); + optText += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${optLabel}`; } + container.addChild(new Text(optText, 0, 0)); } } - return new Text(text, 0, 0); + return container; } // Single question @@ -611,22 +617,26 @@ export const askToolRenderer = { return new Text(formatErrorMessage("No question provided", uiTheme), 0, 0); } - let text = `${label} ${uiTheme.fg("accent", args.question)}`; + const container = new Container(); const meta: string[] = []; if (args.multi) meta.push("multi"); if (args.options?.length) meta.push(`options:${args.options.length}`); - text += formatMeta(meta, uiTheme); + container.addChild(new Text(`${label}${formatMeta(meta, uiTheme)}`, 0, 0)); + container.addChild(new Markdown(args.question, 1, 0, mdTheme, accentStyle)); if (args.options?.length) { + let optText = ""; for (let i = 0; i < args.options.length; i++) { const opt = args.options[i]; const isLast = i === args.options.length - 1; const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; - text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${uiTheme.fg("muted", opt.label)}`; + const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t)); + optText += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${optLabel}`; } + container.addChild(new Text(optText, 0, 0)); } - return new Text(text, 0, 0); + return container; }, renderResult( @@ -635,6 +645,9 @@ export const askToolRenderer = { uiTheme: Theme, ): Component { const { details } = result; + const mdTheme = getMarkdownTheme(); + const accentStyle = { color: (t: string) => uiTheme.fg("accent", t) }; + if (!details) { const txt = result.content[0]; const fallback = txt?.type === "text" && txt.text ? txt.text : ""; @@ -655,7 +668,8 @@ export const askToolRenderer = { }, uiTheme, ); - let text = header; + const container = new Container(); + container.addChild(new Text(header, 0, 0)); for (let i = 0; i < details.results.length; i++) { const r = details.results[i]; @@ -667,22 +681,31 @@ export const askToolRenderer = { ? uiTheme.styledSymbol("status.success", "success") : uiTheme.styledSymbol("status.warning", "warning"); - text += `\n ${uiTheme.fg("dim", branch)} ${statusIcon} ${uiTheme.fg("dim", `[${r.id}]`)} ${uiTheme.fg("accent", r.question)}`; + container.addChild( + new Text(` ${uiTheme.fg("dim", branch)} ${statusIcon} ${uiTheme.fg("dim", `[${r.id}]`)}`, 0, 0), + ); + container.addChild(new Markdown(r.question, 3, 0, mdTheme, accentStyle)); + let answerText = ""; if (r.customInput) { - text += `\n${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", r.customInput)}`; + answerText = `${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", r.customInput)}`; } else if (r.selectedOptions.length > 0) { for (let j = 0; j < r.selectedOptions.length; j++) { const isLast = j === r.selectedOptions.length - 1; const optBranch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; - text += `\n${continuation}${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${uiTheme.fg("toolOutput", r.selectedOptions[j])}`; + const selectedLabel = renderInlineMarkdown(r.selectedOptions[j], mdTheme, t => + uiTheme.fg("toolOutput", t), + ); + answerText += `\n${continuation}${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`; } } else { - text += `\n${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`; + answerText = `${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`; + } + if (answerText) { + container.addChild(new Text(answerText, 0, 0)); } } - - return new Text(text, 0, 0); + return container; } // Single question result @@ -693,25 +716,28 @@ export const askToolRenderer = { } const hasSelection = details.customInput || (details.selectedOptions && details.selectedOptions.length > 0); - const header = renderStatusLine( - { icon: hasSelection ? "success" : "warning", title: "Ask", description: details.question }, - uiTheme, - ); - - let text = header; + const header = renderStatusLine({ icon: hasSelection ? "success" : "warning", title: "Ask" }, uiTheme); + const container = new Container(); + container.addChild(new Text(header, 0, 0)); + container.addChild(new Markdown(details.question, 1, 0, mdTheme, accentStyle)); + let answerText = ""; if (details.customInput) { - text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", details.customInput)}`; + answerText = ` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", details.customInput)}`; } else if (details.selectedOptions && details.selectedOptions.length > 0) { for (let i = 0; i < details.selectedOptions.length; i++) { const isLast = i === details.selectedOptions.length - 1; const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch; - text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${uiTheme.fg("toolOutput", details.selectedOptions[i])}`; + const selectedLabel = renderInlineMarkdown(details.selectedOptions[i], mdTheme, t => + uiTheme.fg("toolOutput", t), + ); + answerText += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`; } } else { - text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`; + answerText = ` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`; } + container.addChild(new Text(answerText, 0, 0)); - return new Text(text, 0, 0); + return container; }, }; diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 45a47cc58..d13917853 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Added + +- Added `renderInlineMarkdown()` function to render inline markdown (bold, italic, code, links, strikethrough) to styled strings ## [13.14.1] - 2026-03-21 ### Added @@ -652,4 +655,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon ### Fixed -- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0)) +- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0)) \ No newline at end of file diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 557cd27f2..2a8a32ee3 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -1,4 +1,4 @@ -import { marked, type Token } from "marked"; +import { marked, type Token, type Tokens } from "marked"; import type { SymbolTheme } from "../symbols"; import { TERMINAL } from "../terminal-capabilities"; import type { Component } from "../tui"; @@ -838,3 +838,68 @@ export class Markdown implements Component { return lines; } } + +/** + * Render inline markdown (bold, italic, code, links, strikethrough) to a styled string. + * Unlike the full Markdown component, this produces a single line with no block-level elements. + */ +export function renderInlineMarkdown(text: string, mdTheme: MarkdownTheme, baseColor?: (t: string) => string): string { + const tokens = marked.lexer(text); + const applyText = baseColor ?? ((t: string) => t); + let result = ""; + for (const token of tokens) { + if (token.type === "paragraph" && token.tokens) { + result += renderInlineTokens(token.tokens, mdTheme, applyText); + } else if (token.type === "list") { + result += token.items + .map((item: Tokens.ListItem, index: number) => { + const prefix = token.ordered ? `${(token.start || 1) + index}. ` : "• "; + const content = item.tokens ? renderInlineTokens(item.tokens, mdTheme, applyText) : applyText(item.text); + return `${applyText(prefix)}${content}`; + }) + .join(applyText(" ")); + } else if ("text" in token && typeof token.text === "string") { + result += applyText(token.text); + } + } + return result; +} + +function renderInlineTokens(tokens: Token[], mdTheme: MarkdownTheme, applyText: (t: string) => string): string { + let result = ""; + const styleReset = applyText(""); + for (const token of tokens) { + switch (token.type) { + case "text": + if (token.tokens && token.tokens.length > 0) { + result += renderInlineTokens(token.tokens, mdTheme, applyText); + } else { + result += applyText(token.text); + } + break; + case "strong": + result += mdTheme.bold(renderInlineTokens(token.tokens || [], mdTheme, applyText)) + styleReset; + break; + case "em": + result += mdTheme.italic(renderInlineTokens(token.tokens || [], mdTheme, applyText)) + styleReset; + break; + case "codespan": + result += mdTheme.code(token.text) + styleReset; + break; + case "del": + result += mdTheme.strikethrough(renderInlineTokens(token.tokens || [], mdTheme, applyText)) + styleReset; + break; + case "link": { + const linkText = renderInlineTokens(token.tokens || [], mdTheme, applyText); + result += mdTheme.link(mdTheme.underline(linkText)) + styleReset; + break; + } + default: + if ("text" in token && typeof token.text === "string") { + result += applyText(token.text); + } + break; + } + } + return result; +} diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 9eb1830c5..58dc63616 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; import type { Terminal as XtermTerminalType } from "@xterm/headless"; import { Chalk } from "chalk"; -import { Markdown } from "../src/components/markdown.js"; +import { Markdown, renderInlineMarkdown } from "../src/components/markdown.js"; import { type Component, TUI } from "../src/tui.js"; import { defaultMarkdownTheme } from "./test-themes.js"; import { VirtualTerminal } from "./virtual-terminal.js"; @@ -19,6 +19,15 @@ function getCellItalic(terminal: VirtualTerminal, row: number, col: number): num return cell!.isItalic(); } +describe("renderInlineMarkdown", () => { + it("preserves ordered list items as visible inline text", () => { + const rendered = renderInlineMarkdown("1. Review against a base branch (PR Style)", defaultMarkdownTheme); + const plain = rendered.replace(/\x1b\[[0-9;]*m/g, ""); + + expect(plain).toBe("1. Review against a base branch (PR Style)"); + }); +}); + describe("Markdown component", () => { describe("Nested lists", () => { it("should render simple nested list", () => {