feat(coding-agent): add configurable chat padding

This commit is contained in:
Armin Ronacher 2026-06-27 08:24:56 +02:00
parent 1d48616328
commit 5a64f09b41
22 changed files with 292 additions and 88 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added `transcriptIndent` and `toolIndent` settings to configure interactive transcript and tool/message indentation.
### Fixed
- Fixed `--session` and `SessionManager.open()` to reject non-empty invalid session files without overwriting them ([#6002](https://github.com/earendil-works/pi/issues/6002)).

View file

@ -2074,6 +2074,7 @@ pi.registerTool({
- `lastComponent` - the previously returned component for that slot, if any
- `invalidate()` - request a rerender of this tool row
- `toolCallId`, `cwd`, `executionStarted`, `argsComplete`, `isPartial`, `expanded`, `showImages`, `isError`
- `toolIndent` - current tool/message indentation in terminal columns; default shells apply it automatically, self-shell renderers can opt in (use `context.toolIndent ?? 1`)
Use `context.state` for cross-slot shared state. Keep slot-local caches on the returned component instance when you want to reuse and mutate the same component across renders.
@ -2154,6 +2155,7 @@ Custom editors and `ctx.ui.custom()` components receive `keybindings: Keybinding
#### Best Practices
- Use `Text` with padding `(0, 0)`. The default Box handles padding.
- Use `context.toolIndent` only when `renderShell: "self"` needs to align with the user's tool indentation setting.
- Use `\n` for multi-line content.
- Handle `isPartial` for streaming progress.
- Support `expanded` for detail on demand.

View file

@ -60,6 +60,8 @@ 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) |
| `transcriptIndent` | number | `1` | Horizontal padding for assistant transcript text (0-4) |
| `toolIndent` | number | `1` | Horizontal padding for tool and message blocks (0-4) |
| `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 |

View file

@ -3034,6 +3034,7 @@ export class AgentSession {
getToolDefinition: (name) => this.getToolDefinition(name),
theme,
cwd: this.sessionManager.getCwd(),
toolIndent: this.settingsManager.getToolIndent(),
});
return await exportSessionToHtml(this.sessionManager, this.state, {

View file

@ -20,6 +20,8 @@ export interface ToolHtmlRendererDeps {
cwd: string;
/** Terminal width for rendering (default: 100) */
width?: number;
/** Tool indentation to expose to custom tool renderers (default: 1) */
toolIndent?: number;
}
export interface ToolHtmlRenderer {
@ -56,7 +58,7 @@ function trimRenderedResultLines(lines: string[]): string[] {
}
export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRenderer {
const { getToolDefinition, theme, cwd, width = 100 } = deps;
const { getToolDefinition, theme, cwd, width = 100, toolIndent = 1 } = deps;
const renderedCallComponents = new Map<string, Component>();
const renderedResultComponents = new Map<string, Component>();
@ -91,6 +93,7 @@ export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRend
isPartial,
expanded,
showImages: false,
toolIndent,
isError,
};
};

View file

@ -425,6 +425,8 @@ export interface ToolRenderContext<TState = any, TArgs = any> {
expanded: boolean;
/** Whether inline images are currently shown in the TUI. */
showImages: boolean;
/** Current tool/message indentation in terminal columns. Core-owned shells apply it automatically. */
toolIndent?: number;
/** Whether the current result is an error. */
isError: boolean;
}

View file

@ -62,6 +62,15 @@ export type DefaultProjectTrust = "ask" | "always" | "never";
export type TransportSetting = Transport;
export const INDENT_VALUES = [0, 1, 2, 3, 4] as const;
function normalizeIndent(value: unknown): number {
if (typeof value !== "number" || !Number.isInteger(value)) {
return 1;
}
return INDENT_VALUES.includes(value as (typeof INDENT_VALUES)[number]) ? value : 1;
}
/**
* Package source for npm/git packages.
* - String form: load all resources from the package
@ -114,6 +123,8 @@ export interface Settings {
editorPaddingX?: number; // Horizontal padding for input editor (default: 0)
autocompleteMaxVisible?: number; // Max visible items in autocomplete dropdown (default: 5)
showHardwareCursor?: boolean; // Show terminal cursor while still positioning it for IME
transcriptIndent?: number; // Horizontal padding for assistant transcript text (default: 1; allowed: 0-4)
toolIndent?: number; // Horizontal padding for tool and message blocks (default: 1; allowed: 0-4)
markdown?: MarkdownSettings;
warnings?: WarningSettings;
sessionDir?: string; // Custom session storage directory (same format as --session-dir CLI flag)
@ -1169,6 +1180,26 @@ export class SettingsManager {
this.save();
}
getTranscriptIndent(): number {
return normalizeIndent(this.settings.transcriptIndent);
}
setTranscriptIndent(indent: number): void {
this.globalSettings.transcriptIndent = normalizeIndent(indent);
this.markModified("transcriptIndent");
this.save();
}
getToolIndent(): number {
return normalizeIndent(this.settings.toolIndent);
}
setToolIndent(indent: number): void {
this.globalSettings.toolIndent = normalizeIndent(indent);
this.markModified("toolIndent");
this.save();
}
getAutocompleteMaxVisible(): number {
return this.settings.autocompleteMaxVisible ?? 5;
}

View file

@ -144,8 +144,8 @@ type EditCallRenderComponent = Box & {
settledError?: boolean;
};
function createEditCallRenderComponent(): EditCallRenderComponent {
return Object.assign(new Box(1, 1, (text: string) => text), {
function createEditCallRenderComponent(toolIndent: number): EditCallRenderComponent {
return Object.assign(new Box(toolIndent, 1, (text: string) => text), {
preview: undefined as EditPreview | undefined,
previewArgsKey: undefined as string | undefined,
previewPending: false,
@ -153,7 +153,11 @@ function createEditCallRenderComponent(): EditCallRenderComponent {
});
}
function getEditCallRenderComponent(state: EditRenderState, lastComponent: unknown): EditCallRenderComponent {
function getEditCallRenderComponent(
state: EditRenderState,
lastComponent: unknown,
toolIndent: number,
): EditCallRenderComponent {
if (lastComponent instanceof Box) {
const component = lastComponent as EditCallRenderComponent;
state.callComponent = component;
@ -162,7 +166,7 @@ function getEditCallRenderComponent(state: EditRenderState, lastComponent: unkno
if (state.callComponent) {
return state.callComponent;
}
const component = createEditCallRenderComponent();
const component = createEditCallRenderComponent(toolIndent);
state.callComponent = component;
return component;
}
@ -361,7 +365,8 @@ export function createEditToolDefinition(
});
},
renderCall(args, theme, context) {
const component = getEditCallRenderComponent(context.state, context.lastComponent);
const toolIndent = context.toolIndent ?? 1;
const component = getEditCallRenderComponent(context.state, context.lastComponent, toolIndent);
const previewInput = getRenderablePreviewInput(args as RenderableEditArgs | undefined);
const argsKey = previewInput
? JSON.stringify({ path: previewInput.path, edits: previewInput.edits })
@ -426,7 +431,7 @@ export function createEditToolDefinition(
return component;
}
component.addChild(new Spacer(1));
component.addChild(new Text(output, 1, 0));
component.addChild(new Text(output, context.toolIndent ?? 1, 0));
return component;
},
};

View file

@ -68,9 +68,11 @@ export class ArminComponent implements Component {
private cachedWidth = 0;
private gridVersion = 0;
private cachedVersion = -1;
private toolIndent: number;
constructor(ui: TUI) {
constructor(ui: TUI, toolIndent = 1) {
this.ui = ui;
this.toolIndent = toolIndent;
this.effect = EFFECTS[Math.floor(Math.random() * EFFECTS.length)];
this.finalGrid = buildFinalGrid();
this.currentGrid = this.createEmptyGrid();
@ -88,20 +90,21 @@ export class ArminComponent implements Component {
return this.cachedLines;
}
const padding = 1;
const availableWidth = width - padding;
const padding = this.toolIndent;
const leftPadding = " ".repeat(padding);
const availableWidth = Math.max(0, width - padding);
this.cachedLines = this.currentGrid.map((row) => {
// Clip row to available width before applying color
const clipped = row.slice(0, availableWidth).join("");
const padRight = Math.max(0, width - padding - clipped.length);
return ` ${theme.fg("accent", clipped)}${" ".repeat(padRight)}`;
return `${leftPadding}${theme.fg("accent", clipped)}${" ".repeat(padRight)}`;
});
// Add "ARMIN SAYS HI" at the end
const message = "ARMIN SAYS HI";
const msgPadRight = Math.max(0, width - padding - message.length);
this.cachedLines.push(` ${theme.fg("accent", message)}${" ".repeat(msgPadRight)}`);
this.cachedLines.push(`${leftPadding}${theme.fg("accent", message)}${" ".repeat(msgPadRight)}`);
this.cachedWidth = width;
this.cachedVersion = this.gridVersion;

View file

@ -14,6 +14,7 @@ export class AssistantMessageComponent extends Container {
private hideThinkingBlock: boolean;
private markdownTheme: MarkdownTheme;
private hiddenThinkingLabel: string;
private transcriptIndent: number;
private lastMessage?: AssistantMessage;
private hasToolCalls = false;
@ -22,12 +23,14 @@ export class AssistantMessageComponent extends Container {
hideThinkingBlock = false,
markdownTheme: MarkdownTheme = getMarkdownTheme(),
hiddenThinkingLabel = "Thinking...",
transcriptIndent = 1,
) {
super();
this.hideThinkingBlock = hideThinkingBlock;
this.markdownTheme = markdownTheme;
this.hiddenThinkingLabel = hiddenThinkingLabel;
this.transcriptIndent = transcriptIndent;
// Container for text/thinking content
this.contentContainer = new Container();
@ -90,7 +93,9 @@ 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.transcriptIndent, 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.
@ -101,7 +106,7 @@ export class AssistantMessageComponent extends Container {
if (this.hideThinkingBlock) {
// Show static thinking label when hidden
this.contentContainer.addChild(
new Text(theme.italic(theme.fg("thinkingText", this.hiddenThinkingLabel)), 1, 0),
new Text(theme.italic(theme.fg("thinkingText", this.hiddenThinkingLabel)), this.transcriptIndent, 0),
);
if (hasVisibleContentAfter) {
this.contentContainer.addChild(new Spacer(1));
@ -109,7 +114,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.transcriptIndent, 0, this.markdownTheme, {
color: (text: string) => theme.fg("thinkingText", text),
italic: true,
}),
@ -134,7 +139,7 @@ export class AssistantMessageComponent extends Container {
"error",
"Error: Model stopped because it reached the maximum output token limit. The response may be incomplete.",
),
1,
this.transcriptIndent,
0,
),
);
@ -145,11 +150,11 @@ export class AssistantMessageComponent extends Container {
? message.errorMessage
: "Operation aborted";
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("error", abortMessage), 1, 0));
this.contentContainer.addChild(new Text(theme.fg("error", abortMessage), this.transcriptIndent, 0));
} else if (message.stopReason === "error") {
const errorMsg = message.errorMessage || "Unknown error";
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0));
this.contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), this.transcriptIndent, 0));
}
}
}

View file

@ -28,10 +28,12 @@ export class BashExecutionComponent extends Container {
private fullOutputPath?: string;
private expanded = false;
private contentContainer: Container;
private toolIndent: number;
constructor(command: string, ui: TUI, excludeFromContext = false) {
constructor(command: string, ui: TUI, excludeFromContext = false, toolIndent = 1) {
super();
this.command = command;
this.toolIndent = toolIndent;
// Use dim border for excluded-from-context commands (!! prefix)
const colorKey = excludeFromContext ? "dim" : "bashMode";
@ -48,7 +50,7 @@ export class BashExecutionComponent extends Container {
this.addChild(this.contentContainer);
// Command header
const header = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0);
const header = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), this.toolIndent, 0);
this.contentContainer.addChild(header);
// Loader
@ -135,7 +137,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("bashMode", theme.bold(`$ ${this.command}`)), this.toolIndent, 0);
this.contentContainer.addChild(header);
// Output
@ -143,7 +145,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.toolIndent, 0));
} else {
// Use shared visual truncation utility with width-aware caching
const styledOutput = previewLogicalLines.map((line) => theme.fg("muted", line)).join("\n");
@ -153,7 +155,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.toolIndent);
cachedLines = result.visualLines;
cachedWidth = width;
}
@ -199,7 +201,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.toolIndent, 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(), toolIndent = 1) {
super(toolIndent, 1, (t) => theme.bg("customMessageBg", t));
this.message = message;
this.markdownTheme = markdownTheme;
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(), toolIndent = 1) {
super(toolIndent, 1, (t) => theme.bg("customMessageBg", t));
this.message = message;
this.markdownTheme = markdownTheme;
this.updateDisplay();

View file

@ -21,6 +21,7 @@ export class CustomMessageComponent extends Container {
message: CustomMessage<unknown>,
customRenderer?: MessageRenderer,
markdownTheme: MarkdownTheme = getMarkdownTheme(),
toolIndent = 1,
) {
super();
this.message = message;
@ -30,7 +31,7 @@ export class CustomMessageComponent extends Container {
this.addChild(new Spacer(1));
// Create box with purple background (used for default rendering)
this.box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
this.box = new Box(toolIndent, 1, (t) => theme.bg("customMessageBg", t));
this.rebuild();
}

View file

@ -25,14 +25,17 @@ function loadImageBase64(): string | undefined {
}
export class EarendilAnnouncementComponent extends Container {
constructor() {
constructor(toolIndent = 1) {
super();
const addText = (content: string) => {
this.addChild(new Text(content, toolIndent, 0));
};
this.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
this.addChild(new Text(theme.bold(theme.fg("accent", "pi has joined Earendil")), 1, 0));
addText(theme.bold(theme.fg("accent", "pi has joined Earendil")));
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("muted", "Read the blog post:"), 1, 0));
this.addChild(new Text(theme.fg("mdLink", BLOG_URL), 1, 0));
addText(theme.fg("muted", "Read the blog post:"));
addText(theme.fg("mdLink", BLOG_URL));
this.addChild(new Spacer(1));
const imageBase64 = loadImageBase64();

View file

@ -13,7 +13,7 @@ import {
Text,
} from "@earendil-works/pi-tui";
import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts";
import type { DefaultProjectTrust, WarningSettings } from "../../../core/settings-manager.ts";
import { type DefaultProjectTrust, INDENT_VALUES, type WarningSettings } from "../../../core/settings-manager.ts";
import {
getSelectListTheme,
getSettingsListTheme,
@ -71,6 +71,8 @@ export interface SettingsConfig {
treeFilterMode: "default" | "no-tools" | "user-only" | "labeled-only" | "all";
showHardwareCursor: boolean;
editorPaddingX: number;
transcriptIndent: number;
toolIndent: number;
autocompleteMaxVisible: number;
quietStartup: boolean;
defaultProjectTrust: DefaultProjectTrust;
@ -100,6 +102,8 @@ export interface SettingsCallbacks {
onTreeFilterModeChange: (mode: "default" | "no-tools" | "user-only" | "labeled-only" | "all") => void;
onShowHardwareCursorChange: (enabled: boolean) => void;
onEditorPaddingXChange: (padding: number) => void;
onTranscriptIndentChange: (indent: number) => void;
onToolIndentChange: (indent: number) => void;
onAutocompleteMaxVisibleChange: (maxVisible: number) => void;
onQuietStartupChange: (enabled: boolean) => void;
onDefaultProjectTrustChange: (defaultProjectTrust: DefaultProjectTrust) => void;
@ -676,9 +680,29 @@ export class SettingsSelectorComponent extends Container {
values: ["0", "1", "2", "3"],
});
// Autocomplete max visible toggle (insert after editor-padding)
// Transcript indent toggle (insert after editor-padding)
const editorPaddingIndex = items.findIndex((item) => item.id === "editor-padding");
items.splice(editorPaddingIndex + 1, 0, {
id: "transcript-indent",
label: "Transcript indent",
description: "Horizontal padding for assistant transcript text (0-4)",
currentValue: String(config.transcriptIndent),
values: INDENT_VALUES.map((value) => String(value)),
});
// Tool indent toggle (insert after transcript-indent)
const transcriptIndentIndex = items.findIndex((item) => item.id === "transcript-indent");
items.splice(transcriptIndentIndex + 1, 0, {
id: "tool-indent",
label: "Tool indent",
description: "Horizontal padding for tool and message blocks (0-4)",
currentValue: String(config.toolIndent),
values: INDENT_VALUES.map((value) => String(value)),
});
// Autocomplete max visible toggle (insert after tool-indent)
const toolIndentIndex = items.findIndex((item) => item.id === "tool-indent");
items.splice(toolIndentIndex + 1, 0, {
id: "autocomplete-max-visible",
label: "Autocomplete max items",
description: "Max visible items in autocomplete dropdown (3-20)",
@ -782,6 +806,12 @@ export class SettingsSelectorComponent extends Container {
case "editor-padding":
callbacks.onEditorPaddingXChange(parseInt(newValue, 10));
break;
case "transcript-indent":
callbacks.onTranscriptIndentChange(parseInt(newValue, 10));
break;
case "tool-indent":
callbacks.onToolIndentChange(parseInt(newValue, 10));
break;
case "autocomplete-max-visible":
callbacks.onAutocompleteMaxVisibleChange(parseInt(newValue, 10));
break;

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(), toolIndent = 1) {
super(toolIndent, 1, (t) => theme.bg("customMessageBg", t));
this.skillBlock = skillBlock;
this.markdownTheme = markdownTheme;
this.updateDisplay();

View file

@ -8,6 +8,7 @@ import { theme } from "../theme/theme.ts";
export interface ToolExecutionOptions {
showImages?: boolean;
imageWidthCells?: number;
toolIndent?: number;
}
export class ToolExecutionComponent extends Container {
@ -25,6 +26,7 @@ export class ToolExecutionComponent extends Container {
private expanded = false;
private showImages: boolean;
private imageWidthCells: number;
private toolIndent: number;
private isPartial = true;
private toolDefinition?: ToolDefinition<any, any>;
private builtInToolDefinition?: ToolDefinition<any, any>;
@ -57,6 +59,7 @@ export class ToolExecutionComponent extends Container {
this.builtInToolDefinition = createAllToolDefinitions(cwd)[toolName as ToolName];
this.showImages = options.showImages ?? true;
this.imageWidthCells = options.imageWidthCells ?? 60;
this.toolIndent = options.toolIndent ?? 1;
this.ui = ui;
this.cwd = cwd;
@ -65,8 +68,8 @@ export class ToolExecutionComponent extends Container {
// Always create all shell variants. contentBox is used for default renderer-based composition.
// selfRenderContainer is used when the tool renders its own framing.
// contentText is reserved for generic fallback rendering when no tool definition exists.
this.contentBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text));
this.contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text));
this.contentBox = new Box(this.toolIndent, 1, (text: string) => theme.bg("toolPendingBg", text));
this.contentText = new Text("", this.toolIndent, 1, (text: string) => theme.bg("toolPendingBg", text));
this.selfRenderContainer = new Container();
if (this.hasRendererDefinition()) {
@ -128,6 +131,7 @@ export class ToolExecutionComponent extends Container {
isPartial: this.isPartial,
expanded: this.expanded,
showImages: this.showImages,
toolIndent: this.toolIndent,
isError: this.result?.isError ?? false,
};
}

View file

@ -11,9 +11,9 @@ const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
export class UserMessageComponent extends Container {
private contentBox: Box;
constructor(text: string, markdownTheme: MarkdownTheme = getMarkdownTheme()) {
constructor(text: string, markdownTheme: MarkdownTheme = getMarkdownTheme(), toolIndent = 1) {
super();
this.contentBox = new Box(1, 1, (content: string) => theme.bg("userMessageBg", content));
this.contentBox = new Box(toolIndent, 1, (content: string) => theme.bg("userMessageBg", content));
this.contentBox.addChild(
new Markdown(
text,

View file

@ -20,9 +20,11 @@ import {
import type {
AutocompleteItem,
AutocompleteProvider,
DefaultTextStyle,
EditorComponent,
Keybinding,
KeyId,
MarkdownOptions,
MarkdownTheme,
OverlayHandle,
OverlayOptions,
@ -598,13 +600,11 @@ export class InteractiveMode {
const versionMatch = this.changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/);
const latestVersion = versionMatch ? versionMatch[1] : this.version;
const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`;
this.chatContainer.addChild(new Text(condensedText, 1, 0));
this.chatContainer.addChild(this.createToolText(condensedText));
} else {
this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
this.chatContainer.addChild(this.createToolText(theme.bold(theme.fg("accent", "What's New"))));
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(
new Markdown(this.changelogMarkdown.trim(), 1, 0, this.getMarkdownThemeWithSettings()),
);
this.chatContainer.addChild(this.createToolMarkdown(this.changelogMarkdown.trim()));
this.chatContainer.addChild(new Spacer(1));
}
this.chatContainer.addChild(new DynamicBorder());
@ -960,6 +960,38 @@ export class InteractiveMode {
};
}
private getTranscriptIndent(): number {
return this.settingsManager.getTranscriptIndent();
}
private getToolIndent(): number {
return this.settingsManager.getToolIndent();
}
private createToolText(text: string, paddingY = 0, customBgFn?: (text: string) => string): Text {
return new Text(text, this.getToolIndent(), paddingY, customBgFn);
}
private createToolMarkdown(
text: string,
paddingY = 0,
defaultTextStyle?: DefaultTextStyle,
options?: MarkdownOptions,
): Markdown {
return new Markdown(
text,
this.getToolIndent(),
paddingY,
this.getMarkdownThemeWithSettings(),
defaultTextStyle,
options,
);
}
private createToolTruncatedText(text: string, paddingY = 0): TruncatedText {
return new TruncatedText(text, this.getToolIndent(), paddingY);
}
// =========================================================================
// Extension System
// =========================================================================
@ -2426,7 +2458,7 @@ export class InteractiveMode {
*/
private showExtensionError(extensionPath: string, error: string, stack?: string): void {
const errorMsg = `Extension "${extensionPath}" error: ${error}`;
const errorText = new Text(theme.fg("error", errorMsg), 1, 0);
const errorText = this.createToolText(theme.fg("error", errorMsg));
this.chatContainer.addChild(errorText);
if (stack) {
// Show stack trace in dim color, indented
@ -2436,7 +2468,7 @@ export class InteractiveMode {
.map((line) => theme.fg("dim", ` ${line.trim()}`))
.join("\n");
if (stackLines) {
this.chatContainer.addChild(new Text(stackLines, 1, 0));
this.chatContainer.addChild(this.createToolText(stackLines));
}
}
this.ui.requestRender();
@ -2793,6 +2825,7 @@ export class InteractiveMode {
this.hideThinkingBlock,
this.getMarkdownThemeWithSettings(),
this.hiddenThinkingLabel,
this.getTranscriptIndent(),
);
this.streamingMessage = event.message;
this.chatContainer.addChild(this.streamingComponent);
@ -2816,6 +2849,7 @@ export class InteractiveMode {
{
showImages: this.settingsManager.getShowImages(),
imageWidthCells: this.settingsManager.getImageWidthCells(),
toolIndent: this.getToolIndent(),
},
this.getRegisteredToolDefinition(content.name),
this.ui,
@ -2885,6 +2919,7 @@ export class InteractiveMode {
{
showImages: this.settingsManager.getShowImages(),
imageWidthCells: this.settingsManager.getImageWidthCells(),
toolIndent: this.getToolIndent(),
},
this.getRegisteredToolDefinition(event.toolName),
this.ui,
@ -3000,7 +3035,7 @@ export class InteractiveMode {
this.showError(event.errorMessage);
} else {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("error", event.errorMessage), 1, 0));
this.chatContainer.addChild(this.createToolText(theme.fg("error", event.errorMessage)));
}
}
void this.flushCompactionQueue({ willRetry: event.willRetry });
@ -3094,7 +3129,7 @@ export class InteractiveMode {
}
const spacer = new Spacer(1);
const text = new Text(theme.fg("dim", message), 1, 0);
const text = this.createToolText(theme.fg("dim", message));
this.chatContainer.addChild(spacer);
this.chatContainer.addChild(text);
this.lastStatusSpacer = spacer;
@ -3105,7 +3140,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.getToolIndent(),
);
if (message.output) {
component.appendOutput(message.output);
}
@ -3121,7 +3161,12 @@ export class InteractiveMode {
case "custom": {
if (message.display) {
const renderer = this.session.extensionRunner.getMessageRenderer(message.customType);
const component = new CustomMessageComponent(message, renderer, this.getMarkdownThemeWithSettings());
const component = new CustomMessageComponent(
message,
renderer,
this.getMarkdownThemeWithSettings(),
this.getToolIndent(),
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
}
@ -3129,14 +3174,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.getToolIndent(),
);
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.getToolIndent(),
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
break;
@ -3153,6 +3206,7 @@ export class InteractiveMode {
const component = new SkillInvocationMessageComponent(
skillBlock,
this.getMarkdownThemeWithSettings(),
this.getToolIndent(),
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
@ -3162,11 +3216,16 @@ export class InteractiveMode {
const userComponent = new UserMessageComponent(
skillBlock.userMessage,
this.getMarkdownThemeWithSettings(),
this.getToolIndent(),
);
this.chatContainer.addChild(userComponent);
}
} else {
const userComponent = new UserMessageComponent(textContent, this.getMarkdownThemeWithSettings());
const userComponent = new UserMessageComponent(
textContent,
this.getMarkdownThemeWithSettings(),
this.getToolIndent(),
);
this.chatContainer.addChild(userComponent);
}
if (options?.populateHistory) {
@ -3181,6 +3240,7 @@ export class InteractiveMode {
this.hideThinkingBlock,
this.getMarkdownThemeWithSettings(),
this.hiddenThinkingLabel,
this.getTranscriptIndent(),
);
this.chatContainer.addChild(assistantComponent);
break;
@ -3227,6 +3287,7 @@ export class InteractiveMode {
{
showImages: this.settingsManager.getShowImages(),
imageWidthCells: this.settingsManager.getImageWidthCells(),
toolIndent: this.getToolIndent(),
},
this.getRegisteredToolDefinition(content.name),
this.ui,
@ -3298,13 +3359,11 @@ export class InteractiveMode {
this.chatContainer.addChild(new Spacer(1));
}
this.chatContainer.addChild(
new Text(
this.createToolText(
theme.fg(
"warning",
`This project is not trusted. Project ${CONFIG_DIR_NAME} resources and packages are ignored. Use /trust to save a trust decision, then restart pi.`,
),
1,
0,
),
);
}
@ -3713,13 +3772,13 @@ export class InteractiveMode {
showError(errorMessage: string): void {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0));
this.chatContainer.addChild(this.createToolText(theme.fg("error", `Error: ${errorMessage}`)));
this.ui.requestRender();
}
showWarning(warningMessage: string): void {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0));
this.chatContainer.addChild(this.createToolText(theme.fg("warning", `Warning: ${warningMessage}`)));
this.ui.requestRender();
}
@ -3736,18 +3795,18 @@ export class InteractiveMode {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
this.chatContainer.addChild(
new Text(`${theme.bold(theme.fg("warning", "Update Available"))}\n${updateInstruction}`, 1, 0),
this.createToolText(`${theme.bold(theme.fg("warning", "Update Available"))}\n${updateInstruction}`),
);
if (note) {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(
new Markdown(note, 1, 0, this.getMarkdownThemeWithSettings(), {
this.createToolMarkdown(note, 0, {
color: (text) => theme.fg("muted", text),
}),
);
this.chatContainer.addChild(new Spacer(1));
}
this.chatContainer.addChild(new Text(changelogLine, 1, 0));
this.chatContainer.addChild(this.createToolText(changelogLine));
this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
this.ui.requestRender();
}
@ -3760,10 +3819,8 @@ export class InteractiveMode {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
this.chatContainer.addChild(
new Text(
this.createToolText(
`${theme.bold(theme.fg("warning", "Package Updates Available"))}\n${updateInstruction}\n${theme.fg("muted", "Packages:")}\n${packageLines}`,
1,
0,
),
);
this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
@ -3813,15 +3870,15 @@ export class InteractiveMode {
this.pendingMessagesContainer.addChild(new Spacer(1));
for (const message of steeringMessages) {
const text = theme.fg("dim", `Steering: ${message}`);
this.pendingMessagesContainer.addChild(new TruncatedText(text, 1, 0));
this.pendingMessagesContainer.addChild(this.createToolTruncatedText(text));
}
for (const message of followUpMessages) {
const text = theme.fg("dim", `Follow-up: ${message}`);
this.pendingMessagesContainer.addChild(new TruncatedText(text, 1, 0));
this.pendingMessagesContainer.addChild(this.createToolTruncatedText(text));
}
const dequeueHint = this.getAppKeyDisplay("app.message.dequeue");
const hintText = theme.fg("dim", `${dequeueHint} to edit all queued messages`);
this.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0));
this.pendingMessagesContainer.addChild(this.createToolTruncatedText(hintText));
}
}
@ -3998,6 +4055,8 @@ export class InteractiveMode {
showHardwareCursor: this.settingsManager.getShowHardwareCursor(),
defaultProjectTrust: this.settingsManager.getDefaultProjectTrust(),
editorPaddingX: this.settingsManager.getEditorPaddingX(),
transcriptIndent: this.settingsManager.getTranscriptIndent(),
toolIndent: this.settingsManager.getToolIndent(),
autocompleteMaxVisible: this.settingsManager.getAutocompleteMaxVisible(),
quietStartup: this.settingsManager.getQuietStartup(),
clearOnShrink: this.settingsManager.getClearOnShrink(),
@ -4063,12 +4122,6 @@ export class InteractiveMode {
onHideThinkingBlockChange: (hidden) => {
this.hideThinkingBlock = hidden;
this.settingsManager.setHideThinkingBlock(hidden);
for (const child of this.chatContainer.children) {
if (child instanceof AssistantMessageComponent) {
child.setHideThinkingBlock(hidden);
}
}
this.chatContainer.clear();
this.rebuildChatFromMessages();
},
onCollapseChangelogChange: (collapsed) => {
@ -4100,6 +4153,14 @@ export class InteractiveMode {
this.editor.setPaddingX(padding);
}
},
onTranscriptIndentChange: (indent) => {
this.settingsManager.setTranscriptIndent(indent);
this.rebuildChatFromMessages();
},
onToolIndentChange: (indent) => {
this.settingsManager.setToolIndent(indent);
this.rebuildChatFromMessages();
},
onAutocompleteMaxVisibleChange: (maxVisible) => {
this.settingsManager.setAutocompleteMaxVisible(maxVisible);
this.defaultEditor.setAutocompleteMaxVisible(maxVisible);
@ -5349,7 +5410,7 @@ export class InteractiveMode {
const currentName = this.sessionManager.getSessionName();
if (currentName) {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("dim", `Session name: ${currentName}`), 1, 0));
this.chatContainer.addChild(this.createToolText(theme.fg("dim", `Session name: ${currentName}`)));
} else {
this.showWarning("Usage: /name <name>");
}
@ -5363,7 +5424,7 @@ export class InteractiveMode {
this.showWarning(`Session name was normalized from ${JSON.stringify(name)} to ${JSON.stringify(sessionName)}`);
}
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("dim", `Session name set: ${sessionName ?? name}`), 1, 0));
this.chatContainer.addChild(this.createToolText(theme.fg("dim", `Session name set: ${sessionName ?? name}`)));
this.ui.requestRender();
}
@ -5400,7 +5461,7 @@ export class InteractiveMode {
}
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(info, 1, 0));
this.chatContainer.addChild(this.createToolText(info));
this.ui.requestRender();
}
@ -5418,9 +5479,9 @@ export class InteractiveMode {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new DynamicBorder());
this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
this.chatContainer.addChild(this.createToolText(theme.bold(theme.fg("accent", "What's New"))));
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1, this.getMarkdownThemeWithSettings()));
this.chatContainer.addChild(this.createToolMarkdown(changelogMarkdown, 1));
this.chatContainer.addChild(new DynamicBorder());
this.ui.requestRender();
}
@ -5547,9 +5608,9 @@ export class InteractiveMode {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new DynamicBorder());
this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Keyboard Shortcuts")), 1, 0));
this.chatContainer.addChild(this.createToolText(theme.bold(theme.fg("accent", "Keyboard Shortcuts"))));
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Markdown(hotkeys.trim(), 1, 1, this.getMarkdownThemeWithSettings()));
this.chatContainer.addChild(this.createToolMarkdown(hotkeys.trim(), 1));
this.chatContainer.addChild(new DynamicBorder());
this.ui.requestRender();
}
@ -5566,7 +5627,7 @@ export class InteractiveMode {
return;
}
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));
this.chatContainer.addChild(this.createToolText(`${theme.fg("accent", "✓ New session started")}`, 1));
this.ui.requestRender();
} catch (error: unknown) {
await this.handleFatalRuntimeError("Failed to create session", error);
@ -5601,20 +5662,20 @@ export class InteractiveMode {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(
new Text(`${theme.fg("accent", "✓ Debug log written")}\n${theme.fg("muted", debugLogPath)}`, 1, 1),
this.createToolText(`${theme.fg("accent", "✓ Debug log written")}\n${theme.fg("muted", debugLogPath)}`, 1),
);
this.ui.requestRender();
}
private handleArminSaysHi(): void {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new ArminComponent(this.ui));
this.chatContainer.addChild(new ArminComponent(this.ui, this.getToolIndent()));
this.ui.requestRender();
}
private handleDementedDelves(): void {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new EarendilAnnouncementComponent());
this.chatContainer.addChild(new EarendilAnnouncementComponent(this.getToolIndent()));
this.ui.requestRender();
}
@ -5646,7 +5707,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.getToolIndent());
if (this.session.isStreaming) {
this.pendingMessagesContainer.addChild(this.bashComponent);
this.pendingBashComponents.push(this.bashComponent);
@ -5674,7 +5735,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.getToolIndent());
if (isDeferred) {
// Show in pending area when agent is streaming

View file

@ -354,6 +354,31 @@ describe("SettingsManager", () => {
});
});
it("should normalize and save transcript/tool indents", async () => {
const settingsPath = join(agentDir, "settings.json");
let manager = SettingsManager.create(projectDir, agentDir);
expect(manager.getTranscriptIndent()).toBe(1);
expect(manager.getToolIndent()).toBe(1);
writeFileSync(settingsPath, JSON.stringify({ transcriptIndent: 0, toolIndent: 4 }));
manager = SettingsManager.create(projectDir, agentDir);
expect(manager.getTranscriptIndent()).toBe(0);
expect(manager.getToolIndent()).toBe(4);
writeFileSync(settingsPath, JSON.stringify({ transcriptIndent: 5, toolIndent: "2" }));
manager = SettingsManager.create(projectDir, agentDir);
expect(manager.getTranscriptIndent()).toBe(1);
expect(manager.getToolIndent()).toBe(1);
manager.setTranscriptIndent(2);
manager.setToolIndent(3);
await manager.flush();
const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8"));
expect(savedSettings.transcriptIndent).toBe(2);
expect(savedSettings.toolIndent).toBe(3);
});
describe("shellCommandPrefix", () => {
it("should load shellCommandPrefix from settings", () => {
const settingsPath = join(agentDir, "settings.json");

View file

@ -67,6 +67,26 @@ describe("ToolExecutionComponent parity", () => {
expect(rendered).toContain("custom result");
});
test("applies tool indentation to default tool shells and render contexts", () => {
const toolDefinition: ToolDefinition = {
...createBaseToolDefinition(),
renderCall: (_args, _theme, context) => new Text(`indent:${context.toolIndent}`, 0, 0),
};
const component = new ToolExecutionComponent(
"custom_tool",
"tool-indent",
{},
{ toolIndent: 4 },
toolDefinition,
createFakeTui(),
process.cwd(),
);
const contentLine = stripAnsi(component.render(40).find((line) => line.includes("indent:")) ?? "").trimEnd();
expect(contentLine).toBe(" indent:4");
});
test("self-rendered empty tool rows take no layout space", () => {
const toolDefinition: ToolDefinition = {
...createBaseToolDefinition(),