mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
feat: add experimental terminal mouse input
This commit is contained in:
parent
f8940ebf26
commit
ece7b15589
21 changed files with 528 additions and 2 deletions
6
.changeset/terminal-mouse-input.md
Normal file
6
.changeset/terminal-mouse-input.md
Normal 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.
|
||||
|
|
@ -112,6 +112,7 @@ export interface SlashCommandHost {
|
|||
restoreEditor(): void;
|
||||
restoreInputText(text: string): void;
|
||||
refreshSlashCommandAutocomplete(): void;
|
||||
resumeTerminalMouseTracking(): void;
|
||||
|
||||
// Session
|
||||
requireSession(): Session;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
146
apps/kimi-code/src/tui/utils/editor-mouse.ts
Normal file
146
apps/kimi-code/src/tui/utils/editor-mouse.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
||||
|
|
|
|||
|
|
@ -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` 表示禁用 |
|
||||
|
|
|
|||
|
|
@ -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` 命令配置的编辑器
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue