diff --git a/.changeset/fix-resume-missing-session-footer-leak.md b/.changeset/fix-resume-missing-session-footer-leak.md new file mode 100644 index 0000000000..58c2e1eede --- /dev/null +++ b/.changeset/fix-resume-missing-session-footer-leak.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix footer leaking onto the terminal when resuming a non-existent session. diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index ad69644dcc..79622dfd52 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -377,6 +377,8 @@ export class KimiTUI { private async initMainTui(): Promise { const shouldReplayHistory = await this.init(); + // Mount only after init() succeeds; see mountFooter(). + this.mountFooter(); this.renderWelcome(); this.setupAutocomplete(); void this.loadPersistedInputHistory(); @@ -601,11 +603,18 @@ export class KimiTUI { ui.addChild(this.state.todoPanelContainer); ui.addChild(this.state.queueContainer); ui.addChild(this.state.editorContainer); - // FooterComponent isn't a Container; wrap it so it picks up the same - // outer gutter as the transcript/panels above. + // Footer is mounted later (mountFooter), not here. + } + + // Footer is the only chrome with content before a session is ready, so + // mounting it at construction lets a stray pre-start render leak it to the + // terminal — e.g. above the error when resuming a missing session. Mount it + // only once init() succeeds. FooterComponent isn't a Container, so wrap it to + // pick up the same outer gutter as the panels above. + private mountFooter(): void { const footerWrap = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); footerWrap.addChild(this.state.footer); - ui.addChild(footerWrap); + this.state.ui.addChild(footerWrap); } // ========================================================================= diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 0b1ba55e9f..570f7efe46 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -776,4 +776,51 @@ describe("KimiTUI startup", () => { await expect(driver.init()).rejects.toThrow("provider config is invalid"); }); + + it("does not mount the footer when resuming a missing session fails", async () => { + // Regression: a stray pre-startEventLoop render used to paint the footer + // (cwd/git + "context:" statusline) to the terminal before the fatal + // error, leaving it stranded above the error message. The footer must not + // be in the layout tree when initMainTui() throws. + const harness = makeHarness(makeSession(), { + listSessions: vi.fn(async () => []), + }); + const driver = makeDriver( + harness, + makeStartupInput({ session: "missing-session" }), + ) as unknown as MigrateExitDriver; + + await expect(driver.initMainTui()).rejects.toThrow( + 'Session "missing-session" not found.', + ); + expect(uiContainsFooter(driver)).toBe(false); + }); + + it("mounts the footer once startup reaches the main TUI", async () => { + const session = makeSession({ id: "ses-target" }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: "ses-target", workDir: "/tmp/proj-a" }]), + }); + const driver = makeDriver( + harness, + makeStartupInput({ session: "ses-target" }), + ) as unknown as MigrateExitDriver; + + // Not mounted until init() succeeds. + expect(uiContainsFooter(driver)).toBe(false); + + await driver.initMainTui(); + + expect(uiContainsFooter(driver)).toBe(true); + }); }); + +function uiContainsFooter(driver: StartupDriver): boolean { + const target: unknown = driver.state.footer; + const visit = (node: unknown): boolean => { + if (node === target) return true; + const children = (node as { children?: unknown[] }).children; + return Array.isArray(children) && children.some(visit); + }; + return visit(driver.state.ui); +}