Skip to content

Commit bb39720

Browse files
authored
fix(tui): rebuild chat after closing full viewport interactions (#328)
1 parent b7456eb commit bb39720

6 files changed

Lines changed: 132 additions & 7 deletions

File tree

‎docs/tui-capabilities.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,10 @@ and initial prompt are not applied.
8585

8686
In regular mode, independent feature panels occupy the complete visible terminal
8787
area, including short Rewind previews and scope pickers. Closing a panel restores
88-
the current conversation. When running content shrinks entirely within the current
89-
screen, the renderer keeps native scrollback and the Composer position stable.
88+
the current conversation. Closing a full-viewport interaction rebuilds the chat
89+
screen so its temporary rows do not leave a large blank area above the conversation.
90+
When running content shrinks entirely within the current screen, the renderer
91+
keeps native scrollback and the Composer position stable.
9092
Freed rows temporarily remain blank at the top of the active screen and subsequent
9193
output reuses them. This avoids resetting the host's scroll position when a turn
9294
finishes. Redundant resize notifications with unchanged dimensions do not rebuild

‎packages/tui/src/tui/app-composition.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,7 @@ export function createTuiApplicationSurface(options: {
425425
readonly liveRunId: (snapshot?: TuiChatSnapshot) => string | undefined;
426426
readonly shouldResumeDraftAfterLogin: () => boolean;
427427
readonly isActive: () => boolean;
428-
readonly requestInteractionRender: () => void;
428+
readonly requestInteractionRender: (rebuild?: boolean) => void;
429429
readonly mode: () => TuiMode;
430430
readonly switchMode: (mode: TuiMode) => boolean;
431431
readonly chatMode: TuiMode;

‎packages/tui/src/tui/app.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -820,8 +820,10 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp {
820820
activity,
821821
composer,
822822
});
823-
function requestInteractionRender(): void {
824-
if (started && !stopped) tui.requestImmediateRender();
823+
function requestInteractionRender(rebuild = false): void {
824+
if (!started || stopped) return;
825+
if (rebuild) tui.requestRender(true);
826+
else tui.requestImmediateRender();
825827
}
826828
const controllerReady = controller.initialize();
827829
const ready = controllerReady.then(async () => {

‎packages/tui/src/tui/shell/interaction-surface.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export class TuiInteractionSurface {
88
constructor(
99
private readonly host: TuiInlinePanelHost,
1010
private readonly surfaces: TuiSurfaceHost,
11-
private readonly requestRender: () => void,
11+
private readonly requestRender: (rebuild?: boolean) => void,
1212
private readonly onActiveChanged?: (active: boolean) => void,
1313
private readonly followFullscreenBottom?: () => void,
1414
) {}
@@ -38,11 +38,13 @@ export class TuiInteractionSurface {
3838

3939
close(panel?: Component): boolean {
4040
if (!this.host.isActive(panel)) return false;
41+
const rebuild = this.surfaces.getChatMode() === 'regular' && this.host.fullscreenViewport;
4142
if (this.layer && !this.layer.close()) return false;
4243
this.layer = undefined;
4344
if (!this.host.close(panel)) return false;
4445
this.onActiveChanged?.(false);
45-
this.requestRender();
46+
if (rebuild) this.requestRender(true);
47+
else this.requestRender();
4648
return true;
4749
}
4850

‎packages/tui/test/unit/tui-app.test.ts‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,13 @@ class FakeTerminal implements Terminal {
189189
}
190190
}
191191

192+
// Apple Terminal can save ED 2 clears into scrollback before the renderer clears history.
193+
class ClearToScrollbackTerminal extends VirtualTerminal {
194+
override write(data: string): void {
195+
super.write(data.replaceAll("\x1b[2J", `\x1b[${this.rows};1H${"\r\n".repeat(this.rows)}\x1b[2J`));
196+
}
197+
}
198+
192199
function renderTerminalViewport(
193200
app: ReturnType<typeof createTuiApp>,
194201
terminal: FakeTerminal,
@@ -6041,6 +6048,93 @@ describe("createTuiApp", () => {
60416048
await app.stop();
60426049
});
60436050

6051+
it("does not push the conversation downward after closing /usage", async () => {
6052+
const terminal = new VirtualTerminal(80, 50);
6053+
const runtime = createRuntime();
6054+
vi.mocked(runtime.getAccountStatus).mockResolvedValue({
6055+
status: "ready",
6056+
authMode: "managed-login",
6057+
managedTokenPresent: true,
6058+
modelSource: "token-plan",
6059+
tokenPlanQuotaState: "available",
6060+
tokenPlanSummary: { tier: "Ultra Plan", creditBalance: "100" },
6061+
tokenPlanQuota: {
6062+
fiveHour: { remainingPercent: 75, unlimited: false },
6063+
weekly: { remainingPercent: 50, unlimited: false },
6064+
video: { remainingCount: 5, totalCount: 5, unlimited: false },
6065+
},
6066+
warnings: [],
6067+
});
6068+
const app = createTuiApp({
6069+
runtime,
6070+
terminal,
6071+
version: "0.1.0",
6072+
workspaceDir: "/workspace",
6073+
});
6074+
6075+
app.start();
6076+
try {
6077+
await app.ready;
6078+
for (let index = 0; index < 2; index++) await app.submit(`Message ${index}`);
6079+
app.tui.renderNow();
6080+
await terminal.flush();
6081+
expect(terminal.getViewport().join("\n")).toContain("Message 1");
6082+
const firstContentRowBefore = terminal.getViewport().findIndex((line) => line.trim().length > 0);
6083+
6084+
await app.submit("/usage");
6085+
app.tui.renderNow();
6086+
await terminal.flush();
6087+
expect(terminal.getViewport().join("\n")).toContain("Usage");
6088+
6089+
terminal.sendInput("\x1b");
6090+
app.tui.renderNow();
6091+
await terminal.flush();
6092+
const viewport = terminal.getViewport().join("\n");
6093+
expect(viewport).toContain("Message 1");
6094+
expect(viewport).toContain("Message");
6095+
expect(app.tui.render(80).findIndex((line) => line.trim().length > 0)).toBe(firstContentRowBefore);
6096+
expect(terminal.getViewport().findIndex((line) => line.trim().length > 0)).toBe(firstContentRowBefore);
6097+
} finally {
6098+
await app.stop();
6099+
}
6100+
});
6101+
6102+
it.each([
6103+
['xterm', VirtualTerminal],
6104+
['clear-to-scrollback terminal', ClearToScrollbackTerminal],
6105+
] as const)('restores unique long history after /usage on %s', async (_name, Terminal) => {
6106+
const terminal = new Terminal(80, 24);
6107+
const app = createTuiApp({
6108+
runtime: createRuntime(),
6109+
terminal,
6110+
version: "0.1.0",
6111+
workspaceDir: "/workspace",
6112+
});
6113+
6114+
app.start();
6115+
try {
6116+
await app.ready;
6117+
for (let index = 0; index < 12; index++) await app.submit(`Message ${index}`);
6118+
await app.submit("/usage");
6119+
app.tui.renderNow();
6120+
await terminal.flush();
6121+
expect(terminal.getViewport().join("\n")).toContain("Usage");
6122+
6123+
terminal.sendInput("\x1b");
6124+
app.tui.renderNow();
6125+
await terminal.flush();
6126+
const history = terminal.getScrollBuffer();
6127+
for (let index = 0; index < 12; index++) {
6128+
expect(history.filter((line) => line.trimEnd().endsWith(`› Message ${index}`))).toHaveLength(1);
6129+
}
6130+
expect(history.join("\n")).not.toContain("Session usage");
6131+
expect(terminal.getViewport().join("\n")).toContain("Ask Mcode to do anything");
6132+
expect(terminal.getViewport().join("\n")).toContain("/workspace");
6133+
} finally {
6134+
await app.stop();
6135+
}
6136+
});
6137+
60446138
it("uses public Runtime owners for Session inspection commands", async () => {
60456139
const terminal = new FakeTerminal();
60466140
terminal.rows = 40;

‎packages/tui/test/unit/tui-surface-host.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,31 @@ function createRecordingPresenter(): {
7070
}
7171

7272
describe('TuiSurfaceHost', () => {
73+
it.each([
74+
{ mode: 'regular', fullscreenViewport: true, rebuild: true },
75+
{ mode: 'regular', fullscreenViewport: false, rebuild: false },
76+
{ mode: 'fullscreen', fullscreenViewport: true, rebuild: false },
77+
] as const)('requests rebuild on interaction close only for a regular full-viewport panel ($mode, $fullscreenViewport)', ({ mode, fullscreenViewport, rebuild }) => {
78+
const inline = new TuiInlinePanelHost();
79+
const host = createSurfaceHost({
80+
chat: { component: inline, focus: component([]) },
81+
chatMode: mode,
82+
mode: () => mode,
83+
setFocus: vi.fn(),
84+
requestRender: vi.fn(),
85+
});
86+
const requestRender = vi.fn();
87+
const interaction = new TuiInteractionSurface(inline, host, requestRender);
88+
const panel = { ...component(['inspection']), fullscreenViewport };
89+
90+
interaction.show(panel);
91+
requestRender.mockClear();
92+
expect(interaction.close(panel)).toBe(true);
93+
expect(requestRender).toHaveBeenCalledOnce();
94+
if (rebuild) expect(requestRender).toHaveBeenCalledWith(true);
95+
else expect(requestRender).toHaveBeenCalledWith();
96+
});
97+
7398
it.each(['regular', 'fullscreen'] as const)(
7499
'passes current terminal size to leaf features in %s',
75100
(mode) => {

0 commit comments

Comments
 (0)