From cdb9bc1ce823807cc1548bd775a2343fdbc0cfa8 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 29 May 2026 15:28:34 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(tui):=20=E9=81=BF=E5=85=8D=20resume=20?= =?UTF-8?q?=E4=B8=8D=E5=AD=98=E5=9C=A8=E7=9A=84=20session=20=E6=97=B6?= =?UTF-8?q?=E6=B3=84=E6=BC=8F=20footer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 启动期间 footer 在构造时就被挂入渲染树,而 init() 早期的 setAppState 会排出一次渲染,在 await 时真正执行(pi-tui 的 stopped 默认为 false, 未 start 也会 doRender),把 footer 画到终端。resume 不存在的 session 时,init() 随后抛错,这次过早渲染就残留在错误信息上方。 将 footer 改为就绪态 chrome:从 buildLayout 移除挂载,改在 initMainTui 中 init() 成功之后再 mountFooter。致命启动错误会在挂载前抛出,footer 不进渲染树,过早渲染只画空树,错误信息落在干净的行上。 --- .../fix-resume-missing-session-footer-leak.md | 5 ++ apps/kimi-code/src/tui/kimi-tui.ts | 22 +++++++-- .../test/tui/kimi-tui-startup.test.ts | 47 +++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-resume-missing-session-footer-leak.md 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..d664911cce --- /dev/null +++ b/.changeset/fix-resume-missing-session-footer-leak.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the footer/statusline leaking onto the terminal above the error when resuming a non-existent session. The footer is now mounted only after startup reaches the main TUI, so a fatal startup failure no longer paints stray chrome. diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index da6e0d5d13..b33a58ffda 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -377,6 +377,10 @@ export class KimiTUI { private async initMainTui(): Promise { const shouldReplayHistory = await this.init(); + // init() reached the main TUI (it throws on fatal startup errors, e.g. a + // missing resume target, before we get here). Now it is safe to mount the + // footer chrome — earlier would risk a stray render leaking it on failure. + this.mountFooter(); this.renderWelcome(); this.setupAutocomplete(); void this.loadPersistedInputHistory(); @@ -586,11 +590,23 @@ 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. + // The footer is deliberately NOT mounted here: it is the only chrome that + // carries content (cwd/git/context) before a session is ready, so mounting + // it at construction lets a stray pre-`startEventLoop()` render (e.g. the + // `setAppState` in `init()`) paint the footer to the terminal. On a fatal + // startup failure — such as resuming a non-existent session — that paint + // is the last thing on screen, leaving the footer + statusline stranded + // above the error. `mountFooter()` adds it once startup reaches the main + // TUI. See initMainTui(). + } + + // FooterComponent isn't a Container; wrap it so it picks up the same outer + // gutter as the transcript/panels above. Mounted only after init() succeeds + // so a failed startup never leaves footer chrome on screen. + 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 e36abe15d3..3ba61f7718 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" }]), + }); + 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); +} From 61608be586ed0061712677861c936d2fa75e8424 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 29 May 2026 15:29:54 +0800 Subject: [PATCH 2/4] =?UTF-8?q?chore:=20=E7=B2=BE=E7=AE=80=20changeset=20?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/fix-resume-missing-session-footer-leak.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-resume-missing-session-footer-leak.md b/.changeset/fix-resume-missing-session-footer-leak.md index d664911cce..58c2e1eede 100644 --- a/.changeset/fix-resume-missing-session-footer-leak.md +++ b/.changeset/fix-resume-missing-session-footer-leak.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Fix the footer/statusline leaking onto the terminal above the error when resuming a non-existent session. The footer is now mounted only after startup reaches the main TUI, so a fatal startup failure no longer paints stray chrome. +Fix footer leaking onto the terminal when resuming a non-existent session. From 93ac194a42da0c3dcf6be1dc2a723cd9ce3bfcaa Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 29 May 2026 15:30:34 +0800 Subject: [PATCH 3/4] =?UTF-8?q?chore:=20=E7=B2=BE=E7=AE=80=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/kimi-code/src/tui/kimi-tui.ts | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index b33a58ffda..d90331e127 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -377,9 +377,7 @@ export class KimiTUI { private async initMainTui(): Promise { const shouldReplayHistory = await this.init(); - // init() reached the main TUI (it throws on fatal startup errors, e.g. a - // missing resume target, before we get here). Now it is safe to mount the - // footer chrome — earlier would risk a stray render leaking it on failure. + // Mount only after init() succeeds; see mountFooter(). this.mountFooter(); this.renderWelcome(); this.setupAutocomplete(); @@ -590,19 +588,14 @@ export class KimiTUI { ui.addChild(this.state.todoPanelContainer); ui.addChild(this.state.queueContainer); ui.addChild(this.state.editorContainer); - // The footer is deliberately NOT mounted here: it is the only chrome that - // carries content (cwd/git/context) before a session is ready, so mounting - // it at construction lets a stray pre-`startEventLoop()` render (e.g. the - // `setAppState` in `init()`) paint the footer to the terminal. On a fatal - // startup failure — such as resuming a non-existent session — that paint - // is the last thing on screen, leaving the footer + statusline stranded - // above the error. `mountFooter()` adds it once startup reaches the main - // TUI. See initMainTui(). - } - - // FooterComponent isn't a Container; wrap it so it picks up the same outer - // gutter as the transcript/panels above. Mounted only after init() succeeds - // so a failed startup never leaves footer chrome on screen. + // 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); From 24c0c364fba66b125de06c4d1b0fa944cb5349e6 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 29 May 2026 15:35:28 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test:=20=E9=80=82=E9=85=8D=E8=B7=A8?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E7=9B=AE=E5=BD=95=20resume=20=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C,=E8=A1=A5=E5=85=A8=20workDir=20mock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/kimi-code/test/tui/kimi-tui-startup.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ef53d89c1f..570f7efe46 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -799,7 +799,7 @@ describe("KimiTUI startup", () => { 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" }]), + listSessions: vi.fn(async () => [{ id: "ses-target", workDir: "/tmp/proj-a" }]), }); const driver = makeDriver( harness,