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..e3532b157c 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,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; @@ -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; @@ -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; }); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 8d182aa97e..62d1b2370b 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.`; } @@ -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 { 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..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 @@ -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,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; + } { + 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); + }); +});