fix(coding-agent): centralize hidden activity state

This commit is contained in:
Daniel Anderson-Little
2026-08-11 17:07:20 -04:00
parent 3a4ea6a376
commit cbd7dc2e70
13 changed files with 108 additions and 126 deletions
@@ -54,7 +54,7 @@ import { EvalExecutionComponent } from "./eval-execution";
import { type LateDiagnosticsFile, LateDiagnosticsMessageComponent } from "./late-diagnostics-message";
import { groupedReadUsageCallIds, ReadToolGroupComponent, readArgsCollapseIntoGroup } from "./read-tool-group";
import { SkillMessageComponent } from "./skill-message";
import { ToolActivityContainer } from "./tool-activity-container";
import { ToolActivityContainer } from "./tool-activity";
import { ToolExecutionComponent } from "./tool-execution";
import { TranscriptContainer } from "./transcript-container";
import { createUsageRowBlock } from "./usage-row";
@@ -97,7 +97,9 @@ export class ChatTranscriptBuilder {
#expandables: Array<{ setExpanded(expanded: boolean): void }> = [];
#expanded = false;
constructor(private readonly deps: ChatTranscriptBuilderDeps) {}
constructor(private readonly deps: ChatTranscriptBuilderDeps) {
this.container.setToolActivityVisible(!settings.get("display.hideToolActivity"));
}
/** Whether the transcript currently holds any rendered rows. */
get isEmpty(): boolean {
@@ -194,7 +196,6 @@ 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);
}
@@ -409,7 +410,6 @@ export class ChatTranscriptBuilder {
this.deps.cwd,
content.id,
);
component.setToolActivityVisible(!settings.get("display.hideToolActivity"));
this.#trackExpandable(component);
this.container.addChild(component);
@@ -470,14 +470,12 @@ export class ChatTranscriptBuilder {
if (!message.display) return;
if (message.customType === "async-result") {
const component = buildAsyncResultBlock(message);
component.setToolActivityVisible(!settings.get("display.hideToolActivity"));
this.container.addChild(component);
return;
}
if (message.customType === LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE) {
const details = (message as CustomMessage<{ files?: LateDiagnosticsFile[] }>).details;
const component = new LateDiagnosticsMessageComponent(details?.files ?? []);
component.setToolActivityVisible(!settings.get("display.hideToolActivity"));
this.#trackExpandable(component);
this.container.addChild(component);
return;
@@ -512,7 +510,6 @@ export class ChatTranscriptBuilder {
);
this.#trackExpandable(messageComponent);
const component = new ToolActivityContainer(messageComponent);
component.setToolActivityVisible(!settings.get("display.hideToolActivity"));
this.container.addChild(component);
return;
}
@@ -1,27 +0,0 @@
import { type Component, Container } from "@oh-my-pi/pi-tui";
import type { ToolActivityComponent } from "./tool-activity";
/** Keeps model/tool activity mounted while making its rows reversible. */
export class ToolActivityContainer extends Container implements ToolActivityComponent {
#visible = true;
constructor(component: Component | Component[]) {
super();
if (Array.isArray(component)) {
for (const child of component) this.addChild(child);
} else {
this.addChild(component);
}
}
setToolActivityVisible(visible: boolean): void {
if (this.#visible === visible) return;
this.#visible = visible;
this.invalidate();
}
override render(width: number): readonly string[] {
if (!this.#visible) return [];
return super.render(width);
}
}
@@ -1,22 +0,0 @@
import { Text } from "@oh-my-pi/pi-tui";
import type { ToolActivityComponent } from "./tool-activity";
/** A tool-originated warning that follows the reversible activity toggle. */
export class ToolActivityWarningComponent extends Text implements ToolActivityComponent {
#visible = true;
constructor(message: string) {
super(message, 1, 0);
}
setToolActivityVisible(visible: boolean): void {
if (this.#visible === visible) return;
this.#visible = visible;
this.invalidate();
}
override render(width: number): readonly string[] {
if (!this.#visible) return [];
return super.render(width);
}
}
@@ -1,11 +1,33 @@
import type { Component } from "@oh-my-pi/pi-tui";
import { type Component, Container } from "@oh-my-pi/pi-tui";
/** A transcript component whose rows belong to model/tool activity. */
export interface ToolActivityComponent {
setToolActivityVisible(visible: boolean): void;
}
/** Narrows mounted transcript children to the reversible tool-activity contract. */
export function isToolActivityComponent(component: Component): component is Component & ToolActivityComponent {
return typeof (component as Partial<ToolActivityComponent>).setToolActivityVisible === "function";
}
export class ToolActivityContainer extends Container implements ToolActivityComponent {
#visible = true;
constructor(component: Component | Component[]) {
super();
if (Array.isArray(component)) {
for (const child of component) this.addChild(child);
} else {
this.addChild(component);
}
}
setToolActivityVisible(visible: boolean): void {
if (this.#visible === visible) return;
this.#visible = visible;
this.invalidate();
}
override render(width: number): readonly string[] {
if (!this.#visible) return [];
return super.render(width);
}
}
@@ -6,6 +6,7 @@ import {
type RenderStablePrefix,
type ViewportTailProvider,
} from "@oh-my-pi/pi-tui";
import { isToolActivityComponent } from "./tool-activity";
/**
* A transcript block that is still mutating (a foreground tool awaiting its
@@ -160,6 +161,7 @@ export class TranscriptContainer
extends Container
implements NativeScrollbackLiveRegion, NativeScrollbackCommittedRows, RenderStablePrefix, ViewportTailProvider
{
#toolActivityVisible = true;
// Bumped to retire every block segment at once (theme change / clear); a
// segment is only reused when its stored generation matches.
#generation = 0;
@@ -182,6 +184,20 @@ export class TranscriptContainer
// consumes the report and re-bases the baseline). Out-of-band renders
// between engine frames lower it; they can never inflate it.
#stableRowsFloor = 0;
override addChild(component: Component): void {
if (isToolActivityComponent(component)) component.setToolActivityVisible(this.#toolActivityVisible);
super.addChild(component);
}
setToolActivityVisible(visible: boolean): void {
if (this.#toolActivityVisible === visible) return;
this.#toolActivityVisible = visible;
for (const child of this.children) {
if (isToolActivityComponent(child)) child.setToolActivityVisible(visible);
}
this.invalidate();
}
override invalidate(): void {
// Theme/global invalidation: retire every diff snapshot so stale styling
// is not diffed against the recolored render.
@@ -345,7 +345,6 @@ 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;
}
@@ -1131,7 +1130,6 @@ 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);
@@ -1384,7 +1382,6 @@ export class EventController {
);
component.setArgsComplete(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);
@@ -1624,13 +1621,13 @@ export class EventController {
// This text can be a provider error copied verbatim off the wire (the
// Cursor todo bridge forwards the server's string), so it may carry
// ANSI escapes, other C0/C1 controls, tabs, newlines, or a line far
// wider than the terminal. `showToolActivityWarning` renders through
// a reversible activity component, so hiding tool activity does not
// lose this warning. `sanitizeText` drops the control sequences (and
// returns the same reference when there are none), then `previewLine`
// collapses the remaining whitespace and bounds the width. Sanitizing
// first matters: truncating before stripping can cut an escape
// mid-sequence and leave a dangling introducer.
// wider than the terminal. `showToolActivityWarning` renders through a
// plain `Text`, which strips none of that — an escape reaches the terminal
// and can repaint outside the row. `sanitizeText` drops the control
// sequences (and returns the same reference when there are none),
// then `previewLine` collapses the remaining whitespace and bounds
// the width. Sanitizing first matters: truncating before stripping
// can cut an escape mid-sequence and leave a dangling introducer.
//
// This is the render boundary, not the persisted result: the stored
// error stays full-fidelity for the transcript and for replays.
@@ -2019,14 +2016,12 @@ export class EventController {
}
const component = new TtsrNotificationComponent(event.rules);
component.setExpanded(this.ctx.toolOutputExpanded);
component.setToolActivityVisible(!this.ctx.hideToolActivity);
this.ctx.present(component);
this.#lastTtsrNotification = component;
}
async #handleTodoReminder(event: Extract<AgentSessionEvent, { type: "todo_reminder" }>): Promise<void> {
const component = new TodoReminderComponent(event.todos, event.attempt, event.maxAttempts);
component.setToolActivityVisible(!this.ctx.hideToolActivity);
this.ctx.present(component);
}
async #handleTodoAutoClear(_event: Extract<AgentSessionEvent, { type: "todo_auto_clear" }>): Promise<void> {
@@ -11,7 +11,6 @@ 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 { isToolActivityComponent } from "../../modes/components/tool-activity";
import { ToolExecutionComponent } from "../../modes/components/tool-execution";
import { TreeSelectorComponent } from "../../modes/components/tree-selector";
import { expandEmoticons } from "../../modes/emoji-autocomplete";
@@ -1925,18 +1924,16 @@ export class InputController {
}
for (const child of this.ctx.chatContainer.children) {
if (isToolActivityComponent(child)) {
if (
!this.ctx.hideToolActivity &&
(child instanceof ToolExecutionComponent || child instanceof ReadToolGroupComponent)
) {
child.setExpanded(false);
}
child.setToolActivityVisible(!this.ctx.hideToolActivity);
if (
!this.ctx.hideToolActivity &&
(child instanceof ToolExecutionComponent || child instanceof ReadToolGroupComponent)
) {
child.setExpanded(false);
} else if (child instanceof AssistantMessageComponent) {
child.setToolResultImagesVisible(!this.ctx.hideToolActivity);
}
}
this.ctx.chatContainer.setToolActivityVisible(!this.ctx.hideToolActivity);
if (this.ctx.hideToolActivity) this.ctx.ui.clearInlineImages();
this.ctx.ui.resetDisplay();
@@ -98,7 +98,6 @@ import { renderSegmentTrack } from "../components/segment-track";
import { SessionAccountSelectorComponent } from "../components/session-account-selector";
import { SessionSelectorComponent, type SessionSelectorOptions } from "../components/session-selector";
import { SettingsSelectorComponent } from "../components/settings-selector";
import { isToolActivityComponent } from "../components/tool-activity";
import { ToolExecutionComponent } from "../components/tool-execution";
import { TranscriptBlock } from "../components/transcript-container";
import { TreeSelectorComponent } from "../components/tree-selector";
@@ -495,15 +494,13 @@ export class SelectorController {
this.ctx.hideToolActivity = hidden;
if (!hidden) this.ctx.toolOutputExpanded = false;
for (const child of this.ctx.chatContainer.children) {
if (isToolActivityComponent(child)) {
if (!hidden && (child instanceof ToolExecutionComponent || child instanceof ReadToolGroupComponent)) {
child.setExpanded(false);
}
child.setToolActivityVisible(!hidden);
if (!hidden && (child instanceof ToolExecutionComponent || child instanceof ReadToolGroupComponent)) {
child.setExpanded(false);
} else if (child instanceof AssistantMessageComponent) {
child.setToolResultImagesVisible(!hidden);
}
}
this.ctx.chatContainer.setToolActivityVisible(!hidden);
if (hidden) this.ctx.ui.clearInlineImages();
this.ctx.ui.resetDisplay();
break;
@@ -838,6 +838,7 @@ export class InteractiveMode implements InteractiveModeContext {
this.editor.setTopBorderProvider(availableWidth => this.statusLine.getTopBorder(availableWidth));
this.hideToolActivity = settings.get("display.hideToolActivity");
this.chatContainer.setToolActivityVisible(!this.hideToolActivity);
this.hideThinkingBlock = settings.get("hideThinkingBlock");
this.proseOnlyThinking = settings.get("proseOnlyThinking");
@@ -16,7 +16,7 @@ import {
import { createIrcMessageCard } from "../../tools/hub";
import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
import { canonicalizeMessage } from "../../utils/thinking-display";
import { ToolActivityContainer } from "../components/tool-activity-container";
import { ToolActivityContainer } from "../components/tool-activity";
import { TranscriptBlock } from "../components/transcript-container";
import { theme } from "../theme/theme";
@@ -32,8 +32,7 @@ import {
} from "../../modes/components/read-tool-group";
import { SkillMessageComponent } from "../../modes/components/skill-message";
import { StrippedToolCallsPlaceholder } from "../../modes/components/stripped-tool-calls-placeholder";
import { ToolActivityContainer } from "../../modes/components/tool-activity-container";
import { ToolActivityWarningComponent } from "../../modes/components/tool-activity-warning";
import { ToolActivityContainer } from "../../modes/components/tool-activity";
import { ToolExecutionComponent } from "../../modes/components/tool-execution";
import { TranscriptBlock } from "../../modes/components/transcript-container";
import { createUsageRowBlock } from "../../modes/components/usage-row";
@@ -164,7 +163,6 @@ export class UiHelpers {
if (message.display) {
if (message.customType === "async-result") {
const component = buildAsyncResultBlock(message);
component.setToolActivityVisible(!this.ctx.hideToolActivity);
this.ctx.chatContainer.addChild(component);
break;
}
@@ -176,7 +174,6 @@ export class UiHelpers {
).details;
const component = new LateDiagnosticsMessageComponent(details?.files ?? []);
component.setExpanded(this.ctx.toolOutputExpanded);
component.setToolActivityVisible(!this.ctx.hideToolActivity);
this.ctx.chatContainer.addChild(component);
break;
}
@@ -187,7 +184,6 @@ export class UiHelpers {
);
messageComponent.setExpanded(this.ctx.toolOutputExpanded);
const component = new ToolActivityContainer(messageComponent);
component.setToolActivityVisible(!this.ctx.hideToolActivity);
this.ctx.chatContainer.addChild(component);
break;
}
@@ -462,7 +458,6 @@ 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);
@@ -477,7 +472,6 @@ 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);
@@ -531,7 +525,6 @@ export class UiHelpers {
content.id,
);
component.setExpanded(this.ctx.toolOutputExpanded);
component.setToolActivityVisible(!this.ctx.hideToolActivity);
this.ctx.chatContainer.addChild(component);
if (hasErrorStop && errorMessage) {
@@ -591,7 +584,6 @@ 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);
@@ -757,9 +749,7 @@ export class UiHelpers {
this.ctx.present([new Spacer(1), text]);
}
showToolActivityWarning(warningMessage: string): void {
const text = new ToolActivityWarningComponent(`Warning: ${warningMessage}`).setStyleFn(t =>
theme.fg("warning", t),
);
const text = new Text(`Warning: ${warningMessage}`, 1, 0).setStyleFn(t => theme.fg("warning", t));
this.ctx.present(new ToolActivityContainer([new Spacer(1), text]));
}
@@ -2,10 +2,11 @@ import { beforeEach, describe, expect, it } from "bun:test";
import { stripVTControlCharacters } from "node:util";
import type { Rule } from "@oh-my-pi/pi-coding-agent/capability/rule";
import { TodoReminderComponent } from "@oh-my-pi/pi-coding-agent/modes/components/todo-reminder";
import { ToolActivityContainer } from "@oh-my-pi/pi-coding-agent/modes/components/tool-activity-container";
import { ToolActivityWarningComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-activity-warning";
import { ToolActivityContainer } from "@oh-my-pi/pi-coding-agent/modes/components/tool-activity";
import { TranscriptContainer } from "@oh-my-pi/pi-coding-agent/modes/components/transcript-container";
import { TtsrNotificationComponent } from "@oh-my-pi/pi-coding-agent/modes/components/ttsr-notification";
import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { Text } from "@oh-my-pi/pi-tui";
const darkTheme = await getThemeByName("dark");
@@ -15,7 +16,7 @@ describe("tool activity visibility", () => {
setThemeInstance(darkTheme);
});
it("hides and restores mounted activity blocks without losing their content", () => {
it("applies visibility to mounted and subsequently added activity blocks", () => {
const rule: Rule = {
name: "ts-no-tiny-functions",
path: "/rules/ts-no-tiny-functions.md",
@@ -27,24 +28,26 @@ describe("tool activity visibility", () => {
level: "project",
},
};
const components = [
{ component: new TtsrNotificationComponent([rule]), text: "ts-no-tiny-functions" },
{
component: new TodoReminderComponent([{ content: "finish the task", status: "in_progress" }], 1, 3),
text: "finish the task",
},
{
component: new ToolActivityContainer(new ToolActivityWarningComponent("Warning: tool failed")),
text: "tool failed",
},
] as const;
const transcript = new TranscriptContainer();
transcript.addChild(new TtsrNotificationComponent([rule]));
transcript.addChild(new TodoReminderComponent([{ content: "finish the task", status: "in_progress" }], 1, 3));
transcript.addChild(new ToolActivityContainer(new Text("tool warning", 1, 0)));
for (const { component, text } of components) {
expect(stripVTControlCharacters(component.render(120).join("\n"))).toContain(text);
component.setToolActivityVisible(false);
expect(stripVTControlCharacters(component.render(120).join("\n"))).toBe("");
component.setToolActivityVisible(true);
expect(stripVTControlCharacters(component.render(120).join("\n"))).toContain(text);
}
const visible = stripVTControlCharacters(transcript.render(120).join("\n"));
expect(visible).toContain("ts-no-tiny-functions");
expect(visible).toContain("finish the task");
expect(visible).toContain("tool warning");
transcript.setToolActivityVisible(false);
expect(stripVTControlCharacters(transcript.render(120).join("\n"))).toBe("");
transcript.addChild(new ToolActivityContainer(new Text("late activity", 1, 0)));
expect(stripVTControlCharacters(transcript.render(120).join("\n"))).toBe("");
transcript.setToolActivityVisible(true);
const restored = stripVTControlCharacters(transcript.render(120).join("\n"));
expect(restored).toContain("ts-no-tiny-functions");
expect(restored).toContain("finish the task");
expect(restored).toContain("tool warning");
expect(restored).toContain("late activity");
});
});
@@ -17,7 +17,7 @@ import type { AssistantMessage, ImageContent, Usage } from "@oh-my-pi/pi-ai";
import { kStreamingPartialJson } from "@oh-my-pi/pi-ai/utils/block-symbols";
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message";
import { StrippedToolCallsPlaceholder } from "@oh-my-pi/pi-coding-agent/modes/components/stripped-tool-calls-placeholder";
import { TranscriptContainer } from "@oh-my-pi/pi-coding-agent/modes/components/transcript-container";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers";
@@ -142,8 +142,9 @@ function makeRenderCtx(
transcript: SessionContext,
showImages = true,
hideToolActivity = false,
): { ctx: InteractiveModeContext; chatContainer: Container } {
const chatContainer = new Container();
): { ctx: InteractiveModeContext; chatContainer: TranscriptContainer } {
const chatContainer = new TranscriptContainer();
chatContainer.setToolActivityVisible(!hideToolActivity);
let helpers: UiHelpers;
const ctx = {
chatContainer,
@@ -157,6 +158,10 @@ function makeRenderCtx(
updateEditorTopBorder: vi.fn(),
ui: { requestRender: vi.fn(), imageBudget: undefined },
resetTranscript: () => chatContainer.clear(),
present: (content: Component | readonly Component[]) => {
const components = Array.isArray(content) ? content : [content];
for (const component of components) chatContainer.addChild(component);
},
// Rebuild paths honor terminal.showImages since the native-image work;
// keep it on so the image-replay contracts below stay meaningful.
settings: {
@@ -487,6 +492,16 @@ describe("UiHelpers.renderInitialMessages — hidden tool activity", () => {
expect(visibleRender).toContain("LAUNCH_COMPLETION_MARKER");
});
it("hides tool-originated warnings when activity is hidden", () => {
const hidden = makeRenderCtx(makeEmptyContext(), true, true);
new UiHelpers(hidden.ctx).showToolActivityWarning("TODO_WARNING_MARKER");
expect(Bun.stripANSI(hidden.chatContainer.render(120).join("\n"))).not.toContain("TODO_WARNING_MARKER");
const visible = makeRenderCtx(makeEmptyContext(), true, false);
new UiHelpers(visible.ctx).showToolActivityWarning("TODO_WARNING_MARKER");
expect(Bun.stripANSI(visible.chatContainer.render(120).join("\n"))).toContain("TODO_WARNING_MARKER");
});
it("hides the stripped-tool-calls placeholder with tool activity and restores it on reveal", () => {
const strippedAssistant: AgentMessage & StrippedToolCallsMarker = {
role: "assistant",
@@ -508,9 +523,7 @@ describe("UiHelpers.renderInitialMessages — hidden tool activity", () => {
);
// A live reveal must restore the placeholder without a transcript rebuild.
for (const child of hidden.chatContainer.children) {
if (child instanceof StrippedToolCallsPlaceholder) child.setToolActivityVisible(true);
}
hidden.chatContainer.setToolActivityVisible(true);
expect(Bun.stripANSI(hidden.chatContainer.render(120).join("\n"))).toContain(
"2 tool calls elided — no result on this branch",
);