diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 266d72b36..b43f3b370 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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)). diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index a9271ee89..cf1560913 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -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. diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 18c9ad975..3c7928c8c 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -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 | diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index fafa1031e..5f0d08e26 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -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, { diff --git a/packages/coding-agent/src/core/export-html/tool-renderer.ts b/packages/coding-agent/src/core/export-html/tool-renderer.ts index c2030a271..6475127d0 100644 --- a/packages/coding-agent/src/core/export-html/tool-renderer.ts +++ b/packages/coding-agent/src/core/export-html/tool-renderer.ts @@ -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(); const renderedResultComponents = new Map(); @@ -91,6 +93,7 @@ export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRend isPartial, expanded, showImages: false, + toolIndent, isError, }; }; diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 7234d4e4f..3efb00058 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -425,6 +425,8 @@ export interface ToolRenderContext { 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; } diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 99ae71a4a..0f496b592 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -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; } diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index 25aa1e17b..245d9050b 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -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; }, }; diff --git a/packages/coding-agent/src/modes/interactive/components/armin.ts b/packages/coding-agent/src/modes/interactive/components/armin.ts index 209ca167f..d8403091f 100644 --- a/packages/coding-agent/src/modes/interactive/components/armin.ts +++ b/packages/coding-agent/src/modes/interactive/components/armin.ts @@ -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; 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..0b03c2bc1 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 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)); } } } diff --git a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts index b46a9d0b9..0dda440cd 100644 --- a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts @@ -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)); } } } 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 af85115f0..f2a38212e 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 @@ -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(); 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 cad613d76..e55247eef 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 @@ -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(); 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 a8fffc6f3..238856149 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-message.ts @@ -21,6 +21,7 @@ export class CustomMessageComponent extends Container { message: CustomMessage, 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(); } diff --git a/packages/coding-agent/src/modes/interactive/components/earendil-announcement.ts b/packages/coding-agent/src/modes/interactive/components/earendil-announcement.ts index f2726d989..30500ccc8 100644 --- a/packages/coding-agent/src/modes/interactive/components/earendil-announcement.ts +++ b/packages/coding-agent/src/modes/interactive/components/earendil-announcement.ts @@ -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(); 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..a6e218430 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -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; diff --git a/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts b/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts index e09febc7c..e4971a631 100644 --- a/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts @@ -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(); diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index ad84f4413..227f7a917 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -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; private builtInToolDefinition?: ToolDefinition; @@ -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, }; } 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 45d655776..b6eb43299 100644 --- a/packages/coding-agent/src/modes/interactive/components/user-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/user-message.ts @@ -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, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 7b6dbbe84..599cc6f5f 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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 "); } @@ -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 diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index d86c3f914..ff83bf47f 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -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"); diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index bf890959d..3683cba9c 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -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(),