From 4bc71bdafc9c67f6497bfc10c3f34eb7353dafc7 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 27 Jul 2026 05:49:42 +0000 Subject: [PATCH] fix(tui): defer large command panels during streaming to stop scrollback dupes /usage, /session, /advisor status, /jobs, /changelog, /context, and /memory view mounted their finalized panel immediately via ctx.present() instead of ctx.presentCommandOutput(), the streaming-deferral path added in #5427 for /tools and /mcp. When invoked mid-turn, the panel landed above a still-growing live block and the append-only scrollback contract recommitted it lower down, so it appeared twice in native scrollback. Route all six large command panels through presentCommandOutput() so they defer until agent_end, matching /tools and /mcp. Fixes #6767 --- packages/coding-agent/CHANGELOG.md | 4 + .../modes/controllers/command-controller.ts | 20 ++-- ...issue-6767-usage-command-streaming.test.ts | 103 ++++++++++++++++++ .../modes/controllers/usage-command.test.ts | 3 + 4 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 packages/coding-agent/test/issue-6767-usage-command-streaming.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8278d1171..2fd49d6cc 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `/usage` and the other large transcript command panels (`/session`, `/advisor status`, `/jobs`, `/changelog`, `/context`, `/memory view`) duplicating in native scrollback when invoked while an agent turn is streaming. These callsites mounted their finalized panel immediately via `present()` instead of deferring it until the turn ends via `presentCommandOutput()` (the path added in #5427 for `/tools`/`/mcp`), so the panel landed above a still-growing live block and was recommitted lower down ([#6767](https://github.com/can1357/oh-my-pi/issues/6767)). + ## [17.1.5] - 2026-07-27 ### Added diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index d53b198dc..cd55ff237 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -346,7 +346,7 @@ export class CommandController { } } - this.ctx.present([new Spacer(1), new Text(info, 1, 0)]); + this.ctx.presentCommandOutput([new Spacer(1), new Text(info, 1, 0)]); } static readonly #advisorStatusGlyph: Record = { @@ -368,7 +368,7 @@ export class CommandController { async handleAdvisorStatusCommand(): Promise { const stats = this.ctx.session.getAdvisorStats(); if (!stats.configured) { - this.ctx.present([new Spacer(1), new Text("Advisor is disabled.", 1, 0)]); + this.ctx.presentCommandOutput([new Spacer(1), new Text("Advisor is disabled.", 1, 0)]); return; } // Fetch live quota data (cached 5 min by the auth-gateway) so we can show @@ -434,7 +434,7 @@ export class CommandController { info += `${theme.fg("dim", "Tokens:")} ${stats.tokens.total.toLocaleString()}\n`; if (stats.cost > 0) info += `${theme.fg("dim", "Cost:")} $${stats.cost.toFixed(4)}\n`; } - this.ctx.present([new Spacer(1), new Text(info, 1, 0)]); + this.ctx.presentCommandOutput([new Spacer(1), new Text(info, 1, 0)]); return; } // Single active advisor — detailed view. @@ -480,7 +480,7 @@ export class CommandController { info += `${theme.fg("dim", "Cache Read:")} ${stats.tokens.cacheRead.toLocaleString()}\n`; } if (stats.cost > 0) info += `${theme.fg("dim", "Cost:")} $${stats.cost.toFixed(4)}\n`; - this.ctx.present([new Spacer(1), new Text(info, 1, 0)]); + this.ctx.presentCommandOutput([new Spacer(1), new Text(info, 1, 0)]); } async handleJobsCommand(): Promise { @@ -497,7 +497,7 @@ export class CommandController { if (snapshot.running.length === 0 && snapshot.recent.length === 0) { info += `\n${theme.fg("dim", "No async jobs yet.")}\n`; - this.ctx.present([new Spacer(1), new Text(info, 1, 0)]); + this.ctx.presentCommandOutput([new Spacer(1), new Text(info, 1, 0)]); return; } @@ -517,7 +517,7 @@ export class CommandController { } } - this.ctx.present([new Spacer(1), new Text(info.trimEnd(), 1, 0)]); + this.ctx.presentCommandOutput([new Spacer(1), new Text(info.trimEnd(), 1, 0)]); } async handleUsageCommand(reports?: UsageReport[] | null): Promise { @@ -558,7 +558,7 @@ export class CommandController { provider => (provider === currentProvider ? activeAccount : undefined), usageModelSelectors, ); - this.ctx.present([new Spacer(1), new Text(output, 1, 0)]); + this.ctx.presentCommandOutput([new Spacer(1), new Text(output, 1, 0)]); } async handleChangelogCommand(showFull = false): Promise { @@ -578,7 +578,7 @@ export class CommandController { block.addChild(new Spacer(1)); block.addChild(new Markdown(changelogMarkdown + hint, 1, 1, getMarkdownTheme())); block.addChild(new DynamicBorder()); - this.ctx.present(block); + this.ctx.presentCommandOutput(block); } handleHotkeysCommand(): void { @@ -607,7 +607,7 @@ export class CommandController { block.addChild(new Spacer(1)); block.addChild(new Text(output, 1, 0)); block.addChild(new DynamicBorder()); - this.ctx.present(block); + this.ctx.presentCommandOutput(block); } async handleMemoryCommand(text: string): Promise { @@ -628,7 +628,7 @@ export class CommandController { block.addChild(new Spacer(1)); block.addChild(new Markdown(payload, 1, 1, getMarkdownTheme())); block.addChild(new DynamicBorder()); - this.ctx.present(block); + this.ctx.presentCommandOutput(block); return; } diff --git a/packages/coding-agent/test/issue-6767-usage-command-streaming.test.ts b/packages/coding-agent/test/issue-6767-usage-command-streaming.test.ts new file mode 100644 index 000000000..bf240aeae --- /dev/null +++ b/packages/coding-agent/test/issue-6767-usage-command-streaming.test.ts @@ -0,0 +1,103 @@ +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 type { UsageReport } from "@oh-my-pi/pi-ai"; +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"; + +const usageReports: UsageReport[] = [ + { + provider: "openai-codex", + fetchedAt: 1_700_000_000_000, + limits: [ + { + id: "codex-weekly", + label: "Weekly", + scope: { provider: "openai-codex", tier: "pro", accountId: "acct-1" }, + window: { id: "weekly", label: "weekly" }, + amount: { remainingFraction: 0.25, unit: "requests" }, + status: "ok", + }, + ], + metadata: { email: "user@example.com" }, + }, +]; + +describe("issue #6767 /usage 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-6767-"); + 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("defers the usage panel until the active turn ends, mounting it once", async () => { + const streamedReply = new Text("agent is streaming", 0, 0); + mode.chatContainer.addChild(streamedReply); + + await mode.handleUsageCommand(usageReports); + + // Mid-stream: the finalized panel must NOT mount above the growing live + // block (that is what duplicates in native scrollback — issue #6767). + expect(mode.chatContainer.children).toEqual([streamedReply]); + + streaming = false; + await mode.eventController.handleEvent({ type: "agent_end", messages: [] } as AgentSessionEvent); + + // streamedReply + the deferred usage panel (Spacer + Text). + expect(mode.chatContainer.children).toHaveLength(3); + const transcript = mode.chatContainer.render(80).join("\n"); + expect(transcript.match(/Usage \(/g)).toHaveLength(1); + }); +}); diff --git a/packages/coding-agent/test/modes/controllers/usage-command.test.ts b/packages/coding-agent/test/modes/controllers/usage-command.test.ts index 3286fc263..0875b50c2 100644 --- a/packages/coding-agent/test/modes/controllers/usage-command.test.ts +++ b/packages/coding-agent/test/modes/controllers/usage-command.test.ts @@ -37,6 +37,7 @@ describe("CommandController /usage", () => { session: createUsageSessionDouble(), ui: { terminal: { columns: 100 } }, present, + presentCommandOutput: present, showWarning: vi.fn(), showError: vi.fn(), } as unknown as InteractiveModeContext; @@ -76,6 +77,7 @@ describe("CommandController /usage", () => { session: createUsageSessionDouble(), ui: { terminal: { columns: 100 } }, present, + presentCommandOutput: present, showWarning: vi.fn(), showError: vi.fn(), } as unknown as InteractiveModeContext; @@ -124,6 +126,7 @@ describe("CommandController /usage", () => { session: createUsageSessionDouble(), ui: { terminal: { columns: 100 } }, present, + presentCommandOutput: present, showWarning: vi.fn(), showError: vi.fn(), } as unknown as InteractiveModeContext;