fix(coding-agent): apply outputPad to all transcript blocks (#10557)

This commit is contained in:
Ramiz Wachtler 2026-10-06 17:27:55 +02:00 • committed by GitHub
parent 76f6c06daf
commit 18336987ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 223 additions and 49 deletions

View file

@ -5,11 +5,17 @@
### Added
- Added `+name` and `-name` entries to `--tools`, which change the default tool selection instead of replacing it, for example `pi -t +codemode`
- Added `outputPad` to the tool render context ([#10557](https://github.com/earendil-works/pi/pull/10557) by [@rwachtler](https://github.com/rwachtler))
### Changed
- Changed `outputPad` to also apply to `!` command output, tool output, and summary blocks ([#9946](https://github.com/earendil-works/pi/issues/9946), [#10557](https://github.com/earendil-works/pi/pull/10557) by [@rwachtler](https://github.com/rwachtler))
### Fixed
- Fixed managed installs keeping every old release; `pi update` now keeps only the new release and the one it updated from ([#10392](https://github.com/earendil-works/pi/issues/10392))
- Fixed standalone binaries loading `.env`, `.env.local`, and `.env.development` from the launch directory into Pi's environment ([#10473](https://github.com/earendil-works/pi/issues/10473))
- Fixed `!!` command headers losing their dim color once output arrives ([#10557](https://github.com/earendil-works/pi/pull/10557) by [@rwachtler](https://github.com/rwachtler))
## [1.0.4] - 2026-10-05

View file

@ -97,7 +97,7 @@ See [Compaction Reference](compaction.md) for trigger, summarization, and valida
| `fullscreenCopyOnSelect` | boolean | `true` | Copy selected text automatically in fullscreen mode. |
| `fullscreenWheelScrollLines` | `"auto"` \| number | `"auto"` | Lines per mouse-wheel event in fullscreen mode, from 1 to 100. `"auto"` moves one line per event in local macOS terminals, which already accelerate wheel and trackpad input; elsewhere, and over SSH, it speeds up fast wheel spins to at most 6 lines per event. Alt+wheel moves five times as far. |
| `editorPaddingX` | number | `0` | Horizontal editor padding from 0 to 3 cells. |
| `outputPad` | `0 \| 1` | `1` | Horizontal transcript padding. |
| `outputPad` | `0 \| 1` | `1` | Horizontal transcript padding for messages, tool output, `!` command output, and summary blocks. |
| `autocompleteMaxVisible` | number | `5` | Visible autocomplete entries, from 3 to 20. |
| `showHardwareCursor` | boolean | `false` | Show the terminal cursor while Pi positions it for input methods. |
| `terminal.showImages` | boolean | `true` | Display inline images when supported. |

View file

@ -92,6 +92,7 @@ export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRend
expanded,
showImages: false,
isError,
outputPad: 1,
};
};

View file

@ -489,6 +489,8 @@ export interface ToolRenderContext<TState = any, TArgs = any> {
showImages: boolean;
/** Whether the current result is an error. */
isError: boolean;
/** Horizontal padding configured by the outputPad setting. Renderers with `renderShell: "self"` apply it themselves. */
outputPad: number;
}
/**

View file

@ -170,7 +170,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 chat message output (default: 1)
outputPad?: 0 | 1; // Horizontal padding for transcript content (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;

View file

@ -133,8 +133,10 @@ function buildEditCallComponent(
args: RenderableEditArgs | undefined,
theme: Theme,
cwd: string,
outputPad: number,
): EditCallRenderComponent {
component.setBgFn(getEditHeaderBg(component.preview, component.settledError, theme));
component.setPaddingX(outputPad);
component.clear();
component.addChild(new Text(formatEditCall(args, theme, cwd), 0, 0));
@ -192,7 +194,13 @@ export const editRenderers: Pick<ToolDefinition<any, any>, "renderCall" | "rende
});
}
return buildEditCallComponent(component, args as RenderableEditArgs | undefined, theme, context.cwd);
return buildEditCallComponent(
component,
args as RenderableEditArgs | undefined,
theme,
context.cwd,
context.outputPad,
);
},
renderResult(result, _options, theme, context) {
const callComponent = context.state.callComponent;
@ -215,7 +223,13 @@ export const editRenderers: Pick<ToolDefinition<any, any>, "renderCall" | "rende
changed = true;
}
if (changed) {
buildEditCallComponent(callComponent, context.args as RenderableEditArgs | undefined, theme, context.cwd);
buildEditCallComponent(
callComponent,
context.args as RenderableEditArgs | undefined,
theme,
context.cwd,
context.outputPad,
);
}
}
@ -232,7 +246,7 @@ export const editRenderers: Pick<ToolDefinition<any, any>, "renderCall" | "rende
return component;
}
component.addChild(new Spacer(1));
component.addChild(new Text(output, 1, 0));
component.addChild(new Text(output, context.outputPad, 0));
return component;
},
};

View file

@ -28,14 +28,16 @@ export class BashExecutionComponent extends Container {
private fullOutputPath?: string;
private expanded = false;
private contentContainer: Container;
/** `dim` marks `!!` commands, whose output is excluded from the model context. */
private readonly colorKey: "dim" | "bashMode";
private outputPad: number;
constructor(command: string, ui: TUI, excludeFromContext = false) {
constructor(command: string, ui: TUI, excludeFromContext = false, outputPad = 1) {
super();
this.command = command;
// Use dim border for excluded-from-context commands (!! prefix)
const colorKey = excludeFromContext ? "dim" : "bashMode";
const borderColor = (str: string) => theme.fg(colorKey, str);
this.colorKey = excludeFromContext ? "dim" : "bashMode";
this.outputPad = outputPad;
const borderColor = (str: string) => theme.fg(this.colorKey, str);
// Add spacer
this.addChild(new Spacer(1));
@ -47,21 +49,17 @@ export class BashExecutionComponent extends Container {
this.contentContainer = new Container();
this.addChild(this.contentContainer);
// Command header
const header = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0);
this.contentContainer.addChild(header);
// Loader
this.loader = new Loader(
ui,
(spinner) => theme.fg(colorKey, spinner),
(spinner) => theme.fg(this.colorKey, spinner),
(text) => theme.fg("muted", text),
`Running... (${keyText("tui.select.cancel")} to cancel)`, // Plain text for loader
);
this.contentContainer.addChild(this.loader);
// Bottom border
this.addChild(new DynamicBorder(borderColor));
this.updateDisplay();
}
/**
@ -72,6 +70,11 @@ export class BashExecutionComponent extends Container {
this.updateDisplay();
}
setOutputPad(outputPad: number): void {
this.outputPad = outputPad;
this.updateDisplay();
}
override invalidate(): void {
super.invalidate();
this.updateDisplay();
@ -135,7 +138,7 @@ export class BashExecutionComponent extends Container {
this.contentContainer.clear();
// Command header
const header = new Text(theme.fg("bashMode", theme.bold(`$ ${this.command}`)), 1, 0);
const header = new Text(theme.fg(this.colorKey, theme.bold(`$ ${this.command}`)), this.outputPad, 0);
this.contentContainer.addChild(header);
// Output
@ -143,7 +146,7 @@ export class BashExecutionComponent extends Container {
if (this.expanded) {
// Show all lines
const displayText = availableLines.map((line) => theme.fg("muted", line)).join("\n");
this.contentContainer.addChild(new Text(`\n${displayText}`, 1, 0));
this.contentContainer.addChild(new Text(`\n${displayText}`, this.outputPad, 0));
} else {
// Use shared visual truncation utility with width-aware caching
const styledOutput = previewLogicalLines.map((line) => theme.fg("muted", line)).join("\n");
@ -153,7 +156,7 @@ export class BashExecutionComponent extends Container {
this.contentContainer.addChild({
render: (width: number) => {
if (cachedLines === undefined || cachedWidth !== width) {
const result = truncateToVisualLines(styledInput, PREVIEW_LINES, width, 1);
const result = truncateToVisualLines(styledInput, PREVIEW_LINES, width, this.outputPad);
cachedLines = result.visualLines;
cachedWidth = width;
}
@ -199,7 +202,7 @@ export class BashExecutionComponent extends Container {
}
if (statusParts.length > 0) {
this.contentContainer.addChild(new Text(`\n${statusParts.join("\n")}`, 1, 0));
this.contentContainer.addChild(new Text(`\n${statusParts.join("\n")}`, this.outputPad, 0));
}
}
}

View file

@ -12,8 +12,8 @@ export class BranchSummaryMessageComponent extends Box {
private message: BranchSummaryMessage;
private markdownTheme: MarkdownTheme;
constructor(message: BranchSummaryMessage, markdownTheme: MarkdownTheme = getMarkdownTheme()) {
super(1, 1, (t) => theme.bg("customMessageBg", t));
constructor(message: BranchSummaryMessage, markdownTheme: MarkdownTheme = getMarkdownTheme(), outputPad = 1) {
super(outputPad, 1, (t) => theme.bg("customMessageBg", t));
this.message = message;
this.markdownTheme = markdownTheme;
this.updateDisplay();
@ -24,6 +24,10 @@ export class BranchSummaryMessageComponent extends Box {
this.updateDisplay();
}
setOutputPad(outputPad: number): void {
this.setPaddingX(outputPad);
}
override invalidate(): void {
super.invalidate();
this.updateDisplay();

View file

@ -12,8 +12,8 @@ export class CompactionSummaryMessageComponent extends Box {
private message: CompactionSummaryMessage;
private markdownTheme: MarkdownTheme;
constructor(message: CompactionSummaryMessage, markdownTheme: MarkdownTheme = getMarkdownTheme()) {
super(1, 1, (t) => theme.bg("customMessageBg", t));
constructor(message: CompactionSummaryMessage, markdownTheme: MarkdownTheme = getMarkdownTheme(), outputPad = 1) {
super(outputPad, 1, (t) => theme.bg("customMessageBg", t));
this.message = message;
this.markdownTheme = markdownTheme;
this.updateDisplay();
@ -24,6 +24,10 @@ export class CompactionSummaryMessageComponent extends Box {
this.updateDisplay();
}
setOutputPad(outputPad: number): void {
this.setPaddingX(outputPad);
}
override invalidate(): void {
super.invalidate();
this.updateDisplay();

View file

@ -13,11 +13,13 @@ export class CustomEntryComponent extends Container {
private renderer: EntryRenderer;
private customComponent?: Component;
private _expanded = false;
private outputPad: number;
constructor(entry: CustomEntry<unknown>, renderer: EntryRenderer) {
constructor(entry: CustomEntry<unknown>, renderer: EntryRenderer, outputPad = 1) {
super();
this.entry = entry;
this.renderer = renderer;
this.outputPad = outputPad;
this.rebuild();
}
@ -32,6 +34,11 @@ export class CustomEntryComponent extends Container {
}
}
setOutputPad(outputPad: number): void {
this.outputPad = outputPad;
this.rebuild();
}
override invalidate(): void {
super.invalidate();
this.rebuild();
@ -46,7 +53,7 @@ export class CustomEntryComponent extends Container {
component = this.renderer(this.entry, { expanded: this._expanded }, theme);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
const box = new Box(this.outputPad, 1, (text) => theme.bg("customMessageBg", text));
box.addChild(new Text(theme.fg("error", `[${this.entry.customType}] renderer failed: ${message}`), 0, 0));
component = box;
}

View file

@ -87,6 +87,7 @@ export class CustomMessageComponent extends Container {
// Default rendering uses our box
this.addChild(this.box);
this.box.clear();
this.box.setPaddingX(this.outputPad);
// Default rendering: label + content
const label = theme.fg("customMessageLabel", `\x1b[1m[${this.message.customType}]\x1b[22m`);

View file

@ -827,7 +827,7 @@ export class SettingsSelectorComponent extends Container {
items.splice(editorPaddingIndex + 1, 0, {
id: "output-padding",
label: "Output padding",
description: "Horizontal padding for user messages, assistant messages, and thinking",
description: "Horizontal padding for messages, tool output, and command output",
currentValue: String(config.outputPad),
values: ["0", "1"],
});

View file

@ -13,8 +13,8 @@ export class SkillInvocationMessageComponent extends Box {
private skillBlock: ParsedSkillBlock;
private markdownTheme: MarkdownTheme;
constructor(skillBlock: ParsedSkillBlock, markdownTheme: MarkdownTheme = getMarkdownTheme()) {
super(1, 1, (t) => theme.bg("customMessageBg", t));
constructor(skillBlock: ParsedSkillBlock, markdownTheme: MarkdownTheme = getMarkdownTheme(), outputPad = 1) {
super(outputPad, 1, (t) => theme.bg("customMessageBg", t));
this.skillBlock = skillBlock;
this.markdownTheme = markdownTheme;
this.updateDisplay();
@ -25,6 +25,10 @@ export class SkillInvocationMessageComponent extends Box {
this.updateDisplay();
}
setOutputPad(outputPad: number): void {
this.setPaddingX(outputPad);
}
override invalidate(): void {
super.invalidate();
this.updateDisplay();

View file

@ -25,6 +25,7 @@ const FALLBACK_PREVIEW_LINES = 10;
export interface ToolExecutionOptions {
showImages?: boolean;
imageWidthCells?: number;
outputPad?: number;
}
export class ToolExecutionComponent extends Container {
@ -46,6 +47,7 @@ export class ToolExecutionComponent extends Container {
private expanded = false;
private showImages: boolean;
private imageWidthCells: number;
private outputPad: number;
private isPartial = true;
private toolDefinition?: ToolRenderers;
private ui: TUI;
@ -75,6 +77,7 @@ export class ToolExecutionComponent extends Container {
this.toolDefinition = toolDefinition;
this.showImages = options.showImages ?? true;
this.imageWidthCells = options.imageWidthCells ?? 60;
this.outputPad = options.outputPad ?? 1;
this.ui = ui;
this.cwd = cwd;
@ -130,6 +133,7 @@ export class ToolExecutionComponent extends Container {
expanded: this.expanded,
showImages: this.showImages,
isError: this.result?.isError ?? false,
outputPad: this.outputPad,
};
}
@ -196,6 +200,11 @@ export class ToolExecutionComponent extends Container {
this.updateDisplay();
}
setOutputPad(outputPad: number): void {
this.outputPad = outputPad;
this.updateDisplay();
}
setShowImages(show: boolean): void {
this.showImages = show;
this.updateDisplay();
@ -267,6 +276,7 @@ export class ToolExecutionComponent extends Container {
const renderContainer = this.getRenderShell() === "self" ? this.selfRenderContainer : this.contentBox;
if (renderContainer instanceof Box) {
renderContainer.setBgFn(bgFn);
renderContainer.setPaddingX(this.outputPad);
}
renderContainer.clear();
@ -318,6 +328,7 @@ export class ToolExecutionComponent extends Container {
}
} else {
this.contentText.setCustomBgFn(bgFn);
this.contentText.setPaddingX(this.outputPad);
this.contentText.setText(this.formatToolExecution());
hasContent = true;
}

View file

@ -3515,6 +3515,7 @@ export class InteractiveMode {
{
showImages: this.settingsManager.getShowImages(),
imageWidthCells: this.settingsManager.getImageWidthCells(),
outputPad: this.outputPad,
},
this.getRegisteredToolDefinition(content.name),
this.ui,
@ -3593,6 +3594,7 @@ export class InteractiveMode {
{
showImages: this.settingsManager.getShowImages(),
imageWidthCells: this.settingsManager.getImageWidthCells(),
outputPad: this.outputPad,
},
this.getRegisteredToolDefinition(event.toolName),
this.ui,
@ -3825,7 +3827,7 @@ export class InteractiveMode {
if (!renderer) {
return;
}
const component = new CustomEntryComponent(entry, renderer);
const component = new CustomEntryComponent(entry, renderer, this.outputPad);
component.setExpanded(this.toolOutputExpanded);
if (!component.hasContent()) {
return;
@ -3845,7 +3847,12 @@ export class InteractiveMode {
private addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void {
switch (message.role) {
case "bashExecution": {
const component = new BashExecutionComponent(message.command, this.ui, message.excludeFromContext);
const component = new BashExecutionComponent(
message.command,
this.ui,
message.excludeFromContext,
this.outputPad,
);
if (message.output) {
component.appendOutput(message.output);
}
@ -3874,14 +3881,22 @@ export class InteractiveMode {
}
case "compactionSummary": {
this.chatContainer.addChild(new Spacer(1));
const component = new CompactionSummaryMessageComponent(message, this.getMarkdownThemeWithSettings());
const component = new CompactionSummaryMessageComponent(
message,
this.getMarkdownThemeWithSettings(),
this.outputPad,
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
break;
}
case "branchSummary": {
this.chatContainer.addChild(new Spacer(1));
const component = new BranchSummaryMessageComponent(message, this.getMarkdownThemeWithSettings());
const component = new BranchSummaryMessageComponent(
message,
this.getMarkdownThemeWithSettings(),
this.outputPad,
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
break;
@ -3900,6 +3915,7 @@ export class InteractiveMode {
const component = new SkillInvocationMessageComponent(
skillBlock,
this.getMarkdownThemeWithSettings(),
this.outputPad,
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
@ -3996,6 +4012,7 @@ export class InteractiveMode {
{
showImages: this.settingsManager.getShowImages(),
imageWidthCells: this.settingsManager.getImageWidthCells(),
outputPad: this.outputPad,
},
this.getRegisteredToolDefinition(content.name),
this.ui,
@ -5040,23 +5057,14 @@ export class InteractiveMode {
onOutputPadChange: (padding) => {
this.settingsManager.setOutputPad(padding);
this.outputPad = padding;
if (this.streamingComponent || this.session.isStreaming) {
for (const child of this.chatContainer.children) {
if (
child instanceof AssistantMessageComponent ||
child instanceof CustomMessageComponent ||
child instanceof UserMessageComponent
) {
for (const container of [this.chatContainer, this.pendingMessagesContainer]) {
for (const child of container.children) {
if ("setOutputPad" in child && typeof child.setOutputPad === "function") {
child.setOutputPad(padding);
}
}
if (this.streamingComponent) {
this.streamingComponent.setOutputPad(padding);
}
this.ui.requestRender();
return;
}
this.rebuildChatFromMessages();
this.ui.requestRender();
},
onAutocompleteMaxVisibleChange: (maxVisible) => {
this.settingsManager.setAutocompleteMaxVisible(maxVisible);
@ -6971,7 +6979,7 @@ export class InteractiveMode {
const result = eventResult.result;
// Create UI component for display
this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext);
this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext, this.outputPad);
if (this.session.isStreaming) {
this.pendingMessagesContainer.addChild(this.bashComponent);
this.pendingBashComponents.push(this.bashComponent);
@ -6999,7 +7007,7 @@ export class InteractiveMode {
// Normal execution path (possibly with custom operations)
const isDeferred = this.session.isStreaming;
this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext);
this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext, this.outputPad);
if (isDeferred) {
// Show in pending area when agent is streaming

View file

@ -26,6 +26,7 @@ function render(
expanded,
showImages: false,
isError,
outputPad: 1,
} satisfies ToolRenderContext;
const component = codemodeRenderers.renderResult?.(
result,

View file

@ -0,0 +1,94 @@
import { type Component, TuiMainScreen } from "@earendil-works/pi-tui";
import { Type } from "typebox";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts";
import type { ToolDefinition } from "../src/core/extensions/types.ts";
import { createEditToolDefinition } from "../src/core/tools/edit.ts";
import { BashExecutionComponent } from "../src/modes/interactive/components/bash-execution.ts";
import { CompactionSummaryMessageComponent } from "../src/modes/interactive/components/compaction-summary-message.ts";
import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../src/utils/ansi.ts";
const ui = new TuiMainScreen(new VirtualTerminal(80, 24));
const tool: ToolDefinition = {
name: "custom_tool",
label: "custom_tool",
description: "custom tool",
parameters: Type.Any(),
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
};
type OutputPaddedComponent = Component & { setOutputPad(outputPad: number): void };
/** Text lines without ANSI codes or trailing fill. Blank lines and full-width borders are skipped. */
function renderLines(component: Component): string[] {
return component
.render(60)
.map((line) => stripAnsi(line).trimEnd())
.filter((line) => /[\w$(]/.test(line));
}
function createTool(definition: ToolDefinition | undefined, outputPad: number): ToolExecutionComponent {
const component = new ToolExecutionComponent("custom_tool", "id", {}, { outputPad }, definition, ui, "/");
component.updateResult({ content: [{ type: "text", text: "ok" }], isError: false });
return component;
}
const components: Array<{ name: string; create: (outputPad: number) => OutputPaddedComponent }> = [
{
name: "bash execution",
create: (outputPad) => {
const component = new BashExecutionComponent("pwd", ui, false, outputPad);
component.appendOutput("/tmp");
component.setComplete(1, false);
return component;
},
},
{ name: "tool execution", create: (outputPad) => createTool(tool, outputPad) },
{ name: "tool execution without a definition", create: (outputPad) => createTool(undefined, outputPad) },
{
name: "self-rendered edit result",
create: (outputPad) => {
const component = new ToolExecutionComponent(
"edit",
"id",
{ path: "file.txt", edits: [{ oldText: "old", newText: "new" }] },
{ outputPad },
createEditToolDefinition("/"),
ui,
"/",
);
component.updateResult({ content: [{ type: "text", text: "Could not find old text" }], isError: true });
return component;
},
},
{
name: "compaction summary",
create: (outputPad) =>
new CompactionSummaryMessageComponent(
{ role: "compactionSummary", summary: "summary", tokensBefore: 10, timestamp: 0 },
undefined,
outputPad,
),
},
];
describe("outputPad", () => {
beforeAll(() => {
initTheme("dark");
});
afterAll(() => {
ui.stop();
});
test.each(components)("$name renders at outputPad 0 and 1", ({ create }) => {
const component = create(0);
const lines = renderLines(component);
expect(lines.filter((line) => line.startsWith(" "))).toEqual([]);
component.setOutputPad(1);
expect(renderLines(component)).toEqual(lines.map((line) => ` ${line}`));
});
});

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added `Box.setPaddingX(...)` and `Text.setPaddingX(...)` ([#10557](https://github.com/earendil-works/pi/pull/10557) by [@rwachtler](https://github.com/rwachtler))
## [1.0.4] - 2026-10-05
## [1.0.3] - 2026-10-05

View file

@ -50,6 +50,11 @@ export class Box implements Component {
// Don't invalidate here - we'll detect bgFn changes by sampling output
}
setPaddingX(paddingX: number): void {
this.paddingX = paddingX;
this.invalidateCache();
}
private invalidateCache(): void {
this.cache = undefined;
}

View file

@ -36,6 +36,11 @@ export class Text implements Component {
this.cachedLines = undefined;
}
setPaddingX(paddingX: number): void {
this.paddingX = paddingX;
this.invalidate();
}
invalidate(): void {
this.cachedText = undefined;
this.cachedWidth = undefined;