From a20690a40b4a1f68e1ac360b4bffb4be4fc8a2fe Mon Sep 17 00:00:00 2001 From: Daniel Anderson-Little Date: Sat, 1 Aug 2026 22:42:53 -0400 Subject: [PATCH] feat(tui): add hidden tool activity mode --- .../coding-agent/src/config/keybindings.ts | 5 ++ .../src/config/settings-schema.ts | 11 +++ .../src/modes/components/assistant-message.ts | 11 +++ .../components/chat-transcript-builder.ts | 4 + .../src/modes/components/custom-editor.ts | 9 ++ .../src/modes/components/read-tool-group.ts | 10 +++ .../src/modes/components/tool-execution.ts | 8 ++ .../src/modes/controllers/event-controller.ts | 3 + .../src/modes/controllers/input-controller.ts | 34 +++++++ .../modes/controllers/selector-controller.ts | 16 ++++ .../src/modes/interactive-mode.ts | 2 + packages/coding-agent/src/modes/types.ts | 1 + .../src/modes/utils/hotkeys-markdown.ts | 1 + .../utils/interactive-context-helpers.ts | 1 + .../src/modes/utils/ui-helpers.ts | 4 + .../test/custom-editor-keybindings.test.ts | 11 +++ ...-controller-mixed-assistant-render.test.ts | 83 ++++++++++++++++- .../test/input-controller-keybindings.test.ts | 22 +++++ .../command-controller-hotkeys.test.ts | 2 + .../input-controller-tool-expansion.test.ts | 75 ++++++++++++++++ .../utils/render-initial-messages.test.ts | 88 ++++++++++++++++++- .../selector-settings-side-effects.test.ts | 38 ++++++++ .../test/settings-manager.test.ts | 6 ++ 23 files changed, 443 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/config/keybindings.ts b/packages/coding-agent/src/config/keybindings.ts index 3ef16c9d2..6836f1eda 100644 --- a/packages/coding-agent/src/config/keybindings.ts +++ b/packages/coding-agent/src/config/keybindings.ts @@ -29,6 +29,7 @@ interface AppKeybindings { "app.model.select": true; "app.model.selectTemporary": true; "app.tools.expand": true; + "app.tools.toggleVisibility": true; "app.editor.external": true; "app.message.followUp": true; "app.retry": true; @@ -124,6 +125,10 @@ export const KEYBINDINGS = { defaultKeys: "ctrl+o", description: "Expand tools", }, + "app.tools.toggleVisibility": { + defaultKeys: "ctrl+shift+o", + description: "Show or hide tool activity", + }, "app.editor.external": { defaultKeys: "ctrl+g", description: "Open external editor", diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index e6a3649d2..0477a8b8f 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -1018,6 +1018,17 @@ export const SETTINGS_SCHEMA = { }, }, + "display.hideToolActivity": { + type: "boolean", + default: false, + ui: { + tab: "appearance", + group: "Display", + label: "Hide Tool Activity", + description: "Hide model-initiated tool calls and results from the transcript", + }, + }, + "display.showTokenUsage": { type: "boolean", default: false, diff --git a/packages/coding-agent/src/modes/components/assistant-message.ts b/packages/coding-agent/src/modes/components/assistant-message.ts index e7377dd5c..7cad89363 100644 --- a/packages/coding-agent/src/modes/components/assistant-message.ts +++ b/packages/coding-agent/src/modes/components/assistant-message.ts @@ -185,6 +185,7 @@ export class AssistantMessageComponent extends Container { #toolImagesByCallId = new Map(); #convertedKittyImages = new Map(); #showImages = true; + #showToolResultImages = true; #kittyConversionsInFlight = new Set(); #transcriptBlockFinalized: boolean; /** @@ -578,6 +579,15 @@ export class AssistantMessageComponent extends Container { } } + /** Toggle only images produced by tool results; assistant-native images remain governed by setImagesVisible. */ + setToolResultImagesVisible(visible: boolean): void { + if (this.#showToolResultImages === visible) return; + this.#showToolResultImages = visible; + if (this.#lastMessage) { + this.updateContent(this.#lastMessage, { transient: this.#lastUpdateTransient }); + } + } + setToolResultImages(toolCallId: string, images: ImageContent[]): void { if (!toolCallId) return; const validImages = images.filter(img => img.type === "image" && img.data && img.mimeType); @@ -649,6 +659,7 @@ export class AssistantMessageComponent extends Container { } #renderToolImages(): void { + if (!this.#showToolResultImages) return; const entries = Array.from(this.#toolImagesByCallId.entries()).flatMap(([toolCallId, images]) => images.map((image, index) => ({ image, key: `${toolCallId}:${index}` })), ); diff --git a/packages/coding-agent/src/modes/components/chat-transcript-builder.ts b/packages/coding-agent/src/modes/components/chat-transcript-builder.ts index e33ab4f3c..c02eb74fa 100644 --- a/packages/coding-agent/src/modes/components/chat-transcript-builder.ts +++ b/packages/coding-agent/src/modes/components/chat-transcript-builder.ts @@ -190,6 +190,7 @@ export class ChatTranscriptBuilder { this.#readGroup = new ReadToolGroupComponent({ showContentPreview: settings.get("read.toolResultPreview"), }); + this.#readGroup.setToolActivityVisible(!settings.get("display.hideToolActivity")); this.#trackExpandable(this.#readGroup); this.container.addChild(this.#readGroup); } @@ -321,6 +322,7 @@ export class ChatTranscriptBuilder { proseOnlyThinking, ); assistantComponent.setImagesVisible(settings.get("terminal.showImages")); + assistantComponent.setToolResultImagesVisible(!settings.get("display.hideToolActivity")); this.#trackExpandable(assistantComponent); this.container.addChild(assistantComponent); @@ -353,6 +355,7 @@ export class ChatTranscriptBuilder { proseOnlyThinking, ); component.setImagesVisible(settings.get("terminal.showImages")); + component.setToolResultImagesVisible(!settings.get("display.hideToolActivity")); this.#trackExpandable(component); this.container.addChild(component); }; @@ -401,6 +404,7 @@ export class ChatTranscriptBuilder { this.deps.cwd, content.id, ); + component.setToolActivityVisible(!settings.get("display.hideToolActivity")); this.#trackExpandable(component); this.container.addChild(component); diff --git a/packages/coding-agent/src/modes/components/custom-editor.ts b/packages/coding-agent/src/modes/components/custom-editor.ts index e742b44c3..315c58245 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.ts @@ -31,6 +31,7 @@ type ConfigurableEditorAction = Extract< | "app.model.select" | "app.model.selectTemporary" | "app.tools.expand" + | "app.tools.toggleVisibility" | "app.thinking.toggle" | "app.editor.external" | "app.history.search" @@ -53,6 +54,7 @@ const DEFAULT_ACTION_KEYS: Record = { "app.model.select": ["alt+m"], "app.model.selectTemporary": ["alt+p"], "app.tools.expand": ["ctrl+o"], + "app.tools.toggleVisibility": ["ctrl+shift+o"], "app.thinking.toggle": ["ctrl+t"], "app.editor.external": ["ctrl+g"], "app.history.search": ["ctrl+r"], @@ -550,6 +552,7 @@ export class CustomEditor extends Editor { onCycleModelBackward?: () => void; onSelectModel?: () => void; onExpandTools?: () => void; + onToggleToolActivity?: () => void; onToggleThinking?: () => void; onExternalEditor?: () => void; onHistorySearch?: () => void; @@ -910,6 +913,12 @@ export class CustomEditor extends Editor { return; } + // Intercept configured tool activity visibility toggle + if (this.#matchesAction(canonical, "app.tools.toggleVisibility") && this.onToggleToolActivity) { + this.onToggleToolActivity(); + return; + } + // Intercept configured backward model cycling (check before forward cycling) if (this.#matchesAction(canonical, "app.model.cycleBackward") && this.onCycleModelBackward) { this.onCycleModelBackward(); diff --git a/packages/coding-agent/src/modes/components/read-tool-group.ts b/packages/coding-agent/src/modes/components/read-tool-group.ts index 4f7c6f9e9..eeb1ced3d 100644 --- a/packages/coding-agent/src/modes/components/read-tool-group.ts +++ b/packages/coding-agent/src/modes/components/read-tool-group.ts @@ -332,6 +332,7 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa #usageBatchByToolCallId = new Map(); #text: Text; #expanded = false; + #toolActivityVisible = true; #showContentPreview: boolean; // A read group accretes entries across multiple assistant completions for as // long as the run of reads is uninterrupted. While it is the active group it @@ -353,6 +354,10 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa this.#updateDisplay(); } + override render(width: number): readonly string[] { + if (!this.#toolActivityVisible) return []; + return super.render(width); + } isTranscriptBlockFinalized(): boolean { if (this.#sealed) return true; if (!this.#finalized) return false; @@ -496,6 +501,11 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa this.#updateDisplay(); } + setToolActivityVisible(visible: boolean): void { + this.#toolActivityVisible = visible; + super.invalidate(); + } + getComponent(): Component { return this; } diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index bffd459e9..016ac6884 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -246,6 +246,7 @@ export interface ToolExecutionHandle extends Component { ): void; setArgsComplete(toolCallId?: string): void; setExpanded(expanded: boolean): void; + setToolActivityVisible(visible: boolean): void; /** Freeze the block as final history: stop spinners and let it commit to scrollback. */ seal(): void; } @@ -287,6 +288,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac #toolLabel: string; #args: any; #expanded = false; + #toolActivityVisible = true; #showImages: boolean; #editFuzzyThreshold: number | undefined; #editAllowFuzzy: boolean | undefined; @@ -901,6 +903,11 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac this.#updateDisplay(); } + setToolActivityVisible(visible: boolean): void { + this.#toolActivityVisible = visible; + super.invalidate(); + } + setShowImages(show: boolean): void { this.#showImages = show; this.#updateDisplay(); @@ -960,6 +967,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac } override render(width: number): readonly string[] { + if (!this.#toolActivityVisible) return []; const lines = super.render(width); // Update the paint-tracking flags after `super.render(width)` — the // override runs on every compose the parent Container performs, so a diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 0856c3ac1..242196fae 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -276,6 +276,7 @@ export class EventController { showContentPreview: this.ctx.settings.get("read.toolResultPreview"), }); group.setExpanded(this.ctx.toolOutputExpanded); + group.setToolActivityVisible(!this.ctx.hideToolActivity); this.ctx.chatContainer.addChild(group); this.#lastReadGroup = group; } @@ -927,6 +928,7 @@ export class EventController { content.id, ); component.setExpanded(this.ctx.toolOutputExpanded); + component.setToolActivityVisible(!this.ctx.hideToolActivity); this.ctx.chatContainer.addChild(component); this.ctx.pendingTools.set(content.id, component); this.#toolTimelineComponents.set(content.id, component); @@ -1176,6 +1178,7 @@ export class EventController { event.toolCallId, ); component.setExpanded(this.ctx.toolOutputExpanded); + component.setToolActivityVisible(!this.ctx.hideToolActivity); this.ctx.chatContainer.addChild(component); this.ctx.pendingTools.set(event.toolCallId, component); this.#toolTimelineComponents.set(event.toolCallId, component); diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index e97777f0e..105ec3333 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -8,8 +8,10 @@ import { isSettingsInitialized, settings } from "../../config/settings"; import { resolveLocalRoot } from "../../internal-urls"; import { AssistantMessageComponent } from "../../modes/components/assistant-message"; import { extractImagePathFromText } from "../../modes/components/custom-editor"; +import { ReadToolGroupComponent } from "../../modes/components/read-tool-group"; import { renderSegmentTrack } from "../../modes/components/segment-track"; import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-title-download-progress"; +import { ToolExecutionComponent } from "../../modes/components/tool-execution"; import { expandEmoticons } from "../../modes/emoji-autocomplete"; import { materializeImageReferenceLinks, shiftImageMarkers } from "../../modes/image-references"; import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete"; @@ -455,6 +457,11 @@ export class InputController { this.ctx.editor.onCopyPrompt = () => this.handleCopyPrompt(); this.ctx.editor.setActionKeys("app.tools.expand", this.ctx.keybindings.getKeys("app.tools.expand")); this.ctx.editor.onExpandTools = () => this.toggleToolOutputExpansion(); + this.ctx.editor.setActionKeys( + "app.tools.toggleVisibility", + this.ctx.keybindings.getKeys("app.tools.toggleVisibility"), + ); + this.ctx.editor.onToggleToolActivity = () => this.toggleToolActivityVisibility(); this.ctx.editor.setActionKeys("app.message.dequeue", this.ctx.keybindings.getKeys("app.message.dequeue")); this.ctx.editor.onDequeue = () => this.handleDequeue(); this.ctx.editor.setActionKeys("app.retry", this.ctx.keybindings.getKeys("app.retry")); @@ -1845,9 +1852,36 @@ export class InputController { } toggleToolOutputExpansion(): void { + if (this.ctx.hideToolActivity) { + const visibilityKey = this.ctx.keybindings.getDisplayString("app.tools.toggleVisibility"); + const visibilityHint = visibilityKey ? `${visibilityKey} or /settings` : "/settings"; + this.ctx.showStatus(`Tool activity is hidden — show it with ${visibilityHint} before expanding`); + return; + } this.setToolsExpanded(!this.ctx.toolOutputExpanded); } + toggleToolActivityVisibility(): void { + this.ctx.hideToolActivity = !this.ctx.hideToolActivity; + this.ctx.settings.set("display.hideToolActivity", this.ctx.hideToolActivity); + + if (!this.ctx.hideToolActivity) { + this.ctx.toolOutputExpanded = false; + } + + for (const child of this.ctx.chatContainer.children) { + if (child instanceof ToolExecutionComponent || child instanceof ReadToolGroupComponent) { + if (!this.ctx.hideToolActivity) child.setExpanded(false); + child.setToolActivityVisible(!this.ctx.hideToolActivity); + } else if (child instanceof AssistantMessageComponent) { + child.setToolResultImagesVisible(!this.ctx.hideToolActivity); + } + } + + this.ctx.ui.resetDisplay(); + this.ctx.showStatus(`Tool activity: ${this.ctx.hideToolActivity ? "hidden" : "visible"}`); + } + setToolsExpanded(expanded: boolean): void { this.ctx.toolOutputExpanded = expanded; for (const child of this.ctx.chatContainer.children) { diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 563ea196a..468728a14 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -92,6 +92,7 @@ import { ModelHubComponent, type ModelRoleSelectionScope } from "../components/m import { ModelPickerComponent } from "../components/model-picker"; import { OAuthSelectorComponent } from "../components/oauth-selector"; import { PluginSelectorComponent } from "../components/plugin-selector"; +import { ReadToolGroupComponent } from "../components/read-tool-group"; import { ResetUsageSelectorComponent } from "../components/reset-usage-selector"; import { renderSegmentTrack } from "../components/segment-track"; import { SessionAccountSelectorComponent } from "../components/session-account-selector"; @@ -474,6 +475,21 @@ export class SelectorController { break; // Settings with UI side effects + case "display.hideToolActivity": { + const hidden = value as boolean; + this.ctx.hideToolActivity = hidden; + if (!hidden) this.ctx.toolOutputExpanded = false; + for (const child of this.ctx.chatContainer.children) { + if (child instanceof ToolExecutionComponent || child instanceof ReadToolGroupComponent) { + if (!hidden) child.setExpanded(false); + child.setToolActivityVisible(!hidden); + } else if (child instanceof AssistantMessageComponent) { + child.setToolResultImagesVisible(!hidden); + } + } + this.ctx.ui.resetDisplay(); + break; + } case "terminal.showImages": case "showImages": { const visible = value as boolean; diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 13a9bdb4d..122046703 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -460,6 +460,7 @@ export class InteractiveMode implements InteractiveModeContext { initialChatRendered = false; isBashMode = false; toolOutputExpanded = false; + hideToolActivity = false; todoExpanded = false; planModeEnabled = false; planModePaused = false; @@ -778,6 +779,7 @@ export class InteractiveMode implements InteractiveModeContext { // hot path where the render never gets to paint the result. this.editor.setTopBorderProvider(availableWidth => this.statusLine.getTopBorder(availableWidth)); + this.hideToolActivity = settings.get("display.hideToolActivity"); this.hideThinkingBlock = settings.get("hideThinkingBlock"); this.proseOnlyThinking = settings.get("proseOnlyThinking"); diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 482e229c8..c204b8386 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -162,6 +162,7 @@ export interface InteractiveModeContext { initialChatRendered: boolean; isBashMode: boolean; toolOutputExpanded: boolean; + hideToolActivity: boolean; todoExpanded: boolean; planModeEnabled: boolean; vibeModeEnabled: boolean; diff --git a/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts b/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts index 106d9beac..528241051 100644 --- a/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts +++ b/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts @@ -46,6 +46,7 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string `| \`${appKey(bindings, "app.plan.toggle")}\` | Toggle plan mode |`, `| \`${appKey(bindings, "app.history.search")}\` | Search prompt history |`, `| \`${appKey(bindings, "app.tools.expand")}\` | Toggle tool output expansion |`, + `| \`${appKey(bindings, "app.tools.toggleVisibility")}\` | Toggle tool activity visibility |`, `| \`${appKey(bindings, "app.thinking.toggle")}\` | Toggle thinking block visibility |`, `| \`${appKey(bindings, "app.editor.external")}\` | Edit message in external editor |`, `| \`${appKey(bindings, "app.retry")}\` | Retry last failed assistant turn |`, diff --git a/packages/coding-agent/src/modes/utils/interactive-context-helpers.ts b/packages/coding-agent/src/modes/utils/interactive-context-helpers.ts index 90979b20d..87e86bb99 100644 --- a/packages/coding-agent/src/modes/utils/interactive-context-helpers.ts +++ b/packages/coding-agent/src/modes/utils/interactive-context-helpers.ts @@ -25,6 +25,7 @@ export function createAssistantMessageComponent( ctx.proseOnlyThinking, ); component.setImagesVisible(ctx.settings.get("terminal.showImages")); + component.setToolResultImagesVisible(!ctx.hideToolActivity); component.setExpanded(ctx.toolOutputExpanded); return component; } diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 608cc89ea..f6bf6cbe0 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -444,6 +444,7 @@ export class UiHelpers { showContentPreview: this.ctx.settings.get("read.toolResultPreview"), }); readGroup.setExpanded(this.ctx.toolOutputExpanded); + readGroup.setToolActivityVisible(!this.ctx.hideToolActivity); this.ctx.chatContainer.addChild(readGroup); } readGroup.updateArgs(content.arguments, content.id); @@ -458,6 +459,7 @@ export class UiHelpers { showContentPreview: this.ctx.settings.get("read.toolResultPreview"), }); readGroup.setExpanded(this.ctx.toolOutputExpanded); + readGroup.setToolActivityVisible(!this.ctx.hideToolActivity); this.ctx.chatContainer.addChild(readGroup); } readGroup.updateArgs(content.arguments, content.id); @@ -510,6 +512,7 @@ export class UiHelpers { content.id, ); component.setExpanded(this.ctx.toolOutputExpanded); + component.setToolActivityVisible(!this.ctx.hideToolActivity); this.ctx.chatContainer.addChild(component); if (hasErrorStop && errorMessage) { @@ -578,6 +581,7 @@ export class UiHelpers { showContentPreview: this.ctx.settings.get("read.toolResultPreview"), }); readGroup.setExpanded(this.ctx.toolOutputExpanded); + readGroup.setToolActivityVisible(!this.ctx.hideToolActivity); this.ctx.chatContainer.addChild(readGroup); } const args = readToolCallArgs.get(message.toolCallId); diff --git a/packages/coding-agent/test/custom-editor-keybindings.test.ts b/packages/coding-agent/test/custom-editor-keybindings.test.ts index 5afbda0ce..341cdbd20 100644 --- a/packages/coding-agent/test/custom-editor-keybindings.test.ts +++ b/packages/coding-agent/test/custom-editor-keybindings.test.ts @@ -19,6 +19,17 @@ describe("CustomEditor keybindings", () => { expect(onRetry).toHaveBeenCalledTimes(1); }); + it("routes the configured tool activity visibility chord through handleInput", () => { + const editor = new CustomEditor(getEditorTheme()); + const onToggleToolActivity = vi.fn(); + + editor.setActionKeys("app.tools.toggleVisibility", ["alt+h"]); + editor.onToggleToolActivity = onToggleToolActivity; + editor.handleInput("\x1bh"); + + expect(onToggleToolActivity).toHaveBeenCalledTimes(1); + }); + it("lets custom handlers keep precedence over the default retry chord", () => { const editor = new CustomEditor(getEditorTheme()); const onRetry = vi.fn(); diff --git a/packages/coding-agent/test/event-controller-mixed-assistant-render.test.ts b/packages/coding-agent/test/event-controller-mixed-assistant-render.test.ts index 1fe499def..4f3e495f9 100644 --- a/packages/coding-agent/test/event-controller-mixed-assistant-render.test.ts +++ b/packages/coding-agent/test/event-controller-mixed-assistant-render.test.ts @@ -15,6 +15,9 @@ const TOOL_RESULT_A_MARKER = "TOOL RESULT FROM FIRST TOOL"; const MIDDLE_MARKER = "MIDDLE TEXT BETWEEN TOOL CALLS"; const TOOL_RESULT_B_MARKER = "TOOL RESULT FROM SECOND TOOL"; const FINAL_MARKER = "FINAL ANSWER AFTER SECOND TOOL"; +const HIDDEN_BASH_COMMAND_MARKER = "HIDDEN BASH COMMAND MARKER"; +const HIDDEN_BASH_FAILURE_MARKER = "HIDDEN BASH FAILURE MARKER"; +const HIDDEN_READ_PATH_MARKER = "hidden-tool-activity.ts"; function zeroUsage(): Usage { return { @@ -48,7 +51,7 @@ function lineContaining(lines: string[], marker: string): number { return index; } -function createFixture() { +function createFixture(hideToolActivity = false) { const chatContainer = new TranscriptContainer(); const pendingTools = new Map(); const ui = { @@ -72,6 +75,7 @@ function createFixture() { transcriptMessageComponents: new WeakMap(), pendingTools, toolOutputExpanded: false, + hideToolActivity, effectiveHideThinkingBlock: false, proseOnlyThinking: true, statusLine: { invalidate: vi.fn() }, @@ -211,4 +215,81 @@ describe("EventController mixed assistant text/tool rendering", () => { expect(middleLine).toBeLessThan(toolResultBLine); expect(toolResultBLine).toBeLessThan(finalLine); }); + + it("keeps assistant text streaming while hiding bash failures and grouped read activity", async () => { + const { controller, chatContainer } = createFixture(true); + const bashCall: ToolCall = { + type: "toolCall", + id: TOOL_CALL_A_ID, + name: "bash", + arguments: { command: `printf '${HIDDEN_BASH_COMMAND_MARKER}'` }, + }; + const readCall: ToolCall = { + type: "toolCall", + id: TOOL_CALL_B_ID, + name: "read", + arguments: { path: HIDDEN_READ_PATH_MARKER }, + }; + const started = assistantMessage([]); + const streaming = assistantMessage([ + { type: "text", text: INTRO_MARKER }, + bashCall, + { type: "text", text: MIDDLE_MARKER }, + readCall, + { type: "text", text: FINAL_MARKER }, + ]); + + await controller.handleEvent({ type: "message_start", message: started } as Extract< + AgentSessionEvent, + { type: "message_start" } + >); + await controller.handleEvent({ + type: "message_update", + message: streaming, + assistantMessageEvent: { + type: "toolcall_end", + contentIndex: 3, + toolCall: readCall, + partial: streaming, + }, + } as Extract); + await controller.handleEvent({ + type: "tool_execution_start", + toolCallId: TOOL_CALL_A_ID, + toolName: "bash", + args: bashCall.arguments, + } as Extract); + await controller.handleEvent({ + type: "tool_execution_end", + toolCallId: TOOL_CALL_A_ID, + toolName: "bash", + result: { content: [{ type: "text", text: HIDDEN_BASH_FAILURE_MARKER }] }, + isError: true, + } as Extract); + await controller.handleEvent({ + type: "tool_execution_start", + toolCallId: TOOL_CALL_B_ID, + toolName: "read", + args: readCall.arguments, + } as Extract); + await controller.handleEvent({ + type: "tool_execution_end", + toolCallId: TOOL_CALL_B_ID, + toolName: "read", + result: { content: [{ type: "text", text: "read result must stay hidden" }] }, + isError: false, + } as Extract); + await controller.handleEvent({ type: "message_end", message: streaming } as Extract< + AgentSessionEvent, + { type: "message_end" } + >); + + const rendered = Bun.stripANSI(chatContainer.render(120).join("\n")); + expect(rendered).toContain(INTRO_MARKER); + expect(rendered).toContain(MIDDLE_MARKER); + expect(rendered).toContain(FINAL_MARKER); + expect(rendered).not.toContain(HIDDEN_BASH_COMMAND_MARKER); + expect(rendered).not.toContain(HIDDEN_BASH_FAILURE_MARKER); + expect(rendered).not.toContain(HIDDEN_READ_PATH_MARKER); + }); }); diff --git a/packages/coding-agent/test/input-controller-keybindings.test.ts b/packages/coding-agent/test/input-controller-keybindings.test.ts index 157db8914..cb75c44ab 100644 --- a/packages/coding-agent/test/input-controller-keybindings.test.ts +++ b/packages/coding-agent/test/input-controller-keybindings.test.ts @@ -20,6 +20,7 @@ type FakeEditor = { onPasteImage?: () => Promise; onCopyPrompt?: () => void; onExpandTools?: () => void; + onToggleToolActivity?: () => void; onToggleThinking?: () => void; onExternalEditor?: () => void; onRetry?: () => void; @@ -62,6 +63,7 @@ async function createContext() { "app.model.select": ["alt+m"], "app.retry": ["alt+r"], "app.clipboard.pasteImage": ["ctrl+v"], + "app.tools.toggleVisibility": ["ctrl+shift+o"], }; const customHandlers = new Map void>(); const setActionKeys = vi.fn(); @@ -190,6 +192,10 @@ async function createContext() { updatePendingMessagesDisplay, isBashMode: false, isPythonMode: false, + hideToolActivity: false, + toolOutputExpanded: false, + settings: { set: vi.fn() }, + chatContainer: { children: [] }, handleHotkeysCommand: vi.fn(), handlePlanModeCommand: vi.fn(), handleClearCommand: vi.fn(), @@ -264,6 +270,22 @@ describe("InputController keybinding setup", () => { expect(spies.resetDisplayAfterAppearanceRefresh).toHaveBeenCalledTimes(1); }); + it("registers the tool activity visibility action", async () => { + const { InputController, ctx, editor, spies } = await createContext(); + const controller = new InputController(ctx); + + controller.setupKeyHandlers(); + + expect(spies.setActionKeys).toHaveBeenCalledWith("app.tools.toggleVisibility", ["ctrl+shift+o"]); + expect(editor.onToggleToolActivity).toBeDefined(); + + editor.onToggleToolActivity?.(); + + expect(ctx.hideToolActivity).toBe(true); + expect(ctx.settings.set).toHaveBeenCalledWith("display.hideToolActivity", true); + expect(spies.resetDisplay).toHaveBeenCalledTimes(1); + }); + it("does not mark pasted shell prompts as Python mode while editing", async () => { const { InputController, ctx, editor } = await createContext(); const controller = new InputController(ctx); diff --git a/packages/coding-agent/test/modes/controllers/command-controller-hotkeys.test.ts b/packages/coding-agent/test/modes/controllers/command-controller-hotkeys.test.ts index 6efa57cee..74487a272 100644 --- a/packages/coding-agent/test/modes/controllers/command-controller-hotkeys.test.ts +++ b/packages/coding-agent/test/modes/controllers/command-controller-hotkeys.test.ts @@ -8,6 +8,7 @@ describe("buildHotkeysMarkdown", () => { "app.clipboard.copyPrompt": "Ctrl+Shift+P", "app.plan.toggle": "Alt+Shift+P", "app.tools.expand": "Ctrl+O", + "app.tools.toggleVisibility": "Ctrl+Shift+O", "app.display.reset": "Alt+L", "app.interrupt": "Esc", "app.clear": "Ctrl+C", @@ -43,6 +44,7 @@ describe("buildHotkeysMarkdown", () => { expect(markdown).toContain("| `Ctrl+L` | Start/stop live voice mode (/live) |"); expect(markdown).toContain("| `Alt+R` | Retry last failed assistant turn |"); expect(markdown).toContain("| `Alt+Shift+P` | Toggle plan mode |"); + expect(markdown).toContain("| `Ctrl+Shift+O` | Toggle tool activity visibility |"); expect(markdown).toContain("| `#` | GitHub issue/PR reference"); expect(markdown).toContain("| `#` / `#` | Prompt actions"); for (const line of lines) { diff --git a/packages/coding-agent/test/modes/controllers/input-controller-tool-expansion.test.ts b/packages/coding-agent/test/modes/controllers/input-controller-tool-expansion.test.ts index 4ba7eb3be..3d91e89a4 100644 --- a/packages/coding-agent/test/modes/controllers/input-controller-tool-expansion.test.ts +++ b/packages/coding-agent/test/modes/controllers/input-controller-tool-expansion.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "bun:test"; +import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message"; import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; @@ -24,4 +25,78 @@ describe("InputController tool output expansion", () => { expect(resetDisplay).toHaveBeenCalledTimes(1); expect(requestRender).not.toHaveBeenCalled(); }); + + it("does not expand hidden tool activity and explains why", () => { + const expandable = { setExpanded: vi.fn() }; + const resetDisplay = vi.fn(); + const showStatus = vi.fn(); + const ctx = { + hideToolActivity: true, + toolOutputExpanded: false, + chatContainer: { children: [expandable] }, + keybindings: { getDisplayString: vi.fn(() => "Alt+H") }, + showStatus, + ui: { resetDisplay }, + } as unknown as InteractiveModeContext; + + new InputController(ctx).toggleToolOutputExpansion(); + + expect(ctx.toolOutputExpanded).toBe(false); + expect(expandable.setExpanded).not.toHaveBeenCalled(); + expect(resetDisplay).not.toHaveBeenCalled(); + expect(showStatus).toHaveBeenCalledWith(expect.stringContaining("Alt+H")); + expect(showStatus).toHaveBeenCalledWith(expect.stringContaining("/settings")); + }); +}); + +describe("InputController tool activity visibility", () => { + it("persists the toggle, preserves transient children, and reveals tools collapsed", () => { + const pendingUserMessage = { kind: "pending-user" }; + const loadingIndicator = { kind: "loading" }; + const assistant = new AssistantMessageComponent(); + const setToolResultImagesVisible = vi.spyOn(assistant, "setToolResultImagesVisible"); + const children = [pendingUserMessage, assistant, loadingIndicator]; + const clear = vi.fn(); + const addChild = vi.fn(); + const rebuildChatFromMessages = vi.fn(); + const set = vi.fn(); + const resetDisplay = vi.fn(); + const showStatus = vi.fn(); + const ctx = { + hideToolActivity: false, + toolOutputExpanded: true, + settings: { set }, + chatContainer: { children, clear, addChild }, + rebuildChatFromMessages, + showStatus, + ui: { resetDisplay }, + }; + const controller = new InputController(ctx as unknown as InteractiveModeContext) as unknown as InputController & { + toggleToolActivityVisibility(): void; + }; + + controller.toggleToolActivityVisibility(); + + expect(ctx.hideToolActivity).toBe(true); + expect(set).toHaveBeenLastCalledWith("display.hideToolActivity", true); + expect(ctx.chatContainer.children).toEqual(children); + expect(clear).not.toHaveBeenCalled(); + expect(addChild).not.toHaveBeenCalled(); + expect(rebuildChatFromMessages).not.toHaveBeenCalled(); + expect(resetDisplay).toHaveBeenCalled(); + expect(showStatus).toHaveBeenLastCalledWith("Tool activity: hidden"); + expect(setToolResultImagesVisible).toHaveBeenLastCalledWith(false); + + controller.toggleToolActivityVisibility(); + + expect(ctx.hideToolActivity).toBe(false); + expect(ctx.toolOutputExpanded).toBe(false); + expect(set).toHaveBeenLastCalledWith("display.hideToolActivity", false); + expect(ctx.chatContainer.children).toEqual(children); + expect(clear).not.toHaveBeenCalled(); + expect(addChild).not.toHaveBeenCalled(); + expect(rebuildChatFromMessages).not.toHaveBeenCalled(); + expect(showStatus).toHaveBeenLastCalledWith("Tool activity: visible"); + expect(setToolResultImagesVisible).toHaveBeenLastCalledWith(true); + }); }); diff --git a/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts b/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts index ee784963d..cad7f9d3a 100644 --- a/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts +++ b/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts @@ -139,6 +139,7 @@ function hasImageComponent(component: Component): boolean { function makeRenderCtx( transcript: SessionContext, showImages = true, + hideToolActivity = false, ): { ctx: InteractiveModeContext; chatContainer: Container } { const chatContainer = new Container(); let helpers: UiHelpers; @@ -156,8 +157,15 @@ function makeRenderCtx( resetTranscript: () => chatContainer.clear(), // Rebuild paths honor terminal.showImages since the native-image work; // keep it on so the image-replay contracts below stay meaningful. - settings: { get: (key: string) => key === "terminal.showImages" && showImages }, + settings: { + get: (key: string) => { + if (key === "terminal.showImages") return showImages; + if (key === "display.hideToolActivity") return hideToolActivity; + return false; + }, + }, toolOutputExpanded: false, + hideToolActivity, hideThinkingBlock: false, focusedAgentId: undefined, editor: { addToHistory: vi.fn() }, @@ -306,6 +314,33 @@ describe("UiHelpers.renderInitialMessages — image replay", () => { expect(hasImageComponent(chatContainer)).toBe(true); }); + it("preserves tool-result images while tool activity is hidden so revealing it can replay the image", async () => { + await Settings.init({ inMemory: true, overrides: { "terminal.showImages": true } }); + setTerminalImageProtocol(ImageProtocol.Sixel); + const transcript = transcriptWith([ + assistantToolCall("read-tool-hidden", "read", { path: "tool-hidden.png" }), + { + role: "toolResult", + toolCallId: "read-tool-hidden", + toolName: "read", + content: [{ type: "text", text: "Read image: tool-hidden.png" }, pngImage], + isError: false, + timestamp: 2, + }, + ]); + const { ctx, chatContainer } = makeRenderCtx(transcript, true, true); + + new UiHelpers(ctx).renderInitialMessages(); + + expect(hasImageComponent(chatContainer)).toBe(false); + const assistant = chatContainer.children.find( + (child): child is AssistantMessageComponent => child instanceof AssistantMessageComponent, + ); + expect(assistant).toBeDefined(); + assistant?.setToolResultImagesVisible(true); + expect(hasImageComponent(chatContainer)).toBe(true); + }); + it("replays reopened session image blocks through the cold-start rebuild path", async () => { await Settings.init({ inMemory: true, overrides: { "terminal.showImages": true } }); setTerminalImageProtocol(ImageProtocol.Sixel); @@ -348,6 +383,57 @@ describe("UiHelpers.renderInitialMessages — image replay", () => { }); }); +describe("UiHelpers.renderInitialMessages — hidden tool activity", () => { + it("hides replayed tool cards without discarding them from the persisted transcript", () => { + const toolCallId = "replayed-hidden-tool"; + const toolArgumentMarker = "REPLAYED TOOL ARGUMENT MARKER"; + const toolResultMarker = "REPLAYED TOOL RESULT MARKER"; + const narrationMarker = "ASSISTANT NARRATION STAYS VISIBLE"; + const finalMarker = "FINAL ASSISTANT RESPONSE STAYS VISIBLE"; + const transcript = transcriptWith([ + { + ...assistantToolCall(toolCallId, "contract_probe", { value: toolArgumentMarker }), + content: [ + { type: "text", text: narrationMarker }, + { type: "toolCall", id: toolCallId, name: "contract_probe", arguments: { value: toolArgumentMarker } }, + ], + }, + { + role: "toolResult", + toolCallId, + toolName: "contract_probe", + content: [{ type: "text", text: toolResultMarker }], + isError: false, + timestamp: 2, + }, + { + role: "assistant", + content: [{ type: "text", text: finalMarker }], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet", + usage: emptyUsage, + stopReason: "stop", + timestamp: 3, + }, + ]); + + const hidden = makeRenderCtx(transcript, true, true); + new UiHelpers(hidden.ctx).renderInitialMessages(); + const hiddenRender = Bun.stripANSI(hidden.chatContainer.render(120).join("\n")); + expect(hiddenRender).toContain(narrationMarker); + expect(hiddenRender).toContain(finalMarker); + expect(hiddenRender).not.toContain(toolArgumentMarker); + expect(hiddenRender).not.toContain(toolResultMarker); + + const visible = makeRenderCtx(transcript, true, false); + new UiHelpers(visible.ctx).renderInitialMessages(); + const visibleRender = Bun.stripANSI(visible.chatContainer.render(120).join("\n")); + expect(visibleRender).toContain(toolArgumentMarker); + expect(visibleRender).toContain(toolResultMarker); + }); +}); + describe("UiHelpers.renderSessionContext — error-stop tool calls", () => { it("keeps the synthetic assistant error result instead of replaying a later tool result", async () => { await Settings.init({ inMemory: true }); diff --git a/packages/coding-agent/test/selector-settings-side-effects.test.ts b/packages/coding-agent/test/selector-settings-side-effects.test.ts index a30f4cb10..d74c2f260 100644 --- a/packages/coding-agent/test/selector-settings-side-effects.test.ts +++ b/packages/coding-agent/test/selector-settings-side-effects.test.ts @@ -9,6 +9,7 @@ import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message"; +import { ReadToolGroupComponent } from "@oh-my-pi/pi-coding-agent/modes/components/read-tool-group"; import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution"; import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/selector-controller"; import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; @@ -125,6 +126,43 @@ describe("selector setting side effects", () => { } } + for (const hidden of [true, false]) { + it(`applies display.hideToolActivity=${hidden} to existing tool components`, () => { + const setToolVisible = vi.fn(); + const setToolExpanded = vi.fn(); + const tool = Object.create(ToolExecutionComponent.prototype) as ToolExecutionComponent; + tool.setToolActivityVisible = setToolVisible; + tool.setExpanded = setToolExpanded; + const setReadVisible = vi.fn(); + const setReadExpanded = vi.fn(); + const readGroup = Object.create(ReadToolGroupComponent.prototype) as ReadToolGroupComponent; + readGroup.setToolActivityVisible = setReadVisible; + readGroup.setExpanded = setReadExpanded; + const setToolResultImagesVisible = vi.fn(); + const assistant = Object.create(AssistantMessageComponent.prototype) as AssistantMessageComponent; + assistant.setToolResultImagesVisible = setToolResultImagesVisible; + const resetDisplay = vi.fn(); + const ctx = { + hideToolActivity: !hidden, + toolOutputExpanded: true, + chatContainer: { children: [tool, readGroup, assistant] }, + ui: { resetDisplay }, + }; + const controller = new SelectorController(ctx as unknown as InteractiveModeContext); + + controller.handleSettingChange("display.hideToolActivity", hidden); + + expect(ctx.hideToolActivity).toBe(hidden); + expect(setToolVisible).toHaveBeenCalledWith(!hidden); + expect(setReadVisible).toHaveBeenCalledWith(!hidden); + expect(setToolResultImagesVisible).toHaveBeenCalledWith(!hidden); + expect(setToolExpanded).toHaveBeenCalledTimes(hidden ? 0 : 1); + expect(setReadExpanded).toHaveBeenCalledTimes(hidden ? 0 : 1); + expect(ctx.toolOutputExpanded).toBe(hidden); + expect(resetDisplay).toHaveBeenCalledTimes(1); + }); + } + it("clears stale default role thinking when auto is selected", async () => { const testTheme = await getThemeByName("dark"); if (!testTheme) throw new Error("Failed to load dark theme for model selector test"); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 5192465b7..2f9bfd6db 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -403,6 +403,12 @@ describe("Settings", () => { expect(getDefault("terminal.showProgress")).toBe(false); }); + it("shows tool activity by default", async () => { + const settings = await Settings.init({ cwd: projectDir, agentDir }); + expect(settings.get("display.hideToolActivity")).toBe(false); + expect(getDefault("display.hideToolActivity")).toBe(false); + }); + it("keeps the normal startup splash disabled by default", async () => { const settings = await Settings.init({ cwd: projectDir, agentDir }); expect(settings.get("startup.showSplash")).toBe(false);