From ece7b155891236e85b1a9b3f1ee975ca8f321eb5 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 5 Jun 2026 13:59:13 +0800 Subject: [PATCH] feat: add experimental terminal mouse input --- .changeset/terminal-mouse-input.md | 6 + apps/kimi-code/src/tui/commands/dispatch.ts | 1 + apps/kimi-code/src/tui/commands/reload.ts | 1 + .../tui/components/editor/custom-editor.ts | 144 ++++++++++++++++- apps/kimi-code/src/tui/constant/terminal.ts | 5 + .../src/tui/controllers/editor-keyboard.ts | 4 + apps/kimi-code/src/tui/kimi-tui.ts | 18 +++ apps/kimi-code/src/tui/utils/editor-mouse.ts | 146 ++++++++++++++++++ .../test/tui/commands/reload.test.ts | 4 + .../components/editor/custom-editor.test.ts | 79 ++++++++++ .../test/tui/kimi-tui-startup.test.ts | 48 +++++- .../kimi-code/test/tui/terminal-focus.test.ts | 53 +++++++ docs/en/configuration/config-files.md | 1 + docs/en/configuration/env-vars.md | 1 + docs/en/reference/keyboard.md | 2 + docs/zh/configuration/config-files.md | 1 + docs/zh/configuration/env-vars.md | 1 + docs/zh/reference/keyboard.md | 2 + packages/agent-core/src/flags/registry.ts | 9 ++ .../agent-core/test/config/configs.test.ts | 3 + packages/node-sdk/test/config.test.ts | 1 + 21 files changed, 528 insertions(+), 2 deletions(-) create mode 100644 .changeset/terminal-mouse-input.md create mode 100644 apps/kimi-code/src/tui/utils/editor-mouse.ts diff --git a/.changeset/terminal-mouse-input.md b/.changeset/terminal-mouse-input.md new file mode 100644 index 000000000..492dc303b --- /dev/null +++ b/.changeset/terminal-mouse-input.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code": minor +--- + +Add experimental terminal mouse input for moving the main input cursor with clicks. diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index fff595d16..8b287c5db 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -112,6 +112,7 @@ export interface SlashCommandHost { restoreEditor(): void; restoreInputText(text: string): void; refreshSlashCommandAutocomplete(): void; + resumeTerminalMouseTracking(): void; // Session requireSession(): Session; diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index a93d7b6ec..47d990a35 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -22,6 +22,7 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise const config = await host.harness.getConfig({ reload: true }); setExperimentalFeatures(await host.harness.getExperimentalFeatures()); host.refreshSlashCommandAutocomplete(); + host.resumeTerminalMouseTracking(); applyRuntimeConfig(host, config); applyReloadedTuiConfig(host, tuiConfig); 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 c77f4af24..72d67b7ba 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -2,7 +2,14 @@ * Custom editor extending pi-tui Editor with app-level keybindings. */ -import { Editor, isKeyRelease, matchesKey, Key, type TUI } from '@earendil-works/pi-tui'; +import { + Editor, + isKeyRelease, + matchesKey, + Key, + visibleWidth, + type TUI, +} from '@earendil-works/pi-tui'; import chalk from 'chalk'; import type { ColorPalette } from '#/tui/theme/colors'; @@ -32,6 +39,26 @@ interface AutocompleteInternals { readonly autocompleteDebounceTimer?: ReturnType; } +interface VisualLine { + readonly logicalLine: number; + readonly startCol: number; + readonly length: number; +} + +interface EditorStateInternals { + state: { + cursorLine: number; + cursorCol: number; + }; + historyIndex: number; + lastAction: unknown; + preferredVisualCol: number | null; + snappedFromCursorCol: number | null; + readonly scrollOffset: number; + buildVisualLineMap(width: number): VisualLine[]; + segment(text: string): Iterable; +} + /** * Workaround for a pi-tui bug that surfaces when Kitty keyboard protocol * is active AND caps_lock is on. In that state terminals emit, e.g., @@ -85,6 +112,73 @@ function stripSgr(s: string): string { return s.replace(ANSI_SGR, ''); } +function cursorOffsetAtVisualColumn( + text: string, + targetCol: number, + allowEnd: boolean, + segments: Iterable, +): number { + if (targetCol <= 0) return 0; + let col = 0; + let lastStart = 0; + + for (const segment of segments) { + const width = visibleWidth(segment.segment); + const end = col + width; + if (targetCol < end) { + return targetCol > col + width / 2 ? segment.index + segment.segment.length : segment.index; + } + col = end; + lastStart = segment.index; + } + + return allowEnd ? text.length : lastStart; +} + +function snapMouseCursorCol( + cursorCol: number, + segments: Iterable, +): number { + for (const segment of segments) { + const end = segment.index + segment.segment.length; + if (segment.segment.length > 1 && cursorCol > segment.index && cursorCol < end) { + return segment.index; + } + } + return cursorCol; +} + +function mouseLayout( + width: number, + requestedPaddingX: number, +): { + readonly contentWidth: number; + readonly layoutWidth: number; + readonly paddingX: number; +} { + const maxPadding = Math.max(0, Math.floor((width - 1) / 2)); + const paddingX = Math.min(requestedPaddingX, maxPadding); + const contentWidth = Math.max(1, width - paddingX * 2); + const layoutWidth = Math.max(1, contentWidth - (paddingX > 0 ? 0 : 1)); + return { contentWidth, layoutWidth, paddingX }; +} + +function setMouseCursor(editor: EditorStateInternals, line: number, col: number): void { + editor.state.cursorLine = line; + editor.state.cursorCol = col; + editor.historyIndex = -1; + editor.lastAction = null; + editor.preferredVisualCol = null; + editor.snappedFromCursorCol = null; +} + +function findBottomBorderRow(lines: readonly string[]): number { + for (let i = 1; i < lines.length; i++) { + if (stripSgr(lines[i] ?? '').startsWith('╰')) return i; + } + return -1; +} + function getNewlineInput(data: string): string | undefined { if (data === '\n' || data === '\u001B\r' || data === '\u001B[13;2~') return data; if (matchesKey(data, Key.ctrl('j'))) return '\n'; @@ -189,6 +283,54 @@ export class CustomEditor extends Editor { (this as unknown as AutocompleteInternals).cancelAutocomplete(); } + moveCursorToMousePosition(row: number, col: number, width: number): boolean { + if ( + !Number.isInteger(row) || + !Number.isInteger(col) || + !Number.isInteger(width) || + row < 1 || + col < 1 || + width < 3 + ) { + return false; + } + + const rendered = this.render(width); + const bottomBorderRow = findBottomBorderRow(rendered); + if (bottomBorderRow < 0 || row >= bottomBorderRow || col >= width - 1) return false; + + const editor = this as unknown as EditorStateInternals; + const { contentWidth, layoutWidth, paddingX } = mouseLayout(width, this.getPaddingX()); + const visualLineIndex = editor.scrollOffset + row - 1; + const visualLines = editor.buildVisualLineMap(layoutWidth); + const visual = visualLines[visualLineIndex]; + if (visual === undefined) return false; + + const lines = this.getLines(); + const logicalLine = lines[visual.logicalLine] ?? ''; + const visibleText = logicalLine.slice(visual.startCol, visual.startCol + visual.length); + const contentCol = Math.max(0, Math.min(contentWidth, col - paddingX)); + const isLastSegment = + visualLineIndex === visualLines.length - 1 || + visualLines[visualLineIndex + 1]?.logicalLine !== visual.logicalLine; + const offset = cursorOffsetAtVisualColumn( + visibleText, + contentCol, + isLastSegment, + editor.segment(visibleText), + ); + const cursorCol = snapMouseCursorCol( + Math.min(logicalLine.length, visual.startCol + offset), + editor.segment(logicalLine), + ); + + if (this.hasAutocompleteActivity()) { + this.cancelAutocompleteActivity(); + } + setMouseCursor(editor, visual.logicalLine, cursorCol); + return true; + } + override render(width: number): string[] { const lines = super.render(width); if (lines.length < 3) return lines; diff --git a/apps/kimi-code/src/tui/constant/terminal.ts b/apps/kimi-code/src/tui/constant/terminal.ts index ec87a0780..360fad200 100644 --- a/apps/kimi-code/src/tui/constant/terminal.ts +++ b/apps/kimi-code/src/tui/constant/terminal.ts @@ -17,6 +17,11 @@ export const TERMINAL_FOCUS_OUT = `${ESC}[O`; export const ENABLE_TERMINAL_FOCUS_REPORTING = `${ESC}[?1004h`; export const DISABLE_TERMINAL_FOCUS_REPORTING = `${ESC}[?1004l`; +// Xterm SGR mouse reporting. Button events are enabled separately from the +// SGR coordinate format; both modes must be active to receive ESC[<...M/m. +export const ENABLE_TERMINAL_MOUSE_REPORTING = `${ESC}[?1000h${ESC}[?1006h`; +export const DISABLE_TERMINAL_MOUSE_REPORTING = `${ESC}[?1006l${ESC}[?1000l`; + // Standard OSC 11 background-color query. The response regex intentionally // allows a missing leading ESC because terminals can echo replies alongside // other raw input, but it requires an OSC terminator so fragmented color diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index b36917263..579771594 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -37,6 +37,8 @@ export interface EditorKeyboardHost { handlePlanToggle(next: boolean): void; clearQueuedMessages(): void; setExternalEditorRunning(running: boolean): void; + suspendTerminalMouseTracking(): void; + resumeTerminalMouseTracking(): void; } export class EditorKeyboardController { @@ -283,6 +285,7 @@ export class EditorKeyboardController { } this.host.setExternalEditorRunning(true); const seed = state.editor.getExpandedText?.() ?? state.editor.getText(); + this.host.suspendTerminalMouseTracking(); state.ui.stop(); await new Promise((resolve) => { setImmediate(resolve); @@ -300,6 +303,7 @@ export class EditorKeyboardController { process.stdin.pause(); } state.ui.start(); + this.host.resumeTerminalMouseTracking(); state.ui.setFocus(state.editor); state.ui.requestRender(true); this.host.setExternalEditorRunning(false); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index cd777a0f6..bcd384818 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -114,6 +114,7 @@ import { import { createTUIState, type TUIState } from './tui-state'; import { isExpandable, isPlanExpandable } from './utils/component-capabilities'; import { isDeadTerminalError } from './utils/dead-terminal'; +import { installEditorMouseTracking } from './utils/editor-mouse'; import { formatErrorMessage } from './utils/event-payload'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; import { extractMediaAttachments } from './utils/image-placeholder'; @@ -208,6 +209,7 @@ export class KimiTUI { aborted = false; private terminalFocusTrackingDispose: (() => void) | undefined; private terminalThemeTrackingDispose: (() => void) | undefined; + private terminalMouseTrackingDispose: (() => void) | undefined; private uninstallRainbowDance: () => void; private signalCleanupHandlers: Array<() => void> = []; private isShuttingDown = false; @@ -416,6 +418,7 @@ export class KimiTUI { this.state.editorContainer.clear(); this.state.editorContainer.addChild(this.state.editor); this.state.ui.setFocus(this.state.editor); + this.resumeTerminalMouseTracking(); return shouldReplayHistory; } @@ -646,6 +649,7 @@ export class KimiTUI { private disposeTerminalTracking(): void { this.stopTerminalThemeTracking(); + this.suspendTerminalMouseTracking(); this.terminalFocusTrackingDispose?.(); this.terminalFocusTrackingDispose = undefined; } @@ -1613,6 +1617,18 @@ export class KimiTUI { this.terminalThemeTrackingDispose = undefined; } + suspendTerminalMouseTracking(): void { + this.terminalMouseTrackingDispose?.(); + this.terminalMouseTrackingDispose = undefined; + } + + resumeTerminalMouseTracking(): void { + this.suspendTerminalMouseTracking(); + if (!isExperimentalFlagEnabled('terminal_mouse_input')) return; + + this.terminalMouseTrackingDispose = installEditorMouseTracking(this.state); + } + private applyResolvedAutoTheme(resolved: ResolvedTheme): void { if (this.state.appState.theme !== 'auto') return; if (this.state.theme.resolvedTheme === resolved) return; @@ -1669,6 +1685,7 @@ export class KimiTUI { // ========================================================================= mountEditorReplacement(panel: Component & Focusable): void { + this.suspendTerminalMouseTracking(); this.state.editorContainer.clear(); this.state.editorContainer.addChild(panel); this.state.ui.setFocus(panel); @@ -1679,6 +1696,7 @@ export class KimiTUI { this.state.editorContainer.clear(); this.state.editorContainer.addChild(this.state.editor); this.state.ui.setFocus(this.state.editor); + this.resumeTerminalMouseTracking(); this.state.ui.requestRender(); } diff --git a/apps/kimi-code/src/tui/utils/editor-mouse.ts b/apps/kimi-code/src/tui/utils/editor-mouse.ts new file mode 100644 index 000000000..9551a0ba5 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/editor-mouse.ts @@ -0,0 +1,146 @@ +import type { Component } from '@earendil-works/pi-tui'; + +import { CHROME_GUTTER } from '#/tui/constant/rendering'; +import { + DISABLE_TERMINAL_MOUSE_REPORTING, + ENABLE_TERMINAL_MOUSE_REPORTING, +} from '#/tui/constant/terminal'; +import type { TUIState } from '#/tui/tui-state'; + +export { + DISABLE_TERMINAL_MOUSE_REPORTING, + ENABLE_TERMINAL_MOUSE_REPORTING, +} from '#/tui/constant/terminal'; + +interface EditorMouseTarget { + readonly row: number; + readonly col: number; + readonly width: number; +} + +type EditorMouseState = Pick; +type TerminalMouseTrackingState = Pick; + +export interface TerminalMouseEvent { + readonly button: number; + readonly col: number; + readonly row: number; + readonly final: 'M' | 'm'; +} + +const SGR_MOUSE_EVENT = /^\u001B\[<(\d+);(\d+);(\d+)([Mm])$/; +const MOUSE_MOTION_BIT = 32; +const MOUSE_WHEEL_BIT = 64; +const MOUSE_BUTTON_MASK = 3; + +export function parseSgrMouseEvent(data: string): TerminalMouseEvent | undefined { + const match = data.match(SGR_MOUSE_EVENT); + if (match === null) return undefined; + + const button = Number(match[1]); + const col = Number(match[2]); + const row = Number(match[3]); + const final = match[4] as 'M' | 'm'; + if (!Number.isInteger(button) || !Number.isInteger(col) || !Number.isInteger(row)) { + return undefined; + } + if (col < 1 || row < 1) return undefined; + + return { button, col, row, final }; +} + +export function isPrimaryMousePress(event: TerminalMouseEvent): boolean { + return ( + event.final === 'M' && + (event.button & MOUSE_WHEEL_BIT) === 0 && + (event.button & MOUSE_MOTION_BIT) === 0 && + (event.button & MOUSE_BUTTON_MASK) === 0 + ); +} + +export function installTerminalMouseTracking( + state: TerminalMouseTrackingState, + onMouseEvent: (event: TerminalMouseEvent) => void, +): () => void { + const disposeInputListener = state.ui.addInputListener((data) => { + const event = parseSgrMouseEvent(data); + if (event === undefined) return undefined; + + onMouseEvent(event); + return { consume: true }; + }); + state.terminal.write(ENABLE_TERMINAL_MOUSE_REPORTING); + + return () => { + disposeInputListener(); + state.terminal.write(DISABLE_TERMINAL_MOUSE_REPORTING); + }; +} + +export function installEditorMouseTracking(state: EditorMouseState): () => void { + return installTerminalMouseTracking(state, (event) => { + if (!isPrimaryMousePress(event)) return; + const target = resolveEditorMouseTarget(state, event); + if (target === undefined) return; + if (!state.editor.moveCursorToMousePosition(target.row, target.col, target.width)) return; + + state.ui.requestRender(); + }); +} + +export function resolveEditorMouseTarget( + state: EditorMouseState, + event: TerminalMouseEvent, +): EditorMouseTarget | undefined { + if (!state.editorContainer.children.includes(state.editor)) return undefined; + + const { columns: terminalWidth, rows: terminalRows } = state.terminal; + if (event.col < 1 || event.row < 1 || event.row > terminalRows) return undefined; + + const layout = locateEditorContainer(state.ui.children, state.editorContainer, terminalWidth); + if (layout === undefined) return undefined; + + const viewportTop = Math.max(0, layout.totalRows - terminalRows); + const screenTop = Math.max(0, terminalRows - layout.totalRows); + const screenRow = event.row - 1; + const logicalRow = viewportTop + screenRow - screenTop; + if (logicalRow < layout.startRow || logicalRow >= layout.endRow) return undefined; + + const editorWidth = Math.max(1, terminalWidth - CHROME_GUTTER * 2); + const editorCol = event.col - CHROME_GUTTER - 1; + if (editorCol < 0 || editorCol >= editorWidth) return undefined; + + return { + row: logicalRow - layout.startRow, + col: editorCol, + width: editorWidth, + }; +} + +function locateEditorContainer( + children: readonly Component[], + editorContainer: Component, + width: number, +): + | { + readonly startRow: number; + readonly endRow: number; + readonly totalRows: number; + } + | undefined { + let row = 0; + let startRow: number | undefined; + let endRow = 0; + + for (const child of children) { + const lineCount = child.render(width).length; + if (child === editorContainer) { + startRow = row; + endRow = row + lineCount; + } + row += lineCount; + } + + if (startRow === undefined) return undefined; + return { startRow, endRow, totalRows: row }; +} diff --git a/apps/kimi-code/test/tui/commands/reload.test.ts b/apps/kimi-code/test/tui/commands/reload.test.ts index 5d6b41f55..a14aa24fc 100644 --- a/apps/kimi-code/test/tui/commands/reload.test.ts +++ b/apps/kimi-code/test/tui/commands/reload.test.ts @@ -51,6 +51,7 @@ auto_install = false expect(host.harness.getConfig).not.toHaveBeenCalled(); expect(host.harness.getExperimentalFeatures).not.toHaveBeenCalled(); + expect(host.resumeTerminalMouseTracking).not.toHaveBeenCalled(); expect(session.reloadSession).not.toHaveBeenCalled(); expect(host.state.appState).toMatchObject({ theme: 'light', @@ -79,6 +80,7 @@ auto_install = false expect(host.harness.getConfig).toHaveBeenCalledWith({ reload: true }); expect(host.harness.getExperimentalFeatures).toHaveBeenCalledOnce(); expect(host.refreshSlashCommandAutocomplete).toHaveBeenCalledOnce(); + expect(host.resumeTerminalMouseTracking).toHaveBeenCalledOnce(); expect(isExperimentalFlagEnabled('goal_command')).toBe(true); expect(host.state.appState.theme).toBe('light'); expect(host.state.appState.availableModels).toEqual({ @@ -138,6 +140,7 @@ function makeHost({ }), refreshTerminalThemeTracking: vi.fn(), refreshSlashCommandAutocomplete: vi.fn(), + resumeTerminalMouseTracking: vi.fn(), reloadCurrentSessionView: vi.fn(async () => {}), showStatus: vi.fn(), } as unknown as SlashCommandHost & { @@ -146,6 +149,7 @@ function makeHost({ readonly getExperimentalFeatures: ReturnType; }; readonly refreshSlashCommandAutocomplete: ReturnType; + readonly resumeTerminalMouseTracking: ReturnType; readonly reloadCurrentSessionView: ReturnType; readonly showStatus: ReturnType; }; 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 6545af266..85b67cbcb 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,14 +4,23 @@ import type { AutocompleteSuggestions, TUI, } from '@earendil-works/pi-tui'; +import { Container, Spacer } from '@earendil-works/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { CustomEditor } from '#/tui/components/editor/custom-editor'; +import { GutterContainer } from '#/tui/components/chrome/gutter-container'; +import { CHROME_GUTTER } from '#/tui/constant/rendering'; import { getColorPalette } from '#/tui/theme/index'; +import type { TUIState } from '#/tui/kimi-tui'; +import { resolveEditorMouseTarget } from '#/tui/utils/editor-mouse'; function makeEditor(): CustomEditor { const tui = { requestRender: vi.fn(), + terminal: { + columns: 80, + rows: 24, + }, } as unknown as TUI; return new CustomEditor(tui, { ...getColorPalette('dark') }); } @@ -232,3 +241,73 @@ describe('CustomEditor shortcut telemetry hooks', () => { expect(onUndo).toHaveBeenCalledOnce(); }); }); + +describe('CustomEditor mouse cursor positioning', () => { + it('moves the cursor to the clicked column on a single-line prompt', () => { + const editor = makeEditor(); + editor.setText('hello world'); + + expect(editor.moveCursorToMousePosition(1, 10, 40)).toBe(true); + expect(editor.getCursor()).toEqual({ line: 0, col: 6 }); + + editor.handleInput('X'); + expect(editor.getText()).toBe('hello Xworld'); + }); + + it('maps prompt clicks to line start and right-padding clicks to line end', () => { + const editor = makeEditor(); + editor.setText('abc'); + + expect(editor.moveCursorToMousePosition(1, 2, 24)).toBe(true); + expect(editor.getCursor()).toEqual({ line: 0, col: 0 }); + + expect(editor.moveCursorToMousePosition(1, 20, 24)).toBe(true); + expect(editor.getCursor()).toEqual({ line: 0, col: 3 }); + }); + + it('moves across logical lines', () => { + const editor = makeEditor(); + editor.setText('one\ntwo'); + + expect(editor.moveCursorToMousePosition(2, 5, 40)).toBe(true); + expect(editor.getCursor()).toEqual({ line: 1, col: 1 }); + }); + + it('maps clicks on wrapped visual lines back to logical columns', () => { + const editor = makeEditor(); + editor.setText('abcdefghij'); + + expect(editor.moveCursorToMousePosition(2, 5, 16)).toBe(true); + expect(editor.getCursor()).toEqual({ line: 0, col: 9 }); + }); + + it('resolves terminal mouse coordinates through the editor container gutter', () => { + const editor = makeEditor(); + const ui = new Container(); + const transcript = new Container(); + const editorContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + transcript.addChild(new Spacer(2)); + editorContainer.addChild(editor); + ui.addChild(transcript); + ui.addChild(editorContainer); + + const state = { + ui, + editor, + editorContainer, + terminal: { + columns: 40, + rows: 20, + }, + } as unknown as TUIState; + + expect( + resolveEditorMouseTarget(state, { + button: 0, + col: CHROME_GUTTER + 6 + 1, + row: 19, + final: 'M', + }), + ).toEqual({ row: 1, col: 6, width: 38 }); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 3b5a0c14a..0b663c8a4 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -1,9 +1,10 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { MigrationPlan } from "@moonshot-ai/migration-legacy"; import { log, type GoalSnapshot } from "@moonshot-ai/kimi-code-sdk"; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from "#/tui/kimi-tui"; +import { setExperimentalFeatures } from "#/tui/commands/experimental-flags"; import { handleLoginCommand, handleLogoutCommand, @@ -19,6 +20,10 @@ import { QUERY_TERMINAL_THEME, TERMINAL_THEME_LIGHT, } from "#/tui/utils/terminal-theme"; +import { + DISABLE_TERMINAL_MOUSE_REPORTING, + ENABLE_TERMINAL_MOUSE_REPORTING, +} from "#/tui/utils/editor-mouse"; vi.mock("#/tui/commands/prompts", async (importOriginal) => { const actual = await importOriginal(); @@ -40,6 +45,12 @@ interface ThemeTrackingDriver extends StartupDriver { refreshTerminalThemeTracking(): void; } +interface MouseTrackingDriver extends StartupDriver { + resumeTerminalMouseTracking(): void; + suspendTerminalMouseTracking(): void; + terminalMouseTrackingDispose?: () => void; +} + interface MigrateExitDriver extends StartupDriver { start(): Promise; onExit?: (code?: number) => Promise; @@ -203,6 +214,10 @@ function captureInputListeners(driver: StartupDriver) { } describe("KimiTUI startup", () => { + beforeEach(() => { + setExperimentalFeatures([]); + }); + it("creates a fresh session from startup flags and syncs runtime state", async () => { const session = makeSession({ getStatus: vi.fn(async () => ({ @@ -406,6 +421,37 @@ describe("KimiTUI startup", () => { expect(write).not.toHaveBeenCalled(); }); + it("does not track terminal mouse reports while the mouse input flag is disabled", () => { + const harness = makeHarness(); + const driver = makeDriver(harness, makeStartupInput()) as unknown as MouseTrackingDriver; + const { write, addInputListener } = captureInputListeners(driver); + + driver.resumeTerminalMouseTracking(); + + expect(addInputListener).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + expect(driver.terminalMouseTrackingDispose).toBeUndefined(); + }); + + it("tracks terminal mouse reports while the mouse input flag is enabled", () => { + setExperimentalFeatures([{ id: "terminal_mouse_input", enabled: true }]); + const harness = makeHarness(); + const driver = makeDriver(harness, makeStartupInput()) as unknown as MouseTrackingDriver; + const { write, addInputListener, removeInputListener } = captureInputListeners(driver); + + driver.resumeTerminalMouseTracking(); + + expect(addInputListener).toHaveBeenCalledOnce(); + expect(write).toHaveBeenCalledWith(ENABLE_TERMINAL_MOUSE_REPORTING); + expect(driver.terminalMouseTrackingDispose).toBeDefined(); + + driver.suspendTerminalMouseTracking(); + + expect(removeInputListener).toHaveBeenCalledOnce(); + expect(write).toHaveBeenCalledWith(DISABLE_TERMINAL_MOUSE_REPORTING); + expect(driver.terminalMouseTrackingDispose).toBeUndefined(); + }); + it("disables terminal theme reports after leaving auto theme", () => { const harness = makeHarness(); const driver = makeDriver( diff --git a/apps/kimi-code/test/tui/terminal-focus.test.ts b/apps/kimi-code/test/tui/terminal-focus.test.ts index 71246b785..a5ec4991a 100644 --- a/apps/kimi-code/test/tui/terminal-focus.test.ts +++ b/apps/kimi-code/test/tui/terminal-focus.test.ts @@ -9,6 +9,13 @@ import { handleTerminalFocusInput, installTerminalFocusTracking, } from '#/tui/utils/terminal-focus'; +import { + DISABLE_TERMINAL_MOUSE_REPORTING, + ENABLE_TERMINAL_MOUSE_REPORTING, + installTerminalMouseTracking, + isPrimaryMousePress, + parseSgrMouseEvent, +} from '#/tui/utils/editor-mouse'; describe('terminal focus tracking', () => { it('updates focus state from terminal focus reporting sequences', () => { @@ -57,3 +64,49 @@ describe('terminal focus tracking', () => { expect(state.terminalState.focused).toBe(true); }); }); + +describe('terminal mouse tracking', () => { + it('parses SGR mouse events and recognizes primary button presses', () => { + const press = parseSgrMouseEvent('\u001B[<0;12;4M'); + expect(press).toEqual({ button: 0, col: 12, row: 4, final: 'M' }); + expect(press === undefined ? false : isPrimaryMousePress(press)).toBe(true); + + const modifiedPress = parseSgrMouseEvent('\u001B[<16;12;4M'); + expect(modifiedPress === undefined ? false : isPrimaryMousePress(modifiedPress)).toBe(true); + + const release = parseSgrMouseEvent('\u001B[<0;12;4m'); + expect(release === undefined ? false : isPrimaryMousePress(release)).toBe(false); + + expect(parseSgrMouseEvent('\u001B[12;4H')).toBeUndefined(); + }); + + it('enables mouse reporting, consumes mouse input, and disables it on dispose', () => { + const listeners: Array<(data: string) => { consume?: boolean } | undefined> = []; + const removeInputListener = vi.fn(); + const onMouseEvent = vi.fn(); + const state = { + terminal: { + write: vi.fn(), + }, + ui: { + addInputListener: vi.fn((listener) => { + listeners.push(listener); + return removeInputListener; + }), + }, + } as unknown as TUIState; + + const dispose = installTerminalMouseTracking(state, onMouseEvent); + + expect(state.terminal.write).toHaveBeenCalledWith(ENABLE_TERMINAL_MOUSE_REPORTING); + expect(listeners).toHaveLength(1); + expect(listeners[0]?.('\u001B[<0;12;4M')).toEqual({ consume: true }); + expect(onMouseEvent).toHaveBeenCalledWith({ button: 0, col: 12, row: 4, final: 'M' }); + expect(listeners[0]?.('x')).toBeUndefined(); + + dispose(); + + expect(removeInputListener).toHaveBeenCalledOnce(); + expect(state.terminal.write).toHaveBeenCalledWith(DISABLE_TERMINAL_MOUSE_REPORTING); + }); +}); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index bbe79d5aa..f59c72d38 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -186,6 +186,7 @@ You can also switch models temporarily without touching the config file — by s | `goal_command` | `boolean` | `false` | Enable `/goal` and goal-management tools | | `micro_compaction` | `boolean` | `false` | Trim older large tool results from context while preserving recent conversation | | `background_ask` | `boolean` | `false` | Allow `AskUserQuestion` to start a background question task when the Agent can continue working | +| `terminal_mouse_input` | `boolean` | `false` | Allow mouse clicks inside the main input box to move the cursor; terminal text selection may require `Shift`-drag | Environment variables take priority over this table. `KIMI_CODE_EXPERIMENTAL_` overrides one feature, and `KIMI_CODE_EXPERIMENTAL_FLAG=1` enables all experimental features for that process. When a feature is controlled by the environment, `/experiments` shows it as locked. diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 6ae65276c..9c5dc041a 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -129,6 +129,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND` | Override `[experimental].goal_command` for this process | Truthy or falsy | | `KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION` | Override `[experimental].micro_compaction` for this process | Truthy or falsy | | `KIMI_CODE_EXPERIMENTAL_BACKGROUND_ASK` | Override `[experimental].background_ask` for this process | Truthy or falsy | +| `KIMI_CODE_EXPERIMENTAL_TERMINAL_MOUSE_INPUT` | Override `[experimental].terminal_mouse_input` for this process | Truthy or falsy | | `KIMI_SHELL_PATH` | Override the Git Bash path on Windows (used when auto-detection fails) | Absolute path | | `KIMI_MODEL_MAX_COMPLETION_TOKENS` | Hard cap on `max_completion_tokens` per LLM step; applies to the `kimi` provider only | Positive integer; `0` or negative disables clamping | | `KIMI_DISABLE_CRON` | Disable the scheduled-task tool (`CronCreate` rejects new schedules; existing tasks do not fire) | `1` to disable | diff --git a/docs/en/reference/keyboard.md b/docs/en/reference/keyboard.md index c097d6238..00dcabd0e 100644 --- a/docs/en/reference/keyboard.md +++ b/docs/en/reference/keyboard.md @@ -37,6 +37,8 @@ Press `Shift-Tab` to enable or disable Plan mode. When enabled, the Agent priori | `Ctrl-E` | Expand or collapse the Plan card (moves the cursor to end-of-line when no Plan card is present) | | `Ctrl--` | Undo | +Mouse cursor placement is experimental. After enabling `terminal_mouse_input` from `/experiments`, `[experimental].terminal_mouse_input = true`, or `KIMI_CODE_EXPERIMENTAL_TERMINAL_MOUSE_INPUT=1`, clicking inside the main input box moves the cursor to the clicked position. While this flag is enabled, many terminals route regular mouse drags to the app; use `Shift`-drag to select terminal text. + Pressing `Ctrl-G` opens an external editor, selected according to the following priority: 1. The editor configured via the `/editor` command diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index e660940ad..8168de262 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -186,6 +186,7 @@ max_context_size = 1047576 | `goal_command` | `boolean` | `false` | 启用 `/goal` 和 goal 管理工具 | | `micro_compaction` | `boolean` | `false` | 清理较旧的大型工具结果内容,同时保留最近对话 | | `background_ask` | `boolean` | `false` | 允许 `AskUserQuestion` 在 Agent 可以继续工作时启动后台提问任务 | +| `terminal_mouse_input` | `boolean` | `false` | 允许在主输入框内通过鼠标点击移动光标;选择终端文本时可能需要按住 `Shift` 拖选 | 环境变量优先级高于这个表。`KIMI_CODE_EXPERIMENTAL_` 可以覆盖单个功能,`KIMI_CODE_EXPERIMENTAL_FLAG=1` 会在当前进程启用所有实验功能。某个功能被环境变量控制时,`/experiments` 会显示为 locked。 diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index ca45fbf5b..36a6e16c9 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -129,6 +129,7 @@ kimi | `KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND` | 覆盖当前进程的 `[experimental].goal_command` | 真值或假值 | | `KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION` | 覆盖当前进程的 `[experimental].micro_compaction` | 真值或假值 | | `KIMI_CODE_EXPERIMENTAL_BACKGROUND_ASK` | 覆盖当前进程的 `[experimental].background_ask` | 真值或假值 | +| `KIMI_CODE_EXPERIMENTAL_TERMINAL_MOUSE_INPUT` | 覆盖当前进程的 `[experimental].terminal_mouse_input` | 真值或假值 | | `KIMI_SHELL_PATH` | Windows 上覆盖 Git Bash 路径(自动探测失败时使用) | 绝对路径 | | `KIMI_MODEL_MAX_COMPLETION_TOKENS` | 单步 LLM 请求的 `max_completion_tokens` 硬上限,仅对 `kimi` 供应商生效 | 正整数;`0` 或负数禁用 clamp | | `KIMI_DISABLE_CRON` | 禁用定时任务工具(`CronCreate` 拒绝新计划,已有任务不触发) | `1` 表示禁用 | diff --git a/docs/zh/reference/keyboard.md b/docs/zh/reference/keyboard.md index 4390ca929..c0222ccf7 100644 --- a/docs/zh/reference/keyboard.md +++ b/docs/zh/reference/keyboard.md @@ -37,6 +37,8 @@ Kimi Code CLI 的 TUI 交互模式支持一套键盘快捷键。键位按使用 | `Ctrl-E` | 展开或折叠 Plan 卡片(无 Plan 卡片时将光标移到行尾) | | `Ctrl--` | 撤销(Undo) | +鼠标定位光标目前是实验功能。通过 `/experiments`、`[experimental].terminal_mouse_input = true` 或 `KIMI_CODE_EXPERIMENTAL_TERMINAL_MOUSE_INPUT=1` 启用后,在主输入框内点击会把光标移动到点击位置。启用这个 flag 时,很多终端会把普通鼠标拖选交给应用;需要选择终端文本时,请按住 `Shift` 再拖选。 + 按 `Ctrl-G` 会打开外部编辑器,编辑器按以下优先级选择: 1. `/editor` 命令配置的编辑器 diff --git a/packages/agent-core/src/flags/registry.ts b/packages/agent-core/src/flags/registry.ts index 1f9b896d4..26d83c796 100644 --- a/packages/agent-core/src/flags/registry.ts +++ b/packages/agent-core/src/flags/registry.ts @@ -36,6 +36,15 @@ export const FLAG_DEFINITIONS = [ default: false, surface: 'core', }, + { + id: 'terminal_mouse_input', + title: 'Terminal mouse input', + description: + 'Allow terminal mouse clicks to move the main input cursor; text selection may require Shift-drag.', + env: 'KIMI_CODE_EXPERIMENTAL_TERMINAL_MOUSE_INPUT', + default: false, + surface: 'tui', + }, ] as const satisfies readonly FlagDefinitionInput[]; /** Literal union of registered flag ids. */ diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index 3af483488..264ec14ff 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -262,6 +262,7 @@ oauth = { storage = "file", key = "oauth/kimi-code-env-1234", oauth_host = "http goal_command = true micro_compaction = false background_ask = true +terminal_mouse_input = false `; const config = parseConfigString(toml, configPath); @@ -269,6 +270,7 @@ background_ask = true 'goal_command': true, 'micro_compaction': false, 'background_ask': true, + 'terminal_mouse_input': false, }); await writeConfigFile(configPath, config); @@ -278,6 +280,7 @@ background_ask = true expect(text).toContain('goal_command = true'); expect(text).toContain('micro_compaction = false'); expect(text).toContain('background_ask = true'); + expect(text).toContain('terminal_mouse_input = false'); expect(parseConfigString(text, configPath).experimental).toEqual(config.experimental); }); diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index 20129f171..938d3601f 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -325,6 +325,7 @@ describe('KimiHarness config API', () => { vi.stubEnv('KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND', ''); vi.stubEnv('KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION', ''); vi.stubEnv('KIMI_CODE_EXPERIMENTAL_BACKGROUND_ASK', ''); + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_TERMINAL_MOUSE_INPUT', ''); const homeDir = await makeTempDir(); await writeFile( join(homeDir, 'config.toml'),