diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3a03a1977..9708423e5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,10 @@ - Memoized non-message token totals (system prompt, tool schemas, skills) so the per-turn compaction and context-threshold paths recompute them at most once per input change instead of on every call. `getContextBreakdown` and `#estimateStoredContextTokens` previously re-tokenized the system prompt and every tool's wire schema (per-tool `JSON.stringify`) several times per turn over inputs that change at most once per turn. +### Fixed + +- Fixed `/mcp`, `/mcp list`, and `/tools` output duplicating in terminal scrollback when invoked during agent streaming by deferring command panels until the active turn ends ([#4806](https://github.com/can1357/oh-my-pi/issues/4806)). + ## [16.3.11] - 2026-07-06 ### Changed diff --git a/packages/coding-agent/src/modes/controllers/command-controller-shared.ts b/packages/coding-agent/src/modes/controllers/command-controller-shared.ts index 2e3eb6471..c51203c21 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller-shared.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller-shared.ts @@ -105,5 +105,5 @@ export function showCommandMessage(ctx: InteractiveModeContext, text: string): v block.addChild(new DynamicBorder()); block.addChild(new Text(text, 1, 1)); block.addChild(new DynamicBorder()); - ctx.present(block); + ctx.presentCommandOutput(block); } diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 6fa6c1265..5c3b47a33 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -59,7 +59,7 @@ function showMarkdownPanel(ctx: InteractiveModeContext, title: string, markdown: block.addChild(new Spacer(1)); block.addChild(new Markdown(markdown.trim(), 1, 1, getMarkdownTheme())); block.addChild(new DynamicBorder()); - ctx.present(block); + ctx.presentCommandOutput(block); } export class CommandController { diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 88a66022b..b997e6c97 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -1116,6 +1116,7 @@ export class EventController { // final history — seal it instead of letting its spinner tick while idle. this.#resolveDisplaceablePoll(); this.#resolveDisplaceableTodo(); + this.ctx.flushPendingCommandOutput(); this.#lastAssistantComponent = undefined; this.ctx.ui.requestRender(); this.#scheduleIdleCompaction(); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 8e064d8ee..b94ddf4fd 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -515,6 +515,7 @@ export class InteractiveMode implements InteractiveModeContext { collabHost?: CollabHost; collabGuest?: CollabGuestLink; + #pendingCommandOutput: Component[] = []; #pendingSlashCommands: SlashCommand[] = []; #cleanupUnsubscribe?: () => void; #signalTeardown?: SessionTeardown; @@ -3490,6 +3491,24 @@ export class InteractiveMode implements InteractiveModeContext { this.ui.requestRender(); } + /** Defer transcript command panels until the active turn can no longer grow above them. */ + presentCommandOutput(content: Component | readonly Component[]): void { + if (!this.session.isStreaming) { + this.present(content); + return; + } + const items = Array.isArray(content) ? content : [content as Component]; + this.#pendingCommandOutput.push(...items); + } + + /** Mount every command panel queued while the agent was streaming. */ + flushPendingCommandOutput(): void { + if (this.#pendingCommandOutput.length === 0) return; + const pending = this.#pendingCommandOutput; + this.#pendingCommandOutput = []; + this.present(pending); + } + #mountChatChild(item: Component): void { this.chatContainer.addChild(item); if (item instanceof ChatBlock) item.mount(this.#chatHost); diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 6eb2c0f4a..9449373aa 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -231,6 +231,14 @@ export interface InteractiveModeContext { * runs) so their timers/subscriptions start. */ present(content: Component | readonly Component[]): void; + /** + * Mount command output immediately while idle, or defer it until the active + * agent turn ends so a growing live block cannot push duplicate rows into + * native scrollback. + */ + presentCommandOutput(content: Component | readonly Component[]): void; + /** Mount command output deferred by {@link presentCommandOutput}. */ + flushPendingCommandOutput(): void; /** * Dispose every live block in the transcript (stopping timers/subscriptions) * and clear it. Used before a full rebuild so animated/streaming blocks do not diff --git a/packages/coding-agent/test/issue-4806-command-output.test.ts b/packages/coding-agent/test/issue-4806-command-output.test.ts new file mode 100644 index 000000000..adb1eac1c --- /dev/null +++ b/packages/coding-agent/test/issue-4806-command-output.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as path from "node:path"; +import { Agent } from "@oh-my-pi/pi-agent-core"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { InteractiveMode } from "@oh-my-pi/pi-coding-agent/modes/interactive-mode"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { HistoryStorage } from "@oh-my-pi/pi-coding-agent/session/history-storage"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { Text } from "@oh-my-pi/pi-tui"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +describe("issue #4806 command output during streaming", () => { + let authStorage: AuthStorage; + let mode: InteractiveMode; + let session: AgentSession; + let streaming = true; + let tempDir: TempDir; + + beforeAll(() => { + initTheme(); + }); + + beforeEach(async () => { + vi.spyOn(process.stdout, "write").mockReturnValue(true); + vi.spyOn(process.stdin, "resume").mockReturnValue(process.stdin); + vi.spyOn(process.stdin, "pause").mockReturnValue(process.stdin); + vi.spyOn(process.stdin, "setEncoding").mockReturnValue(process.stdin); + if (typeof process.stdin.setRawMode === "function") { + vi.spyOn(process.stdin, "setRawMode").mockReturnValue(process.stdin); + } + + resetSettingsForTest(); + tempDir = TempDir.createSync("@pi-issue-4806-"); + await Settings.init({ inMemory: true, cwd: tempDir.path() }); + authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + const modelRegistry = new ModelRegistry(authStorage); + const model = modelRegistry.find("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected claude-sonnet-4-5 test model"); + session = new AgentSession({ + agent: new Agent({ initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] } }), + sessionManager: SessionManager.create(tempDir.path(), tempDir.path()), + settings: Settings.isolated(), + modelRegistry, + }); + streaming = true; + Object.defineProperty(session, "isStreaming", { configurable: true, get: () => streaming }); + mode = new InteractiveMode(session, "test"); + mode.isInitialized = true; + mode.ui.requestRender = vi.fn(); + }); + + afterEach(async () => { + mode?.stop(); + HistoryStorage.resetInstance(); + vi.restoreAllMocks(); + await session?.dispose(); + authStorage?.close(); + tempDir?.removeSync(); + resetSettingsForTest(); + }); + + it("mounts slash-command output once after the active turn ends", async () => { + const streamedReply = new Text("agent is streaming", 0, 0); + mode.chatContainer.addChild(streamedReply); + + mode.handleToolsCommand(); + + expect(mode.chatContainer.children).toEqual([streamedReply]); + + streaming = false; + await mode.eventController.handleEvent({ type: "agent_end", messages: [] } as AgentSessionEvent); + + expect(mode.chatContainer.children).toHaveLength(2); + const transcript = mode.chatContainer.render(80).join("\n"); + expect(transcript.match(/Available Tools/g)).toHaveLength(1); + }); +}); diff --git a/packages/coding-agent/test/issue-956-repro.test.ts b/packages/coding-agent/test/issue-956-repro.test.ts index f5f7f39fd..8ee3138a8 100644 --- a/packages/coding-agent/test/issue-956-repro.test.ts +++ b/packages/coding-agent/test/issue-956-repro.test.ts @@ -84,6 +84,10 @@ describe("issue #956: interactive /mcp test", () => { for (const item of Array.isArray(content) ? content : [content]) addChild(item); requestRender(); }, + presentCommandOutput: (content: unknown) => { + for (const item of Array.isArray(content) ? content : [content]) addChild(item); + requestRender(); + }, ui: { requestRender }, editor: {}, showError, diff --git a/packages/coding-agent/test/mcp-command-reauth.test.ts b/packages/coding-agent/test/mcp-command-reauth.test.ts index 740554a71..ac9157a99 100644 --- a/packages/coding-agent/test/mcp-command-reauth.test.ts +++ b/packages/coding-agent/test/mcp-command-reauth.test.ts @@ -59,6 +59,7 @@ function createController(authStorage: AuthStorage, mcpManagerOverrides: Record< const controller = new MCPCommandController({ chatContainer: { addChild: vi.fn() }, present, + presentCommandOutput: present, ui: { requestRender: vi.fn() }, editor, showError, diff --git a/packages/coding-agent/test/mcp-command-toggle.test.ts b/packages/coding-agent/test/mcp-command-toggle.test.ts index 05593d8e8..2b948b4b3 100644 --- a/packages/coding-agent/test/mcp-command-toggle.test.ts +++ b/packages/coding-agent/test/mcp-command-toggle.test.ts @@ -53,6 +53,7 @@ function createController() { const controller = new MCPCommandController({ chatContainer: { addChild: vi.fn() }, present: vi.fn(), + presentCommandOutput: vi.fn(), ui: { requestRender: vi.fn() }, editor: {}, showError: vi.fn(),