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
@@ -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;
}