Merge PR #7342: feat(tui): add hidden tool activity mode (@dannyboy-ai)

This commit is contained in:
can1357
2026-08-03 05:34:17 +02:00
24 changed files with 464 additions and 2 deletions
+3
View File
@@ -61,6 +61,9 @@
- Fixed `skill://` resolution ignoring explicitly configured `skills.customDirectories` entries when a same-named skill existed in a default discovery path: the custom-directory skill now wins as the higher-priority source ([#7190](https://github.com/can1357/oh-my-pi/issues/7190)).
- Fixed image paste failing on Wayland-only Linux sessions by reading PNG clipboard payloads through `wl-paste` before falling back to the native bridge ([#7316](https://github.com/can1357/oh-my-pi/issues/7316)).
- Fixed prewalk switching to the fast model during read-only investigation: `xd://` devices are dispatched through the `write` tool, so a read-only call such as an `lsp` navigation counted as the first edit/write and armed the one-way hand-off mid-planning. Device dispatches now carry the wrapped tool's approval tier and only trigger the switch at a `write`/`exec` tier — read-only `lsp`, `debug` inspection, and internal-URL `ast_edit` calls no longer downgrade the model ([#7312](https://github.com/can1357/oh-my-pi/issues/7312)).
### Added
- Added `display.hideToolActivity` and the configurable `Ctrl+Shift+O` shortcut to hide model-initiated tool calls, results, failures, and result images while keeping assistant text visible; hidden activity remains persisted and can be revealed later in collapsed form.
## [17.2.4] - 2026-08-01
@@ -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",
@@ -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,
@@ -185,6 +185,7 @@ export class AssistantMessageComponent extends Container {
#toolImagesByCallId = new Map<string, ImageContent[]>();
#convertedKittyImages = new Map<string, ImageContent>();
#showImages = true;
#showToolResultImages = true;
#kittyConversionsInFlight = new Set<string>();
#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}` })),
);
@@ -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);
@@ -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<ConfigurableEditorAction, KeyId[]> = {
"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();
@@ -332,6 +332,7 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
#usageBatchByToolCallId = new Map<string, string>();
#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;
}
@@ -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
@@ -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);
@@ -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,37 @@ 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);
}
}
if (this.ctx.hideToolActivity) this.ctx.ui.clearInlineImages();
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) {
@@ -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,22 @@ 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);
}
}
if (hidden) this.ctx.ui.clearInlineImages();
this.ctx.ui.resetDisplay();
break;
}
case "terminal.showImages":
case "showImages": {
const visible = value as boolean;
@@ -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");
+1
View File
@@ -162,6 +162,7 @@ export interface InteractiveModeContext {
initialChatRendered: boolean;
isBashMode: boolean;
toolOutputExpanded: boolean;
hideToolActivity: boolean;
todoExpanded: boolean;
planModeEnabled: boolean;
vibeModeEnabled: boolean;
@@ -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 |`,
@@ -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;
}
@@ -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);
@@ -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();
@@ -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<AgentSessionEvent, { type: "message_update" }>);
await controller.handleEvent({
type: "tool_execution_start",
toolCallId: TOOL_CALL_A_ID,
toolName: "bash",
args: bashCall.arguments,
} as Extract<AgentSessionEvent, { type: "tool_execution_start" }>);
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<AgentSessionEvent, { type: "tool_execution_end" }>);
await controller.handleEvent({
type: "tool_execution_start",
toolCallId: TOOL_CALL_B_ID,
toolName: "read",
args: readCall.arguments,
} as Extract<AgentSessionEvent, { type: "tool_execution_start" }>);
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<AgentSessionEvent, { type: "tool_execution_end" }>);
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);
});
});
@@ -20,6 +20,7 @@ type FakeEditor = {
onPasteImage?: () => Promise<boolean>;
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<string, () => void>();
const setActionKeys = vi.fn();
@@ -72,6 +74,7 @@ async function createContext() {
customHandlers.clear();
});
const resetDisplay = vi.fn();
const clearInlineImages = vi.fn();
const showModelSelector = vi.fn();
const requestRender = vi.fn();
const showError = vi.fn();
@@ -140,6 +143,7 @@ async function createContext() {
ui: {
requestRender,
resetDisplay,
clearInlineImages,
addInputListener,
addStartListener,
getFocused: vi.fn(() => focused),
@@ -190,6 +194,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(),
@@ -228,6 +236,7 @@ async function createContext() {
retry,
abort,
resetDisplay,
clearInlineImages,
refreshAppearance,
resetDisplayAfterAppearanceRefresh,
handleBtwBranchKey,
@@ -264,6 +273,23 @@ 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.clearInlineImages).toHaveBeenCalledTimes(1);
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);
@@ -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("| `#<number>` | GitHub issue/PR reference");
expect(markdown).toContain("| `#` / `#<text>` | Prompt actions");
for (const line of lines) {
@@ -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,83 @@ 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 clearInlineImages = 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: { clearInlineImages, 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(clearInlineImages).toHaveBeenCalledTimes(1);
expect(resetDisplay).toHaveBeenCalledTimes(1);
expect(clearInlineImages.mock.invocationCallOrder[0]).toBeLessThan(resetDisplay.mock.invocationCallOrder[0]);
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(clearInlineImages).toHaveBeenCalledTimes(1);
expect(resetDisplay).toHaveBeenCalledTimes(2);
expect(showStatus).toHaveBeenLastCalledWith("Tool activity: visible");
expect(setToolResultImagesVisible).toHaveBeenLastCalledWith(true);
});
});
@@ -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 });
@@ -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,50 @@ 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 clearInlineImages = vi.fn();
const resetDisplay = vi.fn();
const ctx = {
hideToolActivity: !hidden,
toolOutputExpanded: true,
chatContainer: { children: [tool, readGroup, assistant] },
ui: { clearInlineImages, 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(clearInlineImages).toHaveBeenCalledTimes(hidden ? 1 : 0);
expect(resetDisplay).toHaveBeenCalledTimes(1);
if (hidden) {
expect(clearInlineImages.mock.invocationCallOrder[0]).toBeLessThan(
resetDisplay.mock.invocationCallOrder[0],
);
}
});
}
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");
@@ -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);