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
This commit is contained in:
can1357
2026-03-22 01:53:06 +01:00
parent 1bf48ca208
commit 855d89cc5e
7 changed files with 158 additions and 48 deletions
+3
View File
@@ -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
@@ -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();
@@ -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) {
+56 -30
View File
@@ -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;
},
};
+4 -1
View File
@@ -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))
+66 -1
View File
@@ -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;
}
+10 -1
View File
@@ -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", () => {