@linxiraos/pi-tui 1.1.13 → 1.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,6 +26,15 @@ import {
26
26
  truncateToWidth,
27
27
  visibleWidth,
28
28
  } from "../utils";
29
+ import {
30
+ lastGraphemeStart,
31
+ nextGraphemeStart,
32
+ type VimCommand,
33
+ type VimMode,
34
+ type VimPosition,
35
+ VimState,
36
+ visualRange,
37
+ } from "../vim";
29
38
  import {
30
39
  borderlessComposerStyle,
31
40
  type ComposerChromeContext,
@@ -370,6 +379,19 @@ function isPlainTextRun(data: string): boolean {
370
379
  return true;
371
380
  }
372
381
 
382
+ /** Named keys Vim's Normal/Visual modes reinterpret as motions instead of letting them edit. */
383
+ const VIM_NAV_KEYS: Record<string, string | undefined> = {
384
+ up: "k",
385
+ down: "j",
386
+ left: "h",
387
+ right: "l",
388
+ home: "0",
389
+ end: "$",
390
+ backspace: "h",
391
+ delete: "x",
392
+ space: "l",
393
+ };
394
+
373
395
  const DEFAULT_PAGE_SCROLL_LINES = 10;
374
396
 
375
397
  const MAX_UNDO_STACK = 100;
@@ -388,6 +410,10 @@ interface LayoutLine {
388
410
  sourceStartCol: number;
389
411
  hasCursor: boolean;
390
412
  cursorPos?: number;
413
+ /** Logical buffer line this row came from, and the offset of `text[0]` within it. Populated
414
+ * only while a Vim visual selection is active, to map the selection span onto wrapped rows. */
415
+ logicalLine?: number;
416
+ startIndex?: number;
391
417
  }
392
418
 
393
419
  /** Per-line measurement carried across renders: exact visible width plus
@@ -421,6 +447,16 @@ interface HistoryStorage {
421
447
  getRecent(limit: number): HistoryEntry[];
422
448
  }
423
449
 
450
+ interface LocalHistoryEntry {
451
+ text: string;
452
+ draft?: {
453
+ pastes: Map<number, string>;
454
+ atoms: Map<string, string>;
455
+ pasteCounter: number;
456
+ restore?: () => void;
457
+ };
458
+ }
459
+
424
460
  /** A synchronous replacement immediately before the editor cursor. */
425
461
  export interface EditorInlineReplacement {
426
462
  /** UTF-16 code units to remove immediately before the cursor. */
@@ -522,6 +558,15 @@ export class Editor implements Component, Focusable {
522
558
  // Character jump mode
523
559
  #jumpMode: "forward" | "backward" | null = null;
524
560
 
561
+ /** Vim-style modal editing (opt-in, see the `tui.vimMode` setting). `null` when disabled, in
562
+ * which case every code path below behaves exactly as it did before the mode existed. */
563
+ #vim: VimState | null = null;
564
+ /** Called with the selected text when Visual mode yanks, so hosts can reach the system
565
+ * clipboard — `packages/tui` deliberately has no clipboard dependency of its own. */
566
+ onYank?: (text: string) => void;
567
+ /** Fired when the modal state changes, so hosts can restyle their chrome (border, status). */
568
+ onVimModeChange?: (mode: VimMode) => void;
569
+
525
570
  // Preferred visual column for vertical cursor movement (sticky column)
526
571
  #preferredVisualCol: number | null = null;
527
572
 
@@ -569,9 +614,11 @@ export class Editor implements Component, Focusable {
569
614
  #pasteHandler = new BracketedPasteHandler();
570
615
 
571
616
  // Prompt history for up/down navigation
572
- #history: string[] = [];
617
+ #history: LocalHistoryEntry[] = [];
573
618
  #historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
574
619
  #historyStorage?: HistoryStorage;
620
+ // Recalled payloads outlive browsing when an edit resets #historyIndex.
621
+ #historyDraftActive = false;
575
622
 
576
623
  // Undo stack for editor state changes
577
624
  #undoStack: EditorState[] = [];
@@ -727,6 +774,48 @@ export class Editor implements Component, Focusable {
727
774
  this.#imeSafeCursorLayout = enabled;
728
775
  }
729
776
 
777
+ /** Enable Vim-style modal editing. Toggling always drops back to Insert mode so the editor is
778
+ * never left in a state where ordinary typing does nothing. */
779
+ setVimMode(enabled: boolean): void {
780
+ if (enabled === (this.#vim !== null)) return;
781
+ this.#vim = enabled ? new VimState() : null;
782
+ if (this.#vim) this.#vim.mode = "insert";
783
+ this.invalidate();
784
+ }
785
+
786
+ /** Current modal state; always `"insert"` when Vim mode is off. */
787
+ get vimMode(): VimMode {
788
+ return this.#vim?.mode ?? "insert";
789
+ }
790
+
791
+ /** True when modal editing is active, regardless of which mode is current. */
792
+ get vimEnabled(): boolean {
793
+ return this.#vim !== null;
794
+ }
795
+
796
+ /** Half-typed Vim command (`"2d"`, `"g"`), or `""` when nothing is pending. */
797
+ get vimPending(): string {
798
+ return this.#vim?.pendingText ?? "";
799
+ }
800
+
801
+ /** Lines spanned by the active Visual selection; 0 outside Visual modes. */
802
+ get vimSelectedLines(): number {
803
+ const selection = this.#vimSelection();
804
+ return selection === null ? 0 : selection.to.line - selection.from.line + 1;
805
+ }
806
+
807
+ /**
808
+ * Whether Escape belongs to the editor right now rather than to the app.
809
+ *
810
+ * Hosts bind Escape to interrupt/clear; in Vim mode it first has to mean "leave Insert mode" and
811
+ * "cancel a half-typed operator". Only a quiet Normal mode gives Escape back to the app.
812
+ */
813
+ vimConsumesEscape(): boolean {
814
+ const vim = this.#vim;
815
+ if (vim === null) return false;
816
+ return vim.mode !== "normal" || vim.pending;
817
+ }
818
+
730
819
  getUseTerminalCursor(): boolean {
731
820
  return this.#useTerminalCursor;
732
821
  }
@@ -764,7 +853,7 @@ export class Editor implements Component, Focusable {
764
853
  setHistoryStorage(storage: HistoryStorage): void {
765
854
  this.#historyStorage = storage;
766
855
  const recent = storage.getRecent(100);
767
- this.#history = recent.map(entry => entry.prompt);
856
+ this.#history = recent.map(entry => ({ text: entry.prompt }));
768
857
  this.#historyIndex = -1;
769
858
  }
770
859
 
@@ -783,13 +872,49 @@ export class Editor implements Component, Focusable {
783
872
  });
784
873
  }
785
874
 
786
- // Don't add consecutive duplicates
787
- if (this.#history.length > 0 && this.#history[0] === trimmed) return;
788
- this.#history.unshift(trimmed);
789
- // Limit history size
790
- if (this.#history.length > 100) {
791
- this.#history.pop();
792
- }
875
+ // Don't add consecutive submitted duplicates; a draft owns separate state.
876
+ const previous = this.#history[0];
877
+ if (previous?.text === trimmed && !previous.draft) return;
878
+ this.#pushHistory({ text: trimmed });
879
+ }
880
+
881
+ /** Retain the current draft for local recall only, never persistent history. */
882
+ rememberDraft(restore?: () => void): void {
883
+ const text = this.getText();
884
+ if (!text.trim()) return;
885
+ const pastes = new Map<number, string>();
886
+ for (const match of text.matchAll(/\[Paste #(\d+)(?:, (?:\+\d+ lines|\d+ chars))?\]/g)) {
887
+ const id = Number(match[1]);
888
+ const value = this.#pastes.get(id);
889
+ if (value !== undefined) pastes.set(id, value);
890
+ }
891
+ this.#pushHistory({
892
+ text,
893
+ draft: {
894
+ pastes,
895
+ atoms: new Map([...this.#atoms].filter(([label]) => text.includes(label))),
896
+ pasteCounter: this.#pasteCounter,
897
+ restore,
898
+ },
899
+ });
900
+ }
901
+
902
+ /** Release the current draft's expansion payloads without touching history. */
903
+ clearPasteState(): void {
904
+ this.#pastes.clear();
905
+ this.#pasteCounter = 0;
906
+ this.#atoms.clear();
907
+ this.#historyDraftActive = false;
908
+ }
909
+
910
+ /** Restore host-owned draft state before history text triggers onChange. */
911
+ restoreHistoryState(restore?: () => void): void {
912
+ restore?.();
913
+ }
914
+
915
+ #pushHistory(entry: LocalHistoryEntry): void {
916
+ this.#history.unshift(entry);
917
+ if (this.#history.length > 100) this.#history.pop();
793
918
  }
794
919
 
795
920
  #isEditorEmpty(): boolean {
@@ -814,13 +939,16 @@ export class Editor implements Component, Focusable {
814
939
  const newIndex = this.#historyIndex - direction; // Up(-1) increases index, Down(1) decreases
815
940
  if (newIndex < -1 || newIndex >= this.#history.length) return;
816
941
  this.#historyIndex = newIndex;
817
- if (this.#historyIndex === -1) {
818
- // Returned to "current" state - clear editor
819
- this.#setTextInternal("", "end");
820
- } else {
821
- const cursorAnchor: HistoryCursorAnchor = direction === -1 ? "start" : "end";
822
- this.#setTextInternal(this.#history[this.#historyIndex] || "", cursorAnchor);
942
+ const entry = this.#history[this.#historyIndex];
943
+ if (entry?.draft || this.#historyDraftActive) {
944
+ this.#pastes = new Map(entry?.draft?.pastes);
945
+ this.#atoms = new Map(entry?.draft?.atoms);
946
+ this.#pasteCounter = entry?.draft?.pasteCounter ?? 0;
947
+ this.restoreHistoryState(entry?.draft?.restore);
948
+ this.#historyDraftActive = entry?.draft !== undefined;
823
949
  }
950
+ const cursorAnchor: HistoryCursorAnchor = direction === -1 ? "start" : "end";
951
+ this.#setTextInternal(entry?.text ?? "", cursorAnchor);
824
952
  }
825
953
  /** Internal setText that doesn't reset history state - used by navigateHistory */
826
954
  #setTextInternal(text: string, cursorAnchor: HistoryCursorAnchor = "end"): void {
@@ -949,7 +1077,25 @@ export class Editor implements Component, Focusable {
949
1077
  );
950
1078
  }
951
1079
 
1080
+ /**
1081
+ * SGR wrapper for the cursor cell drawn *over* a grapheme.
1082
+ *
1083
+ * Vim's block-vs-bar distinction has to survive in a cell grid, so Insert mode underlines the
1084
+ * cell instead of reversing it: both occupy exactly one column, which keeps every width
1085
+ * calculation below untouched, and terminals render SGR 4 far more consistently than a
1086
+ * half-cell bar glyph. Non-Vim editors keep the reverse-video block they always had.
1087
+ */
1088
+ #cursorCell(text: string): string {
1089
+ const insertShape = this.#vim !== null && this.#vim.mode === "insert";
1090
+ return insertShape ? `\x1b[4m${text}\x1b[0m` : `\x1b[7m${text}\x1b[0m`;
1091
+ }
1092
+
952
1093
  #getStyledInputCursor(): { text: string; width: number } {
1094
+ // Normal/Visual rest *on* a grapheme, so past end-of-line they need a full block to look
1095
+ // like Vim; the thin bar glyph stays the Insert/non-Vim caret.
1096
+ if (this.#vim !== null && this.#vim.mode !== "insert") {
1097
+ return { text: "\x1b[7m \x1b[0m", width: 1 };
1098
+ }
953
1099
  const cursorChar = this.#theme.symbols.inputCursor;
954
1100
  // Keep the software cursor steady. Ghostty/cmux can leave visual
955
1101
  // afterimages for SGR blink cells during rapid input-row repaints.
@@ -967,7 +1113,7 @@ export class Editor implements Component, Focusable {
967
1113
  const lastGraphemeWidth = lastGrapheme ? visibleWidth(lastGrapheme) : 0;
968
1114
  const builtInCursor = this.#getStyledInputCursor();
969
1115
  const fallbackReplacement = lastGrapheme
970
- ? { text: `\x1b[7m${lastGrapheme}\x1b[0m`, width: lastGraphemeWidth }
1116
+ ? { text: this.#cursorCell(lastGrapheme), width: lastGraphemeWidth }
971
1117
  : builtInCursor;
972
1118
  const clampReplacement = (candidate: { text: string; width: number }): { text: string; width: number } => {
973
1119
  let text = sliceByColumn(candidate.text, 0, maxWidth, true);
@@ -1113,6 +1259,10 @@ export class Editor implements Component, Focusable {
1113
1259
  const inlineHint = this.#getInlineHint();
1114
1260
  const hintStyle = this.#theme.hintStyle ?? ((t: string) => `\x1b[2m${t}\x1b[0m`);
1115
1261
 
1262
+ // Active Vim visual selection, if any. The cursor always sits inside it, so selected rows
1263
+ // skip the normal cursor-glyph branches: the reverse-video span already marks the spot.
1264
+ const vimSelection = this.#vimSelection();
1265
+
1116
1266
  for (let visibleIndex = 0; visibleIndex < visibleLayoutLines.length; visibleIndex++) {
1117
1267
  const layoutLine = visibleLayoutLines[visibleIndex]!;
1118
1268
  let displayText = layoutLine.text;
@@ -1150,7 +1300,7 @@ export class Editor implements Component, Focusable {
1150
1300
  const promptGlyphWidth = visibleWidth(promptGlyph);
1151
1301
  const remainingCursorWidth = Math.max(0, zeroWidthCursorBudget - promptGlyphWidth);
1152
1302
  if (remainingCursorWidth === 0) {
1153
- result.push(`\x1b[7m${promptGlyph}\x1b[0m${marker}`);
1303
+ result.push(`${this.#cursorCell(promptGlyph)}${marker}`);
1154
1304
  } else {
1155
1305
  const widthLimitedCursor = this.#renderEndOfLineCursorAtWidthLimit(
1156
1306
  "",
@@ -1177,7 +1327,26 @@ export class Editor implements Component, Focusable {
1177
1327
  continue;
1178
1328
  }
1179
1329
 
1180
- if (hasCursor && this.#useTerminalCursor) {
1330
+ const selectionSpan =
1331
+ vimSelection === null
1332
+ ? null
1333
+ : this.#selectionSpanFor(
1334
+ layoutLine,
1335
+ vimSelection,
1336
+ layoutLines[this.#scrollOffset + visibleIndex + 1]?.logicalLine !== layoutLine.logicalLine,
1337
+ );
1338
+
1339
+ if (selectionSpan !== null) {
1340
+ displayText = this.#renderSelectedLine(
1341
+ displayText,
1342
+ selectionSpan,
1343
+ hasCursor ? layoutLine.cursorPos : undefined,
1344
+ marker,
1345
+ decorationContext,
1346
+ );
1347
+ decorated = true;
1348
+ if (selectionSpan.trailingNewline) displayWidth += 1;
1349
+ } else if (hasCursor && this.#useTerminalCursor) {
1181
1350
  if (marker) {
1182
1351
  const before = displayText.slice(0, layoutLine.cursorPos);
1183
1352
  const after = displayText.slice(layoutLine.cursorPos);
@@ -1208,7 +1377,7 @@ export class Editor implements Component, Focusable {
1208
1377
  const afterGraphemes = [...segmenter.segment(after)];
1209
1378
  const firstGrapheme = afterGraphemes[0]?.segment || "";
1210
1379
  const restAfter = after.slice(firstGrapheme.length);
1211
- const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
1380
+ const cursor = this.#cursorCell(firstGrapheme);
1212
1381
  // Decorate the plain text on each side of the cursor glyph. The reverse-video
1213
1382
  // reset (\x1b[0m) ends in "m" (a word char), so a boundary match on restAfter
1214
1383
  // would fail in the whole-line fallback below — decorate the segments here.
@@ -1379,6 +1548,13 @@ export class Editor implements Component, Focusable {
1379
1548
  return;
1380
1549
  }
1381
1550
 
1551
+ // Vim modal editing. Placed after paste handling so bracketed pastes still land as text, and
1552
+ // before the bulk fast path below because in Normal mode a multi-grapheme run is a sequence
1553
+ // of commands, not something to insert.
1554
+ if (this.#vim !== null && this.#handleVimInput(data, canonical)) {
1555
+ return;
1556
+ }
1557
+
1382
1558
  // Bulk printable fast path: a multi-scalar run of plain text (paste
1383
1559
  // remainder, batched stdin) parses to no key, so no binding probe or
1384
1560
  // special-key branch below can consume it — it always falls through to
@@ -1787,6 +1963,292 @@ export class Editor implements Component, Focusable {
1787
1963
  }
1788
1964
  }
1789
1965
 
1966
+ /**
1967
+ * Route one input chunk through the Vim state machine. Returns true when it was consumed.
1968
+ *
1969
+ * Anything the state machine declines — control chords, Enter, Tab — falls through to the
1970
+ * regular dispatch below, so app-level bindings keep working in Normal mode.
1971
+ */
1972
+ #handleVimInput(data: string, canonical: string | undefined): boolean {
1973
+ const vim = this.#vim;
1974
+ if (vim === null) return false;
1975
+
1976
+ // Escape is the one key Vim owns in every mode: Insert → Normal, Visual → Normal, and
1977
+ // cancelling a half-typed operator. A quiet Normal mode hands it back to the host.
1978
+ // An open autocomplete popup still gets the first Escape though — dismissing it is what
1979
+ // the user means, and a second Escape then switches modes.
1980
+ if (canonical === "escape") {
1981
+ return this.isShowingAutocomplete() ? false : this.#runVimKey("escape", vim);
1982
+ }
1983
+ if (vim.mode === "insert") return false;
1984
+
1985
+ const mapped = canonical === undefined ? undefined : VIM_NAV_KEYS[canonical];
1986
+ if (mapped !== undefined) return this.#runVimKey(mapped, vim);
1987
+
1988
+ // Control chords carry no printable text and stay with the host.
1989
+ const printable = extractPrintableText(data);
1990
+ if (!printable) return false;
1991
+
1992
+ // Batched stdin can deliver several keystrokes at once, so replay the run one grapheme at a
1993
+ // time. A command that drops out of Normal mode part-way (`iabc`) turns the rest of the run
1994
+ // back into literal text rather than swallowing it.
1995
+ for (const seg of segmenter.segment(printable)) {
1996
+ if (this.#runVimKey(seg.segment, vim)) continue;
1997
+ this.#insertCharacter(printable.slice(seg.index));
1998
+ return true;
1999
+ }
2000
+ return true;
2001
+ }
2002
+
2003
+ #runVimKey(key: string, vim: VimState): boolean {
2004
+ const before = vim.mode;
2005
+ const pendingBefore = vim.pendingText;
2006
+ const selectedLinesBefore = this.vimSelectedLines;
2007
+ const commands = vim.handleKey(key, this.#state);
2008
+ if (commands === null) return false;
2009
+ this.#applyVimCommands(commands);
2010
+ // Pending and selection size are mode chrome too: hosts echo `2d` and the Visual line count
2011
+ // beside the mode name, and extending a selection changes neither the mode nor the pending
2012
+ // command — so all three have to be compared or the indicator goes stale mid-selection.
2013
+ if (vim.mode !== before || vim.pendingText !== pendingBefore || this.vimSelectedLines !== selectedLinesBefore) {
2014
+ this.onVimModeChange?.(vim.mode);
2015
+ }
2016
+ return true;
2017
+ }
2018
+
2019
+ #applyVimCommands(commands: readonly VimCommand[]): void {
2020
+ for (const command of commands) {
2021
+ switch (command.kind) {
2022
+ case "move":
2023
+ this.#moveVimCursor(command.to);
2024
+ break;
2025
+ case "mode":
2026
+ this.#resetKillSequence();
2027
+ this.#preferredVisualCol = null;
2028
+ break;
2029
+ case "yank": {
2030
+ const body = this.#sliceRange(command.from, command.to, command.linewise);
2031
+ // The trailing newline is what marks a register linewise, so `p` puts it back as
2032
+ // whole lines rather than splicing it mid-line.
2033
+ const text = command.linewise ? `${body}\n` : body;
2034
+ if (text) {
2035
+ this.#killRing.push(text, { prepend: false });
2036
+ this.onYank?.(text);
2037
+ }
2038
+ break;
2039
+ }
2040
+ case "delete":
2041
+ this.#deleteVimRange(command.from, command.to, command.linewise);
2042
+ break;
2043
+ case "openLine":
2044
+ this.#openVimLine(command.below);
2045
+ break;
2046
+ case "paste":
2047
+ this.#pasteVimRegister(command.after, command.count);
2048
+ break;
2049
+ case "undo":
2050
+ this.#applyUndo();
2051
+ break;
2052
+ }
2053
+ }
2054
+ this.#clampVimCursor();
2055
+ this.invalidate();
2056
+ }
2057
+
2058
+ #moveVimCursor(to: VimPosition): void {
2059
+ this.#state.cursorLine = Math.max(0, Math.min(to.line, this.#state.lines.length - 1));
2060
+ const line = this.#state.lines[this.#state.cursorLine] ?? "";
2061
+ this.#setCursorCol(Math.max(0, Math.min(to.col, line.length)));
2062
+ }
2063
+
2064
+ /** Normal mode rests the cursor *on* a grapheme; Insert and Visual may sit one past the end. */
2065
+ #clampVimCursor(): void {
2066
+ if (this.#vim?.mode !== "normal") return;
2067
+ const line = this.#state.lines[this.#state.cursorLine] ?? "";
2068
+ if (this.#state.cursorCol > lastGraphemeStart(line)) {
2069
+ this.#state.cursorCol = lastGraphemeStart(line);
2070
+ }
2071
+ }
2072
+
2073
+ /** Text covered by a half-open `[from, to)` buffer range. Linewise ranges take whole lines. */
2074
+ #sliceRange(from: VimPosition, to: VimPosition, linewise: boolean): string {
2075
+ const lines = this.#state.lines;
2076
+ if (linewise) {
2077
+ return lines.slice(from.line, Math.min(to.line, lines.length - 1) + 1).join("\n");
2078
+ }
2079
+ if (from.line === to.line) {
2080
+ return (lines[from.line] ?? "").slice(from.col, to.col);
2081
+ }
2082
+ const parts = [(lines[from.line] ?? "").slice(from.col)];
2083
+ for (let i = from.line + 1; i < to.line; i++) parts.push(lines[i] ?? "");
2084
+ parts.push((lines[to.line] ?? "").slice(0, to.col));
2085
+ return parts.join("\n");
2086
+ }
2087
+
2088
+ #deleteVimRange(from: VimPosition, to: VimPosition, linewise: boolean): void {
2089
+ const lines = this.#state.lines;
2090
+ if (linewise) {
2091
+ const last = Math.min(to.line, lines.length - 1);
2092
+ const removed = this.#sliceRange(from, to, true);
2093
+ if (!removed && from.line === last && lines.length === 1) return;
2094
+ this.#recordUndoState();
2095
+ this.#killRing.push(`${removed}\n`, { prepend: false });
2096
+ lines.splice(from.line, last - from.line + 1);
2097
+ if (lines.length === 0) lines.push("");
2098
+ this.#state.cursorLine = Math.min(from.line, lines.length - 1);
2099
+ this.#setCursorCol(0);
2100
+ this.#afterVimEdit();
2101
+ return;
2102
+ }
2103
+
2104
+ // A range that cuts through an atomic placeholder (`[Image #1, 800x600]`) swallows the whole
2105
+ // token instead of leaving a corrupt fragment — the same rule backspace follows.
2106
+ let start = from;
2107
+ let end = to;
2108
+ if (start.line === end.line) {
2109
+ const line = lines[start.line] ?? "";
2110
+ const expanded = this.#expandRangeOverAtomicTokens(line, start.col, end.col);
2111
+ start = { line: start.line, col: expanded.start };
2112
+ end = { line: end.line, col: expanded.end };
2113
+ } else {
2114
+ const startLine = lines[start.line] ?? "";
2115
+ const startToken = this.#atomicTokenAt(startLine, start.col);
2116
+ if (startToken !== undefined) start = { line: start.line, col: startToken.start };
2117
+ const endLine = lines[end.line] ?? "";
2118
+ if (end.col > 0) {
2119
+ const endToken = this.#atomicTokenAt(endLine, end.col - 1);
2120
+ if (endToken !== undefined && endToken.end > end.col) end = { line: end.line, col: endToken.end };
2121
+ }
2122
+ }
2123
+
2124
+ const removed = this.#sliceRange(start, end, false);
2125
+ if (!removed) return;
2126
+ this.#recordUndoState();
2127
+ this.#killRing.push(removed, { prepend: false });
2128
+ const head = (lines[start.line] ?? "").slice(0, start.col);
2129
+ const tail = (lines[Math.min(end.line, lines.length - 1)] ?? "").slice(end.col);
2130
+ lines.splice(start.line, Math.min(end.line, lines.length - 1) - start.line + 1, head + tail);
2131
+ this.#state.cursorLine = start.line;
2132
+ this.#setCursorCol(start.col);
2133
+ this.#afterVimEdit();
2134
+ }
2135
+
2136
+ #openVimLine(below: boolean): void {
2137
+ this.#recordUndoState();
2138
+ const at = below ? this.#state.cursorLine + 1 : this.#state.cursorLine;
2139
+ this.#state.lines.splice(at, 0, "");
2140
+ this.#state.cursorLine = at;
2141
+ this.#setCursorCol(0);
2142
+ this.#afterVimEdit();
2143
+ }
2144
+
2145
+ #pasteVimRegister(after: boolean, count: number): void {
2146
+ const entry = this.#killRing.peek();
2147
+ if (!entry) return;
2148
+ this.#recordUndoState();
2149
+ const linewise = entry.endsWith("\n");
2150
+ if (linewise) {
2151
+ const body = entry.slice(0, -1).split("\n");
2152
+ const at = after ? this.#state.cursorLine + 1 : this.#state.cursorLine;
2153
+ const payload: string[] = [];
2154
+ for (let i = 0; i < count; i++) payload.push(...body);
2155
+ this.#state.lines.splice(at, 0, ...payload);
2156
+ this.#state.cursorLine = at;
2157
+ this.#setCursorCol(0);
2158
+ } else {
2159
+ const line = this.#state.lines[this.#state.cursorLine] ?? "";
2160
+ const at = after ? nextGraphemeStart(line, this.#state.cursorCol) : this.#state.cursorCol;
2161
+ const payload = entry.repeat(count);
2162
+ this.#state.lines[this.#state.cursorLine] = line.slice(0, at) + payload + line.slice(at);
2163
+ this.#setCursorCol(at + payload.length - 1);
2164
+ }
2165
+ this.#afterVimEdit();
2166
+ }
2167
+
2168
+ #afterVimEdit(): void {
2169
+ this.#historyIndex = -1;
2170
+ this.#resetKillSequence();
2171
+ this.onChange?.(this.getText());
2172
+ }
2173
+
2174
+ /**
2175
+ * Buffer span highlighted by the active Visual selection, or null when there is none.
2176
+ * Exposed to the render path only; `to` is exclusive.
2177
+ */
2178
+ #vimSelection(): { from: VimPosition; to: VimPosition; linewise: boolean } | null {
2179
+ const vim = this.#vim;
2180
+ if (vim === null || !vim.visual || vim.anchor === null) return null;
2181
+ const linewise = vim.mode === "visual-line";
2182
+ const { from, to } = visualRange(this.#state, vim.anchor, linewise);
2183
+ return { from, to, linewise };
2184
+ }
2185
+
2186
+ /**
2187
+ * Map the active selection onto one layout row, as offsets into that row's `text`.
2188
+ * Returns null when the row is outside the selection.
2189
+ */
2190
+ #selectionSpanFor(
2191
+ layoutLine: LayoutLine,
2192
+ selection: { from: VimPosition; to: VimPosition; linewise: boolean },
2193
+ isLastRowOfLine: boolean,
2194
+ ): { start: number; end: number; trailingNewline: boolean } | null {
2195
+ const logical = layoutLine.logicalLine;
2196
+ if (logical === undefined || logical < selection.from.line || logical > selection.to.line) return null;
2197
+
2198
+ const rowStart = layoutLine.startIndex ?? 0;
2199
+ const rowEnd = rowStart + layoutLine.text.length;
2200
+ const lineStart = logical === selection.from.line ? selection.from.col : 0;
2201
+ const lineEnd = logical === selection.to.line ? selection.to.col : (this.#state.lines[logical] ?? "").length;
2202
+ const start = Math.max(lineStart, rowStart);
2203
+ const end = Math.min(lineEnd, rowEnd);
2204
+ // Vim highlights the newline itself when the selection runs on into the next line.
2205
+ const trailingNewline = isLastRowOfLine && logical < selection.to.line;
2206
+ if (end <= start && !trailingNewline) return null;
2207
+ return { start: start - rowStart, end: Math.max(start, end) - rowStart, trailingNewline };
2208
+ }
2209
+
2210
+ /**
2211
+ * Reverse-video the selected span of one row while keeping the cursor marker at its exact
2212
+ * offset. Unselected fragments are decorated individually, the same way the cursor branch
2213
+ * splits `#decorate` around the cursor glyph.
2214
+ */
2215
+ #renderSelectedLine(
2216
+ text: string,
2217
+ span: { start: number; end: number; trailingNewline: boolean },
2218
+ cursorPos: number | undefined,
2219
+ marker: string,
2220
+ context: EditorTextDecorationContext,
2221
+ ): string {
2222
+ const start = Math.max(0, Math.min(span.start, text.length));
2223
+ const end = Math.max(start, Math.min(span.end, text.length));
2224
+ // Only cut for the marker when there is one to emit: an unfocused editor would otherwise
2225
+ // split the highlight into two identical spans for nothing.
2226
+ const markerPos = !marker || cursorPos === undefined ? undefined : Math.max(0, Math.min(cursorPos, text.length));
2227
+
2228
+ const cuts = new Set<number>([0, start, end, text.length]);
2229
+ if (markerPos !== undefined) cuts.add(markerPos);
2230
+ const points = [...cuts].sort((left, right) => left - right);
2231
+
2232
+ let out = "";
2233
+ for (let i = 0; i < points.length - 1; i++) {
2234
+ const from = points[i]!;
2235
+ const to = points[i + 1]!;
2236
+ if (marker && from === markerPos) out += marker;
2237
+ const segment = text.slice(from, to);
2238
+ out +=
2239
+ from >= start && from < end
2240
+ ? `\x1b[7m${segment}\x1b[27m`
2241
+ : this.#decorate(segment, {
2242
+ ...context,
2243
+ startCol: context.startCol + from,
2244
+ endCol: context.startCol + to,
2245
+ });
2246
+ }
2247
+ if (marker && markerPos !== undefined && markerPos >= text.length) out += marker;
2248
+ if (span.trailingNewline) out += "\x1b[7m \x1b[27m";
2249
+ return out;
2250
+ }
2251
+
1790
2252
  /** Cached per-line measurement: exact visible width now, wrap chunks on demand. */
1791
2253
  #lineEntry(line: string, width: number): WrapEntry {
1792
2254
  const epoch = getWidthConfigEpoch();
@@ -1824,6 +2286,8 @@ export class Editor implements Component, Focusable {
1824
2286
  sourceStartCol: 0,
1825
2287
  hasCursor: true,
1826
2288
  cursorPos: 0,
2289
+ logicalLine: 0,
2290
+ startIndex: 0,
1827
2291
  });
1828
2292
  return layoutLines;
1829
2293
  }
@@ -1844,6 +2308,8 @@ export class Editor implements Component, Focusable {
1844
2308
  sourceStartCol: 0,
1845
2309
  hasCursor: true,
1846
2310
  cursorPos: this.#state.cursorCol,
2311
+ logicalLine: i,
2312
+ startIndex: 0,
1847
2313
  });
1848
2314
  } else {
1849
2315
  layoutLines.push({
@@ -1852,6 +2318,8 @@ export class Editor implements Component, Focusable {
1852
2318
  sourceLine: i,
1853
2319
  sourceStartCol: 0,
1854
2320
  hasCursor: false,
2321
+ logicalLine: i,
2322
+ startIndex: 0,
1855
2323
  });
1856
2324
  }
1857
2325
  } else {
@@ -1896,6 +2364,8 @@ export class Editor implements Component, Focusable {
1896
2364
  sourceStartCol: chunk.startIndex,
1897
2365
  hasCursor: true,
1898
2366
  cursorPos: adjustedCursorPos,
2367
+ logicalLine: i,
2368
+ startIndex: chunk.startIndex,
1899
2369
  });
1900
2370
  } else {
1901
2371
  layoutLines.push({
@@ -1904,6 +2374,8 @@ export class Editor implements Component, Focusable {
1904
2374
  sourceLine: i,
1905
2375
  sourceStartCol: chunk.startIndex,
1906
2376
  hasCursor: false,
2377
+ logicalLine: i,
2378
+ startIndex: chunk.startIndex,
1907
2379
  });
1908
2380
  }
1909
2381
  }
@@ -2449,9 +2921,7 @@ export class Editor implements Component, Focusable {
2449
2921
  const result = this.#expandPasteMarkers(this.#state.lines.join("\n")).trim();
2450
2922
 
2451
2923
  this.#state = { lines: [""], cursorLine: 0, cursorCol: 0 };
2452
- this.#pastes.clear();
2453
- this.#pasteCounter = 0;
2454
- this.#atoms.clear();
2924
+ this.clearPasteState();
2455
2925
  this.#historyIndex = -1;
2456
2926
  this.#scrollOffset = 0;
2457
2927
  this.#undoStack.length = 0;
package/src/mouse.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * SGR mouse report parsing (`\x1b[<button;col;rowM` / `…m`).
3
3
  *
4
- * Mouse tracking is enabled only while a fullscreen overlay holds the
5
- * alternate screen (see tui.ts MOUSE_TRACKING_ON), so consumers are
6
- * fullscreen components hit-testing against their own rendered frame:
7
- * the frame paints from screen row 0, hence `row`/`col` are exposed
8
- * 0-based for direct indexing into rendered lines.
4
+ * Mouse tracking is enabled while a fullscreen overlay holds the alternate
5
+ * screen (see tui.ts MOUSE_TRACKING_ON), or — opt-in via `tui.mouse` — on the
6
+ * normal buffer whenever no overlay is visible. Consumers hit-test
7
+ * against their own rendered frame: the frame paints from screen row 0, hence
8
+ * `row`/`col` are exposed 0-based for direct indexing into rendered lines.
9
9
  */
10
10
 
11
11
  /** A decoded SGR mouse report. */