diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4e23c0b6..271f9078 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- `markdown.codeBlockIndent` setting to customize code block indentation in rendered output + ### Fixed - Fixed `write` tool not displaying errors in the UI when execution fails ([#856](https://github.com/badlogic/pi-mono/issues/856)) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index b491575a..8633df59 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -837,6 +837,7 @@ Global `~/.pi/agent/settings.json` stores persistent preferences: | `showHardwareCursor` | Show terminal cursor while still positioning it for IME support | `false` | | `doubleEscapeAction` | Action for double-escape with empty editor: `tree` or `branch` | `tree` | | `editorPaddingX` | Horizontal padding for input editor (0-3) | `0` | +| `markdown.codeBlockIndent` | Prefix for each rendered code block line | `" "` | | `extensions` | Additional extension file paths | `[]` | --- diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 84d0b2b3..9562badc 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -47,6 +47,10 @@ export interface ThinkingBudgetsSettings { high?: number; } +export interface MarkdownSettings { + codeBlockIndent?: string; // default: " " +} + export interface Settings { lastChangelogVersion?: string; defaultProvider?: string; @@ -72,6 +76,7 @@ export interface Settings { thinkingBudgets?: ThinkingBudgetsSettings; // Custom token budgets for thinking levels editorPaddingX?: number; // Horizontal padding for input editor (default: 0) showHardwareCursor?: boolean; // Show terminal cursor while still positioning it for IME + markdown?: MarkdownSettings; } /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ @@ -500,4 +505,8 @@ export class SettingsManager { this.globalSettings.editorPaddingX = Math.max(0, Math.min(3, Math.floor(padding))); this.save(); } + + getCodeBlockIndent(): string { + return this.settings.markdown?.codeBlockIndent ?? " "; + } } diff --git a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts index 1ddf6f12..5f9acb2c 100644 --- a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts @@ -1,5 +1,5 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; -import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; +import { Container, Markdown, type MarkdownTheme, Spacer, Text } from "@mariozechner/pi-tui"; import { getMarkdownTheme, theme } from "../theme/theme.js"; /** @@ -8,12 +8,18 @@ import { getMarkdownTheme, theme } from "../theme/theme.js"; export class AssistantMessageComponent extends Container { private contentContainer: Container; private hideThinkingBlock: boolean; + private markdownTheme: MarkdownTheme; private lastMessage?: AssistantMessage; - constructor(message?: AssistantMessage, hideThinkingBlock = false) { + constructor( + message?: AssistantMessage, + hideThinkingBlock = false, + markdownTheme: MarkdownTheme = getMarkdownTheme(), + ) { super(); this.hideThinkingBlock = hideThinkingBlock; + this.markdownTheme = markdownTheme; // Container for text/thinking content this.contentContainer = new Container(); @@ -55,7 +61,7 @@ export class AssistantMessageComponent extends Container { if (content.type === "text" && content.text.trim()) { // Assistant text messages with no background - trim the text // Set paddingY=0 to avoid extra spacing before tool executions - this.contentContainer.addChild(new Markdown(content.text.trim(), 1, 0, getMarkdownTheme())); + this.contentContainer.addChild(new Markdown(content.text.trim(), 1, 0, this.markdownTheme)); } else if (content.type === "thinking" && content.thinking.trim()) { // Check if there's text content after this thinking block const hasTextAfter = message.content.slice(i + 1).some((c) => c.type === "text" && c.text.trim()); @@ -69,7 +75,7 @@ export class AssistantMessageComponent extends Container { } else { // Thinking traces in thinkingText color, italic this.contentContainer.addChild( - new Markdown(content.thinking.trim(), 1, 0, getMarkdownTheme(), { + new Markdown(content.thinking.trim(), 1, 0, this.markdownTheme, { color: (text: string) => theme.fg("thinkingText", text), italic: true, }), diff --git a/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts b/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts index 2c9f15cd..bef32d27 100644 --- a/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts @@ -1,4 +1,4 @@ -import { Box, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; +import { Box, Markdown, type MarkdownTheme, Spacer, Text } from "@mariozechner/pi-tui"; import type { BranchSummaryMessage } from "../../../core/messages.js"; import { getMarkdownTheme, theme } from "../theme/theme.js"; import { editorKey } from "./keybinding-hints.js"; @@ -10,10 +10,12 @@ import { editorKey } from "./keybinding-hints.js"; export class BranchSummaryMessageComponent extends Box { private expanded = false; private message: BranchSummaryMessage; + private markdownTheme: MarkdownTheme; - constructor(message: BranchSummaryMessage) { + constructor(message: BranchSummaryMessage, markdownTheme: MarkdownTheme = getMarkdownTheme()) { super(1, 1, (t) => theme.bg("customMessageBg", t)); this.message = message; + this.markdownTheme = markdownTheme; this.updateDisplay(); } @@ -37,7 +39,7 @@ export class BranchSummaryMessageComponent extends Box { if (this.expanded) { const header = "**Branch Summary**\n\n"; this.addChild( - new Markdown(header + this.message.summary, 0, 0, getMarkdownTheme(), { + new Markdown(header + this.message.summary, 0, 0, this.markdownTheme, { color: (text: string) => theme.fg("customMessageText", text), }), ); diff --git a/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts index f8f7eb4e..5e349bc7 100644 --- a/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts @@ -1,4 +1,4 @@ -import { Box, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; +import { Box, Markdown, type MarkdownTheme, Spacer, Text } from "@mariozechner/pi-tui"; import type { CompactionSummaryMessage } from "../../../core/messages.js"; import { getMarkdownTheme, theme } from "../theme/theme.js"; import { editorKey } from "./keybinding-hints.js"; @@ -10,10 +10,12 @@ import { editorKey } from "./keybinding-hints.js"; export class CompactionSummaryMessageComponent extends Box { private expanded = false; private message: CompactionSummaryMessage; + private markdownTheme: MarkdownTheme; - constructor(message: CompactionSummaryMessage) { + constructor(message: CompactionSummaryMessage, markdownTheme: MarkdownTheme = getMarkdownTheme()) { super(1, 1, (t) => theme.bg("customMessageBg", t)); this.message = message; + this.markdownTheme = markdownTheme; this.updateDisplay(); } @@ -38,7 +40,7 @@ export class CompactionSummaryMessageComponent extends Box { if (this.expanded) { const header = `**Compacted from ${tokenStr} tokens**\n\n`; this.addChild( - new Markdown(header + this.message.summary, 0, 0, getMarkdownTheme(), { + new Markdown(header + this.message.summary, 0, 0, this.markdownTheme, { color: (text: string) => theme.fg("customMessageText", text), }), ); diff --git a/packages/coding-agent/src/modes/interactive/components/custom-message.ts b/packages/coding-agent/src/modes/interactive/components/custom-message.ts index 2b4babcc..733c0708 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-message.ts @@ -1,6 +1,6 @@ import type { TextContent } from "@mariozechner/pi-ai"; import type { Component } from "@mariozechner/pi-tui"; -import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; +import { Box, Container, Markdown, type MarkdownTheme, Spacer, Text } from "@mariozechner/pi-tui"; import type { MessageRenderer } from "../../../core/extensions/types.js"; import type { CustomMessage } from "../../../core/messages.js"; import { getMarkdownTheme, theme } from "../theme/theme.js"; @@ -14,12 +14,18 @@ export class CustomMessageComponent extends Container { private customRenderer?: MessageRenderer; private box: Box; private customComponent?: Component; + private markdownTheme: MarkdownTheme; private _expanded = false; - constructor(message: CustomMessage, customRenderer?: MessageRenderer) { + constructor( + message: CustomMessage, + customRenderer?: MessageRenderer, + markdownTheme: MarkdownTheme = getMarkdownTheme(), + ) { super(); this.message = message; this.customRenderer = customRenderer; + this.markdownTheme = markdownTheme; this.addChild(new Spacer(1)); @@ -93,7 +99,7 @@ export class CustomMessageComponent extends Container { } this.box.addChild( - new Markdown(text, 0, 0, getMarkdownTheme(), { + new Markdown(text, 0, 0, this.markdownTheme, { color: (text: string) => theme.fg("customMessageText", text), }), ); diff --git a/packages/coding-agent/src/modes/interactive/components/user-message.ts b/packages/coding-agent/src/modes/interactive/components/user-message.ts index 8b95a3b2..34cc0a48 100644 --- a/packages/coding-agent/src/modes/interactive/components/user-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/user-message.ts @@ -1,15 +1,15 @@ -import { Container, Markdown, Spacer } from "@mariozechner/pi-tui"; +import { Container, Markdown, type MarkdownTheme, Spacer } from "@mariozechner/pi-tui"; import { getMarkdownTheme, theme } from "../theme/theme.js"; /** * Component that renders a user message */ export class UserMessageComponent extends Container { - constructor(text: string) { + constructor(text: string, markdownTheme: MarkdownTheme = getMarkdownTheme()) { super(); this.addChild(new Spacer(1)); this.addChild( - new Markdown(text, 1, 1, getMarkdownTheme(), { + new Markdown(text, 1, 1, markdownTheme, { bgColor: (text: string) => theme.bg("userMessageBg", text), color: (text: string) => theme.fg("userMessageText", text), }), diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 61173f3c..b71defdb 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -22,6 +22,7 @@ import type { EditorComponent, EditorTheme, KeyId, + MarkdownTheme, OverlayHandle, OverlayOptions, SlashCommand, @@ -410,7 +411,7 @@ export class InteractiveMode { } else { this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0)); this.ui.addChild(new Spacer(1)); - this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, getMarkdownTheme())); + this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, this.getMarkdownThemeWithSettings())); this.ui.addChild(new Spacer(1)); } this.ui.addChild(new DynamicBorder()); @@ -585,6 +586,13 @@ export class InteractiveMode { return undefined; } + private getMarkdownThemeWithSettings(): MarkdownTheme { + return { + ...getMarkdownTheme(), + codeBlockIndent: this.settingsManager.getCodeBlockIndent(), + }; + } + // ========================================================================= // Extension System // ========================================================================= @@ -1700,7 +1708,11 @@ export class InteractiveMode { this.updatePendingMessagesDisplay(); this.ui.requestRender(); } else if (event.message.role === "assistant") { - this.streamingComponent = new AssistantMessageComponent(undefined, this.hideThinkingBlock); + this.streamingComponent = new AssistantMessageComponent( + undefined, + this.hideThinkingBlock, + this.getMarkdownThemeWithSettings(), + ); this.streamingMessage = event.message; this.chatContainer.addChild(this.streamingComponent); this.streamingComponent.updateContent(this.streamingMessage); @@ -1991,20 +2003,22 @@ export class InteractiveMode { case "custom": { if (message.display) { const renderer = this.session.extensionRunner?.getMessageRenderer(message.customType); - this.chatContainer.addChild(new CustomMessageComponent(message, renderer)); + this.chatContainer.addChild( + new CustomMessageComponent(message, renderer, this.getMarkdownThemeWithSettings()), + ); } break; } case "compactionSummary": { this.chatContainer.addChild(new Spacer(1)); - const component = new CompactionSummaryMessageComponent(message); + const component = new CompactionSummaryMessageComponent(message, this.getMarkdownThemeWithSettings()); component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); break; } case "branchSummary": { this.chatContainer.addChild(new Spacer(1)); - const component = new BranchSummaryMessageComponent(message); + const component = new BranchSummaryMessageComponent(message, this.getMarkdownThemeWithSettings()); component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); break; @@ -2012,7 +2026,7 @@ export class InteractiveMode { case "user": { const textContent = this.getUserMessageText(message); if (textContent) { - const userComponent = new UserMessageComponent(textContent); + const userComponent = new UserMessageComponent(textContent, this.getMarkdownThemeWithSettings()); this.chatContainer.addChild(userComponent); if (options?.populateHistory) { this.editor.addToHistory?.(textContent); @@ -2021,7 +2035,11 @@ export class InteractiveMode { break; } case "assistant": { - const assistantComponent = new AssistantMessageComponent(message, this.hideThinkingBlock); + const assistantComponent = new AssistantMessageComponent( + message, + this.hideThinkingBlock, + this.getMarkdownThemeWithSettings(), + ); this.chatContainer.addChild(assistantComponent); break; } @@ -3431,7 +3449,7 @@ export class InteractiveMode { this.chatContainer.addChild(new DynamicBorder()); this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0)); this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1, getMarkdownTheme())); + this.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1, this.getMarkdownThemeWithSettings())); this.chatContainer.addChild(new DynamicBorder()); this.ui.requestRender(); } @@ -3561,7 +3579,7 @@ export class InteractiveMode { this.chatContainer.addChild(new DynamicBorder()); this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Keyboard Shortcuts")), 1, 0)); this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Markdown(hotkeys.trim(), 1, 1, getMarkdownTheme())); + this.chatContainer.addChild(new Markdown(hotkeys.trim(), 1, 1, this.getMarkdownThemeWithSettings())); this.chatContainer.addChild(new DynamicBorder()); this.ui.requestRender(); } diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 86927caf..6f4a61b3 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- `codeBlockIndent` property on `MarkdownTheme` to customize code block content indentation (default: 2 spaces) + ## [0.49.2] - 2026-01-19 ## [0.49.1] - 2026-01-18 diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index a271ff5f..8e63fa94 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -41,6 +41,8 @@ export interface MarkdownTheme { strikethrough: (text: string) => string; underline: (text: string) => string; highlightCode?: (code: string, lang?: string) => string[]; + /** Prefix applied to each rendered code block line (default: " ") */ + codeBlockIndent?: string; } export class Markdown implements Component { @@ -263,17 +265,18 @@ export class Markdown implements Component { } case "code": { + const indent = this.theme.codeBlockIndent ?? " "; lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`)); if (this.theme.highlightCode) { const highlightedLines = this.theme.highlightCode(token.text, token.lang); for (const hlLine of highlightedLines) { - lines.push(` ${hlLine}`); + lines.push(`${indent}${hlLine}`); } } else { // Split code by newlines and style each line const codeLines = token.text.split("\n"); for (const codeLine of codeLines) { - lines.push(` ${this.theme.codeBlock(codeLine)}`); + lines.push(`${indent}${this.theme.codeBlock(codeLine)}`); } } lines.push(this.theme.codeBlockBorder("```")); @@ -490,16 +493,17 @@ export class Markdown implements Component { lines.push(text); } else if (token.type === "code") { // Code block in list item + const indent = this.theme.codeBlockIndent ?? " "; lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`)); if (this.theme.highlightCode) { const highlightedLines = this.theme.highlightCode(token.text, token.lang); for (const hlLine of highlightedLines) { - lines.push(` ${hlLine}`); + lines.push(`${indent}${hlLine}`); } } else { const codeLines = token.text.split("\n"); for (const codeLine of codeLines) { - lines.push(` ${this.theme.codeBlock(codeLine)}`); + lines.push(`${indent}${this.theme.codeBlock(codeLine)}`); } } lines.push(this.theme.codeBlockBorder("```"));