import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import type { ToolDisplayCapabilities } from "./capabilities.js"; import { getToolDisplayConfigPath, normalizeToolDisplayConfig } from "./config-store.js"; import { applyToolDisplayMode, parseToolDisplayMode } from "./presets.js"; import { shortenPath } from "./render-utils.js"; import type { InspectorSettingItem } from "./settings-inspector-modal.js"; import { DIFF_COLLAPSED_MODES, RESULT_DISPLAY_MODES, TOOL_CALL_LAYOUTS, type ToolDisplayConfig, } from "./types.js"; interface ToolDisplayConfigController { getConfig(): ToolDisplayConfig; setConfig(next: ToolDisplayConfig, ctx: ExtensionCommandContext): void; getCapabilities(): ToolDisplayCapabilities; } interface ModalOverlayOptions { anchor: "center"; width: number; maxHeight: number; margin: number; } const PREVIEW_ROW_VALUES = ["2", "4", "8", "12", "20", "40"] as const; const BASH_COMMAND_PREVIEW_ROW_VALUES = ["1", "2", "3", "4"] as const; const MODE_COMMAND_HINT = RESULT_DISPLAY_MODES.join("|"); const LAYOUT_COMMAND_HINT = TOOL_CALL_LAYOUTS.join("|"); const INDIVIDUAL_ONLY_SETTING_IDS = new Set([ "resultMode", "previewRows", "toolIntentEnabled", "toolCallStyle", "bashCommandPreviewRows", "diffViewMode", "diffIndicatorMode", "diffCollapsedMode", "enableNativeUserMessageBox", ]); function toOnOff(value: boolean): string { return value ? "on" : "off"; } function toolOwnershipSummary(config: ToolDisplayConfig): string { const ownership = config.registerToolOverrides; return `read:${toOnOff(ownership.read)},grep:${toOnOff(ownership.grep)},find:${toOnOff(ownership.find)},ls:${toOnOff(ownership.ls)},bash:${toOnOff(ownership.bash)},edit:${toOnOff(ownership.edit)},write:${toOnOff(ownership.write)}`; } function summarizeConfig(config: ToolDisplayConfig, capabilities: ToolDisplayCapabilities): string { const parts = [ `layout=${config.toolCallLayout}`, `results=${config.resultMode}/${config.previewRows}rows`, `intent=${toOnOff(config.toolIntent.enabled)}/${config.toolIntent.language}`, `toolCalls=${config.toolCallStyle}/bash${config.bashCommandPreviewRows}rows`, `userMessage=${config.enableNativeUserMessageBox ? "boxed" : "default"}`, `diff=${config.diffViewMode}/${config.diffIndicatorMode}@${config.diffSplitMinWidth}`, `diffRows=${config.diffCollapsedRows}`, `diffFold=${config.diffCollapsedMode}`, `diffWrap=${toOnOff(config.diffWordWrap)}`, `ownership={${toolOwnershipSummary(config)}}`, ]; if (config.toolCallLayout === "aggregate") { parts.push("individualSettings=retained (inactive in aggregate layout)"); } parts.push(capabilities.hasMcpTooling ? "mcp=available" : "mcp=unavailable"); parts.push( capabilities.hasRtkOptimizer ? `rtkHints=${toOnOff(config.showRtkCompactionHints)}` : "rtkHints=unavailable", ); return parts.join(", "); } function parseNumber(value: string, fallback: number): number { const parsed = Number.parseInt(value, 10); return Number.isNaN(parsed) ? fallback : parsed; } function buildAdvancedNotes( config: ToolDisplayConfig, capabilities: ToolDisplayCapabilities, extra: readonly string[], ): string[] { return [ ...extra, "Manual JSON edits expose the grouped sections: intent, toolCalls, results, diff, transcript, tools, and advanced.", `Built-in renderer ownership is currently ${toolOwnershipSummary(config)} and still applies after /reload.`, `Truncation hints are ${toOnOff(config.showTruncationHints)}${capabilities.hasRtkOptimizer ? `; RTK hints are ${toOnOff(config.showRtkCompactionHints)}.` : "."}`, ]; } export function buildInspectorSettings( config: ToolDisplayConfig, capabilities: ToolDisplayCapabilities, ): InspectorSettingItem[] { const configPath = shortenPath(getToolDisplayConfigPath()); const settings: InspectorSettingItem[] = [ { id: "toolCallLayout", label: "Tool call layout", currentValue: config.toolCallLayout, values: TOOL_CALL_LAYOUTS, inspectorTitle: "Tool Call Layout", inspectorSummary: config.toolCallLayout === "aggregate" ? [ "Aggregate uses one bounded Tools summary for every registered tool; successful rows stay done until replacement or the final delayed fold.", "Collapsed errors stay as a failed count. While the turn is running, the latest assistant note stays pinned under the header, above the tool rows, without using a tool slot. After the turn settles, every assistant note hides and a muted receipt under the header shows duration, tokens, cache, and completion time.", "Ctrl+O leaves the Tools ledger, restores mid-turn narration in place, and shows one target/status summary per call.", "Agent keeps its original renderer by default. User prompts always use a compact accent-gutter block with vertical padding. Individual-tool and boxed-user settings are retained but inactive.", ] : [ "Individual preserves the existing per-tool calls, results, diffs, intent, and Ctrl+O expansion.", "Aggregate summarizes every registered tool in one bounded Tools view per user turn.", ], inspectorOptions: [ "individual — preserve the complete existing per-tool display (default)", "aggregate — summarize tools and hide mid-turn narration; Ctrl+O restores the timeline", ], inspectorAdvanced: buildAdvancedNotes(config, capabilities, [ "Changing the layout updates tool schemas and renderer shells after /reload and redraws the whole current branch.", "Aggregate never generates displaySummary or reveals grouped output/diff bodies.", ]), inspectorPath: configPath, searchTerms: ["layout", "individual", "aggregate", "tools", "summary", "reload"], }, { id: "resultMode", label: "Tool result mode", currentValue: config.resultMode, values: RESULT_DISPLAY_MODES, inspectorTitle: "Tool Result Mode", inspectorSummary: [ "Controls how much output read, search, MCP, and bash tools show in the transcript.", "It does not change custom-tool settings, intent, tool-call style, diff rendering, transcript styling, or tool ownership.", ], inspectorOptions: [ "compact — hide read/search/MCP bodies and keep a short bash preview", "summary — show counts or compact summaries", "preview — show wrapped content previews for read, search, MCP, and bash", ], inspectorAdvanced: buildAdvancedNotes(config, capabilities, [ "All content previews use the shared results.previewRows budget.", "Legacy mode names minimal/opencode, balanced, and detailed/verbose map to compact, summary, and preview.", ]), inspectorPath: configPath, searchTerms: ["results", "mode", "compact", "summary", "preview", "output"], }, { id: "previewRows", label: "Preview rows", currentValue: String(config.previewRows), values: PREVIEW_ROW_VALUES, inspectorTitle: "Preview Rows", inspectorSummary: [ "Sets one shared rendered-row budget for every collapsed content preview after terminal wrapping.", "A single long logical line consumes multiple rows instead of bypassing the limit.", ], inspectorOptions: [ "2 — minimum supported preview for a dense transcript", "Higher values show more read, search, MCP, custom, and bash output", ], inspectorAdvanced: buildAdvancedNotes(config, capabilities, [ "advanced.expandedRows separately bounds output after Ctrl+O expansion.", ]), inspectorPath: configPath, searchTerms: ["preview", "rows", "range", "collapsed", "read", "search", "mcp", "bash"], }, { id: "toolIntentEnabled", label: "Model-written intent", currentValue: toOnOff(config.toolIntent.enabled), values: ["on", "off"], inspectorTitle: "Model-written Tool Intent", inspectorSummary: [ "Adds a displaySummary field to owned built-in tool schemas so the current model describes each call's intent.", "The phrase is shown beside deterministic tool metadata and remains available to RPC clients without another inference request.", ], inspectorOptions: [ "on — request and render a short intent phrase", "off — keep deterministic tool rendering only", ], inspectorAdvanced: buildAdvancedNotes(config, capabilities, [ "Changing this setting updates tool schemas and therefore takes effect after /reload.", "Use intent.language and intent.maxLength in config.json for advanced control.", ]), inspectorPath: configPath, searchTerms: ["intent", "summary", "model", "rpc", "displaySummary"], }, { id: "toolCallStyle", label: "Tool call style", currentValue: config.toolCallStyle, values: ["compact", "claude"], inspectorTitle: "Tool Call Style", inspectorSummary: [ "Controls the framing used for tool calls and results in the Pi transcript.", "Claude style uses status markers, Name(target) headers, an unboxed shell, and indented result rows.", ], inspectorOptions: [ "compact — original boxed pi-tool-display layout", "claude — Claude Code-inspired call framing", ], inspectorAdvanced: buildAdvancedNotes(config, capabilities, [ "Changing the shell style takes effect after /reload.", ]), inspectorPath: configPath, searchTerms: ["tool", "style", "claude", "compact", "status", "shell"], }, { id: "bashCommandPreviewRows", label: "Bash command rows", currentValue: String(config.bashCommandPreviewRows), values: BASH_COMMAND_PREVIEW_ROW_VALUES, inspectorTitle: "Bash Command Preview Rows", inspectorSummary: [ "Limits collapsed Bash command text after terminal wrapping while leaving short commands unchanged.", "Long Claude-style calls keep intent in the header and move the bounded command preview to its own row.", ], inspectorOptions: [ "1 — densest transcript and the default", "2–4 — retain more command context before collapsing", ], inspectorAdvanced: buildAdvancedNotes(config, capabilities, [ "Ctrl+O expands the complete original command; results.previewRows controls output rather than call arguments.", ]), inspectorPath: configPath, searchTerms: ["bash", "command", "preview", "rows", "collapse", "expand", "ctrl+o"], }, { id: "diffViewMode", label: "Edit diff layout", currentValue: config.diffViewMode, values: ["auto", "split", "unified"], inspectorTitle: "Edit Diff Layout", inspectorSummary: [ "Controls how edit and write diffs are arranged.", "Auto uses side-by-side diffs in wide panes and unified diffs in narrow panes.", ], inspectorOptions: [ "auto — adaptive layout based on available width", "split — force side-by-side diff columns", "unified — force a single-column diff", ], inspectorAdvanced: buildAdvancedNotes(config, capabilities, [ "Manual JSON tuning exposes diff.splitMinWidth, diff.collapsedRows, diff.indicators, and diff.wordWrap.", ]), inspectorPath: configPath, searchTerms: ["diff", "edit", "write", "split", "unified", "auto"], }, { id: "diffIndicatorMode", label: "Diff indicators", currentValue: config.diffIndicatorMode, values: ["bars", "classic", "none"], inspectorTitle: "Diff Indicators", inspectorSummary: [ "Controls whether changed diff lines use vertical bars, classic +/- markers, or no indicators.", ], inspectorOptions: [ "bars — persistent vertical indicators for changed rows", "classic — + / - markers on the first visual row", "none — no diff indicator marker", ], inspectorAdvanced: buildAdvancedNotes(config, capabilities, []), inspectorPath: configPath, searchTerms: ["diff", "indicator", "bars", "classic", "none"], }, { id: "diffCollapsedMode", label: "Diff collapsed style", currentValue: config.diffCollapsedMode, values: DIFF_COLLAPSED_MODES, inspectorTitle: "Diff Collapsed Style", inspectorSummary: [ "Controls what edit and write diffs render before Ctrl+O expansion.", "summary shows only the +N -M stats line for the densest transcript; body keeps the existing collapsedRows preview.", ], inspectorOptions: [ "body — show the first diff.collapsedRows rows then a fold hint (default)", "summary — show only the stats line; Ctrl+O expands the full diff", ], inspectorAdvanced: buildAdvancedNotes(config, capabilities, [ "When set to summary, diff.collapsedRows is ignored until expansion.", ]), inspectorPath: configPath, searchTerms: ["diff", "collapsed", "summary", "body", "fold", "compact", "ctrl+o"], }, { id: "enableNativeUserMessageBox", label: "User message style", currentValue: config.enableNativeUserMessageBox ? "boxed" : "default", values: ["boxed", "default"], inspectorTitle: "User Message Style", inspectorSummary: [ "Controls whether user prompts use a bordered box or Pi's default transcript style.", "This setting is inactive in aggregate, which always uses a compact accent-gutter block with vertical padding.", ], inspectorOptions: [ "boxed — bordered native user prompt box", "default — Pi's default user message rendering", ], inspectorAdvanced: buildAdvancedNotes(config, capabilities, []), inspectorPath: configPath, searchTerms: ["user", "message", "style", "box", "prompt"], }, ]; return config.toolCallLayout === "aggregate" ? settings.filter((setting) => !INDIVIDUAL_ONLY_SETTING_IDS.has(setting.id)) : settings; } export function applySetting(config: ToolDisplayConfig, id: string, value: string): ToolDisplayConfig { switch (id) { case "toolCallLayout": return { ...config, toolCallLayout: value as ToolDisplayConfig["toolCallLayout"] }; case "resultMode": { const mode = parseToolDisplayMode(value); return mode ? applyToolDisplayMode(config, mode) : config; } case "previewRows": return { ...config, previewRows: parseNumber(value, config.previewRows) }; case "toolIntentEnabled": return { ...config, toolIntent: { ...config.toolIntent, enabled: value === "on" }, }; case "toolCallStyle": return { ...config, toolCallStyle: value as ToolDisplayConfig["toolCallStyle"] }; case "bashCommandPreviewRows": return { ...config, bashCommandPreviewRows: parseNumber(value, config.bashCommandPreviewRows), }; case "enableNativeUserMessageBox": return { ...config, enableNativeUserMessageBox: value === "boxed" }; case "diffViewMode": return { ...config, diffViewMode: value as ToolDisplayConfig["diffViewMode"] }; case "diffIndicatorMode": return { ...config, diffIndicatorMode: value as ToolDisplayConfig["diffIndicatorMode"] }; case "diffCollapsedMode": return { ...config, diffCollapsedMode: value as ToolDisplayConfig["diffCollapsedMode"] }; default: return config; } } function resolveResponsiveOverlayOptions(): ModalOverlayOptions { const terminalWidth = typeof process.stdout.columns === "number" && Number.isFinite(process.stdout.columns) ? process.stdout.columns : 120; const terminalHeight = typeof process.stdout.rows === "number" && Number.isFinite(process.stdout.rows) ? process.stdout.rows : 36; const margin = 1; const availableWidth = Math.max(72, terminalWidth - margin * 2); const preferredWidth = terminalWidth >= 170 ? 128 : terminalWidth >= 145 ? 118 : terminalWidth >= 120 ? 106 : 92; const width = Math.max(72, Math.min(preferredWidth, availableWidth)); const availableHeight = Math.max(14, terminalHeight - margin * 2); const preferredHeight = Math.max(14, Math.floor(terminalHeight * 0.78)); const maxHeight = Math.min(preferredHeight, availableHeight); return { anchor: "center", width, maxHeight, margin }; } export async function openSettingsModal(ctx: ExtensionCommandContext, controller: ToolDisplayConfigController): Promise { const overlayOptions = resolveResponsiveOverlayOptions(); const capabilities = controller.getCapabilities(); const [{ ZellijModal }, { SplitPaneInspectorModal }] = await Promise.all([ import("./zellij-modal.js"), import("./settings-inspector-modal.js"), ]); await ctx.ui.custom( (tui, theme, _keybindings, done) => { const inspector = new SplitPaneInspectorModal( { getSettings: () => buildInspectorSettings(controller.getConfig(), capabilities), onChange: (id, newValue) => { const next = applySetting(controller.getConfig(), id, newValue); controller.setConfig(next, ctx); }, onClose: () => done(), }, theme, ); const modal = new ZellijModal( inspector, { borderStyle: "square", padding: 0, titleBar: {}, overlay: overlayOptions, }, theme, ); return { render: (width: number) => modal.renderModal(width).lines, invalidate: () => modal.invalidate(), handleInput(data: string) { modal.handleInput(data); tui.requestRender(); }, }; }, { overlay: true, overlayOptions }, ); } function applyLayoutCommand( candidate: string, ctx: ExtensionCommandContext, controller: ToolDisplayConfigController, ): boolean { const layout = TOOL_CALL_LAYOUTS.find((entry) => entry === candidate); if (!layout) { ctx.ui.notify(`Unknown tool call layout. Use: /tool-display-intent layout ${LAYOUT_COMMAND_HINT}`, "warning"); return true; } controller.setConfig({ ...controller.getConfig(), toolCallLayout: layout }, ctx); ctx.ui.notify(`Tool call layout set to ${layout}. Run /reload to apply.`, "info"); return true; } function applyModeCommand( candidate: string, ctx: ExtensionCommandContext, controller: ToolDisplayConfigController, ): boolean { const mode = parseToolDisplayMode(candidate); if (!mode) { ctx.ui.notify(`Unknown result mode. Use: /tool-display-intent mode ${MODE_COMMAND_HINT}`, "warning"); return true; } controller.setConfig(applyToolDisplayMode(controller.getConfig(), mode), ctx); ctx.ui.notify(`Tool result mode set to ${mode}.`, "info"); return true; } export function handleToolDisplayArgs(args: string, ctx: ExtensionCommandContext, controller: ToolDisplayConfigController): boolean { const raw = args.trim(); if (!raw) return false; const normalized = raw.toLowerCase(); if (normalized === "show") { ctx.ui.notify( `tool-display-intent: ${summarizeConfig(controller.getConfig(), controller.getCapabilities())}`, "info", ); return true; } if (normalized === "reset") { controller.setConfig(normalizeToolDisplayConfig({}), ctx); ctx.ui.notify("Tool display settings reset to defaults.", "info"); return true; } if (normalized.startsWith("layout ")) { return applyLayoutCommand(normalized.slice("layout ".length).trim(), ctx, controller); } if (normalized.startsWith("mode ")) { return applyModeCommand(normalized.slice("mode ".length).trim(), ctx, controller); } if (normalized.startsWith("preset ")) { return applyModeCommand(normalized.slice("preset ".length).trim(), ctx, controller); } ctx.ui.notify(`Usage: /tool-display-intent [show|reset|layout ${LAYOUT_COMMAND_HINT}|mode ${MODE_COMMAND_HINT}]`, "warning"); return true; } export async function runToolDisplayCommandHandler( args: string, ctx: ExtensionCommandContext, controller: ToolDisplayConfigController, ): Promise { if (handleToolDisplayArgs(args, ctx, controller)) return; if (!ctx.hasUI) { ctx.ui.notify("/tool-display-intent requires interactive TUI mode.", "warning"); return; } await openSettingsModal(ctx, controller); } export function registerToolDisplayCommand(pi: ExtensionAPI, controller: ToolDisplayConfigController): void { pi.registerCommand("tool-display-intent", { description: "Configure intent-aware tool rendering", handler: async (args, ctx) => { await runToolDisplayCommandHandler(args, ctx, controller); }, }); }