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
This commit is contained in:
roboomp
2026-07-27 05:49:42 +00:00
parent d1239fd3e5
commit 4bc71bdafc
4 changed files with 120 additions and 10 deletions
+4
View File
@@ -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
@@ -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<string, string> = {
@@ -368,7 +368,7 @@ export class CommandController {
async handleAdvisorStatusCommand(): Promise<void> {
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<void> {
@@ -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<void> {
@@ -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<void> {
@@ -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<void> {
@@ -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;
}
@@ -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);
});
});
@@ -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;