Skip to content
5 changes: 5 additions & 0 deletions .changeset/mouse-select-editor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/pi-tui": minor
---

Add text selection, replacement, deletion, rendering, and mouse-position mapping to the editor component.
5 changes: 5 additions & 0 deletions .changeset/terminal-mouse-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add experimental mouse selection to the prompt editor. Set `KIMI_CODE_EXPERIMENTAL_TERMINAL_MOUSE_INPUT=1` to enable it.
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export interface SlashCommandHost {
* it while still session-less.
*/
hydrateLazyConfigDefaults(): Promise<void>;
refreshTerminalMouseTracking(): void;

// Session
requireSession(): Session;
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/commands/reload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise<void>
await host.refreshPluginCommands();
}
host.refreshSlashCommandAutocomplete();
host.refreshTerminalMouseTracking();
applyRuntimeConfig(host, config);
await applyReloadedTuiConfig(host, tuiConfig);

Expand Down
10 changes: 10 additions & 0 deletions apps/kimi-code/src/tui/components/editor/custom-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export class CustomEditor extends Editor {
public onNonEscapeInput?: () => void;
public onCtrlD?: () => void;
public onCtrlC?: () => void;
public onCopySelection?: (text: string) => void;
public onToggleToolExpand?: () => void;
public onOpenExternalEditor?: () => void;
public onCtrlS?: () => void;
Expand Down Expand Up @@ -446,6 +447,11 @@ export class CustomEditor extends Editor {
}

if (matchesKey(normalized, Key.ctrl('c'))) {
const selectedText = this.getSelectedText();
if (selectedText !== undefined) {
this.onCopySelection?.(selectedText);
return;
}
this.onCtrlC?.();
return;
}
Expand Down Expand Up @@ -518,6 +524,10 @@ export class CustomEditor extends Editor {
this.cancelAutocompleteActivity();
return;
}
if (this.hasSelection()) {
this.clearSelection();
return;
}
this.onEscape?.();
return;
}
Expand Down
6 changes: 6 additions & 0 deletions apps/kimi-code/src/tui/constant/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ 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-event tracking reports drag motion while
// a button is held; SGR mode keeps coordinates unambiguous in wide terminals.
export const ENABLE_TERMINAL_MOUSE_REPORTING = `${ESC}[?1002h${ESC}[?1006h`;
export const DISABLE_TERMINAL_MOUSE_REPORTING =
`${ESC}[?1006l${ESC}[?1003l${ESC}[?1002l${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
Expand Down
9 changes: 9 additions & 0 deletions apps/kimi-code/src/tui/controllers/editor-keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
readClipboardMedia,
type ClipboardVideo,
} from '#/utils/clipboard/clipboard-image';
import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text';
import { parseImageMeta } from '#/utils/image/image-mime';
import { editInExternalEditor, resolveEditorCommand } from '#/utils/process/external-editor';

Expand Down Expand Up @@ -80,6 +81,8 @@ export interface EditorKeyboardHost {
handleInputModeChange(mode: 'prompt' | 'bash'): void;
clearQueuedMessages(): void;
setExternalEditorRunning(running: boolean): void;
suspendTerminalMouseTracking(): void;
refreshTerminalMouseTracking(): void;
updateActivityPane(): void;
}

Expand Down Expand Up @@ -164,6 +167,10 @@ export class EditorKeyboardController {
this.clearPendingUndoEsc();
};

editor.onCopySelection = (text: string) => {
void copyTextToClipboard(text).catch(() => undefined);
};

editor.onCtrlC = () => {
if (host.cancelInFlight !== undefined) {
const cancel = host.cancelInFlight;
Expand Down Expand Up @@ -755,6 +762,7 @@ export class EditorKeyboardController {
}
this.host.setExternalEditorRunning(true);
const seed = state.editor.getExpandedText?.() ?? state.editor.getText();
this.host.suspendTerminalMouseTracking();
// Fullscreen: a plain stop() would replay the whole transcript into the
// main screen on exit; the external editor only needs the alternate
// screen released, so preserve the screen instead.
Expand All @@ -775,6 +783,7 @@ export class EditorKeyboardController {
process.stdin.pause();
}
state.ui.start();
this.host.refreshTerminalMouseTracking();
state.ui.setFocus(state.editor);
state.ui.requestRender(true);
// terminal.stop() cleared the OSC 9;4 progress indicator while the
Expand Down
4 changes: 4 additions & 0 deletions apps/kimi-code/src/tui/controllers/tasks-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export interface TasksBrowserHost {
readonly session: Session | undefined;
showError(msg: string): void;
setTasksBrowser(value: TasksBrowserState | undefined): void;
suspendTerminalMouseTracking(): void;
refreshTerminalMouseTracking(): void;
}

export type TasksBrowserState = {
Expand Down Expand Up @@ -91,6 +93,7 @@ export class TasksBrowserController {
state.terminal,
);

this.host.suspendTerminalMouseTracking();
const takeover = beginScreenTakeover(state.ui, component);
state.ui.setFocus(component);
state.ui.requestRender(true);
Expand Down Expand Up @@ -129,6 +132,7 @@ export class TasksBrowserController {
endScreenTakeover(state.ui, browser.takeover);
this.host.setTasksBrowser(undefined);
state.ui.setFocus(state.editor);
this.host.refreshTerminalMouseTracking();
state.ui.requestRender(true);
}

Expand Down
32 changes: 30 additions & 2 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ import { formatBashOutputForDisplay } from './utils/shell-output';
import { thinkingEffortFromConfig } from './utils/thinking-config';
import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup';
import { installTerminalFocusTracking } from './utils/terminal-focus';
import { installEditorMouseTracking } from './utils/editor-mouse';
import { notifyTerminalOnce } from './utils/terminal-notification';
import { installTerminalThemeTracking } from './utils/terminal-theme';
import { detectTmuxKeyboardWarning } from './utils/tmux-keyboard';
Expand Down Expand Up @@ -331,6 +332,7 @@ export class KimiTUI {
aborted = false;
private terminalFocusTrackingDispose: (() => void) | undefined;
private terminalThemeTrackingDispose: (() => void) | undefined;
private terminalMouseTrackingDispose: (() => void) | undefined;
private clipboardImageHintController: ClipboardImageHintController | undefined;
private uninstallRainbowDance: () => void;
private signalCleanupHandlers: Array<() => void> = [];
Expand Down Expand Up @@ -624,6 +626,7 @@ export class KimiTUI {
return;
}
const shouldReplayHistory = await this.initMainTui();
this.refreshTerminalMouseTracking();
this.startBackgroundFdAutocomplete();
await this.finishStartup(shouldReplayHistory);
} catch (error) {
Expand All @@ -642,8 +645,14 @@ export class KimiTUI {
// When the trust prompt already started the event loop, starting it
// again would re-run pi-tui's terminal.start() — stacking a second
// Kitty keyboard-protocol push (leaking CSI-u mode past exit) and
// duplicate stdin listeners.
if (!trustPromptStartedLoop) this.startEventLoop();
// duplicate stdin listeners. The prompt started before init() loaded the
// experimental-feature snapshot, so refresh mouse tracking once the main
// editor is mounted instead.
if (trustPromptStartedLoop) {
this.refreshTerminalMouseTracking();
} else {
this.startEventLoop();
}
startupTrace('eventLoop:started');
try {
this.startBackgroundFdAutocomplete();
Expand Down Expand Up @@ -731,6 +740,7 @@ export class KimiTUI {
this.startClipboardImageHintController();
this.terminalFocusTrackingDispose = installTerminalFocusTracking(this.state);
this.refreshTerminalThemeTracking();
this.refreshTerminalMouseTracking();
Comment thread
FeiZhuLulu marked this conversation as resolved.
}

private startClipboardImageHintController(): void {
Expand Down Expand Up @@ -1080,6 +1090,7 @@ export class KimiTUI {

private disposeTerminalTracking(): void {
this.stopTerminalThemeTracking();
this.suspendTerminalMouseTracking();
this.clipboardImageHintController?.stop();
this.clipboardImageHintController = undefined;
this.terminalFocusTrackingDispose?.();
Expand Down Expand Up @@ -3546,6 +3557,20 @@ export class KimiTUI {
this.terminalThemeTrackingDispose = undefined;
}

suspendTerminalMouseTracking(): void {
this.terminalMouseTrackingDispose?.();
this.terminalMouseTrackingDispose = undefined;
}

refreshTerminalMouseTracking(): void {
this.suspendTerminalMouseTracking();
if (this.isShuttingDown) return;
if (!isExperimentalFlagEnabled('terminal_mouse_input')) return;
if (!this.state.ui.children.includes(this.state.editorContainer)) return;
if (!this.state.editorContainer.children.includes(this.state.editor)) return;
this.terminalMouseTrackingDispose = installEditorMouseTracking(this.state);
Comment thread
FeiZhuLulu marked this conversation as resolved.
}

private async applyResolvedAutoTheme(resolved: ResolvedTheme): Promise<void> {
if (this.state.appState.theme !== 'auto') return;
const palette = getBuiltInPalette(resolved);
Expand Down Expand Up @@ -3622,6 +3647,8 @@ export class KimiTUI {
// =========================================================================

mountEditorReplacement(panel: Component & Focusable): void {
this.suspendTerminalMouseTracking();
this.state.editor.clearSelection();
this.state.editorReplacementMounted = true;
this.state.editorContainer.clear();
this.state.editorContainer.addChild(panel);
Expand All @@ -3634,6 +3661,7 @@ export class KimiTUI {
this.state.editorContainer.clear();
this.state.editorContainer.addChild(this.state.editor);
this.state.ui.setFocus(this.state.editor);
this.refreshTerminalMouseTracking();
// Differential render only: closing a tall panel leaves the editor a few
// rows above the bottom (blank tail) until the next append, but avoids a
// destructive full redraw on every dialog close.
Expand Down
Loading