diff --git a/.changeset/mouse-select-editor.md b/.changeset/mouse-select-editor.md new file mode 100644 index 00000000000..a8cf2bd8c75 --- /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 00000000000..545b9daf81b --- /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 6bf367f64b0..65ec39d8445 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -152,6 +152,7 @@ export interface SlashCommandHost { * it while still session-less. */ hydrateLazyConfigDefaults(): Promise; + 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 041ec2d246a..007a4775172 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -35,6 +35,7 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise await host.refreshPluginCommands(); } 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 2a280209325..a0877dd0a62 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; @@ -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; } @@ -518,6 +524,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 ec87a07803e..6ab123b445b 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 e4474e7425f..ad6c4b4eee5 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -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'; @@ -80,6 +81,8 @@ export interface EditorKeyboardHost { handleInputModeChange(mode: 'prompt' | 'bash'): void; clearQueuedMessages(): void; setExternalEditorRunning(running: boolean): void; + suspendTerminalMouseTracking(): void; + refreshTerminalMouseTracking(): void; updateActivityPane(): void; } @@ -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; @@ -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. @@ -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 diff --git a/apps/kimi-code/src/tui/controllers/tasks-browser.ts b/apps/kimi-code/src/tui/controllers/tasks-browser.ts index 7db2f0a82fc..319b0004ee9 100644 --- a/apps/kimi-code/src/tui/controllers/tasks-browser.ts +++ b/apps/kimi-code/src/tui/controllers/tasks-browser.ts @@ -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 = { @@ -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); @@ -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); } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index cb0231d4c0b..b1694177c44 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -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'; @@ -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> = []; @@ -624,6 +626,7 @@ export class KimiTUI { return; } const shouldReplayHistory = await this.initMainTui(); + this.refreshTerminalMouseTracking(); this.startBackgroundFdAutocomplete(); await this.finishStartup(shouldReplayHistory); } catch (error) { @@ -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(); @@ -731,6 +740,7 @@ export class KimiTUI { this.startClipboardImageHintController(); this.terminalFocusTrackingDispose = installTerminalFocusTracking(this.state); this.refreshTerminalThemeTracking(); + this.refreshTerminalMouseTracking(); } private startClipboardImageHintController(): void { @@ -1080,6 +1090,7 @@ export class KimiTUI { private disposeTerminalTracking(): void { this.stopTerminalThemeTracking(); + this.suspendTerminalMouseTracking(); this.clipboardImageHintController?.stop(); this.clipboardImageHintController = undefined; this.terminalFocusTrackingDispose?.(); @@ -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); + } + private async applyResolvedAutoTheme(resolved: ResolvedTheme): Promise { if (this.state.appState.theme !== 'auto') return; const palette = getBuiltInPalette(resolved); @@ -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); @@ -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. 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 00000000000..f738319543c --- /dev/null +++ b/apps/kimi-code/src/tui/utils/editor-mouse.ts @@ -0,0 +1,210 @@ +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.ui.children.includes(state.editorContainer)) return 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 5a93f3821a8..b9943eb57d8 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 e0d9352401e..a1be587676a 100644 --- a/apps/kimi-code/test/tui/commands/reload.test.ts +++ b/apps/kimi-code/test/tui/commands/reload.test.ts @@ -88,6 +88,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({ @@ -222,6 +223,7 @@ function makeHost({ state.appState.theme = theme; }), refreshTerminalThemeTracking: vi.fn(), + refreshTerminalMouseTracking: vi.fn(), refreshSlashCommandAutocomplete: vi.fn(), reloadCurrentSessionView: vi.fn(async () => {}), showStatus: vi.fn(), @@ -231,6 +233,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 6b68d83e197..e9dfff80b11 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 626728e7812..1c0d8c635b9 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; @@ -85,6 +92,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 00000000000..143370b4fbc --- /dev/null +++ b/apps/kimi-code/test/tui/editor-mouse.test.ts @@ -0,0 +1,288 @@ +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, + children: [editorContainer], + 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, + children: [editorContainer], + 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 b2184ee9b6f..05c222876e7 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_FETCH_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; @@ -58,6 +68,17 @@ interface MigrateExitDriver extends StartupDriver { terminalFocusTrackingDispose?: () => void; } +interface TrustPromptStartupDriver extends StartupDriver { + start(): Promise; + registerSignalHandlers(): void; + maybeRunWorkspaceTrustPrompt(): Promise; + initMainTui(): Promise; + startEventLoop(): void; + refreshTerminalMouseTracking(): void; + startBackgroundFdAutocomplete(): void; + finishStartup(shouldReplayHistory: boolean): Promise; +} + const MIGRATION_PLAN: MigrationPlan = { sourceHome: '/x/.kimi', hasConfig: false, @@ -1433,6 +1454,38 @@ 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.ui.clear(); + driver.refreshTerminalMouseTracking(); + expect(addInputListener).toHaveBeenCalledOnce(); + + driver.state.ui.addChild(driver.state.editorContainer); + 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( @@ -2050,6 +2103,38 @@ describe('KimiTUI startup', () => { expect(driver.state.appState.sessionId).toBe(''); }); + it('refreshes terminal mouse tracking after the trust prompt starts the event loop', async () => { + const harness = makeHarness(); + const driver = makeDriver(harness, { + ...makeStartupInput(), + engineV2: true, + }) as unknown as TrustPromptStartupDriver; + vi.spyOn(driver, 'registerSignalHandlers').mockImplementation(() => {}); + const trustPrompt = vi + .spyOn(driver, 'maybeRunWorkspaceTrustPrompt') + .mockResolvedValue(true); + const initMainTui = vi.spyOn(driver, 'initMainTui').mockResolvedValue(false); + const startEventLoop = vi.spyOn(driver, 'startEventLoop').mockImplementation(() => {}); + const refreshMouse = vi + .spyOn(driver, 'refreshTerminalMouseTracking') + .mockImplementation(() => {}); + vi.spyOn(driver, 'startBackgroundFdAutocomplete').mockImplementation(() => {}); + vi.spyOn(driver, 'finishStartup').mockResolvedValue(); + + await driver.start(); + + expect(trustPrompt).toHaveBeenCalledOnce(); + expect(initMainTui).toHaveBeenCalledOnce(); + expect(startEventLoop).not.toHaveBeenCalled(); + expect(refreshMouse).toHaveBeenCalledOnce(); + const initOrder = initMainTui.mock.invocationCallOrder[0]; + const refreshOrder = refreshMouse.mock.invocationCallOrder[0]; + if (initOrder === undefined || refreshOrder === undefined) { + throw new Error('expected startup and mouse refresh calls'); + } + expect(initOrder).toBeLessThan(refreshOrder); + }); + it('disposes terminal focus/theme tracking on the kimi migrate exit', async () => { const harness = makeHarness(); const driver = makeDriver(harness, { diff --git a/apps/kimi-code/test/tui/tasks-browser.test.ts b/apps/kimi-code/test/tui/tasks-browser.test.ts index d173af25ff6..9340d61a2bc 100644 --- a/apps/kimi-code/test/tui/tasks-browser.test.ts +++ b/apps/kimi-code/test/tui/tasks-browser.test.ts @@ -1,5 +1,10 @@ -import type { Terminal } from '@moonshot-ai/pi-tui'; -import type { BackgroundTaskInfo, BackgroundTaskStatus, Event } from '@moonshot-ai/kimi-code-sdk'; +import type { Component, ProcessTerminal, Terminal, TUI } from '@moonshot-ai/pi-tui'; +import type { + BackgroundTaskInfo, + BackgroundTaskStatus, + Event, + Session, +} from '@moonshot-ai/kimi-code-sdk'; import { describe, expect, it, vi } from 'vitest'; import { @@ -9,8 +14,12 @@ import { } from '@/tui/components/dialogs/tasks-browser'; import { AgentActivityViewer } from '@/tui/components/dialogs/agent-activity-viewer'; import { TaskOutputViewer } from '@/tui/components/dialogs/task-output-viewer'; +import { + TasksBrowserController, + type TasksBrowserHost, + type TasksBrowserState, +} from '@/tui/controllers/tasks-browser'; import { SubagentActivityStore } from '@/tui/controllers/subagent-activity-store'; -import { TasksBrowserController } from '@/tui/controllers/tasks-browser'; import { darkColors } from '@/tui/theme/colors'; const ANSI_SGR = /\[[0-9;]*m/g; @@ -557,6 +566,73 @@ describe('TasksBrowserApp — setProps', () => { }); }); +describe('TasksBrowserController — terminal mouse lifecycle', () => { + it('suspends tracking during the full-screen takeover and restores it on close', async () => { + const originalChildren = [ + { render: () => ['transcript'], invalidate: () => {} }, + { render: () => ['editor'], invalidate: () => {} }, + ] as unknown as Component[]; + const children = [...originalChildren]; + const events: string[] = []; + const ui = { + children, + clear: vi.fn(() => { + events.push('clear'); + children.splice(0); + }), + addChild: vi.fn((component: Component) => { + events.push('add'); + children.push(component); + }), + setFocus: vi.fn(), + requestRender: vi.fn(), + } as unknown as TUI; + const state = { + tasksBrowser: undefined as TasksBrowserState | undefined, + theme: darkColors as unknown as TasksBrowserHost['state']['theme'], + terminal: fakeTerminal(30) as unknown as ProcessTerminal, + ui, + editor: {} as TasksBrowserHost['state']['editor'], + }; + const suspendTerminalMouseTracking = vi.fn(() => { + events.push('suspend'); + }); + const refreshTerminalMouseTracking = vi.fn(() => { + events.push('refresh'); + }); + const host: TasksBrowserHost = { + state, + backgroundTasks: new Map(), + sessionEventHandler: { + subAgentEventHandler: { activityStore: new SubagentActivityStore() }, + } as TasksBrowserHost['sessionEventHandler'], + session: { + listBackgroundTasks: vi.fn(async () => []), + } as unknown as Session, + showError: vi.fn(), + setTasksBrowser(value) { + state.tasksBrowser = value; + }, + suspendTerminalMouseTracking, + refreshTerminalMouseTracking, + }; + const controller = new TasksBrowserController(host); + + await controller.show(); + + expect(suspendTerminalMouseTracking).toHaveBeenCalledOnce(); + expect(events.indexOf('suspend')).toBeLessThan(events.indexOf('clear')); + expect(children).toHaveLength(1); + expect(state.tasksBrowser).toBeDefined(); + + controller.close(); + + expect(refreshTerminalMouseTracking).toHaveBeenCalledOnce(); + expect(children).toEqual(originalChildren); + expect(events.lastIndexOf('refresh')).toBeGreaterThan(events.lastIndexOf('add')); + }); +}); + describe('TasksBrowserController — opening an agent task', () => { function makeControllerHost(tasks: BackgroundTaskInfo[], store: SubagentActivityStore) { const ui = { @@ -588,6 +664,8 @@ describe('TasksBrowserController — opening an agent task', () => { setTasksBrowser(value: unknown) { state.tasksBrowser = value; }, + suspendTerminalMouseTracking: vi.fn(), + refreshTerminalMouseTracking: vi.fn(), }; return { host, state }; } 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 00000000000..d40887bd026 --- /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 4d8b900c15c..fe6cee18494 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -151,6 +151,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_CODE_TUI_FULL_SCREEN` | Enable the experimental fullscreen alternate-screen UI: scrollable transcript viewport, mouse text selection, clickable links, and Ctrl-Shift-F transcript search | `1` enables it; anything else keeps the regular inline UI | | `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | Enable the experimental [subagent model pool](./config-files.md#subagent-model-pool) in every launch mode, including the interactive TUI; the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK` | Enable the experimental `fork` parameter on the `Agent` and `AgentSwarm` tools, letting the model start a subagent with a snapshot of the calling agent's conversation history instead of an empty context; the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | 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_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 | | `KIMI_MCP_TOOL_TIMEOUT_MS` | Global default single tool-call timeout (ms) for all MCP servers; takes higher priority than `[mcp] tool_timeout_ms` in `config.toml`, but a per-server `toolTimeoutMs` in `mcp.json` still wins (default `60000`) | Integer from `1` to `2147483647`; invalid values are ignored | | `KIMI_LOOP_MAX_STEPS_PER_TURN` | Maximum Agent steps per turn; takes higher priority than `[loop_control] max_steps_per_turn` in `config.toml` (unset or `0` means unlimited) | Non-negative integer; invalid values are ignored | diff --git a/docs/en/reference/keyboard.md b/docs/en/reference/keyboard.md index a641282d76a..7f79dfeae54 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 67df6d18329..a5f0ec2e214 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -151,6 +151,7 @@ kimi | `KIMI_CODE_TUI_FULL_SCREEN` | 启用实验性的 fullscreen alternate-screen 界面:可滚动的 transcript 视口、鼠标选择文本、可点击链接、Ctrl-Shift-F 搜索 | `1` 开启;其他值保持常规内联界面 | | `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | 在包括交互式 TUI 在内的所有启动方式下启用实验性的[subagent 模型池](./config-files.md#subagent-模型池);master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK` | 在 `Agent` 和 `AgentSwarm` 工具上启用实验性的 `fork` 参数,让模型可以以调用方 Agent 对话历史的快照而不是空上下文启动 subagent;master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用本功能 | 真值:`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_MCP_STARTUP_TIMEOUT_MS` | 所有 MCP server 的全局默认连接超时(毫秒);优先级高于 `config.toml` 的 `[mcp] startup_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `startupTimeoutMs`(默认 `30000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | | `KIMI_MCP_TOOL_TIMEOUT_MS` | 所有 MCP server 的全局默认单次工具调用超时(毫秒);优先级高于 `config.toml` 的 `[mcp] tool_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `toolTimeoutMs`(默认 `60000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | | `KIMI_LOOP_MAX_STEPS_PER_TURN` | Agent 单轮最大步数;优先级高于 `config.toml` 的 `[loop_control] max_steps_per_turn`(不设或 `0` 表示无上限) | 非负整数;非法值被忽略 | diff --git a/docs/zh/reference/keyboard.md b/docs/zh/reference/keyboard.md index 9e3c54a5ae9..ce6ddff7d59 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-v2/src/app/terminalMouseInput/flag.ts b/packages/agent-core-v2/src/app/terminalMouseInput/flag.ts new file mode 100644 index 00000000000..487362f0a66 --- /dev/null +++ b/packages/agent-core-v2/src/app/terminalMouseInput/flag.ts @@ -0,0 +1,15 @@ +import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; + +export const TERMINAL_MOUSE_INPUT_FLAG_ID = 'terminal_mouse_input'; +export const TERMINAL_MOUSE_INPUT_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_TERMINAL_MOUSE_INPUT'; + +export const terminalMouseInputFlag: FlagDefinitionInput = { + id: TERMINAL_MOUSE_INPUT_FLAG_ID, + title: 'Terminal mouse input', + description: 'Allow mouse clicks and drags to position and select text in the main prompt editor.', + env: TERMINAL_MOUSE_INPUT_FLAG_ENV, + default: false, + surface: 'tui', +}; + +registerFlagDefinition(terminalMouseInputFlag); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index a7db8a31869..7b082d9c639 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -304,6 +304,7 @@ import '#/app/flag/flag'; import '#/app/flag/flagRegistry'; import '#/app/flag/flagRegistryService'; import '#/app/flag/flagService'; +import '#/app/terminalMouseInput/flag'; export * from '#/app/flag/flagRegistry'; export * from '#/app/flag/flagRegistryService'; export * from '#/app/flag/flag'; diff --git a/packages/agent-core/src/flags/registry.ts b/packages/agent-core/src/flags/registry.ts index 55903c6d00b..7cc18147679 100644 --- a/packages/agent-core/src/flags/registry.ts +++ b/packages/agent-core/src/flags/registry.ts @@ -41,6 +41,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 3fae01040de..0fa4f257c02 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -356,6 +356,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/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index 433572b9001..98df53e3d6e 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -72,6 +72,7 @@ vi.mock('@moonshot-ai/agent-core-v2/_base/execEnv/environmentProbe', async (impo const tempDirs: string[] = []; afterEach(async () => { + vi.unstubAllEnvs(); // The read-model mirror/query-store close asynchronously on dispose; await // the drains so the rm below never races their final flush (ENOTEMPTY). await drainSessionIndexMirror(); @@ -283,6 +284,27 @@ describe('SDKRpcClientV2 (agent-core-v2 wiring)', () => { } }); + it('registers terminal mouse input in the v2 feature catalog', async () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_TERMINAL_MOUSE_INPUT', '1'); + const { harness } = await makeHarness(); + try { + const feature = (await harness.getExperimentalFeatures()).find( + ({ id }) => id === 'terminal_mouse_input', + ); + expect(feature).toMatchObject({ + id: 'terminal_mouse_input', + title: 'Terminal mouse input', + env: 'KIMI_CODE_EXPERIMENTAL_TERMINAL_MOUSE_INPUT', + surface: 'tui', + defaultEnabled: false, + enabled: true, + source: 'env', + }); + } finally { + await harness.close(); + } + }); + it('uploadFile stores bytes through the klient files facade', async () => { const { harness } = await makeHarness(); try { diff --git a/packages/pi-tui/AGENTS.md b/packages/pi-tui/AGENTS.md index 3ee3f9a0033..4ff5f7aecd4 100644 --- a/packages/pi-tui/AGENTS.md +++ b/packages/pi-tui/AGENTS.md @@ -14,6 +14,8 @@ Never overwrite this directory wholesale when syncing from upstream. Each of the 6. **`src/components/markdown.ts` — `CjkBoundaryUrlTokenizer` autolink CJK boundary**: marked's GFM autolink accepts any non-space characters after the domain and its backpedal strips only ASCII trailing punctuation, so CJK/full-width punctuation right after a bare URL is absorbed into the link text and href (`.../pull/232(本地` renders as one anchor with a CJK target). The `CjkBoundaryUrlTokenizer` subclass (the tokenizer actually registered on the parser) cuts the match at the first CJK punctuation character before the ASCII backpedal; full-width parentheses follow GFM's ASCII-paren rule — balanced pairs stay in the URL (`.../wiki/中华人民共和国(1949年)`, punctuation inside them included), only unbalanced ones terminate the match. `StrictStrikethroughTokenizer` itself stays byte-identical to upstream. Guarding tests: the bare-URL CJK cases in the "Links" group in `test/markdown.test.ts`. 7. **`src/components/editor.ts` — opt-in inline slash autocomplete (`inlineSlashTrigger`)**: when enabled, `/` after whitespace mid-input or at the start of a subsequent line auto-triggers autocomplete (`isAtInlineSlashTrigger`), and typing further token characters (letters, digits, `.`, `-`, `_`, `:`) inside that inline token re-triggers the request (`isInInlineSlashContext`) so the in-flight request from the bare `/` cannot go stale before the menu appears; `:` is required because external skill tokens are shaped `/skill:`. Off by default — prose slashes (paths, fractions) keep upstream behavior. Guarding tests: the "Inline slash trigger" group in `test/editor.test.ts`. 8. **`src/autocomplete.ts` / `src/components/select-list.ts` / `src/components/editor.ts` — `data` on autocomplete items + Enter non-submit for marked completions**: autocomplete items may carry an opaque `data` record; when the selected item's `data.inlineSkill` is set, confirming with Enter applies the completion without submitting the editor (ordinary completions keep upstream Enter-submits behavior). Guarding tests: "does not submit when confirming an inline-marked completion with Enter" and "still submits when confirming an unmarked slash completion with Enter" in `test/editor.test.ts`. +9. **`src/components/editor.ts` — prompt selection model, editing semantics, rendered-cell mapping, and inverse rendering**: preserve the exported `EditorPosition` / `EditorSelectionRange` types and the selection API (`getSelectionRange`, `getSelectedText`, `hasSelection`, `clearSelection`, `beginSelection`, `updateSelection`, `finishSelection`, `positionAtRenderedCell`). The range is anchor/head based and normalized as a half-open document range; deletion and replacement must remain one undo unit across typing, newline, yank, paste, and multiline selections. Render selected text as contiguous inverse spans, including a visible trailing cell for selected logical newlines and empty lines. Rendered-cell mapping must stay aligned with wrapped rows, scroll offsets, CJK width, and multi-code-point graphemes so the app's mouse mapper can address the editor without re-rendering it. Guarding tests: the full "Editor mouse selection" group in `test/editor.test.ts`; cross-package mouse mapping and drag behavior are guarded by "terminal mouse input" in `apps/kimi-code/test/tui/editor-mouse.test.ts`. +10. **`src/tui.ts` / `Container.render` — rendered top-level child layout and preserved viewport introspection**: preserve `RenderedChildLayout`, the per-render child row-range cache exposed by `getRenderedChildLayout`, and `getRenderedViewportTop`'s report of the actual differential-render viewport on `TuiMainScreen`. The Kimi Code mouse integration uses these APIs to translate terminal coordinates into editor cells without re-rendering the component tree, and to remain correct after differential shrink or while dragging through hidden editor rows. Guarding tests: "TUI rendered child layout" in `test/tui-render.test.ts`, plus the editor mouse integration tests above. ## Acceptance after syncing from upstream diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index 276cac7e7a1..a1ec37fbc27 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -224,6 +224,21 @@ interface EditorState { cursorCol: number; } +export interface EditorPosition { + line: number; + col: number; +} + +export interface EditorSelectionRange { + start: EditorPosition; + end: EditorPosition; +} + +interface EditorSelection { + anchor: EditorPosition; + head: EditorPosition; +} + /** Undo snapshot: editor text state plus the paste registry. */ interface EditorSnapshot { state: EditorState; @@ -233,10 +248,22 @@ interface EditorSnapshot { 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; @@ -276,6 +303,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; +} + function createScrollBorder(direction: "↑" | "↓", hiddenLineCount: number, width: number): string { const availableWidth = Math.max(0, width); const indicator = `─── ${direction} ${hiddenLineCount} more `; @@ -307,6 +362,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; @@ -485,6 +546,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; @@ -532,6 +594,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; @@ -558,6 +621,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; @@ -571,7 +635,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[] { @@ -612,6 +746,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); @@ -632,45 +774,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) @@ -833,6 +947,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; } @@ -1027,84 +1142,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, + }); } } @@ -1136,11 +1216,154 @@ 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; + } + + private killSelection(prepend: boolean): boolean { + const selectedText = this.getSelectedText(); + if (selectedText === undefined) return false; + const accumulate = this.lastAction === "kill"; + this.killRing.push(selectedText, { prepend, accumulate }); + this.deleteSelection(); + this.lastAction = "kill"; + return true; + } + setText(text: string, options?: { preservePasteRegistry?: boolean }): void { + this.selection = undefined; this.cancelAutocomplete(); this.lastAction = null; this.exitHistoryBrowsing(); @@ -1167,6 +1390,7 @@ export class Editor implements Component, Focusable { this.pushUndoSnapshot(); this.lastAction = null; this.exitHistoryBrowsing(); + this.deleteSelection(false, false); this.insertTextAtCursorInternal(text); } @@ -1230,13 +1454,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 = skipUndoCoalescing ? null : "type-word"; + } // 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(); } @@ -1303,6 +1533,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 @@ -1371,6 +1602,7 @@ export class Editor implements Component, Focusable { this.lastAction = null; this.pushUndoSnapshot(); + this.deleteSelection(false, false); const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1391,7 +1623,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"); @@ -1406,6 +1638,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(); @@ -1420,6 +1653,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(); @@ -1652,11 +1886,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); @@ -1664,6 +1900,7 @@ export class Editor implements Component, Focusable { private deleteToStartOfLine(): void { this.exitHistoryBrowsing(); + if (this.killSelection(true)) return; const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1699,6 +1936,7 @@ export class Editor implements Component, Focusable { private deleteToEndOfLine(): void { this.exitHistoryBrowsing(); + if (this.killSelection(false)) return; const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1731,6 +1969,7 @@ export class Editor implements Component, Focusable { private deleteWordBackwards(): void { this.exitHistoryBrowsing(); + if (this.killSelection(true)) return; const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1776,6 +2015,7 @@ export class Editor implements Component, Focusable { private deleteWordForward(): void { this.exitHistoryBrowsing(); + if (this.killSelection(false)) return; const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1819,6 +2059,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] || ""; @@ -1932,6 +2173,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); @@ -1999,6 +2241,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)); @@ -2011,6 +2254,7 @@ export class Editor implements Component, Focusable { } private moveWordBackwards(): void { + this.selection = undefined; this.lastAction = null; const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -2039,6 +2283,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); @@ -2159,6 +2404,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.state); @@ -2176,6 +2422,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; @@ -2206,6 +2453,7 @@ export class Editor implements Component, Focusable { } private moveWordForwards(): void { + this.selection = undefined; this.lastAction = null; const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -2447,6 +2695,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 0d5a4a1093b..9ec6b610f71 100644 --- a/packages/pi-tui/src/index.ts +++ b/packages/pi-tui/src/index.ts @@ -12,7 +12,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 { HStack } from "./components/h-stack.ts"; export { Image, type ImageOptions, type ImageTheme } from "./components/image.ts"; export { Input } from "./components/input.ts"; @@ -126,6 +132,7 @@ export { type OverlayMargin, type OverlayOptions, type OverlayUnfocusOptions, + type RenderedChildLayout, type SizeValue, type TUI, type TuiInputListener, diff --git a/packages/pi-tui/src/tui-main-screen.ts b/packages/pi-tui/src/tui-main-screen.ts index d674b95ed5c..043bfbdfdc0 100644 --- a/packages/pi-tui/src/tui-main-screen.ts +++ b/packages/pi-tui/src/tui-main-screen.ts @@ -103,6 +103,10 @@ export class TuiMainScreen extends TuiBase implements TUI { this.previousViewportTop = state.previousViewportTop; } + override getRenderedViewportTop(): number { + return this.previousViewportTop; + } + protected override resetRenderState(): void { this.previousLines = []; this.previousRawLines = []; diff --git a/packages/pi-tui/src/tui.ts b/packages/pi-tui/src/tui.ts index 6ca99c596be..24ee995452a 100644 --- a/packages/pi-tui/src/tui.ts +++ b/packages/pi-tui/src/tui.ts @@ -46,6 +46,13 @@ export interface Component { invalidate(): void; } +export interface RenderedChildLayout { + readonly startRow: number; + readonly endRow: number; + readonly totalRows: number; + readonly width: number; +} + export type TuiInputListenerResult = { consume?: boolean; data?: string } | undefined; export type TuiInputListener = (data: string) => TuiInputListenerResult; type PendingOsc11BackgroundQuery = { @@ -210,6 +217,7 @@ type OverlayFocusRestorePolicy = "clear" | "preserve"; */ export class Container implements Component { children: Component[] = []; + private renderedChildLayouts = new Map(); addChild(component: Component): void { this.children.push(component); @@ -233,18 +241,35 @@ export class Container implements Component { } render(width: number): string[] { - // Extremely narrow terminals can report tiny or even non-positive - // column counts; never propagate a width below 1 into components. 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; const childLines = child.render(width); for (const line of childLines) { lines.push(line); } + 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 }; + } } /** @@ -314,6 +339,8 @@ export interface TUI extends Component { requestRender(force?: boolean): void; addInputListener(listener: TuiInputListener): () => void; removeInputListener(listener: TuiInputListener): void; + getRenderedChildLayout(component: Component): RenderedChildLayout | undefined; + getRenderedViewportTop(): number; onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void; setTerminalColorSchemeNotifications(enabled: boolean): void; queryTerminalBackgroundColor(options: { timeoutMs: number }): Promise; @@ -384,6 +411,10 @@ export abstract class TuiBase extends Container implements TUI { protected afterTerminalStop(_options: TuiStopOptions): void {} + getRenderedViewportTop(): number { + return 0; + } + 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 7ed25e0241c..8edbf49cb08 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -1508,6 +1508,43 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), "hello world"); }); + it("selected kills are available to yank for every kill command", () => { + for (const command of ["\x15", "\x0b", "\x17", "\x1bd"]) { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.setText("alpha beta\ngamma delta"); + editor.beginSelection({ line: 0, col: 6 }); + editor.updateSelection({ line: 1, col: 5 }); + editor.finishSelection(); + + editor.handleInput(command); + assert.strictEqual(editor.getText(), "alpha delta"); + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "alpha beta\ngamma delta"); + } + }); + + it("selected kills preserve backward and forward accumulation order", () => { + const backward = new Editor(createTestTUI(), defaultEditorTheme); + backward.setText("one two three"); + backward.beginSelection({ line: 0, col: 8 }); + backward.updateSelection({ line: 0, col: 13 }); + backward.finishSelection(); + backward.handleInput("\x17"); // Ctrl+W kills selected "three" + backward.handleInput("\x17"); // Ctrl+W prepends "two " + backward.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(backward.getText(), "one two three"); + + const forward = new Editor(createTestTUI(), defaultEditorTheme); + forward.setText("one two three"); + forward.beginSelection({ line: 0, col: 0 }); + forward.updateSelection({ line: 0, col: 3 }); + forward.finishSelection(); + forward.handleInput("\x1bd"); // Alt+D kills selected "one" + forward.handleInput("\x1bd"); // Alt+D appends " two" + forward.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(forward.getText(), "one two three"); + }); + it("Ctrl+Y does nothing when kill ring is empty", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); @@ -4688,6 +4725,194 @@ 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 multi-character 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"); + editor.handleInput("Y"); + editor.handleInput("Z"); + assert.strictEqual(editor.getText(), "hello XYZ"); + 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 f6bb16dde2e..ff5348fffe2 100644 --- a/packages/pi-tui/test/tui-render.test.ts +++ b/packages/pi-tui/test/tui-render.test.ts @@ -89,6 +89,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 TuiMainScreen(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 TuiMainScreen(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 render scheduling", () => { it("renders keyboard input without waiting for a throttled frame", async () => { const terminal = new VirtualTerminal(40, 10); @@ -136,7 +187,6 @@ describe("TUI debug logging", () => { } }); }); - describe("TUI Kitty image cleanup", () => { it("clears reserved Kitty image rows before drawing appended image placements", async () => { setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });