Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/release-tui-resources.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Release pasted images and streaming timers once they are no longer shown, so memory stops growing in long sessions.
5 changes: 5 additions & 0 deletions .changeset/restore-terminal-on-crash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix the terminal being left in raw mode with a hidden cursor and disabled flow control after a crash or abrupt exit.
64 changes: 58 additions & 6 deletions apps/kimi-code/src/cli/run-shell.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { execSync } from 'node:child_process';
import { execSync, spawnSync } from 'node:child_process';
import { homedir } from 'node:os';
import { join } from 'node:path';

Expand All @@ -25,6 +25,7 @@ import { KimiTUI } from '#/tui/index';
import { currentTheme, getColorPalette } from '#/tui/theme';
import { combineStartupNotice } from '#/tui/utils/startup';
import { toTerminalHyperlink } from '#/utils/terminal-hyperlink';
import { restoreTerminalModes } from '#/utils/terminal-restore';

import type { CLIOptions } from './options';
import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry';
Expand Down Expand Up @@ -133,6 +134,59 @@ export async function runShell(
trackLifecycleForSession(tui.getCurrentSessionId(), event, properties);
};

let savedStty: string | undefined;
try {
// stty operates on the terminal behind stdin, so stdin must be the TTY —
// piping /dev/null (ignore) makes stty fail with "not a tty".
const saved = execSync('stty -g', {
encoding: 'utf8',
stdio: ['inherit', 'pipe', 'ignore'],
});
savedStty = typeof saved === 'string' ? saved.trim() : undefined;
execSync('stty -ixon', { stdio: ['inherit', 'ignore', 'ignore'] });
} catch {
/* ignore */
}
const restoreStty = (): void => {
if (savedStty === undefined) return;
const args = savedStty.split(/\s+/).filter((arg) => arg.length > 0);
if (args.length === 0) return;
spawnSync('stty', args, { stdio: ['inherit', 'ignore', 'ignore'] });
};

// If we crash without going through KimiTUI.stop(), the terminal is left in
// raw mode with a hidden cursor and XON/XOFF flow control disabled. Restore
// both before exiting so the user's shell is usable afterwards.
const emergencyExit = (exitCode: number): void => {
restoreTerminalModes();
restoreStty();
process.exit(exitCode);
};
const onUncaughtException = (error: unknown): void => {
try {
log.error('uncaughtException, restoring terminal and exiting', { error: String(error) });
} catch {
/* ignore */
}
emergencyExit(1);
};
const onUnhandledRejection = (reason: unknown): void => {
try {
log.error('unhandledRejection, restoring terminal and exiting', { reason: String(reason) });
} catch {
/* ignore */
}
emergencyExit(1);
};
process.on('uncaughtException', onUncaughtException);
process.on('unhandledRejection', onUnhandledRejection);
// Remove the crash handlers once the TUI exits cleanly so repeated runShell()
// calls in the same process (e.g. tests) don't accumulate process listeners.
const removeCrashHandlers = (): void => {
process.off('uncaughtException', onUncaughtException);
process.off('unhandledRejection', onUnhandledRejection);
};

tui.onExit = async (exitCode = 0) => {
const sessionId = tui.getCurrentSessionId();
const hasContent = tui.hasSessionContent();
Expand All @@ -151,13 +205,10 @@ export async function runShell(
if (hints.length > 0) {
process.stderr.write(`\n${hints.join('\n')}\n`);
}
removeCrashHandlers();
restoreStty();
process.exit(exitCode);
};
try {
execSync('stty -ixon', { stdio: 'ignore' });
} catch {
/* ignore */
}
try {
const initStartedAt = Date.now();
await tui.start();
Expand All @@ -171,6 +222,7 @@ export async function runShell(
mcp_ms: mcpMs,
});
} catch (error) {
removeCrashHandlers();
setCrashPhase('shutdown');
trackLifecycle('exit', { duration_ms: Date.now() - startedAt });
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
Expand Down
61 changes: 38 additions & 23 deletions apps/kimi-code/src/tui/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
FEEDBACK_ISSUE_URL,
FEEDBACK_STATUS_CANCELLED,
FEEDBACK_STATUS_FALLBACK,
FEEDBACK_STATUS_NETWORK_ERROR,
FEEDBACK_STATUS_NOT_SIGNED_IN,
FEEDBACK_STATUS_SUBMITTING,
FEEDBACK_STATUS_SUCCESS,
Expand Down Expand Up @@ -58,29 +59,43 @@ export async function handleFeedbackCommand(host: SlashCommandHost): Promise<voi

const version = withFeedbackVersionPrefix(host.state.appState.version);
const spinner = host.showLoginProgressSpinner(FEEDBACK_STATUS_SUBMITTING);
const res = await host.harness.auth.submitFeedback({
content: input.value,
sessionId: host.state.appState.sessionId,
version,
os: `${osType()} ${osRelease()}`,
model: host.state.appState.model.length > 0 ? host.state.appState.model : null,
});

if (res.kind !== 'ok') {
spinner.stop({ ok: false, label: res.message });
fallback(FEEDBACK_STATUS_FALLBACK);
return;
}

// Stage 3: prepare and upload each requested attachment independently.
const attachmentFailed = await submitFeedbackWithAttachments(host, res.feedbackId, level);

spinner.stop({ ok: true, label: FEEDBACK_STATUS_SUCCESS });
host.showStatus(feedbackSessionLine(host.state.appState.sessionId));
host.showStatus(feedbackIdLine(res.feedbackId));
host.track(FEEDBACK_TELEMETRY_EVENT);
if (attachmentFailed) {
host.showStatus(FEEDBACK_STATUS_UPLOAD_FAILED);
// Guarantee the spinner's underlying setInterval is always cleared, even when
// submitFeedback or submitFeedbackWithAttachments throws — otherwise the
// interval (and its per-frame requestRender) leaks for the rest of the session.
let stopped = false;
const stopSpinner = (opts: { ok: boolean; label: string }): void => {
if (stopped) return;
stopped = true;
spinner.stop(opts);
};
try {
const res = await host.harness.auth.submitFeedback({
content: input.value,
sessionId: host.state.appState.sessionId,
version,
os: `${osType()} ${osRelease()}`,
model: host.state.appState.model.length > 0 ? host.state.appState.model : null,
});

if (res.kind !== 'ok') {
stopSpinner({ ok: false, label: res.message });
fallback(FEEDBACK_STATUS_FALLBACK);
return;
}

// Stage 3: prepare and upload each requested attachment independently.
const attachmentFailed = await submitFeedbackWithAttachments(host, res.feedbackId, level);

stopSpinner({ ok: true, label: FEEDBACK_STATUS_SUCCESS });
host.showStatus(feedbackSessionLine(host.state.appState.sessionId));
host.showStatus(feedbackIdLine(res.feedbackId));
host.track(FEEDBACK_TELEMETRY_EVENT);
if (attachmentFailed) {
host.showStatus(FEEDBACK_STATUS_UPLOAD_FAILED);
}
} catch (error) {
stopSpinner({ ok: false, label: FEEDBACK_STATUS_NETWORK_ERROR });
throw error;
}
}

Expand Down
7 changes: 7 additions & 0 deletions apps/kimi-code/src/tui/components/chrome/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,13 @@ export class FooterComponent implements Component {
}
}

dispose(): void {
if (this.goalTimer !== null) {
clearInterval(this.goalTimer);
this.goalTimer = null;
}
}

private goalWallClockMs(goal: AppState['goal']): number | undefined {
if (goal === null || goal === undefined) return undefined;
if (goal.status !== 'active') return goal.wallClockMs;
Expand Down
4 changes: 4 additions & 0 deletions apps/kimi-code/src/tui/components/chrome/moon-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ export class MoonLoader extends Text {
}
}

dispose(): void {
this.stop();
}

setLabel(label: string): void {
this.label = label;
this.updateDisplay();
Expand Down
5 changes: 5 additions & 0 deletions apps/kimi-code/src/tui/controllers/editor-keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,11 @@ export class EditorKeyboardController {
this.pendingExit = null;
}

dispose(): void {
this.clearPendingExit();
this.clearPendingUndoEsc();
}

private armPendingUndoEsc(): void {
this.clearPendingUndoEsc();
const timer = setTimeout(() => {
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/controllers/streaming-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,7 @@ export class StreamingUIController {
this.disposeAndClearPendingToolComponents();
this._pendingAgentGroup = null;
this._pendingReadGroup = null;
this.resetToolCallState();
}

resetToolCallState(): void {
Expand Down
73 changes: 60 additions & 13 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { openUrl } from '#/utils/open-url';
import { getInputHistoryFile } from '#/utils/paths';
import { detectFdPath, ensureFdPath } from '#/utils/process/fd-detect';
import { quoteShellArg } from '#/utils/shell-quote';
import { restoreTerminalModes } from '#/utils/terminal-restore';

import { BannerProvider } from './banner/banner-provider';
import { readBannerDisplayState, writeBannerDisplayState } from './banner/state';
Expand Down Expand Up @@ -565,6 +566,9 @@ export class KimiTUI {
}

private startEventLoop(): void {
// Dispose any previous focus/clipboard/theme tracking so re-entering the
// event loop (e.g. a future TUI reconnect) can't stack duplicate listeners.
this.disposeTerminalTracking();
this.state.ui.start();
this.startClipboardImageHintController();
this.terminalFocusTrackingDispose = installTerminalFocusTracking(this.state);
Expand Down Expand Up @@ -764,18 +768,42 @@ export class KimiTUI {
this.unregisterSignalHandlers();
this.aborted = true;
this.streamingUI.discardPending();
this.editorKeyboard.clearPendingExit();
// Stop background polling, streaming intervals, and per-component timers
// before tearing the UI down, so they can't keep firing requestRender after
// stop() returns (or leak when stop() runs without process.exit).
this.tasksBrowserController.close();
this.btwPanelController.clear();
this.stopActivitySpinner();
this.streamingUI.disposeActiveCompactionBlock();
this.streamingUI.resetToolUi();
this.disposeTranscriptChildren();
this.editorKeyboard.dispose();
this.state.footer.dispose();
for (const dispose of this.reverseRpcDisposers) {
dispose();
}
this.reverseRpcDisposers.length = 0;
this.disposeTerminalTracking();
await this.closeSession('shutting down');
await this.harness.close();
this.sessionEventHandler.stopAllMcpServerStatusSpinners();
this.uninstallRainbowDance();
await this.state.terminal.drainInput();
this.state.ui.stop();
// Restore the terminal even if closing the session / harness throws — a
// SIGTERM during a network or MCP shutdown must not leave the user stuck in
// raw mode with a hidden cursor.
try {
await this.closeSession('shutting down');
await this.harness.close();
} finally {
this.sessionEventHandler.stopAllMcpServerStatusSpinners();
this.uninstallRainbowDance();
try {
await this.state.terminal.drainInput();
} catch {
// best effort — the terminal may already be dead (SIGHUP / EIO).
}
try {
this.state.ui.stop();
} catch {
// best effort terminal restore.
}
}
if (this.onExit) {
await this.onExit(exitCode);
}
Expand Down Expand Up @@ -839,6 +867,10 @@ export class KimiTUI {
private emergencyTerminalExit(exitCode = 129): never {
this.isShuttingDown = true;
this.unregisterSignalHandlers();
// Best-effort terminal restore: stop() may not have run (SIGHUP) or may
// have thrown (SIGTERM cleanup failure), so recover raw mode / cursor /
// bracketed paste before exiting instead of leaving the user's shell broken.
restoreTerminalModes();
process.exit(exitCode);
}

Expand Down Expand Up @@ -1490,6 +1522,7 @@ export class KimiTUI {
for (const dispose of this.reverseRpcDisposers) {
dispose();
}
this.reverseRpcDisposers.length = 0;
}

private registerSessionHandlers(session: Session): void {
Expand Down Expand Up @@ -1847,19 +1880,24 @@ export class KimiTUI {
this.state.terminal.write(deleteAllKittyImages());
}

private disposeTranscriptChildren(): void {
// Dispose disposable children (e.g. ShellRunComponent's 1s timer,
// ThinkingComponent's spinner) before dropping them, so a /clear, session
// switch, or shutdown can't leak intervals that keep firing requestRender
// on a removed component.
for (const child of this.state.transcriptContainer.children) {
if (hasDispose(child)) child.dispose();
}
}

private clearTranscriptAndRedraw(): void {
this.streamingUI.discardPending();
this.state.transcriptEntries = [];
this.streamingUI.disposeActiveCompactionBlock();
this.streamingUI.resetLiveText();
this.streamingUI.resetToolUi();
this.sessionEventHandler.stopAllMcpServerStatusSpinners();
// Dispose disposable children (e.g. ShellRunComponent's 1s timer) before
// dropping them, so a /clear or session switch can't leak intervals that
// keep firing requestRender on a removed component.
for (const child of this.state.transcriptContainer.children) {
if (hasDispose(child)) child.dispose();
}
this.disposeTranscriptChildren();
this.state.transcriptContainer.clear();
this.btwPanelController.clear();
this.clearTerminalInlineImages();
Expand Down Expand Up @@ -1906,6 +1944,15 @@ export class KimiTUI {
const toRemove = turnsToTrim(turns, TRANSCRIPT_MAX_TURNS, TRANSCRIPT_HYSTERESIS);
if (toRemove.size === 0) return false;

// Reclaim image bytes referenced by trimmed user messages. The transcript
// renders historical thumbnails via imageStore.get(id), so an attachment can
// only be dropped once its owning user message leaves the transcript.
for (const entry of toRemove) {
if (entry.kind === 'user' && entry.imageAttachmentIds !== undefined) {
this.imageStore.removeMany(entry.imageAttachmentIds);
}
}

let boundariesToRemove = 0;
for (const entry of toRemove) {
if (
Expand Down
13 changes: 13 additions & 0 deletions apps/kimi-code/src/tui/utils/image-attachment-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,19 @@ export class ImageAttachmentStore {
this.nextId = 1;
}

/**
* Drop a single attachment, releasing its bytes. Used to reclaim image
* memory once the transcript entry that references it is trimmed.
*/
remove(id: number): void {
this.byId.delete(id);
}

/** Drop many attachments at once. See {@link remove}. */
removeMany(ids: Iterable<number>): void {
for (const id of ids) this.byId.delete(id);
}

size(): number {
return this.byId.size;
}
Expand Down
Loading
Loading