ink-prompt 0.1.8 → 0.2.0

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.
Files changed (87) hide show
  1. package/README.md +118 -8
  2. package/dist/components/MultilineInput/ImageSentinel.d.ts +15 -0
  3. package/dist/components/MultilineInput/ImageSentinel.js +62 -0
  4. package/dist/components/MultilineInput/ImageTypes.d.ts +10 -0
  5. package/dist/components/MultilineInput/ImageTypes.js +2 -0
  6. package/dist/components/MultilineInput/ImageValidator.d.ts +7 -0
  7. package/dist/components/MultilineInput/ImageValidator.js +50 -0
  8. package/dist/components/MultilineInput/KeyHandler.d.ts +2 -1
  9. package/dist/components/MultilineInput/KeyHandler.js +5 -1
  10. package/dist/components/MultilineInput/Placeholder.d.ts +30 -0
  11. package/dist/components/MultilineInput/Placeholder.js +200 -0
  12. package/dist/components/MultilineInput/TextBuffer.d.ts +2 -0
  13. package/dist/components/MultilineInput/TextBuffer.js +140 -22
  14. package/dist/components/MultilineInput/TextRenderer.d.ts +7 -17
  15. package/dist/components/MultilineInput/TextRenderer.js +91 -74
  16. package/dist/components/MultilineInput/__tests__/ImageSentinel.test.d.ts +1 -0
  17. package/dist/components/MultilineInput/__tests__/ImageSentinel.test.js +154 -0
  18. package/dist/components/MultilineInput/__tests__/ImageValidator.test.d.ts +1 -0
  19. package/dist/components/MultilineInput/__tests__/ImageValidator.test.js +91 -0
  20. package/dist/components/MultilineInput/__tests__/KeyHandler_images.test.d.ts +1 -0
  21. package/dist/components/MultilineInput/__tests__/KeyHandler_images.test.js +48 -0
  22. package/dist/components/MultilineInput/__tests__/Placeholder.integration.test.d.ts +1 -0
  23. package/dist/components/MultilineInput/__tests__/Placeholder.integration.test.js +318 -0
  24. package/dist/components/MultilineInput/__tests__/Placeholder.test.d.ts +1 -0
  25. package/dist/components/MultilineInput/__tests__/Placeholder.test.js +235 -0
  26. package/dist/components/MultilineInput/__tests__/TextBuffer_images.test.d.ts +1 -0
  27. package/dist/components/MultilineInput/__tests__/TextBuffer_images.test.js +144 -0
  28. package/dist/components/MultilineInput/__tests__/TextRenderer_images.test.d.ts +1 -0
  29. package/dist/components/MultilineInput/__tests__/TextRenderer_images.test.js +72 -0
  30. package/dist/components/MultilineInput/__tests__/clipboard/clipboardReaders.test.d.ts +1 -0
  31. package/dist/components/MultilineInput/__tests__/clipboard/clipboardReaders.test.js +81 -0
  32. package/dist/components/MultilineInput/__tests__/integration_images.test.d.ts +1 -0
  33. package/dist/components/MultilineInput/__tests__/integration_images.test.js +35 -0
  34. package/dist/components/MultilineInput/__tests__/useClipboardPaste.test.d.ts +1 -0
  35. package/dist/components/MultilineInput/__tests__/useClipboardPaste.test.js +139 -0
  36. package/dist/components/MultilineInput/__tests__/useTextInput.test.js +74 -6
  37. package/dist/components/MultilineInput/__tests__/useTextInput_images.test.d.ts +1 -0
  38. package/dist/components/MultilineInput/__tests__/useTextInput_images.test.js +110 -0
  39. package/dist/components/MultilineInput/clipboard/ClipboardReader.d.ts +12 -0
  40. package/dist/components/MultilineInput/clipboard/ClipboardReader.js +1 -0
  41. package/dist/components/MultilineInput/clipboard/LinuxWaylandClipboardReader.d.ts +13 -0
  42. package/dist/components/MultilineInput/clipboard/LinuxWaylandClipboardReader.js +48 -0
  43. package/dist/components/MultilineInput/clipboard/LinuxX11ClipboardReader.d.ts +13 -0
  44. package/dist/components/MultilineInput/clipboard/LinuxX11ClipboardReader.js +52 -0
  45. package/dist/components/MultilineInput/clipboard/MacOSClipboardReader.d.ts +13 -0
  46. package/dist/components/MultilineInput/clipboard/MacOSClipboardReader.js +56 -0
  47. package/dist/components/MultilineInput/clipboard/WindowsClipboardReader.d.ts +13 -0
  48. package/dist/components/MultilineInput/clipboard/WindowsClipboardReader.js +64 -0
  49. package/dist/components/MultilineInput/clipboard/index.d.ts +3 -0
  50. package/dist/components/MultilineInput/clipboard/index.js +22 -0
  51. package/dist/components/MultilineInput/index.d.ts +36 -92
  52. package/dist/components/MultilineInput/index.js +70 -47
  53. package/dist/components/MultilineInput/types.d.ts +20 -0
  54. package/dist/components/MultilineInput/useClipboardPaste.d.ts +22 -0
  55. package/dist/components/MultilineInput/useClipboardPaste.js +60 -0
  56. package/dist/components/MultilineInput/useTextInput.d.ts +20 -4
  57. package/dist/components/MultilineInput/useTextInput.js +296 -31
  58. package/dist/examples/examples/basic.d.ts +1 -0
  59. package/dist/examples/examples/basic.js +9 -0
  60. package/dist/examples/src/components/MultilineInput/KeyHandler.d.ts +15 -0
  61. package/dist/examples/src/components/MultilineInput/KeyHandler.js +97 -0
  62. package/dist/examples/src/components/MultilineInput/TextBuffer.d.ts +34 -0
  63. package/dist/examples/src/components/MultilineInput/TextBuffer.js +127 -0
  64. package/dist/examples/src/components/MultilineInput/TextRenderer.d.ts +24 -0
  65. package/dist/examples/src/components/MultilineInput/TextRenderer.js +72 -0
  66. package/dist/examples/src/components/MultilineInput/__tests__/KeyHandler.test.d.ts +1 -0
  67. package/dist/examples/src/components/MultilineInput/__tests__/KeyHandler.test.js +115 -0
  68. package/dist/examples/src/components/MultilineInput/__tests__/TextBuffer.test.d.ts +1 -0
  69. package/dist/examples/src/components/MultilineInput/__tests__/TextBuffer.test.js +254 -0
  70. package/dist/examples/src/components/MultilineInput/__tests__/TextRenderer.test.d.ts +1 -0
  71. package/dist/examples/src/components/MultilineInput/__tests__/TextRenderer.test.js +176 -0
  72. package/dist/examples/src/components/MultilineInput/__tests__/integration.test.d.ts +1 -0
  73. package/dist/examples/src/components/MultilineInput/__tests__/integration.test.js +71 -0
  74. package/dist/examples/src/components/MultilineInput/__tests__/useTextInput.test.d.ts +1 -0
  75. package/dist/examples/src/components/MultilineInput/__tests__/useTextInput.test.js +65 -0
  76. package/dist/examples/src/components/MultilineInput/index.d.ts +39 -0
  77. package/dist/examples/src/components/MultilineInput/index.js +82 -0
  78. package/dist/examples/src/components/MultilineInput/types.d.ts +55 -0
  79. package/dist/examples/src/components/MultilineInput/types.js +1 -0
  80. package/dist/examples/src/components/MultilineInput/useTextInput.d.ts +16 -0
  81. package/dist/examples/src/components/MultilineInput/useTextInput.js +82 -0
  82. package/dist/examples/src/hello.test.d.ts +1 -0
  83. package/dist/examples/src/hello.test.js +13 -0
  84. package/dist/examples/src/index.d.ts +2 -0
  85. package/dist/examples/src/index.js +2 -0
  86. package/dist/index.d.ts +1 -0
  87. package/package.json +3 -3
package/README.md CHANGED
@@ -1,8 +1,7 @@
1
1
  # ink-prompt
2
2
 
3
- A React Ink component library focused on terminal-friendly prompts. The first
4
- export is `MultilineInput`, an Ink component for collecting multi-line text in
5
- CLIs.
3
+ A React Ink component library for creating interactive CLI prompts. Provides
4
+ `MultilineInput` for collecting multi-line text in terminal applications.
6
5
 
7
6
  ## Installation
8
7
 
@@ -23,7 +22,6 @@ const App = () => {
23
22
  <Text>Describe your change (press Enter to submit):</Text>
24
23
  <MultilineInput
25
24
  onSubmit={(value) => console.log(value)}
26
- width={80}
27
25
  />
28
26
  </Box>
29
27
  );
@@ -32,12 +30,119 @@ const App = () => {
32
30
  render(<App />);
33
31
  ```
34
32
 
33
+ ### Props
34
+
35
+ | Prop | Type | Default | Description |
36
+ |------|------|---------|-------------|
37
+ | `value` | `string` | | External control of the text value (controlled mode) |
38
+ | `onChange` | `(value: string) => void` | | Called when text content changes |
39
+ | `onSubmit` | `(value: string) => void` | | Called when Enter is pressed (without trailing `\`) |
40
+ | `placeholder` | `string` | | Placeholder text shown when empty and cursor is hidden |
41
+ | `showCursor` | `boolean` | `true` | Whether to display the cursor |
42
+ | `width` | `number` | terminal width | Width for word wrapping (auto-resizes with terminal) |
43
+ | `isActive` | `boolean` | `true` | Whether the input accepts keyboard events |
44
+ | `onCursorChange` | `(offset: number) => void` | | Called when cursor position changes |
45
+ | `cursorOverride` | `number` | | Force cursor to a specific offset |
46
+ | `onBoundaryArrow` | `(dir) => void` | | Called when arrow key reaches a boundary |
47
+ | `undoDebounceMs` | `number` | `200` | Milliseconds of inactivity to commit undo batch (`0` = disable) |
48
+ | `pasteThreshold` | `number` | | Max paste length before text is replaced by a placeholder |
49
+ | `formatPastePlaceholder` | `(id: number) => string` | | Custom placeholder display format |
50
+
51
+ ### Keyboard Controls
52
+
35
53
  `MultilineInput` supports typical editing controls:
36
54
 
37
- - Arrow keys for navigation
38
- - `Ctrl+J` or typing `\` before Enter to add a newline
39
- - `Ctrl+Z`/`Ctrl+Y` for undo/redo
40
- - Enter submits the current buffer
55
+ - **Arrow keys** for navigation
56
+ - `Ctrl+J` or typing `\` before **Enter** to add a newline
57
+ - `Ctrl+Z` / `Ctrl+Y` for undo/redo
58
+ - `Ctrl+A` / `Ctrl+E` for jump to line start/end
59
+ - **Home** / **End** keys for line start/end
60
+ - `Ctrl+V` to paste text or images (when `enableImagePaste` is enabled)
61
+ - **Enter** submits the current buffer
62
+ - **Delete** for forward delete
63
+
64
+ ### Paste Placeholders
65
+
66
+ When pasting large amounts of text, you can use `pasteThreshold` to automatically
67
+ replace the pasted content with a compact placeholder for cleaner display.
68
+
69
+ ```tsx
70
+ <MultilineInput
71
+ onSubmit={(value) => console.log(value)}
72
+ pasteThreshold={200} // Text >200 chars becomes a placeholder
73
+ formatPastePlaceholder={(id) => `[Pasted block #${id}]`} // Optional formatter
74
+ />
75
+ ```
76
+
77
+ **How it works:**
78
+ - Pasted text exceeding `pasteThreshold` is replaced with a placeholder (default: `[Paste text #N]`, customizable via `formatPastePlaceholder`)
79
+ - The full original text is preserved — `onChange` / `onSubmit` return the unmodified content
80
+ - Placeholders are **atomic**: backspace/delete removes the entire placeholder in one action
81
+ - Arrow keys skip over placeholders (cursor cannot land inside one)
82
+ - Undo/redo correctly tracks placeholder state
83
+ - Counter resets per component instance
84
+
85
+ ## Image Paste
86
+
87
+ Image paste is opt-in through `enableImagePaste`. When enabled, `Ctrl+V`
88
+ reads the system clipboard. Text is inserted as usual; supported images are
89
+ inserted into the text buffer as placeholders such as `[Pasted Image #1]` and
90
+ returned separately through `onSubmit` or `onImagesChange`.
91
+
92
+ ```tsx
93
+ import React, {useState} from 'react';
94
+ import {render, Box, Text} from 'ink';
95
+ import {MultilineInput, type ImageRef, type PasteErrorReason} from 'ink-prompt';
96
+
97
+ const App = () => {
98
+ const [images, setImages] = useState<ImageRef[]>([]);
99
+
100
+ return (
101
+ <Box flexDirection="column">
102
+ <Text>Prompt:</Text>
103
+ <MultilineInput
104
+ enableImagePaste
105
+ maxImageCount={5}
106
+ maxImageSizeBytes={5 * 1024 * 1024}
107
+ acceptedMimeTypes={['image/png', 'image/jpeg', 'image/webp', 'image/gif']}
108
+ images={images}
109
+ onImagesChange={setImages}
110
+ onPasteError={(reason: PasteErrorReason) => {
111
+ console.error(`Paste failed: ${reason}`);
112
+ }}
113
+ onSubmit={(value, submittedImages) => {
114
+ console.log(value);
115
+ console.log(submittedImages);
116
+ }}
117
+ width={80}
118
+ />
119
+ </Box>
120
+ );
121
+ };
122
+
123
+ render(<App />);
124
+ ```
125
+
126
+ Supported image types are detected from image bytes: PNG, JPEG, WebP, and GIF.
127
+ Clipboard access is platform-specific:
128
+
129
+ - macOS: `osascript`
130
+ - Linux X11: `xclip`
131
+ - Linux Wayland: `wl-paste`
132
+ - Windows: PowerShell and `System.Windows.Forms.Clipboard`
133
+
134
+ Related props:
135
+
136
+ - `enableImagePaste?: boolean` - enables image-aware `Ctrl+V` handling.
137
+ - `images?: ImageRef[]` and `onImagesChange?: (images: ImageRef[]) => void` -
138
+ controlled image state for pasted images.
139
+ - `onSubmit?: (value: string, images?: ImageRef[]) => void` - receives the text
140
+ buffer and current images.
141
+ - `onPasteError?: (reason: PasteErrorReason) => void` - receives paste and
142
+ validation failures.
143
+ - `maxImageSizeBytes?: number` - defaults to 10 MiB.
144
+ - `maxImageCount?: number` - defaults to 10.
145
+ - `acceptedMimeTypes?: string[]` - restricts accepted image MIME types.
41
146
 
42
147
  ## Development
43
148
 
@@ -53,6 +158,11 @@ npm run dev
53
158
 
54
159
  # Type check
55
160
  npm run type-check
161
+
162
+ # Run tests
163
+ npm test
164
+ npm run test:watch
165
+ npm run test:ui
56
166
  ```
57
167
 
58
168
  ## License
@@ -0,0 +1,15 @@
1
+ export declare function generateImageId(): string;
2
+ export declare function createSentinel(id: string, displayNumber: number): string;
3
+ export interface SentinelInfo {
4
+ id: string;
5
+ displayNumber: number;
6
+ start: number;
7
+ end: number;
8
+ }
9
+ export declare function parseSentinels(text: string): SentinelInfo[];
10
+ export declare function findSentinelAt(text: string, offset: number): SentinelInfo | null;
11
+ export declare function isInsideSentinel(text: string, offset: number): boolean;
12
+ export declare function removeSentinel(text: string, offset: number): string;
13
+ export declare function getPlaceholderText(displayNumber: number): string;
14
+ export declare function getPlaceholderVisualWidth(displayNumber: number): number;
15
+ export declare function getSentinelVisualWidthFromText(text: string, offset: number): number | null;
@@ -0,0 +1,62 @@
1
+ import { SENTINEL_OPEN, SENTINEL_CLOSE } from './ImageTypes.js';
2
+ export function generateImageId() {
3
+ return Math.random().toString(36).substring(2, 10) + Math.random().toString(36).substring(2, 6);
4
+ }
5
+ export function createSentinel(id, displayNumber) {
6
+ return `${SENTINEL_OPEN}${id}:${displayNumber}${SENTINEL_CLOSE}`;
7
+ }
8
+ export function parseSentinels(text) {
9
+ const result = [];
10
+ let i = 0;
11
+ while (i < text.length) {
12
+ const openIdx = text.indexOf(SENTINEL_OPEN, i);
13
+ if (openIdx === -1)
14
+ break;
15
+ const closeIdx = text.indexOf(SENTINEL_CLOSE, openIdx + 1);
16
+ if (closeIdx === -1)
17
+ break;
18
+ const raw = text.substring(openIdx + 1, closeIdx);
19
+ const colonIdx = raw.lastIndexOf(':');
20
+ const id = colonIdx >= 0 ? raw.substring(0, colonIdx) : raw;
21
+ const displayNumber = colonIdx >= 0 ? parseInt(raw.substring(colonIdx + 1), 10) || 1 : 1;
22
+ result.push({ id, displayNumber, start: openIdx, end: closeIdx + 1 });
23
+ i = closeIdx + 1;
24
+ }
25
+ return result;
26
+ }
27
+ export function findSentinelAt(text, offset) {
28
+ const sentinels = parseSentinels(text);
29
+ for (const s of sentinels) {
30
+ if (offset >= s.start && offset <= s.end) {
31
+ return s;
32
+ }
33
+ }
34
+ return null;
35
+ }
36
+ export function isInsideSentinel(text, offset) {
37
+ const sentinels = parseSentinels(text);
38
+ for (const s of sentinels) {
39
+ if (offset >= s.start && offset < s.end) {
40
+ return true;
41
+ }
42
+ }
43
+ return false;
44
+ }
45
+ export function removeSentinel(text, offset) {
46
+ const sentinel = findSentinelAt(text, offset);
47
+ if (!sentinel)
48
+ return text;
49
+ return text.slice(0, sentinel.start) + text.slice(sentinel.end);
50
+ }
51
+ export function getPlaceholderText(displayNumber) {
52
+ return `[Pasted Image #${displayNumber}]`;
53
+ }
54
+ export function getPlaceholderVisualWidth(displayNumber) {
55
+ return getPlaceholderText(displayNumber).length;
56
+ }
57
+ export function getSentinelVisualWidthFromText(text, offset) {
58
+ const s = findSentinelAt(text, offset);
59
+ if (!s)
60
+ return null;
61
+ return getPlaceholderVisualWidth(s.displayNumber);
62
+ }
@@ -0,0 +1,10 @@
1
+ export interface ImageRef {
2
+ id: string;
3
+ data: string;
4
+ mimeType: string;
5
+ byteSize: number;
6
+ displayNumber: number;
7
+ }
8
+ export type PasteErrorReason = 'clipboard-timeout' | 'clipboard-read-error' | 'clipboard-unsupported-type' | 'image-too-large' | 'too-many-images' | 'clipboard-empty';
9
+ export declare const SENTINEL_OPEN = "\uE000";
10
+ export declare const SENTINEL_CLOSE = "\uE001";
@@ -0,0 +1,2 @@
1
+ export const SENTINEL_OPEN = '\uE000';
2
+ export const SENTINEL_CLOSE = '\uE001';
@@ -0,0 +1,7 @@
1
+ import type { ImageRef } from './ImageTypes.js';
2
+ export interface ValidateImageOptions {
3
+ maxImageSizeBytes?: number;
4
+ maxImageCount?: number;
5
+ acceptedMimeTypes?: string[];
6
+ }
7
+ export declare function validateImage(bytes: Buffer, existingImages: ImageRef[], options: ValidateImageOptions): ImageRef;
@@ -0,0 +1,50 @@
1
+ import { generateImageId } from './ImageSentinel.js';
2
+ const MAGIC_BYTES = [
3
+ { mimeType: 'image/png', magic: [0x89, 0x50, 0x4e, 0x47] },
4
+ { mimeType: 'image/jpeg', magic: [0xff, 0xd8, 0xff] },
5
+ { mimeType: 'image/webp', magic: [0x52, 0x49, 0x46, 0x46] },
6
+ { mimeType: 'image/gif', magic: [0x47, 0x49, 0x46, 0x38] },
7
+ ];
8
+ function detectMimeType(bytes) {
9
+ for (const { mimeType, magic } of MAGIC_BYTES) {
10
+ if (magic.every((b, i) => bytes[i] === b)) {
11
+ // WebP requires additional check for WEBP header at offset 8
12
+ if (mimeType === 'image/webp') {
13
+ if (bytes.length >= 12 &&
14
+ bytes[8] === 0x57 && bytes[9] === 0x45 &&
15
+ bytes[10] === 0x42 && bytes[11] === 0x50) {
16
+ return mimeType;
17
+ }
18
+ continue;
19
+ }
20
+ return mimeType;
21
+ }
22
+ }
23
+ return null;
24
+ }
25
+ export function validateImage(bytes, existingImages, options) {
26
+ const maxSize = options.maxImageSizeBytes ?? 10 * 1024 * 1024;
27
+ const maxCount = options.maxImageCount ?? 10;
28
+ if (bytes.length > maxSize) {
29
+ throw new Error('image-too-large');
30
+ }
31
+ if (existingImages.length >= maxCount) {
32
+ throw new Error('too-many-images');
33
+ }
34
+ const mimeType = detectMimeType(bytes);
35
+ if (!mimeType) {
36
+ throw new Error('clipboard-unsupported-type');
37
+ }
38
+ if (options.acceptedMimeTypes && !options.acceptedMimeTypes.includes(mimeType)) {
39
+ throw new Error('clipboard-unsupported-type');
40
+ }
41
+ const maxDisplayNumber = existingImages.reduce((max, img) => Math.max(max, img.displayNumber), 0);
42
+ const displayNumber = maxDisplayNumber + 1;
43
+ return {
44
+ id: generateImageId(),
45
+ data: bytes.toString('base64'),
46
+ mimeType,
47
+ byteSize: bytes.length,
48
+ displayNumber,
49
+ };
50
+ }
@@ -1,8 +1,9 @@
1
1
  import { type Key, type Buffer, type Cursor } from './types.js';
2
2
  import { type UseTextInputResult } from './useTextInput.js';
3
- export interface KeyHandlerActions extends Omit<UseTextInputResult, 'value' | 'cursor' | 'cursorOffset' | 'setCursorOffset'> {
3
+ export interface KeyHandlerActions extends Omit<UseTextInputResult, 'value' | 'cursor' | 'cursorOffset' | 'setCursorOffset' | 'buffer' | 'placeholderState' | 'insertImage' | 'images' | 'getImages' | 'setImages'> {
4
4
  submit: () => void;
5
5
  onBoundaryArrow?: (direction: 'up' | 'down' | 'left' | 'right') => void;
6
+ paste?: () => void;
6
7
  }
7
8
  /**
8
9
  * Handles keyboard input and maps it to text input actions.
@@ -192,8 +192,12 @@ export function handleKey(key, input, buffer, actions, cursor, rawInput, width)
192
192
  actions.moveCursor('lineEnd');
193
193
  return;
194
194
  }
195
- // History
195
+ // Paste / History
196
196
  if (key.ctrl) {
197
+ if (input === 'v' && actions.paste) {
198
+ actions.paste();
199
+ return;
200
+ }
197
201
  if (input === 'z') {
198
202
  actions.undo();
199
203
  return;
@@ -0,0 +1,30 @@
1
+ import type { PlaceholderInfo, PlaceholderState } from './types.js';
2
+ export declare const MARKER_REGEX: RegExp;
3
+ export declare function createMarker(id: number): string;
4
+ export declare function createPlaceholderState(): PlaceholderState;
5
+ export declare function addPlaceholder(state: PlaceholderState, originalText: string, displayText: string): {
6
+ id: number;
7
+ marker: string;
8
+ state: PlaceholderState;
9
+ };
10
+ export declare function removePlaceholder(state: PlaceholderState, id: number): PlaceholderState;
11
+ export declare function getDisplayLine(line: string, placeholders: Map<number, PlaceholderInfo>): string;
12
+ export declare function getValue(lines: string[], placeholders: Map<number, PlaceholderInfo>): string;
13
+ export interface MarkerRange {
14
+ id: number;
15
+ start: number;
16
+ end: number;
17
+ }
18
+ export declare function findPlaceholderAt(line: string, column: number): MarkerRange | null;
19
+ export declare function findPlaceholderAfter(line: string, column: number): MarkerRange | null;
20
+ export declare function findPlaceholderBefore(line: string, column: number): MarkerRange | null;
21
+ export declare function bufferColToDisplayCol(line: string, column: number, placeholders: Map<number, PlaceholderInfo>): number;
22
+ export declare function displayColToBufferCol(line: string, displayColumn: number, placeholders: Map<number, PlaceholderInfo>): number;
23
+ export declare function getValueCursorOffset(lines: string[], cursor: {
24
+ line: number;
25
+ column: number;
26
+ }, placeholders: Map<number, PlaceholderInfo>): number;
27
+ export declare function getCursorFromValueOffset(lines: string[], offset: number, placeholders: Map<number, PlaceholderInfo>): {
28
+ line: number;
29
+ column: number;
30
+ };
@@ -0,0 +1,200 @@
1
+ export const MARKER_REGEX = /\x00P(\d+)\x00/g;
2
+ export function createMarker(id) {
3
+ return `\x00P${id}\x00`;
4
+ }
5
+ export function createPlaceholderState() {
6
+ return { placeholders: new Map(), nextId: 0 };
7
+ }
8
+ export function addPlaceholder(state, originalText, displayText) {
9
+ const id = state.nextId;
10
+ const marker = createMarker(id);
11
+ const newPlaceholders = new Map(state.placeholders);
12
+ newPlaceholders.set(id, { id, originalText, displayText });
13
+ return { id, marker, state: { placeholders: newPlaceholders, nextId: id + 1 } };
14
+ }
15
+ export function removePlaceholder(state, id) {
16
+ const newPlaceholders = new Map(state.placeholders);
17
+ newPlaceholders.delete(id);
18
+ return { ...state, placeholders: newPlaceholders };
19
+ }
20
+ export function getDisplayLine(line, placeholders) {
21
+ return line.replace(MARKER_REGEX, (_, idStr) => {
22
+ const info = placeholders.get(Number(idStr));
23
+ return info ? info.displayText : '';
24
+ });
25
+ }
26
+ export function getValue(lines, placeholders) {
27
+ const text = lines.join('\n');
28
+ return text.replace(MARKER_REGEX, (_, idStr) => {
29
+ const info = placeholders.get(Number(idStr));
30
+ return info ? info.originalText : '';
31
+ });
32
+ }
33
+ export function findPlaceholderAt(line, column) {
34
+ MARKER_REGEX.lastIndex = 0;
35
+ let match;
36
+ while ((match = MARKER_REGEX.exec(line)) !== null) {
37
+ const start = match.index;
38
+ const end = start + match[0].length;
39
+ if (column > start && column < end) {
40
+ return { id: Number(match[1]), start, end };
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+ export function findPlaceholderAfter(line, column) {
46
+ MARKER_REGEX.lastIndex = 0;
47
+ let match;
48
+ while ((match = MARKER_REGEX.exec(line)) !== null) {
49
+ if (match.index === column) {
50
+ return { id: Number(match[1]), start: match.index, end: match.index + match[0].length };
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+ export function findPlaceholderBefore(line, column) {
56
+ MARKER_REGEX.lastIndex = 0;
57
+ let match;
58
+ while ((match = MARKER_REGEX.exec(line)) !== null) {
59
+ const end = match.index + match[0].length;
60
+ if (end === column) {
61
+ return { id: Number(match[1]), start: match.index, end };
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+ export function bufferColToDisplayCol(line, column, placeholders) {
67
+ if (!placeholders || placeholders.size === 0)
68
+ return column;
69
+ let displayCol = 0;
70
+ let lastEnd = 0;
71
+ MARKER_REGEX.lastIndex = 0;
72
+ let match;
73
+ while ((match = MARKER_REGEX.exec(line)) !== null) {
74
+ const markerStart = match.index;
75
+ const markerEnd = markerStart + match[0].length;
76
+ if (column <= markerStart) {
77
+ return displayCol + (column - lastEnd);
78
+ }
79
+ displayCol += markerStart - lastEnd;
80
+ const info = placeholders.get(Number(match[1]));
81
+ const displayLen = info ? info.displayText.length : 0;
82
+ if (column <= markerEnd) {
83
+ return displayCol + displayLen;
84
+ }
85
+ displayCol += displayLen;
86
+ lastEnd = markerEnd;
87
+ }
88
+ return displayCol + (column - lastEnd);
89
+ }
90
+ export function displayColToBufferCol(line, displayColumn, placeholders) {
91
+ if (!placeholders || placeholders.size === 0)
92
+ return displayColumn;
93
+ let bufPos = 0;
94
+ let dispPos = 0;
95
+ MARKER_REGEX.lastIndex = 0;
96
+ let match;
97
+ while ((match = MARKER_REGEX.exec(line)) !== null) {
98
+ const markerStart = match.index;
99
+ const markerEnd = markerStart + match[0].length;
100
+ const textLen = markerStart - bufPos;
101
+ if (displayColumn <= dispPos + textLen) {
102
+ return bufPos + (displayColumn - dispPos);
103
+ }
104
+ dispPos += textLen;
105
+ bufPos = markerStart;
106
+ const info = placeholders.get(Number(match[1]));
107
+ const displayLen = info ? info.displayText.length : 0;
108
+ if (displayColumn <= dispPos + displayLen) {
109
+ return markerEnd;
110
+ }
111
+ dispPos += displayLen;
112
+ bufPos = markerEnd;
113
+ }
114
+ return bufPos + (displayColumn - dispPos);
115
+ }
116
+ export function getValueCursorOffset(lines, cursor, placeholders) {
117
+ let offset = 0;
118
+ for (let i = 0; i < cursor.line; i++) {
119
+ offset += getExpandedLineLength(lines[i], placeholders) + 1;
120
+ }
121
+ const line = lines[cursor.line];
122
+ let bufPos = 0;
123
+ MARKER_REGEX.lastIndex = 0;
124
+ let match;
125
+ while ((match = MARKER_REGEX.exec(line)) !== null) {
126
+ const markerStart = match.index;
127
+ const markerEnd = markerStart + match[0].length;
128
+ if (cursor.column <= markerStart) {
129
+ offset += cursor.column - bufPos;
130
+ return offset;
131
+ }
132
+ offset += markerStart - bufPos;
133
+ const info = placeholders.get(Number(match[1]));
134
+ offset += info ? info.originalText.length : 0;
135
+ bufPos = markerEnd;
136
+ if (cursor.column <= markerEnd) {
137
+ return offset;
138
+ }
139
+ }
140
+ offset += cursor.column - bufPos;
141
+ return offset;
142
+ }
143
+ export function getCursorFromValueOffset(lines, offset, placeholders) {
144
+ let currentOffset = 0;
145
+ const lineCount = lines.length;
146
+ for (let i = 0; i < lineCount; i++) {
147
+ const lineLen = getExpandedLineLength(lines[i], placeholders);
148
+ if (i === lineCount - 1) {
149
+ if (offset <= currentOffset + lineLen) {
150
+ const colInExpanded = offset - currentOffset;
151
+ return { line: i, column: valueOffsetToBufferColumn(lines[i], colInExpanded, placeholders) };
152
+ }
153
+ return { line: i, column: lines[i].length };
154
+ }
155
+ if (offset <= currentOffset + lineLen) {
156
+ const colInExpanded = offset - currentOffset;
157
+ return { line: i, column: valueOffsetToBufferColumn(lines[i], colInExpanded, placeholders) };
158
+ }
159
+ currentOffset += lineLen + 1;
160
+ }
161
+ const lastIdx = lines.length - 1;
162
+ return { line: lastIdx, column: lines[lastIdx].length };
163
+ }
164
+ function getExpandedLineLength(line, placeholders) {
165
+ let len = 0;
166
+ let lastEnd = 0;
167
+ MARKER_REGEX.lastIndex = 0;
168
+ let match;
169
+ while ((match = MARKER_REGEX.exec(line)) !== null) {
170
+ len += match.index - lastEnd;
171
+ const info = placeholders.get(Number(match[1]));
172
+ len += info ? info.originalText.length : 0;
173
+ lastEnd = match.index + match[0].length;
174
+ }
175
+ len += line.length - lastEnd;
176
+ return len;
177
+ }
178
+ function valueOffsetToBufferColumn(line, offsetInExpanded, placeholders) {
179
+ let bufPos = 0;
180
+ let expandedPos = 0;
181
+ MARKER_REGEX.lastIndex = 0;
182
+ let match;
183
+ while ((match = MARKER_REGEX.exec(line)) !== null) {
184
+ const markerStart = match.index;
185
+ const textLen = markerStart - bufPos;
186
+ if (offsetInExpanded <= expandedPos + textLen) {
187
+ return bufPos + (offsetInExpanded - expandedPos);
188
+ }
189
+ expandedPos += textLen;
190
+ bufPos = markerStart;
191
+ const info = placeholders.get(Number(match[1]));
192
+ const originalLen = info ? info.originalText.length : 0;
193
+ if (offsetInExpanded <= expandedPos + originalLen) {
194
+ return markerStart + match[0].length;
195
+ }
196
+ expandedPos += originalLen;
197
+ bufPos = markerStart + match[0].length;
198
+ }
199
+ return bufPos + (offsetInExpanded - expandedPos);
200
+ }
@@ -45,6 +45,8 @@ interface VisualRowInfo {
45
45
  * Break a line into visual rows using word-aware wrapping.
46
46
  * Words are kept intact when possible, breaking at spaces.
47
47
  * Long words that exceed width are hard-wrapped.
48
+ * Sentinel blocks are atomic: never split, and occupy visual width
49
+ * equal to their placeholder text length.
48
50
  */
49
51
  export declare function getVisualRows(line: string, width: number): VisualRowInfo[];
50
52
  /**