import { CustomEditor } from "@earendil-works/pi-coding-agent"; import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui"; import { PiHistoryAdapter } from "./adapters/pi-history-adapter.js"; import { RuntimePiEditorAdapter } from "./adapters/pi-editor-adapter.js"; import type { PromptBuffer } from "./engine/buffer.js"; import { ViCommandParser } from "./engine/command-parser.js"; import { ViMarks } from "./engine/marks.js"; import { deleteCommandCharacters, deleteCommandCharactersBackward, openCommandLine, replaceAtCursor, replaceCommandCharacters, toggleCommandCharacterCase, } from "./engine/commands.js"; import { moveCommandCursorLeft, findCommandCharacter, moveCommandCursorRight, moveCommandCursorVertical, moveToCommandFirstPrintable, moveToCommandLineEnd, moveToCommandLineStart, moveToEndOfBigWord, moveToEndOfWord, moveToMatchingDelimiter, moveToNextBigWord, moveToNextWord, moveToPreviousBigWord, moveToPreviousWord, } from "./engine/motions.js"; import { commandInputForReadlineName } from "./inputrc/command-dispatch.js"; import type { TrieResolution } from "./inputrc/key-trie.js"; import { expandInputrcMacro } from "./inputrc/macro-expansion.js"; import type { ReadlineProfileResolver } from "./inputrc/profile-resolver.js"; import { deleteOperatorRange, putRegister, yankOperatorRange, type ViRegister, } from "./engine/operators.js"; import { ReadlineUndoStack } from "./engine/undo.js"; import { enterViAppendMode, enterViCommandMode, initialReadlineState, } from "./engine/modal-state.js"; export type ReadlineViMode = "vi-insert" | "vi-command" | "vi-replace"; type ModeBackground = "selectedBg" | "toolSuccessBg" | "toolErrorBg"; type ModeTheme = { bg?: (color: ModeBackground, text: string) => string; fg?: (color: "text", text: string) => string; style?: (text: string, options: { fg: "text"; bg: ModeBackground }) => string; }; const FALLBACK_MODE_BACKGROUNDS: Readonly> = { selectedBg: "\x1b[48;5;24m", toolSuccessBg: "\x1b[48;5;22m", toolErrorBg: "\x1b[48;5;52m", }; const FALLBACK_MODE_FOREGROUND = "\x1b[97m"; /** * Small Phase 2 vertical slice. The full keymap dispatcher replaces this * explicit routing in Phase 4, but this class proves the Pi custom-editor and * private-state adapter boundary before the complete engine is introduced. */ export class ReadlineViEditor extends CustomEditor { #adapter: RuntimePiEditorAdapter; #theme: ModeTheme; #modeThemeProvider: () => ModeTheme; #commandParser = new ViCommandParser(); #commentBegin = "#"; #history = new PiHistoryAdapter(); #macroDepth = 0; #marks = new ViMarks(); #mode: ReadlineViMode = "vi-insert"; #pendingHistorySearch: | { direction: "backward" | "forward"; query: string } | undefined; #pendingMarkAction: "set" | "jump" | "jump-line" | undefined; #pendingOperator: { command: "d" | "c" | "y"; count: number } | undefined; #pendingOperatorCharacterSearch: | { operator: "d" | "c" | "y"; count: number; direction: "forward" | "backward"; till: boolean; } | undefined; #pendingReplacementCount: number | undefined; #register: ViRegister | undefined; #lastCharacterSearch: | { target: string; direction: "forward" | "backward"; till: boolean } | undefined; #pendingCharacterSearch: | { direction: "forward" | "backward"; till: boolean } | undefined; #profileResolver: ReadlineProfileResolver | undefined; #undo = new ReadlineUndoStack(); #insertionSnapshot: PromptBuffer | undefined; #lineBaseline: PromptBuffer; #lineBaselineInitialized = false; #lastChangeInputs: readonly string[] | undefined; #replaying = false; #activeInsertionInputs: string[] | undefined; constructor(...args: ConstructorParameters) { super(...args); this.#theme = args[1] as ModeTheme; this.#modeThemeProvider = () => this.#theme; this.#adapter = new RuntimePiEditorAdapter(this); this.#insertionSnapshot = this.#adapter.read(); this.#lineBaseline = this.#adapter.read(); } get mode(): ReadlineViMode { return this.#mode; } override addToHistory(text: string): void { super.addToHistory(text); this.#history.add(text); } setModeThemeProvider(provider: () => ModeTheme): void { this.#modeThemeProvider = provider; } setProfileResolver(resolver: ReadlineProfileResolver): void { this.#profileResolver = resolver; this.#commentBegin = resolver.variables.get("comment-begin") ?? "#"; this.#history.setSearchIgnoreCase( resolver.variables.get("search-ignore-case") === "on", ); } unsupportedReadlineVariables(): readonly string[] { const supported = new Set([ "comment-begin", "editing-mode", "enable-bracketed-paste", "keymap", "search-ignore-case", ]); return [...(this.#profileResolver?.variables.keys() ?? [])] .filter((name) => !supported.has(name)) .sort(); } resolveInputrcBinding(input: string): TrieResolution | undefined { const keymap = this.#mode === "vi-command" ? "vi-command" : "vi-insert"; return this.#profileResolver?.resolve(keymap, input); } describeInputrcBinding(input: string): string { const resolution = this.resolveInputrcBinding(input); if (resolution === undefined || resolution.kind === "none") return "unbound"; if (resolution.kind === "prefix") return "key-sequence prefix"; const binding = resolution.binding; if (binding.kind === "command") return `command ${binding.command}`; if (binding.kind === "macro") return "macro (expansion hidden)"; return "explicitly unbound"; } override render(width: number): string[] { const lines = super.render(width); const bottom = lines.at(-1); if (bottom === undefined) return lines; const pending = this.#pendingOperator?.command ?? (this.#pendingCharacterSearch === undefined ? undefined : "char") ?? (this.#pendingMarkAction === undefined ? undefined : "mark") ?? (this.#pendingHistorySearch === undefined ? undefined : `/${this.#pendingHistorySearch.query}`); const label = this.#mode === "vi-insert" ? " INSERT " : this.#mode === "vi-replace" ? " REPLACE " : pending === undefined ? " COMMAND " : ` COMMAND ${pending} `; const modeBackground: ModeBackground = this.#mode === "vi-insert" ? "toolSuccessBg" : this.#mode === "vi-replace" ? "toolErrorBg" : "selectedBg"; const modeTheme = this.#modeThemeProvider(); const themedLabel = modeTheme.fg?.("text", label); const visibleLabel = themedLabel ?? label; const fallbackLabel = themedLabel ?? `${FALLBACK_MODE_FOREGROUND}${label}\x1b[39m`; const styledLabel = modeTheme.style?.(label, { fg: "text", bg: modeBackground }) ?? modeTheme.bg?.(modeBackground, visibleLabel) ?? `${FALLBACK_MODE_BACKGROUNDS[modeBackground]}${fallbackLabel}\x1b[49m`; lines[lines.length - 1] = width <= label.length ? truncateToWidth(styledLabel, width, "") : `${truncateToWidth(bottom, width - label.length, "")}${styledLabel}`; return lines; } override handleInput(data: string): void { // Modal ownership is limited to the focused prompt. Pi overlays and // selectors must retain their own arrows and Ctrl-N/Ctrl-P bindings. if (!this.focused) { super.handleInput(data); return; } if (matchesKey(data, "ctrl+p")) { this.#selectPreviousHistory(); return; } if (matchesKey(data, "ctrl+n")) { this.#selectNextHistory(); return; } if (matchesKey(data, "escape")) { this.#commandParser.reset(); this.#pendingCharacterSearch = undefined; this.#pendingHistorySearch = undefined; this.#pendingMarkAction = undefined; this.#pendingOperator = undefined; this.#pendingOperatorCharacterSearch = undefined; this.#pendingReplacementCount = undefined; if (this.#mode !== "vi-command") { const current = this.#adapter.read(); if (!this.#lineBaselineInitialized) { this.#lineBaseline = current; this.#lineBaselineInitialized = true; } if (this.#insertionSnapshot !== undefined) { this.#undo.record(this.#insertionSnapshot, current); this.#transformMarks(this.#insertionSnapshot, current); if ( !this.#replaying && this.#activeInsertionInputs !== undefined && this.#insertionSnapshot.lines.join("\n") !== current.lines.join("\n") ) this.#lastChangeInputs = [...this.#activeInsertionInputs, "\x1b"]; } this.#insertionSnapshot = undefined; this.#activeInsertionInputs = undefined; const next = enterViCommandMode( initialReadlineState(this.#adapter.read()), ); this.#adapter.setCursor(next.buffer.cursor); this.#setMode("vi-command"); return; } super.handleInput(data); return; } const inputrcData = matchesKey(data, "ctrl+n") ? "\x0e" : matchesKey(data, "ctrl+p") ? "\x10" : matchesKey(data, "up") ? "\x1b[A" : matchesKey(data, "down") ? "\x1b[B" : data; if (this.#mode === "vi-insert") { const binding = this.resolveInputrcBinding(inputrcData); if (binding?.kind === "exact" || binding?.kind === "exact-and-prefix") { if (binding.binding.kind === "unbound") return; if (binding.binding.kind === "macro") { this.#dispatchMacro(binding.binding.expansion); return; } if (binding.binding.command === "previous-history") { const entry = this.#history.previous(this.getText()); if (entry !== undefined) this.#selectHistory(entry); return; } if (binding.binding.command === "next-history") { const entry = this.#history.next(); if (entry !== undefined) this.#selectHistory(entry); return; } const translated: Readonly> = { abort: "\x07", "backward-char": "\x1b[D", "backward-delete-char": "\x7f", "beginning-of-line": "\x1b[H", complete: "\t", "delete-char": "\x1b[3~", "end-of-line": "\x1b[F", "forward-char": "\x1b[C", "kill-line": "\x0b", "menu-complete": "\x1b[B", "menu-complete-backward": "\x1b[A", "transpose-chars": "\x14", "unix-line-discard": "\x15", }; if ( binding.binding.command === "self-insert" && data.charCodeAt(0) < 32 ) { this.#activeInsertionInputs?.push(data); this.insertTextAtCursor(data); return; } if (binding.binding.command !== "self-insert") { const input = translated[binding.binding.command]; if (input !== undefined) super.handleInput(input); else if ( binding.binding.command === "accept-line" || binding.binding.command === "bracketed-paste-begin" || binding.binding.command === "vi-eof-maybe" ) super.handleInput(data); return; } } this.#activeInsertionInputs?.push(data); super.handleInput(data); return; } if (this.#pendingHistorySearch !== undefined) { if (matchesKey(data, "enter")) { const search = this.#pendingHistorySearch; this.#pendingHistorySearch = undefined; const match = this.#history.search(search.query, search.direction); if (match !== undefined) this.#selectHistory(match); return; } if (matchesKey(data, "backspace")) { this.#pendingHistorySearch.query = this.#pendingHistorySearch.query.slice(0, -1); } else if (data.length === 1 && data.charCodeAt(0) >= 32) { this.#pendingHistorySearch.query += data; } this.tui.requestRender(); return; } if (this.#mode === "vi-replace") { if (data.length === 1 && data.charCodeAt(0) >= 32) { this.#activeInsertionInputs?.push(data); this.#adapter.apply(replaceAtCursor(this.#adapter.read(), data)); return; } super.handleInput(data); return; } if (this.#pendingMarkAction !== undefined) { const action = this.#pendingMarkAction; this.#pendingMarkAction = undefined; if (action === "set") { this.#marks.set(data, this.#adapter.read().cursor); } else { const position = this.#marks.get(data); if (position !== undefined) { if (action === "jump-line") { const atLine = { ...this.#adapter.read(), cursor: { line: position.line, column: 0 }, }; this.#adapter.setCursor(moveToCommandFirstPrintable(atLine).cursor); } else { this.#adapter.setCursor(position); } } } return; } if (this.#pendingOperatorCharacterSearch !== undefined) { const pending = this.#pendingOperatorCharacterSearch; this.#pendingOperatorCharacterSearch = undefined; this.#executeCharacterSearchOperator(pending, data); return; } if (this.#pendingReplacementCount !== undefined) { const count = this.#pendingReplacementCount; this.#pendingReplacementCount = undefined; this.#applyEdit( replaceCommandCharacters(this.#adapter.read(), data, count), ); if (!this.#replaying) this.#lastChangeInputs = [...this.#countedInputs("r", count), data]; return; } if (this.#pendingCharacterSearch !== undefined) { const search = this.#pendingCharacterSearch; this.#pendingCharacterSearch = undefined; this.#lastCharacterSearch = { ...search, target: data }; this.#adapter.setCursor( findCommandCharacter( this.#adapter.read(), data, search.direction, search.till, ).cursor, ); return; } const resolution = this.resolveInputrcBinding(inputrcData); const exactBinding = resolution?.kind === "exact" || resolution?.kind === "exact-and-prefix" ? resolution.binding : undefined; if (exactBinding?.kind === "macro") { this.#dispatchMacro(exactBinding.expansion); return; } if (exactBinding?.kind === "unbound") return; const resolvedCommand = exactBinding?.kind === "command" ? commandInputForReadlineName(exactBinding.command) : undefined; if (exactBinding?.kind === "command" && resolvedCommand === undefined) return; const keySensitiveCommands = new Set([ "bash-vi-complete", "vi-change-to", "vi-char-search", "vi-delete-to", "vi-end-word", "vi-goto-mark", "vi-next-word", "vi-prev-word", "vi-put", "vi-search", "vi-search-again", "vi-subst", "vi-yank-to", ]); const preserveKeyIdentity = exactBinding?.kind === "command" && keySensitiveCommands.has(exactBinding.command); const fallbackCommand = inputrcData === "\x10" || inputrcData === "\x1b[A" ? "k" : inputrcData === "\x0e" || inputrcData === "\x1b[B" ? "j" : data; const commandInput = preserveKeyIdentity ? data : (resolvedCommand ?? fallbackCommand); const parsed = this.#commandParser.push(commandInput); if (parsed.kind === "pending") { this.tui.requestRender(); return; } const { command, count } = parsed; if (this.#pendingOperator !== undefined) { const pending = this.#pendingOperator; this.#pendingOperator = undefined; if ( command === "f" || command === "t" || command === "F" || command === "T" ) { this.#pendingOperatorCharacterSearch = { operator: pending.command, count: pending.count * count, direction: command === "f" || command === "t" ? "forward" : "backward", till: command === "t" || command === "T", }; return; } this.#executeOperator(pending.command, command, pending.count * count); return; } if (command === "m" || command === "`" || command === "'") { this.#pendingMarkAction = command === "m" ? "set" : command === "`" ? "jump" : "jump-line"; return; } if (command === "d" || command === "c" || command === "y") { this.#pendingOperator = { command, count }; return; } if ((command === "p" || command === "P") && this.#register !== undefined) { let after = this.#adapter.read(); for (let index = 0; index < count; index += 1) { after = putRegister( after, this.#register, command === "p" ? "after" : "before", ); } this.#applyEdit(after); if (!this.#replaying) this.#lastChangeInputs = this.#countedInputs(command, count); return; } if (command === "r") { this.#pendingReplacementCount = count; return; } if (command === "D" || command === "C") { this.#executeOperator(command === "D" ? "d" : "c", "$", count); return; } if (command === "Y") { this.#executeOperator("y", "y", count); return; } if (command === "s") { const before = this.#adapter.read(); const after = deleteCommandCharacters(before, count); if (after !== before) { this.#recordDirectDeletion(before, after, "forward"); this.#adapter.apply(after); this.#beginInsertion("vi-insert", before); } return; } if (command === "S") { this.#executeOperator("c", "c", count); return; } if (command === "." && this.#lastChangeInputs !== undefined) { this.#replaying = true; for (const input of this.#lastChangeInputs) this.handleInput(input); this.#replaying = false; return; } if (command === "*" || command === "=" || command === "\\") { super.handleInput("\t"); return; } if (command === "#") { const text = this.getText(); if (!text.startsWith(this.#commentBegin)) { const before = this.#adapter.read(); this.setText(`${this.#commentBegin}${text}`); this.#undo.record(before, this.#adapter.read()); } return; } if (command === "&" || command === "_") { // Shell expansion and shell-word history extraction are intentionally // classified as inapplicable in a non-shell Pi prompt. return; } if (command === "v") { this.actionHandlers.get("app.editor.external")?.(); return; } if (command === "U") { this.#applyEdit(this.#lineBaseline); return; } if (command === "u" || data === "\x1f") { const snapshot = this.#undo.undo(); if (snapshot !== undefined) this.#adapter.apply(snapshot); return; } if (command === "i") { this.#beginInsertion("vi-insert", this.#adapter.read(), ["i"]); return; } if (command === "R") { this.#beginInsertion("vi-replace", this.#adapter.read(), ["R"]); return; } if (command === "I") { this.#adapter.setCursor( moveToCommandLineStart(this.#adapter.read()).cursor, ); this.#beginInsertion("vi-insert", this.#adapter.read(), ["I"]); return; } if (command === "A") { const buffer = this.#adapter.read(); const line = buffer.lines[buffer.cursor.line] ?? ""; this.#adapter.setCursor({ line: buffer.cursor.line, column: line.length, }); this.#beginInsertion("vi-insert", this.#adapter.read(), ["A"]); return; } if (command === "a") { const next = enterViAppendMode({ ...initialReadlineState(this.#adapter.read()), mode: "vi-command", }); this.#adapter.setCursor(next.buffer.cursor); this.#beginInsertion("vi-insert", this.#adapter.read(), ["a"]); return; } if (command === "o" || command === "O") { const before = this.#adapter.read(); this.#adapter.apply( openCommandLine(before, command === "o" ? "below" : "above"), ); this.#beginInsertion("vi-insert", before, [command]); return; } if ( command === "/" || command === "?" || data === "\x12" || data === "\x13" ) { this.#pendingHistorySearch = { direction: command === "?" || data === "\x13" ? "forward" : "backward", query: "", }; this.tui.requestRender(); return; } if (command === "n" || command === "N") { const match = this.#history.repeatSearch( command === "n" ? "backward" : "forward", ); if (match !== undefined) this.#selectHistory(match); return; } if (data === "j" || data === "k") { let moved = this.#adapter.read(); for (let index = 0; index < count; index += 1) moved = moveCommandCursorVertical(moved, data === "j" ? "down" : "up"); this.#adapter.setCursor(moved.cursor); return; } if (command === "G" || command === "j" || command === "k") { if (inputrcData === "\x1b[A") this.#selectPreviousHistory(); else if (inputrcData === "\x1b[B") this.#selectNextHistory(); return; } if (command === "0") { this.#adapter.setCursor( moveToCommandLineStart(this.#adapter.read()).cursor, ); return; } if (command === "$") { this.#adapter.setCursor( moveToCommandLineEnd(this.#adapter.read()).cursor, ); return; } if (command === "^") { this.#adapter.setCursor( moveToCommandFirstPrintable(this.#adapter.read()).cursor, ); return; } if ( (command === ";" || command === ",") && this.#lastCharacterSearch !== undefined ) { const search = this.#lastCharacterSearch; const direction = command === ";" ? search.direction : search.direction === "forward" ? "backward" : "forward"; this.#adapter.setCursor( findCommandCharacter( this.#adapter.read(), search.target, direction, search.till, ).cursor, ); return; } if ( command === "f" || command === "t" || command === "F" || command === "T" ) { this.#pendingCharacterSearch = { direction: command === "f" || command === "t" ? "forward" : "backward", till: command === "t" || command === "T", }; return; } if (command === "%") { this.#adapter.setCursor( moveToMatchingDelimiter(this.#adapter.read()).cursor, ); return; } if (command === "E") { for (let index = 0; index < count; index += 1) { this.#adapter.setCursor( moveToEndOfBigWord(this.#adapter.read()).cursor, ); } return; } if (command === "e") { for (let index = 0; index < count; index += 1) { this.#adapter.setCursor(moveToEndOfWord(this.#adapter.read()).cursor); } return; } if (command === "B") { for (let index = 0; index < count; index += 1) { this.#adapter.setCursor( moveToPreviousBigWord(this.#adapter.read()).cursor, ); } return; } if (command === "W") { for (let index = 0; index < count; index += 1) { this.#adapter.setCursor(moveToNextBigWord(this.#adapter.read()).cursor); } return; } if (command === "w") { for (let index = 0; index < count; index += 1) { this.#adapter.setCursor(moveToNextWord(this.#adapter.read()).cursor); } return; } if (command === "b") { for (let index = 0; index < count; index += 1) { this.#adapter.setCursor( moveToPreviousWord(this.#adapter.read()).cursor, ); } return; } if (command === "h") { for (let index = 0; index < count; index += 1) { this.#adapter.setCursor( moveCommandCursorLeft(this.#adapter.read()).cursor, ); } return; } if (command === "l") { for (let index = 0; index < count; index += 1) { this.#adapter.setCursor( moveCommandCursorRight(this.#adapter.read()).cursor, ); } return; } if (command === "x" || command === "X") { const before = this.#adapter.read(); const after = command === "x" ? deleteCommandCharacters(before, count) : deleteCommandCharactersBackward(before, count); if (after !== before) { this.#recordDirectDeletion( before, after, command === "x" ? "forward" : "backward", ); this.#applyEdit(after); if (!this.#replaying) this.#lastChangeInputs = this.#countedInputs(command, count); } return; } if (command === "~") { this.#applyEdit(toggleCommandCharacterCase(this.#adapter.read(), count)); if (!this.#replaying) this.#lastChangeInputs = this.#countedInputs(command, count); return; } // Command-mode printable keys are commands, never literal prompt text. if (data.length === 1 && data.charCodeAt(0) >= 32) return; super.handleInput(data); } #executeCharacterSearchOperator( pending: { operator: "d" | "c" | "y"; count: number; direction: "forward" | "backward"; till: boolean; }, target: string, ): void { const before = this.#adapter.read(); let moved = before; for (let index = 0; index < pending.count; index += 1) { const next = findCommandCharacter( moved, target, pending.direction, pending.till, ); if (next.cursor.column === moved.cursor.column) return; moved = next; } this.#lastCharacterSearch = { target, direction: pending.direction, till: pending.till, }; let start = before.cursor; let end = moved.cursor; if (pending.direction === "backward") { start = moved.cursor; end = before.cursor; } else { const line = moved.lines[moved.cursor.line] ?? ""; const next = moveCommandCursorRight(moved).cursor; end = next.column === moved.cursor.column ? { line: moved.cursor.line, column: line.length } : next; } const range = { start, end }; const result = pending.operator === "y" ? yankOperatorRange(before, range) : deleteOperatorRange(before, range, this.#register); if (result.register !== undefined) this.#register = result.register; if (result.changed) { const searchCommand = pending.direction === "forward" ? pending.till ? "t" : "f" : pending.till ? "T" : "F"; const replayPrefix = [ ...this.#countedInputs(pending.operator, pending.count), searchCommand, target, ]; if (pending.operator === "c") { this.#adapter.apply(result.buffer); this.#beginInsertion("vi-insert", before, replayPrefix); } else { this.#applyEdit(result.buffer); if (!this.#replaying) this.#lastChangeInputs = replayPrefix; } } } #recordDirectDeletion( before: ReturnType, after: ReturnType, direction: "forward" | "backward", ): void { const beforeLine = before.lines[before.cursor.line] ?? ""; const afterLine = after.lines[after.cursor.line] ?? ""; const length = beforeLine.length - afterLine.length; if (length <= 0) return; const start = direction === "forward" ? before.cursor.column : before.cursor.column - length; this.#register = { text: beforeLine.slice(start, start + length), linewise: false, }; } #executeOperator( operator: "d" | "c" | "y", motion: string, count: number, ): void { const before = this.#adapter.read(); const line = before.lines[before.cursor.line] ?? ""; let destination = before.cursor; let rangeStart = before.cursor; const linewise = motion === operator; if (linewise) { rangeStart = { line: before.cursor.line, column: 0 }; destination = { line: before.cursor.line, column: line.length }; } else if (motion === "w") { let moved = before; for (let index = 0; index < count; index += 1) moved = moveToNextWord(moved); destination = moved.cursor; } else if (motion === "W") { let moved = before; for (let index = 0; index < count; index += 1) moved = moveToNextBigWord(moved); destination = moved.cursor; } else if (motion === "h" || motion === "l") { let moved = before; for (let index = 0; index < count; index += 1) moved = motion === "h" ? moveCommandCursorLeft(moved) : moveCommandCursorRight(moved); if (motion === "h") { rangeStart = moved.cursor; destination = before.cursor; } else { destination = moved.cursor; } } else if (motion === "b" || motion === "B") { let moved = before; for (let index = 0; index < count; index += 1) moved = motion === "b" ? moveToPreviousWord(moved) : moveToPreviousBigWord(moved); rangeStart = moved.cursor; destination = before.cursor; } else if (motion === "e" || motion === "E") { let moved = before; for (let index = 0; index < count; index += 1) moved = motion === "e" ? moveToEndOfWord(moved) : moveToEndOfBigWord(moved); const destinationLine = moved.lines[moved.cursor.line] ?? ""; const next = moveCommandCursorRight(moved).cursor; destination = next.column === moved.cursor.column ? { line: moved.cursor.line, column: destinationLine.length } : next; } else if (motion === "0" || motion === "^") { const moved = motion === "0" ? moveToCommandLineStart(before) : moveToCommandFirstPrintable(before); rangeStart = moved.cursor; destination = before.cursor; } else if (motion === "%") { const moved = moveToMatchingDelimiter(before); if (moved.cursor.column === before.cursor.column) return; if (moved.cursor.column < before.cursor.column) { rangeStart = moved.cursor; destination = moveCommandCursorRight(before).cursor; if (destination.column === before.cursor.column) destination = { line: before.cursor.line, column: line.length }; } else { const next = moveCommandCursorRight(moved).cursor; destination = next.column === moved.cursor.column ? { line: moved.cursor.line, column: line.length } : next; } } else if (motion === "$") { destination = { line: before.cursor.line, column: line.length }; } else { return; } const range = { start: rangeStart, end: destination }; const result = operator === "y" ? yankOperatorRange(before, range, { linewise }) : deleteOperatorRange(before, range, this.#register, { linewise }); if (result.register !== undefined) this.#register = result.register; if (result.changed) { const replayPrefix = [...this.#countedInputs(operator, count), motion]; if (operator === "c") { this.#adapter.apply(result.buffer); this.#beginInsertion("vi-insert", before, replayPrefix); } else { this.#applyEdit(result.buffer); if (!this.#replaying) this.#lastChangeInputs = replayPrefix; } } } #dispatchMacro(expansion: string): void { if (this.#macroDepth >= 16) return; this.#macroDepth += 1; try { for (const input of expandInputrcMacro(expansion)) this.handleInput(input); } finally { this.#macroDepth -= 1; } } #selectPreviousHistory(): void { const entry = this.#history.previous(this.getText()); if (entry !== undefined) this.#selectHistory(entry); } #selectNextHistory(): void { const entry = this.#history.next(); if (entry !== undefined) this.#selectHistory(entry); } #selectHistory(text: string): void { this.setText(text); const buffer = this.#adapter.read(); this.#adapter.setCursor(moveToCommandLineStart(buffer).cursor); } #countedInputs(command: string, count: number): string[] { return [...(count === 1 ? "" : String(count)), command]; } #beginInsertion( mode: "vi-insert" | "vi-replace", snapshot = this.#adapter.read(), replayPrefix: readonly string[] = [], ): void { this.#insertionSnapshot = snapshot; this.#activeInsertionInputs = [...replayPrefix]; this.#setMode(mode); } #applyEdit(after: PromptBuffer): void { const before = this.#adapter.read(); this.#undo.record(before, after); this.#transformMarks(before, after); this.#adapter.apply(after); } #transformMarks(before: PromptBuffer, after: PromptBuffer): void { if (before.lines.length !== 1 || after.lines.length !== 1) return; const oldLine = before.lines[0] ?? ""; const newLine = after.lines[0] ?? ""; let start = 0; while (start < oldLine.length && oldLine[start] === newLine[start]) start += 1; let oldEnd = oldLine.length; let newEnd = newLine.length; while ( oldEnd > start && newEnd > start && oldLine[oldEnd - 1] === newLine[newEnd - 1] ) { oldEnd -= 1; newEnd -= 1; } this.#marks.transform({ start: { line: 0, column: start }, end: { line: 0, column: oldEnd }, insertedText: newLine.slice(start, newEnd), }); } #setMode(mode: ReadlineViMode): void { this.#mode = mode; this.tui.requestRender(); } }