Skip to content

Commit 947ab80

Browse files
feat(ts): handle terminal resize by remounting Shell UI
Re-render Ink on terminal resize so <Static> scrollback reflows to the new width instead of staying garbled. The soul/wire/app stay alive — only the UI layer unmounts and re-renders.
1 parent 090fa31 commit 947ab80

4 files changed

Lines changed: 119 additions & 15 deletions

File tree

src/kimi_cli_ts/cli/index.ts

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,9 @@ program
363363
// Pending reload info — set by onReload, checked after waitUntilExit
364364
let pendingReload: { sessionId: string; prefillText?: string } | null = null;
365365

366+
// Resize remount: UI-only unmount/re-render without touching app/soul.
367+
let pendingResizeRemount = false;
368+
366369
// pushEvent will be set by Shell's onWireReady callback
367370
pushEvent = null;
368371

@@ -375,8 +378,11 @@ program
375378
inkUnmountFn?.();
376379
};
377380

378-
// Create a UILoopFn that translates Wire messages → WireUIEvent for Shell
379-
const createShellUILoopFn = (pe: (event: WireUIEvent) => void): UILoopFn => {
381+
// Create a UILoopFn that translates Wire messages → WireUIEvent for Shell.
382+
// Uses the outer `pushEvent` variable (not a parameter) so that after a
383+
// resize remount, the running UILoopFn automatically routes events to the
384+
// new Shell instance's wire once onWireReady updates pushEvent.
385+
const createShellUILoopFn = (): UILoopFn => {
380386
return async (wire) => {
381387
const uiSide = wire.uiSide(true);
382388
while (true) {
@@ -387,6 +393,8 @@ program
387393
if (err instanceof QueueShutDown) break;
388394
throw err;
389395
}
396+
const pe = pushEvent;
397+
if (!pe) continue; // Shell not mounted yet (between resize remounts)
390398
const t = msg.__wireType ?? "";
391399
if (t === "TurnBegin") {
392400
const raw = msg.user_input;
@@ -475,6 +483,18 @@ program
475483
process.exit(1);
476484
}
477485

486+
// ── Render loop: re-renders Ink on resize without recreating app ──
487+
// Inner loop handles resize remounts (UI-only). Outer loop handles
488+
// full reloads (session switch — app.shutdown + KimiCLI.create).
489+
let rootHubQueue: any = null;
490+
let rootHubStarted = false;
491+
let initialPromptFired = false;
492+
// eslint-disable-next-line no-constant-condition
493+
while (true) {
494+
pushEvent = null;
495+
pendingResizeRemount = false;
496+
inkUnmountFn = null;
497+
478498
const { waitUntilExit, unmount: inkUnmount } = render(
479499
React.createElement(Shell, {
480500
modelName: app.soul.modelName,
@@ -490,7 +510,7 @@ program
490510
currentCancelController = cancelController;
491511
const wireFile = app.session.wireFile ? new WireFile(app.session.wireFile) : undefined;
492512
try {
493-
await runSoul(app.soul, input, createShellUILoopFn(pushEvent!), cancelController, {
513+
await runSoul(app.soul, input, createShellUILoopFn(), cancelController, {
494514
wireFile,
495515
runtime: app.soul.runtime,
496516
});
@@ -536,6 +556,10 @@ program
536556
onReload: (newSessionId: string, prefill?: string) => {
537557
triggerReload(newSessionId, prefill);
538558
},
559+
onResizeRemount: () => {
560+
pendingResizeRemount = true;
561+
inkUnmountFn?.();
562+
},
539563
extraSlashCommands: app.soul.availableSlashCommands,
540564
}),
541565
{ exitOnCtrlC: false },
@@ -546,8 +570,11 @@ program
546570
// Start background task to forward RootWireHub events to UI.
547571
// ApprovalRequests flow through ApprovalRuntime → RootWireHub,
548572
// NOT through the soul's Wire, so we need a separate subscriber.
549-
let rootHubQueue: any = null;
550-
if (app.soul.runtime.rootWireHub) {
573+
// Set up once before the inner render loop — survives resize remounts
574+
// because it reads the outer `pushEvent` variable which gets updated on
575+
// each Shell mount via onWireReady.
576+
if (!rootHubStarted && app.soul.runtime.rootWireHub) {
577+
rootHubStarted = true;
551578
rootHubQueue = app.soul.runtime.rootWireHub.subscribe();
552579
(async () => {
553580
try {
@@ -583,12 +610,13 @@ program
583610
})();
584611
}
585612

586-
// Run initial prompt if provided (only on first iteration)
587-
if (currentPrompt) {
613+
// Run initial prompt if provided (only on first render, not on resize remounts)
614+
if (currentPrompt && !initialPromptFired) {
615+
initialPromptFired = true;
588616
const cancelController = new AbortController();
589617
currentCancelController = cancelController;
590618
const wireFile = app.session.wireFile ? new WireFile(app.session.wireFile) : undefined;
591-
runSoul(app.soul, currentPrompt, createShellUILoopFn(pushEvent!), cancelController, {
619+
runSoul(app.soul, currentPrompt, createShellUILoopFn(), cancelController, {
592620
wireFile,
593621
runtime: app.soul.runtime,
594622
}).catch((err) => {
@@ -597,6 +625,18 @@ program
597625
}
598626

599627
await waitUntilExit();
628+
629+
// Resize remount: only re-render Ink, keep app/soul/wire alive.
630+
// The inner render loop continues; soul streams reconnect via pushEvent.
631+
if (pendingResizeRemount) {
632+
currentPrefillText = undefined; // Don't re-show prefill text
633+
continue; // → inner render loop
634+
}
635+
636+
// Not a resize — break out to outer loop for reload or exit
637+
break;
638+
} // end inner render loop
639+
600640
disableBracketedPaste();
601641
if (rootHubQueue && app.soul.runtime.rootWireHub) {
602642
app.soul.runtime.rootWireHub.unsubscribe(rootHubQueue);

src/kimi_cli_ts/ui/renderer/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ function hasEraseScreen(s: string): boolean {
135135

136136
// ── stdout.write Wrapper ────────────────────────────────
137137

138+
// Saved reference to the original stdout.write before wrapping.
139+
// Used by writeRawToStdout() to bypass the erase-screen stripping.
140+
let _originalStdoutWrite: typeof process.stdout.write | null = null;
141+
138142
function installWrapper(stream: NodeJS.WriteStream): void {
139143
if (!stream.isTTY) {
140144
log("wrapper: skip (not TTY)");
@@ -148,6 +152,7 @@ function installWrapper(stream: NodeJS.WriteStream): void {
148152
(stream as any).__rendererWrapped = true;
149153

150154
const originalWrite = stream.write.bind(stream) as typeof stream.write;
155+
_originalStdoutWrite = originalWrite;
151156
let buffer: string[] = [];
152157
let inSyncBlock = false;
153158
let syncWriteCount = 0;
@@ -257,6 +262,19 @@ export function getLastFrameHeight(): number {
257262
return (process.stdout as any).__lastFrameHeight ?? 5;
258263
}
259264

265+
/**
266+
* Write directly to stdout, bypassing the erase-screen stripping wrapper.
267+
* Use this for intentional terminal clears (e.g., resize-triggered reload)
268+
* where we actually want \x1b[2J/\x1b[3J to reach the terminal.
269+
*/
270+
export function writeRawToStdout(data: string): void {
271+
if (_originalStdoutWrite) {
272+
_originalStdoutWrite(data);
273+
} else {
274+
process.stdout.write(data);
275+
}
276+
}
277+
260278
// ── Public API ──────────────────────────────────────────
261279

262280
let installed = false;

src/kimi_cli_ts/ui/shell/Shell.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { setActiveTheme } from "../theme.ts";
3232
import { createAllCommands } from "./shell-commands.ts";
3333
import { useShellCallbacks } from "./useShellCallbacks.ts";
3434
import { getPromptSymbol } from "./usePromptSymbol.ts";
35-
import { getLastFrameHeight } from "../renderer/index.ts";
35+
import { getLastFrameHeight, writeRawToStdout } from "../renderer/index.ts";
3636
import { useShellLayout } from "./useShellLayout.ts";
3737
import type { WireUIEvent } from "./events.ts";
3838
import type { ApprovalResponseKind } from "../../wire/types.ts";
@@ -52,6 +52,8 @@ export interface ShellProps {
5252
onApprovalResponse?: (requestId: string, decision: ApprovalResponseKind, feedback?: string) => void;
5353
onWireReady?: (pushEvent: (event: WireUIEvent) => void) => void;
5454
onReload?: (sessionId: string, prefillText?: string) => void;
55+
/** UI-only remount (no app restart). Used for terminal resize. */
56+
onResizeRemount?: () => void;
5557
extraSlashCommands?: SlashCommand[];
5658
}
5759

@@ -72,6 +74,7 @@ export function Shell({
7274
onApprovalResponse,
7375
onWireReady,
7476
onReload,
77+
onResizeRemount,
7578
extraSlashCommands = [],
7679
}: ShellProps) {
7780
const { exit } = useApp();
@@ -193,7 +196,22 @@ export function Shell({
193196
});
194197

195198
// ── Layout ──
196-
const { staticItems } = useShellLayout(wire.messages, wire.isStreaming);
199+
// On terminal resize, trigger a full Shell reload so <Static> scrollback
200+
// is re-rendered at the new terminal width instead of staying garbled.
201+
const handleTerminalResize = useCallback(() => {
202+
if (onResizeRemount) {
203+
// Clear scrollback to remove garbled <Static> content before remounting.
204+
// Uses writeRawToStdout to bypass patchInkLogUpdate's erase-screen stripping.
205+
writeRawToStdout("\x1b[3J\x1b[2J\x1b[H");
206+
onResizeRemount();
207+
}
208+
}, [onResizeRemount]);
209+
210+
const { staticItems } = useShellLayout({
211+
messages: wire.messages,
212+
isStreaming: wire.isStreaming,
213+
onTerminalResize: handleTerminalResize,
214+
});
197215
const mode = inputState.mode;
198216
const promptSymbol = getPromptSymbol(mode, inputState.shellMode, thinking, wire.status?.plan_mode ?? false);
199217

src/kimi_cli_ts/ui/shell/useShellLayout.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* useShellLayout.ts — Terminal height tracking and static items computation.
33
*/
44

5-
import React, { useEffect, useState } from "react";
5+
import React, { useEffect, useRef, useState } from "react";
66
import { useStdout } from "ink";
77
import type { UIMessage } from "./events.ts";
88

@@ -16,15 +16,43 @@ export function buildStaticItems(messages: UIMessage[], isStreaming: boolean): S
1616
return [welcome, ...msgs];
1717
}
1818

19+
interface ShellLayoutOptions {
20+
messages: UIMessage[];
21+
isStreaming: boolean;
22+
/** Called when terminal is resized. Debounced to avoid rapid-fire triggers. */
23+
onTerminalResize?: () => void;
24+
}
25+
1926
/** Hook that tracks terminal height and exposes layout helpers. */
20-
export function useShellLayout(messages: UIMessage[], isStreaming: boolean) {
27+
export function useShellLayout({ messages, isStreaming, onTerminalResize }: ShellLayoutOptions) {
2128
const { stdout } = useStdout();
2229
const [termHeight, setTermHeight] = useState(stdout?.rows || 24);
2330

31+
// Keep callback in a ref so the resize listener always calls the latest version
32+
// without re-subscribing on every render.
33+
const onResizeRef = useRef(onTerminalResize);
34+
onResizeRef.current = onTerminalResize;
35+
36+
// Debounce timer ref to collapse rapid resize events into a single callback.
37+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
38+
2439
useEffect(() => {
25-
const onResize = () => setTermHeight(stdout?.rows || 24);
26-
stdout?.on("resize", onResize);
27-
return () => { stdout?.off("resize", onResize); };
40+
const handleResize = () => {
41+
setTermHeight(stdout?.rows || 24);
42+
43+
// Debounce the external callback — terminal resize can fire many events
44+
// when the user drags the window edge.
45+
if (debounceRef.current) clearTimeout(debounceRef.current);
46+
debounceRef.current = setTimeout(() => {
47+
debounceRef.current = null;
48+
onResizeRef.current?.();
49+
}, 300);
50+
};
51+
stdout?.on("resize", handleResize);
52+
return () => {
53+
stdout?.off("resize", handleResize);
54+
if (debounceRef.current) clearTimeout(debounceRef.current);
55+
};
2856
}, [stdout]);
2957

3058
const staticItems = React.useMemo(

0 commit comments

Comments
 (0)