feat: add experimental terminal mouse input

This commit is contained in:
_Kerman 2026-06-05 13:59:13 +08:00
parent f8940ebf26
commit ece7b15589
21 changed files with 528 additions and 2 deletions

View file

@ -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.

View file

@ -112,6 +112,7 @@ export interface SlashCommandHost {
restoreEditor(): void;
restoreInputText(text: string): void;
refreshSlashCommandAutocomplete(): void;
resumeTerminalMouseTracking(): void;
// Session
requireSession(): Session;

View file

@ -22,6 +22,7 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise<void>
const config = await host.harness.getConfig({ reload: true });
setExperimentalFeatures(await host.harness.getExperimentalFeatures());
host.refreshSlashCommandAutocomplete();
host.resumeTerminalMouseTracking();
applyRuntimeConfig(host, config);
applyReloadedTuiConfig(host, tuiConfig);

View file

@ -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<typeof setTimeout>;
}
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<Intl.SegmentData>;
}
/**
* 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<Intl.SegmentData>,
): 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<Intl.SegmentData>,
): 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;

View file

@ -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

View file

@ -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<void>((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);

View file

@ -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();
}

View file

@ -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<TUIState, 'editor' | 'editorContainer' | 'terminal' | 'ui'>;
type TerminalMouseTrackingState = Pick<TUIState, 'terminal' | 'ui'>;
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 };
}

View file

@ -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<typeof vi.fn>;
};
readonly refreshSlashCommandAutocomplete: ReturnType<typeof vi.fn>;
readonly resumeTerminalMouseTracking: ReturnType<typeof vi.fn>;
readonly reloadCurrentSessionView: ReturnType<typeof vi.fn>;
readonly showStatus: ReturnType<typeof vi.fn>;
};

View file

@ -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 });
});
});

View file

@ -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<typeof import("#/tui/commands/prompts")>();
@ -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<void>;
onExit?: (code?: number) => Promise<void>;
@ -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(

View file

@ -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);
});
});

View file

@ -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_<NAME>` 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.

View file

@ -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 |

View file

@ -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

View file

@ -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_<NAME>` 可以覆盖单个功能,`KIMI_CODE_EXPERIMENTAL_FLAG=1` 会在当前进程启用所有实验功能。某个功能被环境变量控制时,`/experiments` 会显示为 locked。

View file

@ -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` 表示禁用 |

View file

@ -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` 命令配置的编辑器

View file

@ -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. */

View file

@ -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);
});

View file

@ -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'),