From 55575aa924f3e52884aa851fa883b3fc74e3d074 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 6 Jul 2026 14:44:46 +0800 Subject: [PATCH 1/3] fix(tui): keep input anchored after slash command menu closes Force a full re-render when the slash command menu closes, but only when the session content already overflows one screen; skipped under tmux. Detect the close edge from a render frame so asynchronous closes (Backspace deleting the leading slash) are covered too. Apply the same overflow/tmux gating when restoring the editor from selector panels. --- .changeset/fix-slash-menu-input-shift.md | 5 + .../tui/components/editor/custom-editor.ts | 39 ++++-- apps/kimi-code/src/tui/kimi-tui.ts | 42 ++++--- .../components/editor/custom-editor.test.ts | 116 +++++++++++++++++- 4 files changed, 178 insertions(+), 24 deletions(-) create mode 100644 .changeset/fix-slash-menu-input-shift.md diff --git a/.changeset/fix-slash-menu-input-shift.md b/.changeset/fix-slash-menu-input-shift.md new file mode 100644 index 0000000000..459a6f91d5 --- /dev/null +++ b/.changeset/fix-slash-menu-input-shift.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the input box shifting upward after the slash command menu closes. diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index c2d4bf98a9..f1fe39c5ff 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -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'; @@ -163,6 +163,7 @@ export class CustomEditor extends Editor { private consumingPaste = false; private consumeBuffer = ''; private argumentHints: ReadonlyMap = new Map(); + private autocompleteWasShowing = false; setArgumentHints(hints: ReadonlyMap): void { this.argumentHints = hints; @@ -257,7 +258,35 @@ 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; + 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; @@ -600,10 +629,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; @@ -751,8 +777,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; }); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 8d182aa97e..dc5e921994 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -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, @@ -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'; @@ -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'; @@ -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, @@ -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'; @@ -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.`; } @@ -2598,10 +2602,20 @@ export class KimiTUI { } restoreEditor(): void { + // Estimate whether the content already overflows one screen while the panel + // is still mounted. A forced full re-render clears the screen and homes the + // cursor, which is only safe when content fills the viewport; below one + // screen it would yank the editor to the top and leave a blank tail. + const { columns, rows } = this.state.terminal; + const overflowsViewport = this.state.ui.render(columns).length > rows; this.state.editorContainer.clear(); this.state.editorContainer.addChild(this.state.editor); this.state.ui.setFocus(this.state.editor); - this.state.ui.requestRender(); + // 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 { diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index 1384dfc0bd..8f408aa531 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -4,7 +4,7 @@ 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'; @@ -12,6 +12,7 @@ import { FileMentionProvider } from '#/tui/components/editor/file-mention-provid function makeEditor(): CustomEditor { const tui = { requestRender: vi.fn(), + render: vi.fn(() => []), terminal: { rows: 40, cols: 120 }, } as unknown as TUI; return new CustomEditor(tui); @@ -71,7 +72,9 @@ describe('CustomEditor autocomplete Escape handling', () => { getSuggestions: vi.fn( () => new Promise((resolve) => { - resolveSuggestions = (items) =>{ resolve({ items, prefix: '/' }); }; + resolveSuggestions = (items) => { + resolve({ items, prefix: '/' }); + }; }), ), applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ lines, cursorLine, cursorCol })), @@ -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; }, }, @@ -756,3 +760,109 @@ 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; + } { + 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('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); + }); +}); From e050c1904b96b2209d7c9c97df9d3a84cc8e0d2c Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 6 Jul 2026 14:55:58 +0800 Subject: [PATCH 2/3] fix(tui): measure overflow against restored editor tree Address Codex review: the overflow probe ran before the editor container was swapped back, so it counted the tall replacement panel and forced a full clear/home even when the restored content fit on one screen, yanking the editor to the top. Measure after the editor is mounted instead. --- apps/kimi-code/src/tui/kimi-tui.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index dc5e921994..bc88754be3 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -2602,15 +2602,14 @@ export class KimiTUI { } restoreEditor(): void { - // Estimate whether the content already overflows one screen while the panel - // is still mounted. A forced full re-render clears the screen and homes the - // cursor, which is only safe when content fills the viewport; below one - // screen it would yank the editor to the top and leave a blank tail. - const { columns, rows } = this.state.terminal; - const overflowsViewport = this.state.ui.render(columns).length > rows; 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. + 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) From a196f4e33dbed4fcd8a391e8e6a27b2d3f83c44b Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 6 Jul 2026 15:05:17 +0800 Subject: [PATCH 3/3] fix(tui): redraw when content exactly fills one screen Address Codex review: the guard skipped the forced redraw when the post-close layout is exactly one screen, leaving the editor shifted up because the differential renderer keeps the old viewport offset after a shrink. An exact fill is safe to clear (no blank tail), so redraw when content fills or overflows the viewport, and cover it with a test. --- .../src/tui/components/editor/custom-editor.ts | 5 ++++- apps/kimi-code/src/tui/kimi-tui.ts | 4 +++- .../components/editor/custom-editor.test.ts | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index f1fe39c5ff..e3532b157c 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -266,7 +266,10 @@ export class CustomEditor extends Editor { private requestFullRenderOnAutocompleteClose(): void { if (isInsideTmux()) return; const { columns, rows } = this.tui.terminal; - if (this.tui.render(columns).length <= rows) return; + // 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); } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index bc88754be3..62d1b2370b 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -2608,8 +2608,10 @@ export class KimiTUI { // 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; + 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) diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index 8f408aa531..96598f6f57 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -820,6 +820,24 @@ describe('CustomEditor full re-render on autocomplete close', () => { 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);