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/tui-drop-forced-full-redraws.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Reduce frequent full-screen redraws in the TUI.
33 changes: 0 additions & 33 deletions apps/kimi-code/src/tui/components/editor/custom-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
import { currentTheme } from '#/tui/theme';
import { createEditorTheme } from '#/tui/theme/pi-tui-theme';
import { printableChar } from '#/tui/utils/printable-key';
import { isInsideTmux } from '#/tui/utils/terminal-notification';

import { extractAtPrefix } from './file-mention-provider';
import { WrappingSelectList } from './wrapping-select-list';
Expand Down Expand Up @@ -163,7 +162,6 @@ export class CustomEditor extends Editor {
private consumingPaste = false;
private consumeBuffer = '';
private argumentHints: ReadonlyMap<string, string> = new Map();
private autocompleteWasShowing = false;

setArgumentHints(hints: ReadonlyMap<string, string>): void {
this.argumentHints = hints;
Expand Down Expand Up @@ -258,38 +256,7 @@ export class CustomEditor extends Editor {
(this as unknown as AutocompleteInternals).cancelAutocomplete();
}

// Force a full re-render when the autocomplete dropdown closes, so the editor
// snaps back to the bottom instead of sitting where the taller dropdown left it.
// Only worthwhile when the session content already overflows one screen; below
// that a full clear + home would pull the editor to the top and leave a blank
// tail. Always skipped inside tmux, whose own reflow handles the shrink.
private requestFullRenderOnAutocompleteClose(): void {
if (isInsideTmux()) return;
const { columns, rows } = this.tui.terminal;
// Redraw when content fills or overflows the viewport. An exact fill (==
// rows) is safe to clear (no blank tail) and still needs the redraw: the
// differential renderer keeps the old viewport offset after a shrink.
if (this.tui.render(columns).length < rows) return;
this.tui.requestRender(true);
}

// Detect an autocomplete open→close edge from a render frame and force a full
// re-render. Running from render() (not handleInput) also catches asynchronous
// closes — e.g. Backspace deleting the leading `/`, where pi-tui only cancels
// the menu once the provider re-query resolves. The render request is deferred
// to a microtask so the overflow probe inside the helper does not re-enter
// render() synchronously.
private trackAutocompleteCloseForFullRender(): void {
const showing = this.isShowingAutocomplete();
const closed = this.autocompleteWasShowing && !showing;
this.autocompleteWasShowing = showing;
if (closed) {
queueMicrotask(() => this.requestFullRenderOnAutocompleteClose());
}
}

override render(width: number): string[] {
this.trackAutocompleteCloseForFullRender();
const lines = super.render(width);
if (lines.length < 3) return lines;
const firstContentIdx = 1;
Expand Down
35 changes: 12 additions & 23 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2052,11 +2052,10 @@ export class KimiTUI {
this.state.todoPanelContainer.clear();
this.imageStore.clear();
this.renderWelcome();
// Session resets (/new, /clear, session switch) want a pristine screen.
// Force a destructive full render: the renderer's collapse repaint
// intentionally preserves scrollback, which would leave the previous
// session's text above the welcome banner.
this.state.ui.requestRender(true);
// No forced full render on session reset: let the differential renderer
// converge on its own (a mass change above the viewport still makes the
// engine repaint everything, but nothing is forced destructively here).
this.state.ui.requestRender();
}

private isTurnBoundaryComponent(child: Component): boolean {
Expand Down Expand Up @@ -2550,12 +2549,10 @@ export class KimiTUI {
if (!isExpandable(child)) continue;
child.setExpanded(this.state.toolOutputExpanded && i >= expandCutoff);
}
// Expanding/collapsing shifts content above the viewport; the clamped
// differential render would paint a second copy below the stale one in
// scrollback. This is a deliberate user action (like /clear), so do a
// destructive full render: scrollback holds exactly one copy and the
// expanded output can be read by scrolling up.
this.state.ui.requestRender(true);
// Differential render only — no destructive full redraw on expand/collapse.
// (When the expanded region reaches above the viewport, the engine's own
// fallback may still do a full render; that path is not forced from here.)
this.state.ui.requestRender();
Comment thread
liruifengv marked this conversation as resolved.
}

toggleTodoPanelExpansion(): void {
Expand Down Expand Up @@ -2793,18 +2790,10 @@ export class KimiTUI {
this.state.editorContainer.clear();
this.state.editorContainer.addChild(this.state.editor);
this.state.ui.setFocus(this.state.editor);
// Measure overflow against the restored tree (editor mounted), not the tall
// panel just removed — otherwise a short session with a tall panel looks like
// it overflows and we take a full clear/home that yanks the editor to the top.
// Treat an exact one-screen fill as overflowing too: a full redraw is safe
// there (no blank tail) and clears a stale viewport offset after a shrink.
const { columns, rows } = this.state.terminal;
const overflowsViewport = this.state.ui.render(columns).length >= rows;
// Force a full re-render after replacing a tall panel with the shorter editor:
// differential rendering leaves the editor shifted up when the bottom-anchored
// region shrinks in place. Skip under tmux (its own reflow handles the shrink)
// and when content fits on one screen (a full clear would pull the editor up).
this.state.ui.requestRender(!this.state.terminalState.insideTmux && overflowsViewport);
// Differential render only: closing a tall panel leaves the editor a few
// rows above the bottom (blank tail) until the next append, but avoids a
// destructive full redraw on every dialog close.
this.state.ui.requestRender();
}

restoreInputText(text: string): void {
Expand Down
126 changes: 1 addition & 125 deletions apps/kimi-code/test/tui/components/editor/custom-editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type {
AutocompleteSuggestions,
TUI,
} from '@moonshot-ai/pi-tui';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { describe, expect, it, vi } from 'vitest';

import { CustomEditor } from '#/tui/components/editor/custom-editor';
import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider';
Expand Down Expand Up @@ -788,127 +788,3 @@ describe('CustomEditor bash mode file completion', () => {
expect(calls.every((call) => call.force === true)).toBe(true);
});
});

describe('CustomEditor full re-render on autocomplete close', () => {
function makeEditorWithRenderSpy(contentLines: number): {
editor: CustomEditor;
requestRender: ReturnType<typeof vi.fn>;
} {
const requestRender = vi.fn();
const tui = {
requestRender,
terminal: { rows: 40, cols: 120 },
render: vi.fn(() => Array.from({ length: contentLines }, () => '')),
} as unknown as TUI;
return { editor: new CustomEditor(tui), requestRender };
}

// Drive one render frame so the render-edge detector observes the menu state.
function renderFrame(editor: CustomEditor): void {
editor.render(120);
}

afterEach(() => {
vi.unstubAllEnvs();
});

it('forces a full re-render on the render frame after Escape closes the menu (content overflows)', async () => {
vi.stubEnv('TMUX', '');
const { editor, requestRender } = makeEditorWithRenderSpy(50);
editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }]));

editor.handleInput('/');
await flushAutocomplete();
expect(editor.isShowingAutocomplete()).toBe(true);
renderFrame(editor); // record wasShowing = true

editor.handleInput('');
expect(editor.isShowingAutocomplete()).toBe(false);

renderFrame(editor); // close edge -> schedule helper
await flushAutocomplete();
expect(requestRender).toHaveBeenCalledWith(true);
});

it('keeps differential rendering when the content fits on one screen', async () => {
vi.stubEnv('TMUX', '');
const { editor, requestRender } = makeEditorWithRenderSpy(10);
editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }]));

editor.handleInput('/');
await flushAutocomplete();
expect(editor.isShowingAutocomplete()).toBe(true);
renderFrame(editor);

editor.handleInput('');
expect(editor.isShowingAutocomplete()).toBe(false);

renderFrame(editor);
await flushAutocomplete();
expect(requestRender).not.toHaveBeenCalledWith(true);
});

it('forces a full re-render when the content exactly fills one screen', async () => {
vi.stubEnv('TMUX', '');
const { editor, requestRender } = makeEditorWithRenderSpy(40);
editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }]));

editor.handleInput('/');
await flushAutocomplete();
expect(editor.isShowingAutocomplete()).toBe(true);
renderFrame(editor);

editor.handleInput('');
expect(editor.isShowingAutocomplete()).toBe(false);

renderFrame(editor);
await flushAutocomplete();
expect(requestRender).toHaveBeenCalledWith(true);
});

it('does not force a full re-render inside tmux', async () => {
vi.stubEnv('TMUX', '/tmp/tmux-501/default,1234,0');
const { editor, requestRender } = makeEditorWithRenderSpy(50);
editor.setAutocompleteProvider(providerReturning([{ value: 'help', label: 'help' }]));

editor.handleInput('/');
await flushAutocomplete();
expect(editor.isShowingAutocomplete()).toBe(true);
renderFrame(editor);

editor.handleInput('');
expect(editor.isShowingAutocomplete()).toBe(false);

renderFrame(editor);
await flushAutocomplete();
expect(requestRender).not.toHaveBeenCalledWith(true);
});

it('forces a full re-render when Backspace deletes the slash and the menu closes asynchronously', async () => {
vi.stubEnv('TMUX', '');
const { editor, requestRender } = makeEditorWithRenderSpy(50);
const provider: AutocompleteProvider = {
getSuggestions: vi.fn(async (lines, cursorLine, cursorCol) => {
const text = (lines[cursorLine] ?? '').slice(0, cursorCol);
if (!text.startsWith('/')) return { items: [], prefix: text };
return { items: [{ value: 'help', label: 'help' }], prefix: '/' };
}),
applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ lines, cursorLine, cursorCol })),
};
editor.setAutocompleteProvider(provider);

editor.handleInput('/');
await flushAutocomplete();
expect(editor.isShowingAutocomplete()).toBe(true);
renderFrame(editor); // record wasShowing = true

editor.handleInput(''); // Backspace deletes the '/'
await flushAutocomplete();
await new Promise((resolve) => setTimeout(resolve, 0)); // let async cancelAutocomplete settle
expect(editor.isShowingAutocomplete()).toBe(false);

renderFrame(editor); // close edge -> schedule helper
await flushAutocomplete();
expect(requestRender).toHaveBeenCalledWith(true);
});
});
Loading