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/fix-slash-menu-input-shift.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix the input box shifting upward after the slash command menu closes.
42 changes: 35 additions & 7 deletions apps/kimi-code/src/tui/components/editor/custom-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ 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,6 +163,7 @@ 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 @@ -257,7 +258,38 @@ 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 Expand Up @@ -600,10 +632,7 @@ function goalCommandPathRanges(
return ranges;
}

function readTokenRange(
visible: string,
start: number,
): { start: number; end: number } | null {
function readTokenRange(visible: string, start: number): { start: number; end: number } | null {
let tokenStart = start;
while (tokenStart < visible.length && isTokenSpace(visible[tokenStart])) tokenStart++;
if (tokenStart >= visible.length) return null;
Expand Down Expand Up @@ -751,8 +780,7 @@ export function wrapWithSideBorders(
const firstCh = line[0];
const lastCh = line.at(-1);
const head = firstCh === ' ' ? paint('│') : (firstCh ?? '');
const tail =
line.length > 1 && lastCh === ' ' ? paint('│') : (lastCh ?? '');
const tail = line.length > 1 && lastCh === ' ' ? paint('│') : (lastCh ?? '');
if (line.length === 1) return head;
return head + line.slice(1, -1) + tail;
});
Expand Down
43 changes: 29 additions & 14 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';

import {
deleteAllKittyImages,
type Component,
type Focusable,
getCapabilities,
Spacer,
} from '@moonshot-ai/pi-tui';
import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth';
import type {
ApprovalRequest,
Expand All @@ -20,6 +13,13 @@ import type {
Session,
} from '@moonshot-ai/kimi-code-sdk';
import type { MigrationPlan } from '@moonshot-ai/migration-legacy';
import {
deleteAllKittyImages,
type Component,
type Focusable,
getCapabilities,
Spacer,
} from '@moonshot-ai/pi-tui';
import { resolve } from 'pathe';

import type { CLIOptions } from '#/cli/options';
Expand Down Expand Up @@ -75,15 +75,15 @@ import {
GoalCompletionMessageComponent,
GoalSetMessageComponent,
} from './components/messages/goal-panel';
import { SkillActivationComponent } from './components/messages/skill-activation';
import { PluginCommandComponent } from './components/messages/plugin-command';
import { ShellRunComponent } from './components/messages/shell-run';
import { SkillActivationComponent } from './components/messages/skill-activation';
import {
NoticeMessageComponent,
StatusMessageComponent,
} from './components/messages/status-message';
import { ThinkingComponent } from './components/messages/thinking';
import { StepSummaryComponent } from './components/messages/step-summary';
import { ThinkingComponent } from './components/messages/thinking';
import { ToolCallComponent } from './components/messages/tool-call';
import { UserMessageComponent } from './components/messages/user-message';
import { ActivityPaneComponent, type ActivityPaneMode } from './components/panes/activity-pane';
Expand Down Expand Up @@ -135,12 +135,17 @@ import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attach
import { extractMediaAttachments } from './utils/image-placeholder';
import { hasPatchChanges } from './utils/object-patch';
import { sessionRowsForPicker } from './utils/session-picker-rows';
import { formatBashOutputForDisplay } from './utils/shell-output';
import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup';
import { installTerminalFocusTracking } from './utils/terminal-focus';
import { notifyTerminalOnce } from './utils/terminal-notification';
import { installTerminalThemeTracking } from './utils/terminal-theme';
import { detectTmuxKeyboardWarning } from './utils/tmux-keyboard';
import { getTranscriptComponentEntry, markTranscriptComponent } from './utils/transcript-component-metadata';
import {
getTranscriptComponentEntry,
markTranscriptComponent,
} from './utils/transcript-component-metadata';
import { nextTranscriptId } from './utils/transcript-id';
import {
TRANSCRIPT_EXPAND_TURNS,
TRANSCRIPT_HYSTERESIS,
Expand All @@ -150,8 +155,6 @@ import {
groupTurns,
turnsToTrim,
} from './utils/transcript-window';
import { formatBashOutputForDisplay } from './utils/shell-output';
import { nextTranscriptId } from './utils/transcript-id';

export type { TUIState } from './tui-state';
export { createTUIState } from './tui-state';
Expand Down Expand Up @@ -2453,7 +2456,8 @@ export class KimiTUI {
if (detached === 0 && alreadyFinished > 0) {
hint = alreadyFinished === 1 ? 'Task already finished.' : 'Tasks already finished.';
} else if (detached === targets.length) {
hint = detached === 1 ? 'Moved 1 task to background.' : `Moved ${detached} tasks to background.`;
hint =
detached === 1 ? 'Moved 1 task to background.' : `Moved ${detached} tasks to background.`;
} else {
hint = `Moved ${detached} of ${targets.length} tasks to background.`;
}
Expand Down Expand Up @@ -2601,7 +2605,18 @@ export class KimiTUI {
this.state.editorContainer.clear();
this.state.editorContainer.addChild(this.state.editor);
this.state.ui.setFocus(this.state.editor);
this.state.ui.requestRender();
// 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);
}

restoreInputText(text: string): void {
Expand Down
134 changes: 131 additions & 3 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,14 +4,15 @@ import type {
AutocompleteSuggestions,
TUI,
} from '@moonshot-ai/pi-tui';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { CustomEditor } from '#/tui/components/editor/custom-editor';
import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider';

function makeEditor(): CustomEditor {
const tui = {
requestRender: vi.fn(),
render: vi.fn(() => []),
terminal: { rows: 40, cols: 120 },
} as unknown as TUI;
return new CustomEditor(tui);
Expand Down Expand Up @@ -71,7 +72,9 @@ describe('CustomEditor autocomplete Escape handling', () => {
getSuggestions: vi.fn(
() =>
new Promise<AutocompleteSuggestions | null>((resolve) => {
resolveSuggestions = (items) =>{ resolve({ items, prefix: '/' }); };
resolveSuggestions = (items) => {
resolve({ items, prefix: '/' });
};
}),
),
applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ lines, cursorLine, cursorCol })),
Expand Down Expand Up @@ -152,7 +155,8 @@ describe('CustomEditor slash argument completion refresh', () => {
description: 'Add directory',
getArgumentCompletions: (prefix) => {
if (prefix === '/') return [{ value: '/tmp/shared/', label: 'shared/' }];
if (prefix === '/tmp/shared/') return [{ value: '/tmp/shared/child/', label: 'child/' }];
if (prefix === '/tmp/shared/')
return [{ value: '/tmp/shared/child/', label: 'child/' }];
return null;
},
},
Expand Down Expand Up @@ -756,3 +760,127 @@ 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