diff --git a/.changeset/mouse-select-editor.md b/.changeset/mouse-select-editor.md new file mode 100644 index 0000000000..a8cf2bd8c7 --- /dev/null +++ b/.changeset/mouse-select-editor.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/pi-tui": minor +--- + +Add text selection, replacement, deletion, rendering, and mouse-position mapping to the editor component. diff --git a/.changeset/terminal-mouse-selection.md b/.changeset/terminal-mouse-selection.md new file mode 100644 index 0000000000..545b9daf81 --- /dev/null +++ b/.changeset/terminal-mouse-selection.md @@ -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. diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index dcfb904733..45dea3fc29 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -115,6 +115,7 @@ export interface SlashCommandHost { restoreEditor(): void; restoreInputText(text: string): void; refreshSlashCommandAutocomplete(): void; + refreshTerminalMouseTracking(): 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 6f28a16da5..7c9c884ee6 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -23,6 +23,7 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise const config = await host.harness.getConfig({ reload: true }); setExperimentalFeatures(await host.harness.getExperimentalFeatures()); host.refreshSlashCommandAutocomplete(); + host.refreshTerminalMouseTracking(); applyRuntimeConfig(host, config); await 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 b1677e87b2..1186e0e879 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -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; @@ -424,6 +425,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; } @@ -496,6 +502,10 @@ export class CustomEditor extends Editor { this.cancelAutocompleteActivity(); return; } + if (this.hasSelection()) { + this.clearSelection(); + return; + } this.onEscape?.(); return; } diff --git a/apps/kimi-code/src/tui/constant/terminal.ts b/apps/kimi-code/src/tui/constant/terminal.ts index ec87a07803..6ab123b445 100644 --- a/apps/kimi-code/src/tui/constant/terminal.ts +++ b/apps/kimi-code/src/tui/constant/terminal.ts @@ -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 diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 76d0363f80..c59893d467 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -2,6 +2,7 @@ import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; import { compressImageForModel, persistOriginalImage, sessionMediaOriginalsDir } from '@moonshot-ai/kimi-code-sdk'; import { ClipboardMediaError, readClipboardMedia } 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'; @@ -55,6 +56,8 @@ export interface EditorKeyboardHost { handleInputModeChange(mode: 'prompt' | 'bash'): void; clearQueuedMessages(): void; setExternalEditorRunning(running: boolean): void; + suspendTerminalMouseTracking(): void; + refreshTerminalMouseTracking(): void; } export class EditorKeyboardController { @@ -120,6 +123,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; @@ -512,6 +519,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); @@ -529,6 +537,7 @@ export class EditorKeyboardController { process.stdin.pause(); } state.ui.start(); + this.host.refreshTerminalMouseTracking(); 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 f0764c82b9..67377df9ce 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -143,6 +143,7 @@ import { sessionRowsForPicker } from './utils/session-picker-rows'; import { formatBashOutputForDisplay } from './utils/shell-output'; import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup'; import { installTerminalFocusTracking } from './utils/terminal-focus'; +import { installEditorMouseTracking } from './utils/editor-mouse'; import { notifyTerminalOnce } from './utils/terminal-notification'; import { installTerminalThemeTracking } from './utils/terminal-theme'; import { detectTmuxKeyboardWarning } from './utils/tmux-keyboard'; @@ -313,6 +314,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> = []; @@ -539,6 +541,7 @@ export class KimiTUI { return; } const shouldReplayHistory = await this.initMainTui(); + this.refreshTerminalMouseTracking(); this.startBackgroundFdAutocomplete(); await this.finishStartup(shouldReplayHistory); } catch (error) { @@ -635,6 +638,7 @@ export class KimiTUI { this.startClipboardImageHintController(); this.terminalFocusTrackingDispose = installTerminalFocusTracking(this.state); this.refreshTerminalThemeTracking(); + this.refreshTerminalMouseTracking(); } private startClipboardImageHintController(): void { @@ -940,6 +944,7 @@ export class KimiTUI { private disposeTerminalTracking(): void { this.stopTerminalThemeTracking(); + this.suspendTerminalMouseTracking(); this.clipboardImageHintController?.stop(); this.clipboardImageHintController = undefined; this.terminalFocusTrackingDispose?.(); @@ -2694,6 +2699,18 @@ export class KimiTUI { this.terminalThemeTrackingDispose = undefined; } + suspendTerminalMouseTracking(): void { + this.terminalMouseTrackingDispose?.(); + this.terminalMouseTrackingDispose = undefined; + } + + refreshTerminalMouseTracking(): void { + this.suspendTerminalMouseTracking(); + if (!isExperimentalFlagEnabled('terminal_mouse_input')) return; + if (!this.state.editorContainer.children.includes(this.state.editor)) return; + this.terminalMouseTrackingDispose = installEditorMouseTracking(this.state); + } + private async applyResolvedAutoTheme(resolved: ResolvedTheme): Promise { if (this.state.appState.theme !== 'auto') return; const palette = getBuiltInPalette(resolved); @@ -2770,6 +2787,8 @@ export class KimiTUI { // ========================================================================= mountEditorReplacement(panel: Component & Focusable): void { + this.suspendTerminalMouseTracking(); + this.state.editor.clearSelection(); this.state.editorContainer.clear(); this.state.editorContainer.addChild(panel); this.state.ui.setFocus(panel); @@ -2780,6 +2799,7 @@ export class KimiTUI { this.state.editorContainer.clear(); this.state.editorContainer.addChild(this.state.editor); this.state.ui.setFocus(this.state.editor); + this.refreshTerminalMouseTracking(); // Measure overflow against the restored tree (editor mounted), not the tall // panel just removed — otherwise a short session with a tall panel looks like // it overflows and we take a full clear/home that yanks the editor to the top. 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 0000000000..ef00e91917 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/editor-mouse.ts @@ -0,0 +1,209 @@ +import type { EditorPosition } from '@moonshot-ai/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'; + +interface EditorMouseTarget { + readonly row: number; + readonly col: number; +} + +type EditorMouseState = Pick; +type TerminalMouseTrackingState = Pick; + +export type TerminalMouseEventType = 'left-down' | 'left-drag' | 'left-up' | 'other'; + +export interface TerminalMouseEvent { + readonly type: TerminalMouseEventType; + readonly button: number; + readonly col: number; + readonly row: number; + readonly final: 'M' | 'm'; +} + +// oxlint-disable-next-line no-control-regex -- ESC is required for SGR mouse input. +const SGR_MOUSE_EVENT = /\u001B\[<(\d+);(\d+);(\d+)([Mm])/g; +const MOUSE_MOTION_BIT = 32; +const MOUSE_WHEEL_BIT = 64; +const MOUSE_BUTTON_MASK = 3; +const DRAG_UPDATE_INTERVAL_MS = 16; + +function classifyMouseEvent(button: number, final: 'M' | 'm'): TerminalMouseEventType { + if ((button & MOUSE_WHEEL_BIT) !== 0) return 'other'; + const buttonId = button & MOUSE_BUTTON_MASK; + const moving = (button & MOUSE_MOTION_BIT) !== 0; + if (final === 'm') return buttonId === 0 || buttonId === 3 ? 'left-up' : 'other'; + if (buttonId === 3 && !moving) return 'left-up'; + if (buttonId !== 0) return 'other'; + return moving ? 'left-drag' : 'left-down'; +} + +export function parseSgrMouseEvent(data: string): TerminalMouseEvent | undefined { + SGR_MOUSE_EVENT.lastIndex = 0; + const match = SGR_MOUSE_EVENT.exec(data); + if (match === null || match.index !== 0 || match[0].length !== data.length) 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 { type: classifyMouseEvent(button, final), button, col, row, final }; +} + +export function installTerminalMouseTracking( + state: TerminalMouseTrackingState, + onMouseEvent: (event: TerminalMouseEvent) => void, +): () => void { + const disposeInputListener = state.ui.addInputListener((data) => { + let remaining = ''; + let lastIndex = 0; + let matched = false; + SGR_MOUSE_EVENT.lastIndex = 0; + + for (const match of data.matchAll(SGR_MOUSE_EVENT)) { + matched = true; + remaining += data.slice(lastIndex, match.index); + lastIndex = match.index + match[0].length; + const event = parseSgrMouseEvent(match[0]); + if (event !== undefined) onMouseEvent(event); + } + + if (!matched) return undefined; + remaining += data.slice(lastIndex); + return remaining.length === 0 ? { consume: true } : { data: remaining }; + }); + state.terminal.write(ENABLE_TERMINAL_MOUSE_REPORTING); + + return () => { + disposeInputListener(); + state.terminal.write(DISABLE_TERMINAL_MOUSE_REPORTING); + }; +} + +export function installEditorMouseTracking(state: EditorMouseState): () => void { + let dragActive = false; + let lastAppliedPosition: EditorPosition | undefined; + let pendingPosition: EditorPosition | undefined; + let dragUpdateTimer: ReturnType | undefined; + + const samePosition = (a: EditorPosition | undefined, b: EditorPosition): boolean => + a?.line === b.line && a.col === b.col; + + const applyDragPosition = (position: EditorPosition): void => { + if (samePosition(lastAppliedPosition, position)) return; + lastAppliedPosition = position; + state.editor.updateSelection(position); + }; + + const clearDragTimer = (): void => { + if (dragUpdateTimer === undefined) return; + clearTimeout(dragUpdateTimer); + dragUpdateTimer = undefined; + }; + + const flushPendingDrag = (): void => { + dragUpdateTimer = undefined; + const position = pendingPosition; + pendingPosition = undefined; + if (!dragActive || position === undefined) return; + applyDragPosition(position); + dragUpdateTimer = setTimeout(flushPendingDrag, DRAG_UPDATE_INTERVAL_MS); + }; + + const queueDragPosition = (position: EditorPosition): void => { + if (samePosition(lastAppliedPosition, position) || samePosition(pendingPosition, position)) return; + if (dragUpdateTimer === undefined) { + applyDragPosition(position); + dragUpdateTimer = setTimeout(flushPendingDrag, DRAG_UPDATE_INTERVAL_MS); + return; + } + pendingPosition = position; + }; + + const disposeTracking = installTerminalMouseTracking(state, (event) => { + if (event.type === 'left-down') { + const position = resolveEditorPosition(state, event, false); + if (position === undefined) return; + clearDragTimer(); + pendingPosition = undefined; + dragActive = true; + lastAppliedPosition = position; + state.editor.beginSelection(position); + return; + } + + if (!dragActive) return; + if (event.type === 'left-drag') { + const position = resolveEditorPosition(state, event, true); + if (position !== undefined) queueDragPosition(position); + return; + } + + if (event.type === 'left-up') { + clearDragTimer(); + const position = resolveEditorPosition(state, event, true) ?? pendingPosition; + pendingPosition = undefined; + if (position !== undefined) applyDragPosition(position); + state.editor.finishSelection(); + dragActive = false; + lastAppliedPosition = undefined; + } + }); + + return () => { + clearDragTimer(); + pendingPosition = undefined; + dragActive = false; + lastAppliedPosition = undefined; + disposeTracking(); + }; +} + +function resolveEditorPosition( + state: EditorMouseState, + event: TerminalMouseEvent, + clamp: boolean, +): EditorPosition | undefined { + const target = resolveEditorMouseTarget(state, event, clamp); + if (target === undefined) return undefined; + return state.editor.positionAtRenderedCell(target.row, target.col, clamp); +} + +export function resolveEditorMouseTarget( + state: EditorMouseState, + event: Pick, + clamp: boolean, +): EditorMouseTarget | undefined { + if (!state.editorContainer.children.includes(state.editor)) return undefined; + + const { columns: terminalWidth, rows: terminalRows } = state.terminal; + if (terminalWidth < CHROME_GUTTER * 2 + 1 || terminalRows < 1) return undefined; + if (!clamp && (event.col > terminalWidth || event.row > terminalRows)) return undefined; + const layout = state.ui.getRenderedChildLayout(state.editorContainer); + if (layout === undefined || layout.width !== terminalWidth) return undefined; + + const viewportTop = state.ui.getRenderedViewportTop(); + const screenRow = Math.max(0, Math.min(terminalRows - 1, event.row - 1)); + const logicalRow = viewportTop + screenRow; + const editorWidth = Math.max(1, terminalWidth - CHROME_GUTTER * 2); + if (editorWidth < 3) return undefined; + const localRow = logicalRow - layout.startRow; + const localCol = event.col - CHROME_GUTTER - 1; + + if (!clamp) { + if (logicalRow < layout.startRow || logicalRow >= layout.endRow) return undefined; + if (localCol < 1 || localCol >= editorWidth - 1) return undefined; + return { row: localRow, col: localCol }; + } + + return { + row: Math.max(0, Math.min(layout.endRow - layout.startRow - 1, localRow)), + col: Math.max(1, Math.min(editorWidth - 2, localCol)), + }; +} diff --git a/apps/kimi-code/src/utils/terminal-restore.ts b/apps/kimi-code/src/utils/terminal-restore.ts index 5a93f3821a..b9943eb57d 100644 --- a/apps/kimi-code/src/utils/terminal-restore.ts +++ b/apps/kimi-code/src/utils/terminal-restore.ts @@ -14,8 +14,11 @@ */ // Show cursor (`?25h`), disable bracketed paste (`?2004l`), pop the Kitty -// keyboard protocol (`4;0m`). -const TERMINAL_RESTORE_SEQUENCE = '\u001B[?25h\u001B[?2004l\u001B[4;0m'; +// keyboard protocol (`4;0m`), and defensively +// disable all common terminal mouse tracking modes. +const TERMINAL_RESTORE_SEQUENCE = + '\u001B[?25h\u001B[?2004l\u001B[4;0m' + + '\u001B[?1006l\u001B[?1003l\u001B[?1002l\u001B[?1000l'; export function restoreTerminalModes(): void { try { diff --git a/apps/kimi-code/test/tui/commands/reload.test.ts b/apps/kimi-code/test/tui/commands/reload.test.ts index 77f582a1b9..3382df2c89 100644 --- a/apps/kimi-code/test/tui/commands/reload.test.ts +++ b/apps/kimi-code/test/tui/commands/reload.test.ts @@ -82,6 +82,7 @@ auto_install = false expect(host.harness.getConfig).toHaveBeenCalledWith({ reload: true }); expect(host.harness.getExperimentalFeatures).toHaveBeenCalledOnce(); expect(host.refreshSlashCommandAutocomplete).toHaveBeenCalledOnce(); + expect(host.refreshTerminalMouseTracking).toHaveBeenCalledOnce(); expect(isExperimentalFlagEnabled('micro_compaction')).toBe(true); expect(host.state.appState.theme).toBe('light'); expect(host.state.appState.availableModels).toEqual({ @@ -167,6 +168,7 @@ function makeHost({ state.appState.theme = theme; }), refreshTerminalThemeTracking: vi.fn(), + refreshTerminalMouseTracking: vi.fn(), refreshSlashCommandAutocomplete: vi.fn(), reloadCurrentSessionView: vi.fn(async () => {}), showStatus: vi.fn(), @@ -176,6 +178,7 @@ function makeHost({ readonly getExperimentalFeatures: ReturnType; }; readonly refreshSlashCommandAutocomplete: ReturnType; + readonly refreshTerminalMouseTracking: 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 cf7184b3bd..eb0d165afb 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 @@ -116,6 +116,41 @@ describe('CustomEditor onNonEscapeInput', () => { }); }); +describe('CustomEditor Ctrl+C selection copy', () => { + it('copies the selection instead of invoking the app-level Ctrl+C action', () => { + const editor = makeEditor(); + const onCopySelection = vi.fn(); + const onCtrlC = vi.fn(); + editor.onCopySelection = onCopySelection; + editor.onCtrlC = onCtrlC; + editor.setText('hello world'); + editor.beginSelection({ line: 0, col: 6 }); + editor.updateSelection({ line: 0, col: 11 }); + editor.finishSelection(); + + editor.handleInput('\u0003'); + + expect(onCopySelection).toHaveBeenCalledWith('world'); + expect(onCtrlC).not.toHaveBeenCalled(); + expect(editor.getText()).toBe('hello world'); + expect(editor.hasSelection()).toBe(true); + }); + + it('preserves the existing app-level Ctrl+C behavior without a selection', () => { + const editor = makeEditor(); + const onCopySelection = vi.fn(); + const onCtrlC = vi.fn(); + editor.onCopySelection = onCopySelection; + editor.onCtrlC = onCtrlC; + editor.setText('hello world'); + + editor.handleInput('\u0003'); + + expect(onCopySelection).not.toHaveBeenCalled(); + expect(onCtrlC).toHaveBeenCalledOnce(); + }); +}); + describe('CustomEditor slash argument completion refresh', () => { it('reopens /add-dir directory completions after tab completion and entering slash', async () => { const editor = makeEditor(); diff --git a/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts index 8f15e68426..7d1258646c 100644 --- a/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts @@ -6,6 +6,13 @@ import { type EditorKeyboardHost, } from '#/tui/controllers/editor-keyboard'; import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; +import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; + +vi.mock('#/utils/clipboard/clipboard-text', () => ({ + copyTextToClipboard: vi.fn(async () => 'native'), +})); + +const copyTextToClipboardMock = vi.mocked(copyTextToClipboard); interface Harness { readonly host: EditorKeyboardHost; @@ -83,6 +90,21 @@ function pressNonEscape(editor: Harness['editor']): void { (handler as () => void)(); } +describe('EditorKeyboardController selection copy', () => { + it('writes selected text to the clipboard without changing editor content', async () => { + copyTextToClipboardMock.mockRejectedValueOnce(new Error('clipboard unavailable')); + const { editor } = createHarness(); + const handler = editor['onCopySelection']; + if (handler === undefined) throw new Error('onCopySelection handler not installed'); + + (handler as unknown as (text: string) => void)('selected text'); + await Promise.resolve(); + + expect(copyTextToClipboardMock).toHaveBeenCalledWith('selected text'); + expect(editor['setText']).not.toHaveBeenCalled(); + }); +}); + describe('EditorKeyboardController double-Esc undo', () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/apps/kimi-code/test/tui/editor-mouse.test.ts b/apps/kimi-code/test/tui/editor-mouse.test.ts new file mode 100644 index 0000000000..34ee608edd --- /dev/null +++ b/apps/kimi-code/test/tui/editor-mouse.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { TUIState } from '#/tui/kimi-tui'; +import { + DISABLE_TERMINAL_MOUSE_REPORTING, + ENABLE_TERMINAL_MOUSE_REPORTING, +} from '#/tui/constant/terminal'; +import { + installEditorMouseTracking, + installTerminalMouseTracking, + parseSgrMouseEvent, + resolveEditorMouseTarget, +} from '#/tui/utils/editor-mouse'; + +type InputListener = Parameters[0]; + +function trackingState() { + const listeners: InputListener[] = []; + const removeInputListener = vi.fn(); + const terminal = { + columns: 40, + rows: 20, + write: vi.fn(), + }; + const ui = { + addInputListener: vi.fn((listener: InputListener) => { + listeners.push(listener); + return removeInputListener; + }), + getRenderedViewportTop: vi.fn(() => 0), + }; + return { listeners, removeInputListener, terminal, ui }; +} + +describe('terminal mouse input', () => { + it('classifies SGR left-button events', () => { + expect(parseSgrMouseEvent('\u001B[<0;10;5M')).toMatchObject({ + type: 'left-down', + col: 10, + row: 5, + }); + expect(parseSgrMouseEvent('\u001B[<32;11;6M')).toMatchObject({ + type: 'left-drag', + col: 11, + row: 6, + }); + expect(parseSgrMouseEvent('\u001B[<0;11;6m')).toMatchObject({ + type: 'left-up', + col: 11, + row: 6, + }); + expect(parseSgrMouseEvent('\u001B[<3;11;6M')).toMatchObject({ + type: 'left-up', + col: 11, + row: 6, + }); + expect(parseSgrMouseEvent('x')).toBeUndefined(); + }); + + it('consumes mouse sequences while preserving ordinary input', () => { + const state = trackingState(); + const events: string[] = []; + const dispose = installTerminalMouseTracking( + state as unknown as Pick, + (event) => events.push(event.type), + ); + + expect(state.terminal.write).toHaveBeenCalledWith(ENABLE_TERMINAL_MOUSE_REPORTING); + expect(state.listeners).toHaveLength(1); + expect(state.listeners[0]?.('\u001B[<0;10;5M')).toEqual({ consume: true }); + expect(state.listeners[0]?.('a\u001B[<32;11;6Mb')).toEqual({ data: 'ab' }); + expect(events).toEqual(['left-down', 'left-drag']); + + dispose(); + expect(state.removeInputListener).toHaveBeenCalledOnce(); + expect(state.terminal.write).toHaveBeenCalledWith(DISABLE_TERMINAL_MOUSE_REPORTING); + }); + + it('drives editor selection from press, drag, and release', () => { + const state = trackingState(); + state.terminal.rows = 4; + const editorContainer = { + children: [] as unknown[], + render: vi.fn(() => ['editor-top', 'editor-line', 'editor-bottom']), + }; + const editor = { + beginSelection: vi.fn(), + updateSelection: vi.fn(), + finishSelection: vi.fn(), + positionAtRenderedCell: vi.fn((row: number, col: number) => ({ line: row - 1, col })), + }; + editorContainer.children.push(editor); + const transcript = { render: vi.fn(() => ['transcript']) }; + const ui = { + ...state.ui, + children: [transcript, editorContainer], + getRenderedChildLayout: vi.fn(() => ({ + startRow: 1, + endRow: 4, + totalRows: 4, + width: 40, + })), + }; + + const dispose = installEditorMouseTracking({ + terminal: state.terminal, + ui, + editor, + editorContainer, + } as unknown as Pick); + + const listener = state.listeners[0]; + listener?.('\u001B[<0;2;3M'); + expect(editor.beginSelection).not.toHaveBeenCalled(); + + listener?.('\u001B[<0;3;3M'); + listener?.('\u001B[<32;5;3M'); + listener?.('\u001B[<0;5;3m'); + + expect(editor.beginSelection).toHaveBeenCalledOnce(); + expect(editor.updateSelection).toHaveBeenCalledOnce(); + expect(editor.finishSelection).toHaveBeenCalledOnce(); + expect(transcript.render).not.toHaveBeenCalled(); + expect(editorContainer.render).not.toHaveBeenCalled(); + + dispose(); + }); + + it('maps a top-aligned short frame without vertical or prompt-column offset', () => { + const state = trackingState(); + state.terminal.rows = 6; + const editor = {}; + const editorContainer = { children: [editor] }; + const ui = { + ...state.ui, + getRenderedChildLayout: vi.fn(() => ({ + startRow: 1, + endRow: 4, + totalRows: 4, + width: 40, + })), + }; + + const target = resolveEditorMouseTarget( + { + terminal: state.terminal, + ui, + editor, + editorContainer, + } as unknown as Pick, + { row: 3, col: 6 }, + false, + ); + + // Screen row 3 is the editor's first content row. Column 6 is the first + // text cell after outer gutter + border + `> ` prompt padding. + expect(target).toEqual({ row: 1, col: 4 }); + }); + + it('coalesces high-frequency drag events and resets cleanly between drags', () => { + vi.useFakeTimers(); + try { + const state = trackingState(); + state.terminal.rows = 4; + const editorContainer = { children: [] as unknown[] }; + const editor = { + beginSelection: vi.fn(), + updateSelection: vi.fn(), + finishSelection: vi.fn(), + positionAtRenderedCell: vi.fn((row: number, col: number) => ({ line: row - 1, col })), + }; + editorContainer.children.push(editor); + const ui = { + ...state.ui, + children: [editorContainer], + getRenderedChildLayout: vi.fn(() => ({ + startRow: 0, + endRow: 3, + totalRows: 3, + width: 40, + })), + }; + + const dispose = installEditorMouseTracking({ + terminal: state.terminal, + ui, + editor, + editorContainer, + } as unknown as Pick); + const listener = state.listeners[0]!; + + for (let drag = 0; drag < 3; drag++) { + listener('\u001B[<0;3;2M'); + for (let col = 4; col <= 30; col++) { + listener(`\u001B[<32;${col};2M`); + } + expect(editor.updateSelection).toHaveBeenCalledTimes(drag * 2 + 1); + vi.advanceTimersByTime(16); + expect(editor.updateSelection).toHaveBeenCalledTimes(drag * 2 + 2); + listener('\u001B[<0;30;2m'); + expect(editor.finishSelection).toHaveBeenCalledTimes(drag + 1); + vi.runOnlyPendingTimers(); + expect(editor.updateSelection).toHaveBeenCalledTimes(drag * 2 + 2); + } + + dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it('uses the actual preserved viewport after the editor shrinks', () => { + const state = trackingState(); + state.terminal.rows = 4; + const editorContainer = { children: [] as unknown[] }; + const editor = { + beginSelection: vi.fn(), + updateSelection: vi.fn(), + finishSelection: vi.fn(), + positionAtRenderedCell: vi.fn(() => ({ line: 0, col: 0 })), + }; + editorContainer.children.push(editor); + const ui = { + ...state.ui, + getRenderedViewportTop: vi.fn(() => 2), + getRenderedChildLayout: vi.fn(() => ({ + startRow: 2, + endRow: 5, + totalRows: 5, + width: 40, + })), + }; + + installEditorMouseTracking({ + terminal: state.terminal, + ui, + editor, + editorContainer, + } as unknown as Pick); + + state.listeners[0]?.('\u001B[<0;3;2M'); + + expect(editor.positionAtRenderedCell).toHaveBeenCalledWith(1, 1, false); + expect(editor.beginSelection).toHaveBeenCalledOnce(); + }); + + it('ignores drags that did not start in the editor', () => { + const state = trackingState(); + state.terminal.rows = 4; + const editorContainer = { + children: [] as unknown[], + render: vi.fn(() => ['editor-top', 'editor-line', 'editor-bottom']), + }; + const editor = { + beginSelection: vi.fn(), + updateSelection: vi.fn(), + finishSelection: vi.fn(), + positionAtRenderedCell: vi.fn(() => ({ line: 0, col: 0 })), + }; + editorContainer.children.push(editor); + const ui = { + ...state.ui, + children: [{ render: vi.fn(() => ['transcript']) }, editorContainer], + getRenderedChildLayout: vi.fn(() => ({ + startRow: 1, + endRow: 4, + totalRows: 4, + width: 40, + })), + }; + + installEditorMouseTracking({ + terminal: state.terminal, + ui, + editor, + editorContainer, + } as unknown as Pick); + + state.listeners[0]?.('\u001B[<32;5;3M'); + state.listeners[0]?.('\u001B[<0;5;3m'); + + expect(editor.beginSelection).not.toHaveBeenCalled(); + expect(editor.updateSelection).not.toHaveBeenCalled(); + expect(editor.finishSelection).not.toHaveBeenCalled(); + }); +}); 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 79f8cfb552..4a1d1b10f0 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it, vi } from 'vitest'; import { BannerProvider } from '#/tui/banner/banner-provider'; import { readBannerDisplayState } from '#/tui/banner/state'; import { handleLoginCommand, handleLogoutCommand } from '#/tui/commands/auth'; +import { setExperimentalFeatures } from '#/tui/commands/experimental-flags'; import { promptPlatformSelection, promptLogoutProviderSelection } from '#/tui/commands/prompts'; import { BannerComponent } from '#/tui/components/chrome/banner'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; @@ -16,6 +17,10 @@ import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui import { REPLAY_TURN_LIMIT } from '#/tui/utils/message-replay'; import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; import { quoteShellArg } from '#/utils/shell-quote'; +import { + DISABLE_TERMINAL_MOUSE_REPORTING, + ENABLE_TERMINAL_MOUSE_REPORTING, +} from '#/tui/constant/terminal'; import { DISABLE_TERMINAL_THEME_REPORTING, ENABLE_TERMINAL_THEME_REPORTING, @@ -50,6 +55,11 @@ interface ThemeTrackingDriver extends StartupDriver { refreshTerminalThemeTracking(): void; } +interface MouseTrackingDriver extends StartupDriver { + refreshTerminalMouseTracking(): void; + suspendTerminalMouseTracking(): void; +} + interface MigrateExitDriver extends StartupDriver { start(): Promise; onExit?: (code?: number) => Promise; @@ -1043,6 +1053,33 @@ describe('KimiTUI startup', () => { expect(stop).not.toHaveBeenCalled(); }); + it('tracks terminal mouse input only while the main editor is mounted', () => { + const harness = makeHarness(); + const driver = makeDriver(harness, makeStartupInput()) as unknown as MouseTrackingDriver; + const { write, addInputListener, removeInputListener } = captureInputListeners(driver); + + try { + setExperimentalFeatures([{ id: 'terminal_mouse_input', enabled: true }]); + driver.state.editorContainer.clear(); + driver.state.editorContainer.addChild(driver.state.editor); + + driver.refreshTerminalMouseTracking(); + + expect(addInputListener).toHaveBeenCalledOnce(); + expect(write).toHaveBeenCalledWith(ENABLE_TERMINAL_MOUSE_REPORTING); + + driver.suspendTerminalMouseTracking(); + expect(removeInputListener).toHaveBeenCalledOnce(); + expect(write).toHaveBeenCalledWith(DISABLE_TERMINAL_MOUSE_REPORTING); + + driver.state.editorContainer.clear(); + driver.refreshTerminalMouseTracking(); + expect(addInputListener).toHaveBeenCalledOnce(); + } finally { + setExperimentalFeatures([]); + } + }); + it('tracks terminal theme reports while auto theme is active', () => { const harness = makeHarness(); const driver = makeDriver( diff --git a/apps/kimi-code/test/utils/terminal-restore.test.ts b/apps/kimi-code/test/utils/terminal-restore.test.ts new file mode 100644 index 0000000000..d40887bd02 --- /dev/null +++ b/apps/kimi-code/test/utils/terminal-restore.test.ts @@ -0,0 +1,21 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { restoreTerminalModes } from '#/utils/terminal-restore'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('restoreTerminalModes', () => { + it('disables terminal mouse reporting during emergency restoration', () => { + const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + restoreTerminalModes(); + + const output = write.mock.calls.map(([chunk]) => String(chunk)).join(''); + expect(output).toContain('\u001B[?1000l'); + expect(output).toContain('\u001B[?1002l'); + expect(output).toContain('\u001B[?1003l'); + expect(output).toContain('\u001B[?1006l'); + }); +}); diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 1457746245..0cea44041f 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_AGENT_SWARM_MAX_CONCURRENCY` | Cap how many AgentSwarm subagents run concurrently during the initial ramp; leave unset for no cap | Positive integer; invalid values fail fast | | `KIMI_SUBAGENT_TIMEOUT_MS` | Maximum wall-clock time (ms) a single subagent (`Agent` / `AgentSwarm`) may run; takes higher priority than `[subagent] timeout_ms` in `config.toml` (default `7200000`, i.e. 2 hours) | Positive integer; invalid values fall back to the config or default | | `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | Enable experimental secondary-model behavior under `kimi web`; `kimi -p` still requires `KIMI_CODE_EXPERIMENTAL_FLAG=1` to select the v2 engine, which also enables this feature | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | +| `KIMI_CODE_EXPERIMENTAL_TERMINAL_MOUSE_INPUT` | Enable click positioning and left-button drag selection in the TUI prompt editor. While enabled, hold `Shift` for the terminal's native text selection or scrollback behavior | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_SECONDARY_MODEL` | Secondary model; takes higher priority than `[secondary_model] model` in `config.toml`. When the secondary-model experiment is enabled, newly spawned subagents (`Agent` / `AgentSwarm`) bind to it by default instead of inheriting the main agent's model (not supported in the TUI) | A model id from your configured `[models]`, e.g. `kimi-code/kimi-k2.5`; blank values are ignored | | `KIMI_SECONDARY_EFFORT` | Thinking effort for the secondary model; takes higher priority than `[secondary_model] default_effort` in `config.toml` and applies only when both the model and its experiment are enabled (not supported in the TUI) | An effort value, e.g. `low`; blank values are ignored | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | Global default connection timeout (ms) for all MCP servers; takes higher priority than `[mcp] startup_timeout_ms` in `config.toml`, but a per-server `startupTimeoutMs` in `mcp.json` still wins (default `30000`) | Integer from `1` to `2147483647`; invalid values are ignored | diff --git a/docs/en/reference/keyboard.md b/docs/en/reference/keyboard.md index fb2ba76b03..a27041025c 100644 --- a/docs/en/reference/keyboard.md +++ b/docs/en/reference/keyboard.md @@ -40,6 +40,10 @@ Type `!` in an empty input box to enter shell mode and run terminal commands dir | `Alt-V` | Paste an image or video from the clipboard (Windows) | | `Ctrl--` | Undo | | `Esc` `Esc` | Open the undo selector (double-press while idle) | +| `Ctrl-C` | Copy selected prompt text; without a selection, keep the existing cancel, clear, or exit behavior | +| Left-button drag | Select prompt text when `KIMI_CODE_EXPERIMENTAL_TERMINAL_MOUSE_INPUT=1` | + +With experimental terminal mouse input enabled, a click moves the prompt cursor and a left-button drag selects text. Use `Ctrl-C` to copy it, a delete key to remove it, or type to replace it. Hold `Shift` for the terminal's native text selection or scrollback behavior instead. Pressing `Ctrl-G` opens an external editor, selected according to the following priority: diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index e5a654ebe0..bdb302bfb3 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -129,6 +129,7 @@ kimi | `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | 限制 AgentSwarm 初始提升并发阶段可同时运行的子 Agent 数量;不设置表示不限制 | 正整数;非法值会立即失败 | | `KIMI_SUBAGENT_TIMEOUT_MS` | 单个子 Agent(`Agent` / `AgentSwarm`)可运行的最长时间(毫秒);优先级高于 `config.toml` 的 `[subagent] timeout_ms`(默认 `7200000`,即 2 小时) | 正整数;非法值回退到配置或默认值 | | `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | 在 `kimi web` 下启用实验性的次主力模型功能;`kimi -p` 仍需通过 `KIMI_CODE_EXPERIMENTAL_FLAG=1` 选择 v2 引擎,该 master flag 也会启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | +| `KIMI_CODE_EXPERIMENTAL_TERMINAL_MOUSE_INPUT` | 启用 TUI 主输入框的鼠标单击定位和左键拖动选区。启用后,如需使用终端原生文本选择或回滚缓冲区,请按住 `Shift` 操作 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_SECONDARY_MODEL` | 次主力模型;优先级高于 `config.toml` 的 `[secondary_model] model`。次主力模型实验功能启用后,新派生的子 Agent 默认绑定该模型,而不再继承主 Agent 的模型(TUI 不支持) | 已配置 `[models]` 中的模型 id,如 `kimi-code/kimi-k2.5`;空白值被忽略 | | `KIMI_SECONDARY_EFFORT` | 次主力模型的 thinking effort;优先级高于 `config.toml` 的 `[secondary_model] default_effort`,仅在次主力模型及其实验功能均启用时生效(TUI 不支持) | effort 取值,如 `low`;空白值被忽略 | | `KIMI_MCP_STARTUP_TIMEOUT_MS` | 所有 MCP server 的全局默认连接超时(毫秒);优先级高于 `config.toml` 的 `[mcp] startup_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `startupTimeoutMs`(默认 `30000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | diff --git a/docs/zh/reference/keyboard.md b/docs/zh/reference/keyboard.md index 9e3c54a5ae..ce6ddff7d5 100644 --- a/docs/zh/reference/keyboard.md +++ b/docs/zh/reference/keyboard.md @@ -40,6 +40,10 @@ Kimi Code CLI 的 TUI 交互模式支持一套键盘快捷键。键位按使用 | `Alt-V` | 粘贴剪贴板中的图片或视频(Windows) | | `Ctrl--` | 撤销(Undo) | | `Esc` `Esc` | 双击打开撤销选择框(空闲状态下) | +| `Ctrl-C` | 有输入框选区时复制选中文本;无选区时执行原有取消、清空或退出操作 | +| 鼠标左键拖动 | 设置 `KIMI_CODE_EXPERIMENTAL_TERMINAL_MOUSE_INPUT=1` 后选择输入框文本 | + +启用实验性终端鼠标输入后,单击可移动输入框光标,按住左键拖动可选择文本,并可使用 `Ctrl-C` 复制、删除键删除或直接输入进行替换。需要使用终端原生文本选择或回滚缓冲区时,请按住 `Shift` 操作。 按 `Ctrl-G` 会打开外部编辑器,编辑器按以下优先级选择: diff --git a/packages/agent-core/src/flags/registry.ts b/packages/agent-core/src/flags/registry.ts index dbec75b80b..f392556355 100644 --- a/packages/agent-core/src/flags/registry.ts +++ b/packages/agent-core/src/flags/registry.ts @@ -32,6 +32,14 @@ export const FLAG_DEFINITIONS = [ default: false, surface: 'core', }, + { + id: 'terminal_mouse_input', + title: 'Terminal mouse input', + description: 'Allow mouse clicks and drags to position and select text in the main prompt editor.', + 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/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index 9484facf1f..a40203eb7a 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -345,6 +345,16 @@ describe('KimiHarness config API', () => { enabled: false, source: 'default', }, + { + id: 'terminal_mouse_input', + title: 'Terminal mouse input', + description: 'Allow mouse clicks and drags to position and select text in the main prompt editor.', + surface: 'tui', + env: 'KIMI_CODE_EXPERIMENTAL_TERMINAL_MOUSE_INPUT', + defaultEnabled: false, + enabled: false, + source: 'default', + }, ]); }); diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index dfaa3a59e4..c1453828a5 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -224,12 +224,39 @@ interface EditorState { cursorCol: number; } +export interface EditorPosition { + line: number; + col: number; +} + +export interface EditorSelectionRange { + start: EditorPosition; + end: EditorPosition; +} + +interface EditorSelection { + anchor: EditorPosition; + head: EditorPosition; +} + interface LayoutLine { text: string; + logicalLine: number; + startCol: number; + endCol: number; hasCursor: boolean; cursorPos?: number; } +interface RenderedEditorLayout { + width: number; + paddingX: number; + contentWidth: number; + lines: LayoutLine[]; + allLines: LayoutLine[]; + scrollOffset: number; +} + export interface EditorTheme { borderColor: (str: string) => string; selectList: SelectListTheme; @@ -262,6 +289,34 @@ function buildDebouncePattern(triggerCharacters: string[]): RegExp { return new RegExp(`(?:^|[ \\t])(?:@(?:"[^"]*|[^\\s]*)|[${escapedWithoutAt.join("")}][^\\s]*)$`); } +function compareEditorPositions(a: EditorPosition, b: EditorPosition): number { + return a.line === b.line ? a.col - b.col : a.line - b.line; +} + +function inverse(text: string): string { + return `\x1b[7m${text}\x1b[27m`; +} + +function offsetAtVisualColumn( + text: string, + targetCol: number, + segments: Iterable, +): number { + if (targetCol <= 0) return 0; + let visibleCol = 0; + + for (const segment of segments) { + const width = visibleWidth(segment.segment); + const end = visibleCol + width; + if (targetCol < end) { + return targetCol >= visibleCol + width / 2 ? segment.index + segment.segment.length : segment.index; + } + visibleCol = end; + } + + return text.length; +} + export class Editor implements Component, Focusable { private state: EditorState = { lines: [""], @@ -282,6 +337,12 @@ export class Editor implements Component, Focusable { // Vertical scrolling support private scrollOffset: number = 0; + // Mouse-driven text selection. The range is stored as anchor/head so + // reverse drags preserve the active endpoint while getSelectionRange() + // exposes a normalized half-open range. + private selection: EditorSelection | undefined; + private renderedLayout: RenderedEditorLayout | undefined; + // Border color (can be changed dynamically) public borderColor: (str: string) => string; @@ -458,6 +519,7 @@ export class Editor implements Component, Focusable { } private navigateHistory(direction: 1 | -1): void { + this.selection = undefined; this.lastAction = null; if (this.history.length === 0) return; @@ -505,6 +567,7 @@ export class Editor implements Component, Focusable { this.historyDraft = null; if (draft) { this.state = draft; + this.selection = undefined; this.preferredVisualCol = null; this.snappedFromCursorCol = null; this.scrollOffset = 0; @@ -531,6 +594,7 @@ export class Editor implements Component, Focusable { /** Internal setText that doesn't reset history state - used by navigateHistory */ private setTextInternal(text: string, cursorPlacement: "start" | "end" = "end"): void { + this.selection = undefined; const lines = text.split("\n"); this.state.lines = lines.length === 0 ? [""] : lines; this.state.cursorLine = cursorPlacement === "start" ? 0 : this.state.lines.length - 1; @@ -544,7 +608,77 @@ export class Editor implements Component, Focusable { } invalidate(): void { - // No cached state to invalidate currently + this.renderedLayout = undefined; + } + + private selectionOffsets( + line: LayoutLine, + ): { start: number; end: number; lineBreakSelected: boolean } | undefined { + const range = this.getSelectionRange(); + if (!range || line.logicalLine < range.start.line || line.logicalLine > range.end.line) { + return undefined; + } + const logicalText = this.state.lines[line.logicalLine] || ""; + const startCol = line.logicalLine === range.start.line ? range.start.col : 0; + const endCol = line.logicalLine === range.end.line ? range.end.col : logicalText.length; + const start = Math.max(line.startCol, startCol); + const end = Math.min(line.endCol, endCol); + const lineBreakSelected = line.logicalLine < range.end.line && line.endCol === logicalText.length; + return end > start || lineBreakSelected + ? { start: start - line.startCol, end: end - line.startCol, lineBreakSelected } + : undefined; + } + + private renderLayoutText(line: LayoutLine, emitCursorMarker: boolean): { text: string; endCellVisible: boolean } { + const selected = this.selectionOffsets(line); + const cursorPos = line.hasCursor ? line.cursorPos : undefined; + const cursorAtEnd = cursorPos === line.text.length; + let cursorEnd: number | undefined; + + if (cursorPos !== undefined && !cursorAtEnd) { + const cursorSegment = this.segment(line.text.slice(cursorPos), "grapheme")[Symbol.iterator]().next().value; + cursorEnd = cursorPos + (cursorSegment?.segment.length ?? 1); + } + + const boundaries = new Set([0, line.text.length]); + if (selected !== undefined) { + boundaries.add(selected.start); + boundaries.add(selected.end); + } + if (cursorPos !== undefined && cursorEnd !== undefined) { + boundaries.add(cursorPos); + boundaries.add(cursorEnd); + } + + const offsets = [...boundaries].sort((a, b) => a - b); + let rendered = ""; + for (let index = 0; index < offsets.length - 1; index++) { + const start = offsets[index]!; + const end = offsets[index + 1]!; + if (end <= start) continue; + if (start === cursorPos && emitCursorMarker) rendered += CURSOR_MARKER; + const text = line.text.slice(start, end); + const isCursor = start === cursorPos && end === cursorEnd; + const isSelected = selected !== undefined && start >= selected.start && end <= selected.end; + if (isCursor) { + rendered += `\x1b[7m${text}\x1b[0m`; + } else { + rendered += isSelected ? inverse(text) : text; + } + } + + if (cursorAtEnd && emitCursorMarker) rendered += CURSOR_MARKER; + if (cursorAtEnd) { + rendered += "\x1b[7m \x1b[0m"; + } else if (selected?.lineBreakSelected === true) { + const inverseEnd = "\x1b[27m"; + if (selected.end === line.text.length && selected.end > selected.start && rendered.endsWith(inverseEnd)) { + rendered = `${rendered.slice(0, -inverseEnd.length)} ${inverseEnd}`; + } else { + rendered += inverse(" "); + } + } + return { text: rendered, endCellVisible: cursorAtEnd || selected?.lineBreakSelected === true }; } render(width: number): string[] { @@ -585,6 +719,14 @@ export class Editor implements Component, Focusable { // Get visible lines slice const visibleLines = layoutLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines); + this.renderedLayout = { + width, + paddingX, + contentWidth, + lines: visibleLines, + allLines: layoutLines, + scrollOffset: this.scrollOffset, + }; const result: string[] = []; const leftPadding = " ".repeat(paddingX); @@ -610,45 +752,17 @@ export class Editor implements Component, Focusable { const emitCursorMarker = this.focused; for (const layoutLine of visibleLines) { - let displayText = layoutLine.text; + const rendered = this.renderLayoutText(layoutLine, emitCursorMarker); let lineVisibleWidth = visibleWidth(layoutLine.text); let cursorInPadding = false; - - // Add cursor if this line has it - if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) { - const before = displayText.slice(0, layoutLine.cursorPos); - const after = displayText.slice(layoutLine.cursorPos); - - // Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning) - const marker = emitCursorMarker ? CURSOR_MARKER : ""; - - if (after.length > 0) { - // Cursor is on a character (grapheme) - replace it with highlighted version - // Get the first grapheme from 'after' - const afterGraphemes = [...this.segment(after, "grapheme")]; - const firstGrapheme = afterGraphemes[0]?.segment || ""; - const restAfter = after.slice(firstGrapheme.length); - const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`; - displayText = before + marker + cursor + restAfter; - // lineVisibleWidth stays the same - we're replacing, not adding - } else { - // Cursor is at the end - add highlighted space - const cursor = "\x1b[7m \x1b[0m"; - displayText = before + marker + cursor; - lineVisibleWidth = lineVisibleWidth + 1; - // If cursor overflows content width into the padding, flag it - if (lineVisibleWidth > contentWidth && paddingX > 0) { - cursorInPadding = true; - } - } + if (rendered.endCellVisible) { + lineVisibleWidth++; + if (lineVisibleWidth > contentWidth && paddingX > 0) cursorInPadding = true; } - // Calculate padding based on actual visible width const padding = " ".repeat(Math.max(0, contentWidth - lineVisibleWidth)); const lineRightPadding = cursorInPadding ? rightPadding.slice(1) : rightPadding; - - // Render the line (no side borders, just horizontal lines above and below) - result.push(`${leftPadding}${displayText}${padding}${lineRightPadding}`); + result.push(`${leftPadding}${rendered.text}${padding}${lineRightPadding}`); } // Render bottom border (with scroll indicator if more content below) @@ -808,6 +922,7 @@ export class Editor implements Component, Focusable { // Tab - trigger completion if (kb.matches(data, "tui.input.tab") && !this.autocompleteState) { + this.selection = undefined; this.handleTabCompletion(); return; } @@ -990,84 +1105,49 @@ export class Editor implements Component, Focusable { const layoutLines: LayoutLine[] = []; if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) { - // Empty editor layoutLines.push({ text: "", + logicalLine: 0, + startCol: 0, + endCol: 0, hasCursor: true, cursorPos: 0, }); return layoutLines; } - // Process each logical line for (let i = 0; i < this.state.lines.length; i++) { const line = this.state.lines[i] || ""; const isCurrentLine = i === this.state.cursorLine; - const lineVisibleWidth = visibleWidth(line); + const chunks = + visibleWidth(line) <= contentWidth + ? [{ text: line, startIndex: 0, endIndex: line.length }] + : wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")]); + + for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { + const chunk = chunks[chunkIndex]; + if (!chunk) continue; + const isLastChunk = chunkIndex === chunks.length - 1; + let hasCursor = false; + let cursorPos: number | undefined; - if (lineVisibleWidth <= contentWidth) { - // Line fits in one layout line if (isCurrentLine) { - layoutLines.push({ - text: line, - hasCursor: true, - cursorPos: this.state.cursorCol, - }); - } else { - layoutLines.push({ - text: line, - hasCursor: false, - }); - } - } else { - // Line needs wrapping - use word-aware wrapping - const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")]); - - for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { - const chunk = chunks[chunkIndex]; - if (!chunk) continue; - - const cursorPos = this.state.cursorCol; - const isLastChunk = chunkIndex === chunks.length - 1; - - // Determine if cursor is in this chunk - // For word-wrapped chunks, we need to handle the case where - // cursor might be in trimmed whitespace at end of chunk - let hasCursorInChunk = false; - let adjustedCursorPos = 0; - - if (isCurrentLine) { - if (isLastChunk) { - // Last chunk: cursor belongs here if >= startIndex - hasCursorInChunk = cursorPos >= chunk.startIndex; - adjustedCursorPos = cursorPos - chunk.startIndex; - } else { - // Non-last chunk: cursor belongs here if in range [startIndex, endIndex) - // But we need to handle the visual position in the trimmed text - hasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex; - if (hasCursorInChunk) { - adjustedCursorPos = cursorPos - chunk.startIndex; - // Clamp to text length (in case cursor was in trimmed whitespace) - if (adjustedCursorPos > chunk.text.length) { - adjustedCursorPos = chunk.text.length; - } - } - } - } - - if (hasCursorInChunk) { - layoutLines.push({ - text: chunk.text, - hasCursor: true, - cursorPos: adjustedCursorPos, - }); - } else { - layoutLines.push({ - text: chunk.text, - hasCursor: false, - }); + hasCursor = isLastChunk + ? this.state.cursorCol >= chunk.startIndex + : this.state.cursorCol >= chunk.startIndex && this.state.cursorCol < chunk.endIndex; + if (hasCursor) { + cursorPos = Math.min(chunk.text.length, this.state.cursorCol - chunk.startIndex); } } + + layoutLines.push({ + text: chunk.text, + logicalLine: i, + startCol: chunk.startIndex, + endCol: chunk.endIndex, + hasCursor, + cursorPos, + }); } } @@ -1099,11 +1179,144 @@ export class Editor implements Component, Focusable { return [...this.state.lines]; } - getCursor(): { line: number; col: number } { + getCursor(): EditorPosition { return { line: this.state.cursorLine, col: this.state.cursorCol }; } + getSelectionRange(): EditorSelectionRange | undefined { + if (!this.selection || compareEditorPositions(this.selection.anchor, this.selection.head) === 0) { + return undefined; + } + return compareEditorPositions(this.selection.anchor, this.selection.head) < 0 + ? { start: { ...this.selection.anchor }, end: { ...this.selection.head } } + : { start: { ...this.selection.head }, end: { ...this.selection.anchor } }; + } + + getSelectedText(): string | undefined { + const range = this.getSelectionRange(); + if (!range) return undefined; + if (range.start.line === range.end.line) { + return (this.state.lines[range.start.line] || "").slice(range.start.col, range.end.col); + } + + const lines = [ + (this.state.lines[range.start.line] || "").slice(range.start.col), + ...this.state.lines.slice(range.start.line + 1, range.end.line), + (this.state.lines[range.end.line] || "").slice(0, range.end.col), + ]; + return lines.join("\n"); + } + + hasSelection(): boolean { + return this.getSelectionRange() !== undefined; + } + + clearSelection(): void { + if (!this.selection) return; + this.selection = undefined; + this.tui.requestRender(); + } + + beginSelection(position: EditorPosition): void { + const normalized = this.normalizePosition(position); + this.cancelAutocomplete(); + this.exitHistoryBrowsing(); + this.lastAction = null; + this.selection = { anchor: normalized, head: normalized }; + this.setCursorPosition(normalized); + this.tui.requestRender(); + } + + updateSelection(position: EditorPosition): void { + if (!this.selection) return; + const normalized = this.normalizePosition(position); + if (compareEditorPositions(this.selection.head, normalized) === 0) return; + this.selection = { anchor: this.selection.anchor, head: normalized }; + this.setCursorPosition(normalized); + this.tui.requestRender(); + } + + finishSelection(): void { + if (!this.selection) return; + if (!this.hasSelection()) { + this.selection = undefined; + } + this.tui.requestRender(); + } + + /** + * Map a terminal cell in the most recently rendered editor frame to a + * logical insertion position. row/col are zero-based and include the + * editor's top border and horizontal padding. + */ + positionAtRenderedCell(row: number, col: number, clamp = false): EditorPosition | undefined { + const layout = this.renderedLayout; + if (!layout || !Number.isInteger(row) || !Number.isInteger(col)) return undefined; + if (!clamp && (row < 1 || row > layout.lines.length || col < 0 || col >= layout.width)) { + return undefined; + } + + let line: LayoutLine | undefined; + if (clamp && row < 1) { + line = layout.allLines[Math.max(0, layout.scrollOffset - 1)]; + } else if (clamp && row > layout.lines.length) { + line = layout.allLines[Math.min(layout.allLines.length - 1, layout.scrollOffset + layout.lines.length)]; + } else { + const contentRow = Math.max(0, Math.min(layout.lines.length - 1, row - 1)); + line = layout.lines[contentRow]; + } + if (!line) return undefined; + const contentCol = Math.max(0, Math.min(layout.contentWidth, col - layout.paddingX)); + const logicalText = this.state.lines[line.logicalLine] || ""; + const lineWidth = visibleWidth(line.text); + const logicalCol = + contentCol >= lineWidth + ? line.endCol + : line.startCol + offsetAtVisualColumn(line.text, contentCol, this.segment(line.text, "grapheme")); + return this.normalizePosition({ + line: line.logicalLine, + col: Math.min(logicalText.length, logicalCol), + }); + } + + private normalizePosition(position: EditorPosition): EditorPosition { + const line = Math.max(0, Math.min(this.state.lines.length - 1, Math.floor(position.line))); + const text = this.state.lines[line] || ""; + let col = Math.max(0, Math.min(text.length, Math.floor(position.col))); + for (const segment of this.segment(text, "grapheme")) { + const end = segment.index + segment.segment.length; + if (col > segment.index && col < end) { + col = segment.index; + break; + } + } + return { line, col }; + } + + private setCursorPosition(position: EditorPosition): void { + this.state.cursorLine = position.line; + this.setCursorCol(position.col); + } + + private deleteSelection(pushUndo = true, notifyChange = true): boolean { + const range = this.getSelectionRange(); + if (!range) return false; + if (pushUndo) this.pushUndoSnapshot(); + this.cancelAutocomplete(); + this.exitHistoryBrowsing(); + this.lastAction = null; + + const before = (this.state.lines[range.start.line] || "").slice(0, range.start.col); + const after = (this.state.lines[range.end.line] || "").slice(range.end.col); + this.state.lines.splice(range.start.line, range.end.line - range.start.line + 1, before + after); + this.selection = undefined; + this.setCursorPosition(range.start); + if (notifyChange && this.onChange) this.onChange(this.getText()); + return true; + } + setText(text: string): void { + this.selection = undefined; this.cancelAutocomplete(); this.lastAction = null; this.exitHistoryBrowsing(); @@ -1126,6 +1339,7 @@ export class Editor implements Component, Focusable { this.pushUndoSnapshot(); this.lastAction = null; this.exitHistoryBrowsing(); + this.deleteSelection(false, false); this.insertTextAtCursorInternal(text); } @@ -1189,13 +1403,19 @@ export class Editor implements Component, Focusable { // All the editor methods from before... private insertCharacter(char: string, skipUndoCoalescing?: boolean): void { this.exitHistoryBrowsing(); + const replacingSelection = this.hasSelection(); + if (replacingSelection) { + this.pushUndoSnapshot(); + this.deleteSelection(false, false); + this.lastAction = null; + } // Undo coalescing (fish-style): // - Consecutive word chars coalesce into one undo unit // - Space captures state before itself (so undo removes space+following word together) // - Each space is separately undoable // Skip coalescing when called from atomic operations (e.g., handlePaste) - if (!skipUndoCoalescing) { + if (!skipUndoCoalescing && !replacingSelection) { if (isWhitespaceChar(char) || this.lastAction !== "type-word") { this.pushUndoSnapshot(); } @@ -1253,6 +1473,7 @@ export class Editor implements Component, Focusable { this.lastAction = null; this.pushUndoSnapshot(); + this.deleteSelection(false, false); // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode // control bytes inside bracketed paste as CSI-u Ctrl+ sequences @@ -1321,6 +1542,7 @@ export class Editor implements Component, Focusable { this.lastAction = null; this.pushUndoSnapshot(); + this.deleteSelection(false, false); const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1341,7 +1563,7 @@ export class Editor implements Component, Focusable { } private shouldSubmitOnBackslashEnter(data: string, kb: ReturnType): boolean { - if (this.disableSubmit) return false; + if (this.disableSubmit || this.hasSelection()) return false; if (!matchesKey(data, "enter")) return false; const submitKeys = kb.getKeys("tui.input.submit"); const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return"); @@ -1356,6 +1578,7 @@ export class Editor implements Component, Focusable { const result = this.expandPasteMarkers(this.state.lines.join("\n")).trim(); this.state = { lines: [""], cursorLine: 0, cursorCol: 0 }; + this.selection = undefined; this.pastes.clear(); this.pasteCounter = 0; this.exitHistoryBrowsing(); @@ -1370,6 +1593,7 @@ export class Editor implements Component, Focusable { private handleBackspace(): void { this.exitHistoryBrowsing(); this.lastAction = null; + if (this.deleteSelection()) return; if (this.state.cursorCol > 0) { this.pushUndoSnapshot(); @@ -1574,11 +1798,13 @@ export class Editor implements Component, Focusable { } private moveToLineStart(): void { + this.selection = undefined; this.lastAction = null; this.setCursorCol(0); } private moveToLineEnd(): void { + this.selection = undefined; this.lastAction = null; const currentLine = this.state.lines[this.state.cursorLine] || ""; this.setCursorCol(currentLine.length); @@ -1586,6 +1812,7 @@ export class Editor implements Component, Focusable { private deleteToStartOfLine(): void { this.exitHistoryBrowsing(); + if (this.deleteSelection()) return; const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1621,6 +1848,7 @@ export class Editor implements Component, Focusable { private deleteToEndOfLine(): void { this.exitHistoryBrowsing(); + if (this.deleteSelection()) return; const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1653,6 +1881,7 @@ export class Editor implements Component, Focusable { private deleteWordBackwards(): void { this.exitHistoryBrowsing(); + if (this.deleteSelection()) return; const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1698,6 +1927,7 @@ export class Editor implements Component, Focusable { private deleteWordForward(): void { this.exitHistoryBrowsing(); + if (this.deleteSelection()) return; const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1741,6 +1971,7 @@ export class Editor implements Component, Focusable { private handleForwardDelete(): void { this.exitHistoryBrowsing(); this.lastAction = null; + if (this.deleteSelection()) return; const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1854,6 +2085,7 @@ export class Editor implements Component, Focusable { } private moveCursor(deltaLine: number, deltaCol: number): void { + this.selection = undefined; this.lastAction = null; const visualLines = this.buildVisualLineMap(this.lastWidth); const currentVisualLine = this.findCurrentVisualLine(visualLines); @@ -1921,6 +2153,7 @@ export class Editor implements Component, Focusable { * Moves cursor by the page size while keeping it in bounds. */ private pageScroll(direction: -1 | 1): void { + this.selection = undefined; this.lastAction = null; const terminalRows = this.tui.terminal.rows; const pageSize = Math.max(5, Math.floor(terminalRows * 0.3)); @@ -1933,6 +2166,7 @@ export class Editor implements Component, Focusable { } private moveWordBackwards(): void { + this.selection = undefined; this.lastAction = null; const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1961,6 +2195,7 @@ export class Editor implements Component, Focusable { if (this.killRing.length === 0) return; this.pushUndoSnapshot(); + this.deleteSelection(false, false); const text = this.killRing.peek()!; this.insertYankedText(text); @@ -2081,6 +2316,7 @@ export class Editor implements Component, Focusable { private undo(): void { this.exitHistoryBrowsing(); + this.selection = undefined; const snapshot = this.undoStack.pop(); if (!snapshot) return; Object.assign(this.state, snapshot); @@ -2096,6 +2332,7 @@ export class Editor implements Component, Focusable { * Multi-line search. Case-sensitive. Skips the current cursor position. */ private jumpToChar(char: string, direction: "forward" | "backward"): void { + this.selection = undefined; this.lastAction = null; const isForward = direction === "forward"; const lines = this.state.lines; @@ -2126,6 +2363,7 @@ export class Editor implements Component, Focusable { } private moveWordForwards(): void { + this.selection = undefined; this.lastAction = null; const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -2335,6 +2573,7 @@ export class Editor implements Component, Focusable { if (options.force && options.explicitTab && suggestions.items.length === 1) { const item = suggestions.items[0]!; this.pushUndoSnapshot(); + this.selection = undefined; this.lastAction = null; const result = this.autocompleteProvider.applyCompletion( this.state.lines, diff --git a/packages/pi-tui/src/index.ts b/packages/pi-tui/src/index.ts index 4e76b1079b..5a2f5b0a5a 100644 --- a/packages/pi-tui/src/index.ts +++ b/packages/pi-tui/src/index.ts @@ -11,7 +11,13 @@ export { // Components export { Box } from "./components/box.ts"; export { CancellableLoader } from "./components/cancellable-loader.ts"; -export { Editor, type EditorOptions, type EditorTheme } from "./components/editor.ts"; +export { + Editor, + type EditorOptions, + type EditorPosition, + type EditorSelectionRange, + type EditorTheme, +} from "./components/editor.ts"; export { Image, type ImageOptions, type ImageTheme } from "./components/image.ts"; export { Input } from "./components/input.ts"; export { Loader, type LoaderIndicatorOptions } from "./components/loader.ts"; @@ -107,6 +113,7 @@ export { type OverlayMargin, type OverlayOptions, type OverlayUnfocusOptions, + type RenderedChildLayout, type SizeValue, TUI, } from "./tui.ts"; diff --git a/packages/pi-tui/src/tui.ts b/packages/pi-tui/src/tui.ts index e4556a8f47..efdcaf5265 100644 --- a/packages/pi-tui/src/tui.ts +++ b/packages/pi-tui/src/tui.ts @@ -97,6 +97,13 @@ export interface Component { invalidate(): void; } +export interface RenderedChildLayout { + readonly startRow: number; + readonly endRow: number; + readonly totalRows: number; + readonly width: number; +} + type InputListenerResult = { consume?: boolean; data?: string } | undefined; type InputListener = (data: string) => InputListenerResult; type PendingOsc11BackgroundQuery = { @@ -342,6 +349,7 @@ export class TUI extends Container { private pendingOsc11BackgroundQueries: PendingOsc11BackgroundQuery[] = []; private terminalColorSchemeListeners = new Set<(scheme: TerminalColorScheme) => void>(); private terminalColorSchemeNotificationsEnabled = false; + private renderedChildLayouts = new Map(); // Overlay stack for modal components rendered on top of base content private focusOrderCounter = 0; @@ -356,6 +364,46 @@ export class TUI extends Container { } } + override render(width: number): string[] { + width = Math.max(1, width); + const lines: string[] = []; + const pendingLayouts: Array<{ + component: Component; + startRow: number; + endRow: number; + }> = []; + + for (const child of this.children) { + const startRow = lines.length; + lines.push(...child.render(width)); + pendingLayouts.push({ component: child, startRow, endRow: lines.length }); + } + + const totalRows = lines.length; + this.renderedChildLayouts = new Map( + pendingLayouts.map(({ component, startRow, endRow }) => [ + component, + { startRow, endRow, totalRows, width }, + ]), + ); + return lines; + } + + getRenderedChildLayout(component: Component): RenderedChildLayout | undefined { + const layout = this.renderedChildLayouts.get(component); + return layout === undefined ? undefined : { ...layout }; + } + + /** + * Return the logical row currently shown at the top of the terminal viewport. + * This may be larger than max(0, renderedLineCount - terminalHeight) after a + * differential shrink, because the renderer deliberately preserves the + * existing viewport instead of replaying scrollback. + */ + getRenderedViewportTop(): number { + return this.previousViewportTop; + } + get fullRedraws(): number { return this.fullRedrawCount; } diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index a47f45dd7d..7dc20ab03c 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -4388,6 +4388,192 @@ describe("wordWrapLine narrow width", () => { }); }); +describe("Editor mouse selection", () => { + it("normalizes reverse selections as half-open ranges", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.setText("hello"); + editor.beginSelection({ line: 0, col: 4 }); + editor.updateSelection({ line: 0, col: 1 }); + editor.finishSelection(); + + assert.deepStrictEqual(editor.getSelectionRange(), { + start: { line: 0, col: 1 }, + end: { line: 0, col: 4 }, + }); + }); + + it("returns selected text in document order for reverse multiline drags", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.setText("abcDEF\nGHI\nJKLmno"); + editor.beginSelection({ line: 2, col: 3 }); + editor.updateSelection({ line: 0, col: 3 }); + editor.finishSelection(); + + assert.strictEqual(editor.getSelectedText(), "DEF\nGHI\nJKL"); + assert.strictEqual(editor.getText(), "abcDEF\nGHI\nJKLmno"); + }); + + it("deletes a multiline selection and restores it with one undo", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.setText("abcDEF\nGHI\nJKLmno"); + editor.beginSelection({ line: 0, col: 3 }); + editor.updateSelection({ line: 2, col: 3 }); + editor.finishSelection(); + + editor.handleInput("\x7f"); + assert.strictEqual(editor.getText(), "abcmno"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); + + editor.handleInput("\x1b[45;5u"); + assert.strictEqual(editor.getText(), "abcDEF\nGHI\nJKLmno"); + }); + + it("replaces a selection with typing as one undo unit", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.setText("hello world"); + editor.beginSelection({ line: 0, col: 6 }); + editor.updateSelection({ line: 0, col: 11 }); + editor.finishSelection(); + + editor.handleInput("X"); + assert.strictEqual(editor.getText(), "hello X"); + editor.handleInput("\x1b[45;5u"); + assert.strictEqual(editor.getText(), "hello world"); + }); + + it("replaces a selection with a newline", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.setText("hello world"); + editor.beginSelection({ line: 0, col: 5 }); + editor.updateSelection({ line: 0, col: 6 }); + editor.finishSelection(); + + editor.handleInput("\n"); + assert.strictEqual(editor.getText(), "hello\nworld"); + }); + + it("maps rendered wrapped rows and wide graphemes to logical positions", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.setText("A你B"); + editor.render(8); + assert.deepStrictEqual(editor.positionAtRenderedCell(1, 1), { line: 0, col: 1 }); + assert.deepStrictEqual(editor.positionAtRenderedCell(1, 2), { line: 0, col: 2 }); + assert.deepStrictEqual(editor.positionAtRenderedCell(1, 3), { line: 0, col: 2 }); + + editor.setText("abcdef"); + editor.render(4); + assert.deepStrictEqual(editor.positionAtRenderedCell(1, 3), { line: 0, col: 3 }); + assert.deepStrictEqual(editor.positionAtRenderedCell(2, 1), { line: 0, col: 4 }); + }); + + it("maps clicks through the editor scroll window", () => { + const editor = new Editor(createTestTUI(80, 10), defaultEditorTheme); + editor.setText(Array.from({ length: 10 }, (_, index) => `line-${index}`).join("\n")); + editor.render(80); + + assert.deepStrictEqual(editor.positionAtRenderedCell(1, 0), { line: 5, col: 0 }); + }); + + it("maps clamped edge drags to adjacent hidden visual rows", () => { + const editor = new Editor(createTestTUI(80, 10), defaultEditorTheme); + editor.setText(Array.from({ length: 10 }, (_, index) => `line-${index}`).join("\n")); + editor.render(80); + + const firstHidden = editor.positionAtRenderedCell(0, 0, true); + assert.deepStrictEqual(firstHidden, { line: 4, col: 0 }); + editor.beginSelection({ line: 9, col: 6 }); + editor.updateSelection(firstHidden!); + editor.render(80); + + assert.deepStrictEqual(editor.positionAtRenderedCell(0, 0, true), { line: 3, col: 0 }); + }); + + it("replaces a selection with a yank as one undo unit", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.setText("replace me"); + editor.handleInput("\x17"); + editor.handleInput("\x1b[45;5u"); + editor.beginSelection({ line: 0, col: 0 }); + editor.updateSelection({ line: 0, col: 7 }); + editor.finishSelection(); + + editor.handleInput("\x19"); + assert.strictEqual(editor.getText(), "me me"); + editor.handleInput("\x1b[45;5u"); + assert.strictEqual(editor.getText(), "replace me"); + }); + + it("keeps a ZWJ emoji atomic when selecting and deleting", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const emoji = "👨‍👩‍👧‍👦"; + editor.setText(`A${emoji}B`); + editor.beginSelection({ line: 0, col: 2 }); + editor.updateSelection({ line: 0, col: 1 + emoji.length }); + editor.finishSelection(); + + editor.handleInput("\x7f"); + assert.strictEqual(editor.getText(), "AB"); + }); + + it("keeps paste markers atomic when selecting and deleting", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const paste = Array.from({ length: 11 }, (_, index) => `line-${index}`).join("\n"); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + const marker = editor.getText(); + assert.match(marker, /^\[paste #1 /); + + editor.beginSelection({ line: 0, col: 2 }); + editor.updateSelection({ line: 0, col: marker.length }); + editor.finishSelection(); + editor.handleInput("\x7f"); + + assert.strictEqual(editor.getText(), ""); + }); + + it("renders each selected line as a contiguous inverse span", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.setText("hello"); + editor.beginSelection({ line: 0, col: 1 }); + editor.updateSelection({ line: 0, col: 4 }); + editor.finishSelection(); + + const rendered = editor.render(20).join("\n"); + assert.match(rendered, /\x1b\[7mell\x1b\[27m/); + assert.doesNotMatch(rendered, /\x1b\[7me\x1b\[27m\x1b\[7ml/); + }); + + it("renders selected line breaks and empty lines as inverse cells", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.setText("a\n\nb"); + editor.beginSelection({ line: 0, col: 1 }); + editor.updateSelection({ line: 2, col: 0 }); + editor.finishSelection(); + + const rendered = editor.render(20).join("\n"); + const selectedBreaks = rendered.match(/\x1b\[7m \x1b\[27m/g)?.length ?? 0; + assert.strictEqual(selectedBreaks, 2); + }); + + it("keeps inverse control sequences proportional to visual lines", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const text = [ + "hello world", + "中文拖动测试", + "A👨‍👩‍👧‍👦B", + "This is a deliberately long line that should wrap across multiple terminal rows.", + "last line", + ].join("\n"); + editor.setText(text); + editor.beginSelection({ line: 0, col: 0 }); + editor.updateSelection({ line: 4, col: "last line".length }); + editor.finishSelection(); + + const rendered = editor.render(80).join("\n"); + const inverseStarts = rendered.match(/\x1b\[7m/g)?.length ?? 0; + assert.ok(inverseStarts <= 8, `expected at most one selected span per visual line, got ${inverseStarts}`); + }); +}); + describe("Editor narrow width rendering", () => { it("renders CJK text without crashing at widths 1-8 (default padding)", () => { for (let width = 1; width <= 8; width++) { diff --git a/packages/pi-tui/test/tui-render.test.ts b/packages/pi-tui/test/tui-render.test.ts index d40811d0e8..1e34c9916e 100644 --- a/packages/pi-tui/test/tui-render.test.ts +++ b/packages/pi-tui/test/tui-render.test.ts @@ -72,6 +72,57 @@ function getCellItalic(terminal: VirtualTerminal, row: number, col: number): num return cell.isItalic(); } +describe("TUI rendered child layout", () => { + it("records top-level child row ranges during normal rendering", () => { + const tui = new TUI(new VirtualTerminal(40, 10)); + const first = new TestComponent(); + const second = new TestComponent(); + first.lines = ["a", "b"]; + second.lines = ["c", "d", "e"]; + tui.addChild(first); + tui.addChild(second); + + assert.strictEqual(tui.getRenderedChildLayout(second), undefined); + assert.deepStrictEqual(tui.render(40), ["a", "b", "c", "d", "e"]); + assert.deepStrictEqual(tui.getRenderedChildLayout(first), { + startRow: 0, + endRow: 2, + totalRows: 5, + width: 40, + }); + assert.deepStrictEqual(tui.getRenderedChildLayout(second), { + startRow: 2, + endRow: 5, + totalRows: 5, + width: 40, + }); + }); + + it("reports the preserved viewport after a differential shrink", async () => { + const terminal = new VirtualTerminal(40, 4); + const tui = new TUI(terminal); + tui.setClearOnShrink(false); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["0", "1", "2", "3", "4", "5"]; + tui.start(); + await terminal.waitForRender(); + assert.strictEqual(tui.getRenderedViewportTop(), 2); + + component.lines = ["0", "1", "2", "3", "4"]; + tui.requestRender(); + await terminal.waitForRender(); + + assert.strictEqual( + tui.getRenderedViewportTop(), + 2, + "the terminal keeps the old viewport instead of moving to totalRows - height", + ); + tui.stop(); + }); +}); + describe("TUI Kitty image cleanup", () => { it("clears reserved Kitty image rows before drawing appended image placements", async () => { setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });