From 6564d9471702727141e20b305d17679e06373e57 Mon Sep 17 00:00:00 2001 From: Alexey Zaytsev Date: Tue, 30 Jun 2026 04:44:34 -0500 Subject: [PATCH] feat(coding-agent): add configurable assistant output padding Closes #6168 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/settings.md | 1 + .../coding-agent/src/core/settings-manager.ts | 11 ++++++++ .../components/assistant-message.ts | 14 +++++++++-- .../components/settings-selector.ts | 17 ++++++++++++- .../src/modes/interactive/interactive-mode.ts | 20 +++++++++++++++ .../test/assistant-message.test.ts | 25 +++++++++++++++++++ .../test/settings-manager.test.ts | 23 +++++++++++++++++ .../5943-session-start-notify.test.ts | 2 ++ 9 files changed, 111 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2332aace7..c7563adc0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Added an `externalEditor` settings.json override for Ctrl+G external editor commands, with default fallbacks to Notepad on Windows and `nano` elsewhere ([#6122](https://github.com/earendil-works/pi/issues/6122)). +- Added an `outputPad` setting for assistant message and thinking horizontal padding. ### Fixed diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 3a0660571..e93ea1e4e 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -61,6 +61,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses | `doubleEscapeAction` | string | `"tree"` | Action for double-escape: `"tree"`, `"fork"`, or `"none"` | | `treeFilterMode` | string | `"default"` | Default filter for `/tree`: `"default"`, `"no-tools"`, `"user-only"`, `"labeled-only"`, `"all"` | | `editorPaddingX` | number | `0` | Horizontal padding for input editor (0-3) | +| `outputPad` | number | `1` | Horizontal padding for assistant messages and thinking (0 or 1) | | `autocompleteMaxVisible` | number | `5` | Max visible items in autocomplete dropdown (3-20) | | `showHardwareCursor` | boolean | `false` | Show the terminal cursor while TUI positions it for IME support | diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 0778f9388..44b489ab2 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -113,6 +113,7 @@ export interface Settings { treeFilterMode?: "default" | "no-tools" | "user-only" | "labeled-only" | "all"; // Default filter when opening /tree thinkingBudgets?: ThinkingBudgetsSettings; // Custom token budgets for thinking levels editorPaddingX?: number; // Horizontal padding for input editor (default: 0) + outputPad?: 0 | 1; // Horizontal padding for assistant output (default: 1) autocompleteMaxVisible?: number; // Max visible items in autocomplete dropdown (default: 5) showHardwareCursor?: boolean; // Show terminal cursor while still positioning it for IME markdown?: MarkdownSettings; @@ -1182,6 +1183,16 @@ export class SettingsManager { this.save(); } + getOutputPad(): 0 | 1 { + return this.settings.outputPad === 0 ? 0 : 1; + } + + setOutputPad(padding: 0 | 1): void { + this.globalSettings.outputPad = padding; + this.markModified("outputPad"); + this.save(); + } + getAutocompleteMaxVisible(): number { return this.settings.autocompleteMaxVisible ?? 5; } 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 e8781c75f..f849b2bcb 100644 --- a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts @@ -14,6 +14,7 @@ export class AssistantMessageComponent extends Container { private hideThinkingBlock: boolean; private markdownTheme: MarkdownTheme; private hiddenThinkingLabel: string; + private outputPad: number; private lastMessage?: AssistantMessage; private hasToolCalls = false; @@ -22,12 +23,14 @@ export class AssistantMessageComponent extends Container { hideThinkingBlock = false, markdownTheme: MarkdownTheme = getMarkdownTheme(), hiddenThinkingLabel = "Thinking...", + outputPad = 1, ) { super(); this.hideThinkingBlock = hideThinkingBlock; this.markdownTheme = markdownTheme; this.hiddenThinkingLabel = hiddenThinkingLabel; + this.outputPad = outputPad; // Container for text/thinking content this.contentContainer = new Container(); @@ -59,6 +62,13 @@ export class AssistantMessageComponent extends Container { } } + setOutputPad(padding: number): void { + this.outputPad = padding; + if (this.lastMessage) { + this.updateContent(this.lastMessage); + } + } + override render(width: number): string[] { const lines = super.render(width); if (this.hasToolCalls || lines.length === 0) { @@ -90,7 +100,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, this.markdownTheme)); + this.contentContainer.addChild(new Markdown(content.text.trim(), this.outputPad, 0, this.markdownTheme)); } else if (content.type === "thinking" && content.thinking.trim()) { // Add spacing only when another visible assistant content block follows. // This avoids a superfluous blank line before separately-rendered tool execution blocks. @@ -109,7 +119,7 @@ export class AssistantMessageComponent extends Container { } else { // Thinking traces in thinkingText color, italic this.contentContainer.addChild( - new Markdown(content.thinking.trim(), 1, 0, this.markdownTheme, { + new Markdown(content.thinking.trim(), this.outputPad, 0, this.markdownTheme, { color: (text: string) => theme.fg("thinkingText", text), italic: true, }), diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 7cc92614d..e67e335a3 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -71,6 +71,7 @@ export interface SettingsConfig { treeFilterMode: "default" | "no-tools" | "user-only" | "labeled-only" | "all"; showHardwareCursor: boolean; editorPaddingX: number; + outputPad: 0 | 1; autocompleteMaxVisible: number; quietStartup: boolean; defaultProjectTrust: DefaultProjectTrust; @@ -100,6 +101,7 @@ export interface SettingsCallbacks { onTreeFilterModeChange: (mode: "default" | "no-tools" | "user-only" | "labeled-only" | "all") => void; onShowHardwareCursorChange: (enabled: boolean) => void; onEditorPaddingXChange: (padding: number) => void; + onOutputPadChange: (padding: 0 | 1) => void; onAutocompleteMaxVisibleChange: (maxVisible: number) => void; onQuietStartupChange: (enabled: boolean) => void; onDefaultProjectTrustChange: (defaultProjectTrust: DefaultProjectTrust) => void; @@ -676,9 +678,19 @@ export class SettingsSelectorComponent extends Container { values: ["0", "1", "2", "3"], }); - // Autocomplete max visible toggle (insert after editor-padding) + // Output padding toggle (insert after editor-padding) const editorPaddingIndex = items.findIndex((item) => item.id === "editor-padding"); items.splice(editorPaddingIndex + 1, 0, { + id: "output-padding", + label: "Output padding", + description: "Horizontal padding for assistant messages and thinking", + currentValue: String(config.outputPad), + values: ["0", "1"], + }); + + // Autocomplete max visible toggle (insert after output-padding) + const outputPaddingIndex = items.findIndex((item) => item.id === "output-padding"); + items.splice(outputPaddingIndex + 1, 0, { id: "autocomplete-max-visible", label: "Autocomplete max items", description: "Max visible items in autocomplete dropdown (3-20)", @@ -782,6 +794,9 @@ export class SettingsSelectorComponent extends Container { case "editor-padding": callbacks.onEditorPaddingXChange(parseInt(newValue, 10)); break; + case "output-padding": + callbacks.onOutputPadChange(newValue === "0" ? 0 : 1); + break; case "autocomplete-max-visible": callbacks.onAutocompleteMaxVisibleChange(parseInt(newValue, 10)); break; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 3945f3044..a9f0f8152 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -321,6 +321,7 @@ export class InteractiveMode { // Thinking block visibility state private hideThinkingBlock = false; + private outputPad = 1; // Skill commands: command name -> skill file path private skillCommands = new Map(); @@ -429,6 +430,7 @@ export class InteractiveMode { // Load hide thinking block setting this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); + this.outputPad = this.settingsManager.getOutputPad(); // Register themes from resource loader and initialize setRegisteredThemes(this.session.resourceLoader.getThemes().themes); @@ -1624,6 +1626,7 @@ export class InteractiveMode { this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled); this.footerDataProvider.setCwd(this.sessionManager.getCwd()); this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); + this.outputPad = this.settingsManager.getOutputPad(); this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor()); const clearOnShrink = this.settingsManager.getClearOnShrink(); this.ui.setClearOnShrink(clearOnShrink); @@ -2803,6 +2806,7 @@ export class InteractiveMode { this.hideThinkingBlock, this.getMarkdownThemeWithSettings(), this.hiddenThinkingLabel, + this.outputPad, ); this.streamingMessage = event.message; this.chatContainer.addChild(this.streamingComponent); @@ -3143,6 +3147,7 @@ export class InteractiveMode { this.hideThinkingBlock, this.getMarkdownThemeWithSettings(), this.hiddenThinkingLabel, + this.outputPad, ); this.chatContainer.addChild(assistantComponent); break; @@ -3959,6 +3964,7 @@ export class InteractiveMode { showHardwareCursor: this.settingsManager.getShowHardwareCursor(), defaultProjectTrust: this.settingsManager.getDefaultProjectTrust(), editorPaddingX: this.settingsManager.getEditorPaddingX(), + outputPad: this.settingsManager.getOutputPad(), autocompleteMaxVisible: this.settingsManager.getAutocompleteMaxVisible(), quietStartup: this.settingsManager.getQuietStartup(), clearOnShrink: this.settingsManager.getClearOnShrink(), @@ -4061,6 +4067,19 @@ export class InteractiveMode { this.editor.setPaddingX(padding); } }, + onOutputPadChange: (padding) => { + this.settingsManager.setOutputPad(padding); + this.outputPad = padding; + for (const child of this.chatContainer.children) { + if (child instanceof AssistantMessageComponent) { + child.setOutputPad(padding); + } + } + if (this.streamingComponent) { + this.streamingComponent.setOutputPad(padding); + } + this.ui.requestRender(); + }, onAutocompleteMaxVisibleChange: (maxVisible) => { this.settingsManager.setAutocompleteMaxVisible(maxVisible); this.defaultEditor.setAutocompleteMaxVisible(maxVisible); @@ -5043,6 +5062,7 @@ export class InteractiveMode { return; } this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); + this.outputPad = this.settingsManager.getOutputPad(); this.rebuildChatFromMessages(); chatRestoredBeforeSessionStart = true; }; diff --git a/packages/coding-agent/test/assistant-message.test.ts b/packages/coding-agent/test/assistant-message.test.ts index c2e74daab..5ad1cc632 100644 --- a/packages/coding-agent/test/assistant-message.test.ts +++ b/packages/coding-agent/test/assistant-message.test.ts @@ -2,6 +2,7 @@ import type { AssistantMessage } from "@earendil-works/pi-ai"; import { describe, expect, test } from "vitest"; import { AssistantMessageComponent } from "../src/modes/interactive/components/assistant-message.ts"; import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../src/utils/ansi.ts"; const OSC133_ZONE_START = "\x1b]133;A\x07"; const OSC133_ZONE_END = "\x1b]133;B\x07"; @@ -71,4 +72,28 @@ describe("AssistantMessageComponent", () => { expect(rendered).toContain("maximum output token limit"); expect(rendered).toContain("response may be incomplete"); }); + + test("uses configured output padding for text and thinking", () => { + initTheme("dark"); + + const component = new AssistantMessageComponent( + createAssistantMessage([ + { type: "text", text: "hello" }, + { type: "thinking", thinking: "reasoning" }, + ]), + false, + undefined, + "Thinking...", + 1, + ); + const lines = component.render(80).map((line) => stripAnsi(line)); + + expect(lines.some((line) => line.includes(" hello"))).toBe(true); + expect(lines.some((line) => line.includes(" reasoning"))).toBe(true); + + component.setOutputPad(0); + const updatedLines = component.render(80).map((line) => stripAnsi(line)); + expect(updatedLines.some((line) => line.startsWith("hello"))).toBe(true); + expect(updatedLines.some((line) => line.startsWith("reasoning"))).toBe(true); + }); }); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 7e5928749..2642b0d67 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -397,6 +397,29 @@ describe("SettingsManager", () => { }); }); + describe("outputPad", () => { + it("should default to 1 and persist binary values", async () => { + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getOutputPad()).toBe(1); + + manager.setOutputPad(0); + await manager.flush(); + + expect(manager.getOutputPad()).toBe(0); + const savedSettings = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8")); + expect(savedSettings.outputPad).toBe(0); + }); + + it("should treat unsupported outputPad values as default padding", () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ outputPad: 2 })); + + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getOutputPad()).toBe(1); + }); + }); + describe("shellCommandPrefix", () => { it("should load shellCommandPrefix from settings", () => { const settingsPath = join(agentDir, "settings.json"); diff --git a/packages/coding-agent/test/suite/regressions/5943-session-start-notify.test.ts b/packages/coding-agent/test/suite/regressions/5943-session-start-notify.test.ts index 2c8d88e00..b020205d1 100644 --- a/packages/coding-agent/test/suite/regressions/5943-session-start-notify.test.ts +++ b/packages/coding-agent/test/suite/regressions/5943-session-start-notify.test.ts @@ -97,6 +97,7 @@ type ReloadCommandContext = { settingsManager: { getHttpIdleTimeoutMs: () => number; getHideThinkingBlock: () => boolean; + getOutputPad: () => 0 | 1; getEditorPaddingX: () => number; getAutocompleteMaxVisible: () => number; getShowHardwareCursor: () => boolean; @@ -168,6 +169,7 @@ function createReloadCommandContext(overrides: ReloadCommandContextOverrides = { settingsManager: { getHttpIdleTimeoutMs: () => 0, getHideThinkingBlock: () => false, + getOutputPad: () => 1, getEditorPaddingX: () => 1, getAutocompleteMaxVisible: () => 10, getShowHardwareCursor: () => false,