Skip to content

Commit fb2690c

Browse files
committed
fix(tui): stop early frames anchoring to the shell cursor
pi-tui paints on requestRender before ui.start(), so frames rendered during construction and mounting were anchored at the shell cursor and the fixed layout's scroll-to-home pushed the panel border into scrollback. Rendering is now gated until the event loop starts, which runs before the main TUI mounts. The gate writes a private pi-tui field, so a test asserts both the gate and ui.start() clearing it — an upgrade that changes either fails there.
1 parent e1e7bba commit fb2690c

3 files changed

Lines changed: 40 additions & 1 deletion

File tree

apps/pythinker-code/src/tui/pythinker-tui.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,9 +468,14 @@ export class PythinkerTUI {
468468
return;
469469
}
470470

471-
const shouldReplayHistory = await this.initMainTui();
471+
// Start the loop before mounting anything: pi-tui paints on invalidate
472+
// even before ui.start(), so mounting first anchors early frames to the
473+
// shell cursor and startEventLoop's scroll-to-home would push the live
474+
// frame's top rows into scrollback for good. Same order as the
475+
// migration branch above.
472476
this.startEventLoop();
473477
try {
478+
const shouldReplayHistory = await this.initMainTui();
474479
this.startBackgroundFdAutocomplete();
475480
await this.finishStartup(shouldReplayHistory);
476481
this.startKeybindingsWatcher();
@@ -550,6 +555,14 @@ export class PythinkerTUI {
550555

551556
private startEventLoop(): void {
552557
const start = (): void => {
558+
// The fixed layout emits exactly `terminal.rows` lines per frame, and
559+
// pi-tui's first render assumes a clean screen. Any shell output already
560+
// on screen would scroll the frame's top rows (panel border included)
561+
// out of view for good, so scroll the history up first and start at home.
562+
if (this.state.layout === 'fixed') {
563+
const rows = this.state.terminal.rows;
564+
this.presentation.writeTerminalControl('\n'.repeat(Math.max(1, rows)) + '\u001B[H');
565+
}
553566
this.presentation.start(() => {
554567
this.refreshFooter();
555568
this.state.ui.requestRender();

apps/pythinker-code/src/tui/tui-state.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ export function createTUIState(options: PythinkerTUIOptions): TUIState {
7171

7272
const terminal = new ProcessTerminal();
7373
const ui = new TUI(terminal);
74+
// Gate rendering until the event loop starts: pi-tui paints on requestRender
75+
// even before ui.start() (stopped defaults to false), so construction-time
76+
// renders would anchor frames to the shell cursor. The field is private in
77+
// pi-tui's types; ui.start() flips it back to false.
78+
(ui as unknown as { stopped: boolean }).stopped = true;
7479

7580
const transcriptContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER);
7681
const activityContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER);

apps/pythinker-code/test/tui/create-tui-state.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,27 @@ thinkingLevel: 'off',
3636
}
3737

3838
describe('createTUIState', () => {
39+
it('gates rendering until the event loop starts, and ui.start() re-opens it', () => {
40+
// The gate writes pi-tui's PRIVATE `stopped` field, and `ui.start()` clearing
41+
// it again is what lets the TUI ever paint. If an upgrade renames the field or
42+
// stops resetting it, the app renders nothing — a silent, total failure. This
43+
// asserts both halves so that upgrade fails here instead of in someone's terminal.
44+
const state = createTUIState({
45+
initialAppState: fakeInitialAppState(),
46+
startup: { continueLast: false, yolo: false, auto: false, plan: false },
47+
layout: 'fixed',
48+
});
49+
const ui = state.ui as unknown as { stopped: boolean; start: () => void };
50+
51+
expect(ui.stopped).toBe(true);
52+
53+
vi.spyOn(state.terminal, 'start').mockImplementation(() => {});
54+
vi.spyOn(state.terminal, 'hideCursor').mockImplementation(() => {});
55+
ui.start();
56+
57+
expect(ui.stopped).toBe(false);
58+
});
59+
3960
it('initializes all fields with sensible defaults', () => {
4061
const opts: PythinkerTUIOptions = {
4162
initialAppState: fakeInitialAppState(),

0 commit comments

Comments
 (0)