ink-prompt 0.2.0 → 0.2.2

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.
package/README.md CHANGED
@@ -36,17 +36,24 @@ render(<App />);
36
36
  |------|------|---------|-------------|
37
37
  | `value` | `string` | | External control of the text value (controlled mode) |
38
38
  | `onChange` | `(value: string) => void` | | Called when text content changes |
39
- | `onSubmit` | `(value: string) => void` | | Called when Enter is pressed (without trailing `\`) |
39
+ | `onSubmit` | `(value: string, images?: ImageRef[]) => void` | | Called when Enter is pressed (without trailing `\`) |
40
40
  | `placeholder` | `string` | | Placeholder text shown when empty and cursor is hidden |
41
41
  | `showCursor` | `boolean` | `true` | Whether to display the cursor |
42
42
  | `width` | `number` | terminal width | Width for word wrapping (auto-resizes with terminal) |
43
43
  | `isActive` | `boolean` | `true` | Whether the input accepts keyboard events |
44
44
  | `onCursorChange` | `(offset: number) => void` | | Called when cursor position changes |
45
45
  | `cursorOverride` | `number` | | Force cursor to a specific offset |
46
- | `onBoundaryArrow` | `(dir) => void` | | Called when arrow key reaches a boundary |
46
+ | `onBoundaryArrow` | `(direction: 'up' \| 'down' \| 'left' \| 'right') => void` | | Called when arrow key reaches a boundary |
47
47
  | `undoDebounceMs` | `number` | `200` | Milliseconds of inactivity to commit undo batch (`0` = disable) |
48
48
  | `pasteThreshold` | `number` | | Max paste length before text is replaced by a placeholder |
49
49
  | `formatPastePlaceholder` | `(id: number) => string` | | Custom placeholder display format |
50
+ | `enableImagePaste` | `boolean` | `false` | Enables image-aware Ctrl+V handling |
51
+ | `images` | `ImageRef[]` | | Controlled image state for pasted images |
52
+ | `onImagesChange` | `(images: ImageRef[]) => void` | | Called when images change |
53
+ | `onPasteError` | `(reason: PasteErrorReason) => void` | | Called when paste fails |
54
+ | `maxImageSizeBytes` | `number` | `10485760` | Maximum image size in bytes (default 10 MiB) |
55
+ | `maxImageCount` | `number` | `10` | Maximum number of pasted images |
56
+ | `acceptedMimeTypes` | `string[]` | | Restricts accepted image MIME types |
50
57
 
51
58
  ### Keyboard Controls
52
59
 
@@ -0,0 +1,25 @@
1
+ import type { PlaceholderInfo } from './types.js';
2
+ export type AtomicBlock = {
3
+ kind: 'sentinel';
4
+ id: string;
5
+ displayNumber: number;
6
+ start: number;
7
+ end: number;
8
+ displayWidth: number;
9
+ displayText: string;
10
+ } | {
11
+ kind: 'placeholder';
12
+ id: number;
13
+ start: number;
14
+ end: number;
15
+ displayWidth: number;
16
+ displayText: string;
17
+ };
18
+ export type Placeholders = Map<number, PlaceholderInfo> | undefined;
19
+ export declare function findAtomicBlocks(line: string, placeholders?: Placeholders): AtomicBlock[];
20
+ /** Block that strictly contains offset (offset > start && offset < end) — cursor is in the interior. */
21
+ export declare function findAtomicBlockSpanning(line: string, offset: number, placeholders?: Placeholders): AtomicBlock | null;
22
+ /** Block whose end === offset (the block immediately to the left of the cursor). */
23
+ export declare function findAtomicBlockBefore(line: string, offset: number, placeholders?: Placeholders): AtomicBlock | null;
24
+ /** Block whose start === offset (the block immediately to the right of the cursor). */
25
+ export declare function findAtomicBlockAfter(line: string, offset: number, placeholders?: Placeholders): AtomicBlock | null;
@@ -0,0 +1,60 @@
1
+ import { parseSentinels, getPlaceholderText } from './ImageSentinel.js';
2
+ import { MARKER_REGEX } from './Placeholder.js';
3
+ export function findAtomicBlocks(line, placeholders) {
4
+ const blocks = [];
5
+ for (const s of parseSentinels(line)) {
6
+ const displayText = getPlaceholderText(s.displayNumber);
7
+ blocks.push({
8
+ kind: 'sentinel',
9
+ id: s.id,
10
+ displayNumber: s.displayNumber,
11
+ start: s.start,
12
+ end: s.end,
13
+ displayWidth: displayText.length,
14
+ displayText,
15
+ });
16
+ }
17
+ if (placeholders && placeholders.size > 0) {
18
+ MARKER_REGEX.lastIndex = 0;
19
+ let m;
20
+ while ((m = MARKER_REGEX.exec(line)) !== null) {
21
+ const id = Number(m[1]);
22
+ const info = placeholders.get(id);
23
+ const displayText = info ? info.displayText : '';
24
+ blocks.push({
25
+ kind: 'placeholder',
26
+ id,
27
+ start: m.index,
28
+ end: m.index + m[0].length,
29
+ displayWidth: displayText.length,
30
+ displayText,
31
+ });
32
+ }
33
+ }
34
+ blocks.sort((a, b) => a.start - b.start);
35
+ return blocks;
36
+ }
37
+ /** Block that strictly contains offset (offset > start && offset < end) — cursor is in the interior. */
38
+ export function findAtomicBlockSpanning(line, offset, placeholders) {
39
+ for (const b of findAtomicBlocks(line, placeholders)) {
40
+ if (offset > b.start && offset < b.end)
41
+ return b;
42
+ }
43
+ return null;
44
+ }
45
+ /** Block whose end === offset (the block immediately to the left of the cursor). */
46
+ export function findAtomicBlockBefore(line, offset, placeholders) {
47
+ for (const b of findAtomicBlocks(line, placeholders)) {
48
+ if (b.end === offset)
49
+ return b;
50
+ }
51
+ return null;
52
+ }
53
+ /** Block whose start === offset (the block immediately to the right of the cursor). */
54
+ export function findAtomicBlockAfter(line, offset, placeholders) {
55
+ for (const b of findAtomicBlocks(line, placeholders)) {
56
+ if (b.start === offset)
57
+ return b;
58
+ }
59
+ return null;
60
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Buffer, Cursor, Direction } from './types.js';
2
+ import { type Placeholders } from './AtomicBlocks.js';
2
3
  /**
3
4
  * Create a new buffer from optional initial text
4
5
  */
@@ -14,14 +15,14 @@ export declare function insertText(buffer: Buffer, cursor: Cursor, text: string)
14
15
  /**
15
16
  * Delete character before cursor (backspace)
16
17
  */
17
- export declare function deleteChar(buffer: Buffer, cursor: Cursor): {
18
+ export declare function deleteChar(buffer: Buffer, cursor: Cursor, placeholders?: Placeholders): {
18
19
  buffer: Buffer;
19
20
  cursor: Cursor;
20
21
  };
21
22
  /**
22
23
  * Delete character after cursor (forward delete / Delete key)
23
24
  */
24
- export declare function deleteCharForward(buffer: Buffer, cursor: Cursor): {
25
+ export declare function deleteCharForward(buffer: Buffer, cursor: Cursor, placeholders?: Placeholders): {
25
26
  buffer: Buffer;
26
27
  cursor: Cursor;
27
28
  };
@@ -48,13 +49,13 @@ interface VisualRowInfo {
48
49
  * Sentinel blocks are atomic: never split, and occupy visual width
49
50
  * equal to their placeholder text length.
50
51
  */
51
- export declare function getVisualRows(line: string, width: number): VisualRowInfo[];
52
+ export declare function getVisualRows(line: string, width: number, placeholders?: Placeholders): VisualRowInfo[];
52
53
  /**
53
54
  * Move cursor in specified direction with bounds checking.
54
55
  * When width is provided, up/down movement is based on visual lines (accounting for wrapping).
55
56
  * When width is not provided, up/down movement is based on buffer lines.
56
57
  */
57
- export declare function moveCursor(buffer: Buffer, cursor: Cursor, direction: Direction, width?: number): Cursor;
58
+ export declare function moveCursor(buffer: Buffer, cursor: Cursor, direction: Direction, width?: number, placeholders?: Placeholders): Cursor;
58
59
  /**
59
60
  * Get the full text content from buffer (lines joined with newlines)
60
61
  */
@@ -1,5 +1,4 @@
1
- import { SENTINEL_OPEN } from './ImageTypes.js';
2
- import { parseSentinels, findSentinelAt, getPlaceholderVisualWidth } from './ImageSentinel.js';
1
+ import { findAtomicBlocks, findAtomicBlockBefore, findAtomicBlockAfter, } from './AtomicBlocks.js';
3
2
  /**
4
3
  * Create a new buffer from optional initial text
5
4
  */
@@ -44,7 +43,7 @@ export function insertText(buffer, cursor, text) {
44
43
  /**
45
44
  * Delete character before cursor (backspace)
46
45
  */
47
- export function deleteChar(buffer, cursor) {
46
+ export function deleteChar(buffer, cursor, placeholders) {
48
47
  const { line, column } = cursor;
49
48
  // At the very start of the buffer - nothing to delete
50
49
  if (line === 0 && column === 0) {
@@ -63,20 +62,17 @@ export function deleteChar(buffer, cursor) {
63
62
  cursor: { line: line - 1, column: previousLine.length },
64
63
  };
65
64
  }
66
- // Check if deleting a sentinel closer - atomically remove whole block
65
+ // Atomic-block deletion (sentinel or placeholder marker) \u2014 remove the whole block
67
66
  const currentLine = buffer.lines[line];
68
- const charBefore = currentLine[column - 1];
69
- if (charBefore === '\uE001') {
70
- const sentinel = findSentinelAt(currentLine, column - 1);
71
- if (sentinel) {
72
- const newLine = currentLine.slice(0, sentinel.start) + currentLine.slice(sentinel.end);
73
- const newLines = [...buffer.lines];
74
- newLines[line] = newLine;
75
- return {
76
- buffer: { lines: newLines },
77
- cursor: { line, column: sentinel.start },
78
- };
79
- }
67
+ const block = findAtomicBlockBefore(currentLine, column, placeholders);
68
+ if (block) {
69
+ const newLine = currentLine.slice(0, block.start) + currentLine.slice(block.end);
70
+ const newLines = [...buffer.lines];
71
+ newLines[line] = newLine;
72
+ return {
73
+ buffer: { lines: newLines },
74
+ cursor: { line, column: block.start },
75
+ };
80
76
  }
81
77
  const newLine = currentLine.slice(0, column - 1) + currentLine.slice(column);
82
78
  const newLines = [...buffer.lines];
@@ -89,7 +85,7 @@ export function deleteChar(buffer, cursor) {
89
85
  /**
90
86
  * Delete character after cursor (forward delete / Delete key)
91
87
  */
92
- export function deleteCharForward(buffer, cursor) {
88
+ export function deleteCharForward(buffer, cursor, placeholders) {
93
89
  const { line, column } = cursor;
94
90
  const currentLine = buffer.lines[line];
95
91
  const lineCount = buffer.lines.length;
@@ -109,19 +105,16 @@ export function deleteCharForward(buffer, cursor) {
109
105
  cursor, // Cursor stays in place
110
106
  };
111
107
  }
112
- // Check if cursor is at a sentinel opener - atomically remove whole block
113
- const charAt = currentLine[column];
114
- if (charAt === '\uE000') {
115
- const sentinel = findSentinelAt(currentLine, column);
116
- if (sentinel) {
117
- const newLine = currentLine.slice(0, sentinel.start) + currentLine.slice(sentinel.end);
118
- const newLines = [...buffer.lines];
119
- newLines[line] = newLine;
120
- return {
121
- buffer: { lines: newLines },
122
- cursor,
123
- };
124
- }
108
+ // Atomic-block deletion forward (sentinel or placeholder marker) \u2014 remove the whole block
109
+ const block = findAtomicBlockAfter(currentLine, column, placeholders);
110
+ if (block) {
111
+ const newLine = currentLine.slice(0, block.start) + currentLine.slice(block.end);
112
+ const newLines = [...buffer.lines];
113
+ newLines[line] = newLine;
114
+ return {
115
+ buffer: { lines: newLines },
116
+ cursor,
117
+ };
125
118
  }
126
119
  // Delete character after cursor within the line
127
120
  const newLine = currentLine.slice(0, column) + currentLine.slice(column + 1);
@@ -158,14 +151,15 @@ function getVisualWidth(char) {
158
151
  * Sentinel blocks are atomic: never split, and occupy visual width
159
152
  * equal to their placeholder text length.
160
153
  */
161
- export function getVisualRows(line, width) {
154
+ export function getVisualRows(line, width, placeholders) {
162
155
  const safeWidth = Math.max(1, width);
163
156
  const rows = [];
164
157
  if (line.length === 0) {
165
158
  return [{ start: 0, length: 0 }];
166
159
  }
167
- // Fast path: no sentinels, use original logic
168
- if (line.indexOf(SENTINEL_OPEN) === -1) {
160
+ const blocks = findAtomicBlocks(line, placeholders);
161
+ // Fast path: no atomic blocks
162
+ if (blocks.length === 0) {
169
163
  let offset = 0;
170
164
  let remaining = line;
171
165
  while (remaining.length > 0) {
@@ -191,21 +185,19 @@ export function getVisualRows(line, width) {
191
185
  }
192
186
  return rows;
193
187
  }
194
- const sentinels = parseSentinels(line);
195
188
  let charPos = 0;
196
189
  let rowStart = 0;
197
190
  let rowVisualWidth = 0;
198
191
  let lastSpaceCharPos = -1;
192
+ let blockIdx = 0;
199
193
  while (charPos < line.length) {
200
- const sentinel = charPos < sentinels.length && sentinels[charPos] !== undefined
201
- ? sentinels.find(s => s.start === charPos)
194
+ while (blockIdx < blocks.length && blocks[blockIdx].start < charPos)
195
+ blockIdx++;
196
+ const block = blockIdx < blocks.length && blocks[blockIdx].start === charPos
197
+ ? blocks[blockIdx]
202
198
  : undefined;
203
- let effectiveSentinel;
204
- if (line[charPos] === SENTINEL_OPEN) {
205
- effectiveSentinel = sentinels.find(s => s.start === charPos);
206
- }
207
- if (effectiveSentinel) {
208
- const svw = getPlaceholderVisualWidth(effectiveSentinel.displayNumber);
199
+ if (block) {
200
+ const svw = block.displayWidth;
209
201
  if (rowVisualWidth > 0 && rowVisualWidth + svw > safeWidth) {
210
202
  if (lastSpaceCharPos >= rowStart) {
211
203
  rows.push({ start: rowStart, length: lastSpaceCharPos - rowStart });
@@ -222,7 +214,7 @@ export function getVisualRows(line, width) {
222
214
  }
223
215
  }
224
216
  rowVisualWidth += svw;
225
- charPos = effectiveSentinel.end;
217
+ charPos = block.end;
226
218
  lastSpaceCharPos = -1;
227
219
  continue;
228
220
  }
@@ -262,8 +254,8 @@ export function getVisualRows(line, width) {
262
254
  * and the column within that visual row.
263
255
  * Uses word-aware wrapping.
264
256
  */
265
- function getVisualPosition(bufferColumn, line, width) {
266
- const rows = getVisualRows(line, width);
257
+ function getVisualPosition(bufferColumn, line, width, placeholders) {
258
+ const rows = getVisualRows(line, width, placeholders);
267
259
  for (let i = 0; i < rows.length; i++) {
268
260
  const row = rows[i];
269
261
  const rowEnd = row.start + row.length;
@@ -290,15 +282,15 @@ function getVisualPosition(bufferColumn, line, width) {
290
282
  * Calculate how many visual rows a buffer line takes.
291
283
  * Uses word-aware wrapping.
292
284
  */
293
- function getVisualRowCount(line, width) {
294
- return getVisualRows(line, width).length;
285
+ function getVisualRowCount(line, width, placeholders) {
286
+ return getVisualRows(line, width, placeholders).length;
295
287
  }
296
288
  /**
297
289
  * Convert visual position back to buffer column.
298
290
  * Uses word-aware wrapping.
299
291
  */
300
- function visualToBufferColumn(visualRow, visualCol, line, width) {
301
- const rows = getVisualRows(line, width);
292
+ function visualToBufferColumn(visualRow, visualCol, line, width, placeholders) {
293
+ const rows = getVisualRows(line, width, placeholders);
302
294
  if (visualRow >= rows.length) {
303
295
  return line.length;
304
296
  }
@@ -309,8 +301,8 @@ function visualToBufferColumn(visualRow, visualCol, line, width) {
309
301
  * Get the length of a specific visual row within a buffer line.
310
302
  * Uses word-aware wrapping.
311
303
  */
312
- function getVisualRowLength(line, visualRow, width) {
313
- const rows = getVisualRows(line, width);
304
+ function getVisualRowLength(line, visualRow, width, placeholders) {
305
+ const rows = getVisualRows(line, width, placeholders);
314
306
  if (visualRow >= rows.length)
315
307
  return 0;
316
308
  return rows[visualRow].length;
@@ -320,21 +312,17 @@ function getVisualRowLength(line, visualRow, width) {
320
312
  * When width is provided, up/down movement is based on visual lines (accounting for wrapping).
321
313
  * When width is not provided, up/down movement is based on buffer lines.
322
314
  */
323
- export function moveCursor(buffer, cursor, direction, width) {
315
+ export function moveCursor(buffer, cursor, direction, width, placeholders) {
324
316
  const { line, column } = cursor;
325
317
  const currentLine = buffer.lines[line];
326
318
  const lineCount = buffer.lines.length;
327
319
  switch (direction) {
328
320
  case 'left': {
329
321
  if (column > 0) {
330
- // If position before cursor is a sentinel closer, jump to before opener
331
- const charBefore = currentLine[column - 1];
332
- if (charBefore === '\uE001') {
333
- const sentinel = findSentinelAt(currentLine, column - 1);
334
- if (sentinel) {
335
- return { line, column: sentinel.start };
336
- }
337
- }
322
+ // If position before cursor is the end of an atomic block, jump to its start
323
+ const block = findAtomicBlockBefore(currentLine, column, placeholders);
324
+ if (block)
325
+ return { line, column: block.start };
338
326
  return { line, column: column - 1 };
339
327
  }
340
328
  // Wrap to end of previous line
@@ -345,14 +333,10 @@ export function moveCursor(buffer, cursor, direction, width) {
345
333
  }
346
334
  case 'right': {
347
335
  if (column < currentLine.length) {
348
- // If cursor is immediately before a sentinel opener, jump to after closer
349
- const charAt = currentLine[column];
350
- if (charAt === '\uE000') {
351
- const sentinel = findSentinelAt(currentLine, column);
352
- if (sentinel) {
353
- return { line, column: sentinel.end };
354
- }
355
- }
336
+ // If cursor is at the start of an atomic block, jump past its end
337
+ const block = findAtomicBlockAfter(currentLine, column, placeholders);
338
+ if (block)
339
+ return { line, column: block.end };
356
340
  return { line, column: column + 1 };
357
341
  }
358
342
  // Wrap to start of next line
@@ -363,27 +347,23 @@ export function moveCursor(buffer, cursor, direction, width) {
363
347
  }
364
348
  case 'up':
365
349
  if (width !== undefined) {
366
- // Visual-aware movement (word-aware wrapping)
367
- const { visualRow, visualCol } = getVisualPosition(column, currentLine, width);
350
+ const { visualRow, visualCol } = getVisualPosition(column, currentLine, width, placeholders);
368
351
  if (visualRow > 0) {
369
- // Move to previous visual row within the same buffer line
370
352
  const targetVisualRow = visualRow - 1;
371
- const targetVisualRowLength = getVisualRowLength(currentLine, targetVisualRow, width);
353
+ const targetVisualRowLength = getVisualRowLength(currentLine, targetVisualRow, width, placeholders);
372
354
  const targetVisualCol = Math.min(visualCol, targetVisualRowLength);
373
- return { line, column: visualToBufferColumn(targetVisualRow, targetVisualCol, currentLine, width) };
355
+ return { line, column: visualToBufferColumn(targetVisualRow, targetVisualCol, currentLine, width, placeholders) };
374
356
  }
375
- // At first visual row of current line - move to last visual row of previous buffer line
376
357
  if (line > 0) {
377
358
  const prevLine = buffer.lines[line - 1];
378
- const prevLineVisualRows = getVisualRowCount(prevLine, width);
359
+ const prevLineVisualRows = getVisualRowCount(prevLine, width, placeholders);
379
360
  const targetVisualRow = prevLineVisualRows - 1;
380
- const targetVisualRowLength = getVisualRowLength(prevLine, targetVisualRow, width);
361
+ const targetVisualRowLength = getVisualRowLength(prevLine, targetVisualRow, width, placeholders);
381
362
  const targetVisualCol = Math.min(visualCol, targetVisualRowLength);
382
- return { line: line - 1, column: visualToBufferColumn(targetVisualRow, targetVisualCol, prevLine, width) };
363
+ return { line: line - 1, column: visualToBufferColumn(targetVisualRow, targetVisualCol, prevLine, width, placeholders) };
383
364
  }
384
365
  return cursor;
385
366
  }
386
- // Buffer-line movement (no width provided)
387
367
  if (line > 0) {
388
368
  const targetLine = buffer.lines[line - 1];
389
369
  return { line: line - 1, column: Math.min(column, targetLine.length) };
@@ -391,20 +371,17 @@ export function moveCursor(buffer, cursor, direction, width) {
391
371
  return cursor;
392
372
  case 'down':
393
373
  if (width !== undefined) {
394
- // Visual-aware movement (word-aware wrapping)
395
- const { visualRow, visualCol } = getVisualPosition(column, currentLine, width);
396
- const currentLineVisualRows = getVisualRowCount(currentLine, width);
374
+ const { visualRow, visualCol } = getVisualPosition(column, currentLine, width, placeholders);
375
+ const currentLineVisualRows = getVisualRowCount(currentLine, width, placeholders);
397
376
  if (visualRow < currentLineVisualRows - 1) {
398
- // Move to next visual row within the same buffer line
399
377
  const targetVisualRow = visualRow + 1;
400
- const targetVisualRowLength = getVisualRowLength(currentLine, targetVisualRow, width);
378
+ const targetVisualRowLength = getVisualRowLength(currentLine, targetVisualRow, width, placeholders);
401
379
  const targetVisualCol = Math.min(visualCol, targetVisualRowLength);
402
- return { line, column: visualToBufferColumn(targetVisualRow, targetVisualCol, currentLine, width) };
380
+ return { line, column: visualToBufferColumn(targetVisualRow, targetVisualCol, currentLine, width, placeholders) };
403
381
  }
404
- // At last visual row of current line - move to first visual row of next buffer line
405
382
  if (line < lineCount - 1) {
406
383
  const nextLine = buffer.lines[line + 1];
407
- const targetVisualRowLength = getVisualRowLength(nextLine, 0, width);
384
+ const targetVisualRowLength = getVisualRowLength(nextLine, 0, width, placeholders);
408
385
  const targetVisualCol = Math.min(visualCol, targetVisualRowLength);
409
386
  return { line: line + 1, column: Math.min(targetVisualCol, nextLine.length) };
410
387
  }
@@ -10,5 +10,17 @@ export interface TextRendererProps {
10
10
  placeholderState?: PlaceholderState;
11
11
  images?: Record<string, ImageRef>;
12
12
  }
13
- export declare function wrapLines(buffer: Buffer, cursor: Cursor, width: number, images?: Record<string, ImageRef>): WrapResult;
14
- export declare function TextRenderer({ buffer, cursor, width: propWidth, showCursor, placeholderState, images, }: TextRendererProps): React.ReactElement;
13
+ interface VisualSegment {
14
+ text: string;
15
+ dim: boolean;
16
+ }
17
+ interface VisualRow {
18
+ segments: VisualSegment[];
19
+ /** Total visual length (sum of segment text lengths). */
20
+ visualLength: number;
21
+ }
22
+ export declare function wrapLines(buffer: Buffer, cursor: Cursor, width: number, placeholders?: PlaceholderState['placeholders']): WrapResult & {
23
+ rows: VisualRow[];
24
+ };
25
+ export declare function TextRenderer({ buffer, cursor, width: propWidth, showCursor, placeholderState, }: TextRendererProps): React.ReactElement;
26
+ export {};
@@ -2,11 +2,53 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
2
2
  import { Box, Text } from 'ink';
3
3
  import { useTerminalWidth } from '../../hooks/useTerminalWidth.js';
4
4
  import { getVisualRows } from './TextBuffer.js';
5
- import { getDisplayLine, bufferColToDisplayCol } from './Placeholder.js';
6
- import { parseSentinels, getPlaceholderText } from './ImageSentinel.js';
7
- const PLACEHOLDER_PATTERN = /\[Pasted Image #\d+\]/g;
8
- export function wrapLines(buffer, cursor, width, images) {
5
+ import { findAtomicBlocks } from './AtomicBlocks.js';
6
+ /** Expand a [start, end) range of the raw line into styled segments. */
7
+ function expandRange(line, start, end, blocks) {
8
+ const segments = [];
9
+ let raw = start;
10
+ let plainStart = start;
11
+ while (raw < end) {
12
+ const block = blocks.find((b) => b.start === raw && b.end <= end);
13
+ if (block) {
14
+ if (raw > plainStart) {
15
+ segments.push({ text: line.slice(plainStart, raw), dim: false });
16
+ }
17
+ segments.push({ text: block.displayText, dim: block.kind === 'sentinel' });
18
+ raw = block.end;
19
+ plainStart = raw;
20
+ }
21
+ else {
22
+ raw++;
23
+ }
24
+ }
25
+ if (plainStart < end) {
26
+ segments.push({ text: line.slice(plainStart, end), dim: false });
27
+ }
28
+ return segments;
29
+ }
30
+ /** Visual column inside a row corresponding to a raw buffer column. */
31
+ function visualColumnForRawColumn(line, rowStart, column, blocks) {
32
+ let visualCol = 0;
33
+ let rawIndex = rowStart;
34
+ while (rawIndex < column) {
35
+ const block = blocks.find((b) => b.start === rawIndex);
36
+ if (block) {
37
+ if (column <= block.start)
38
+ return visualCol;
39
+ visualCol += block.displayWidth;
40
+ rawIndex = block.end;
41
+ }
42
+ else {
43
+ visualCol += 1;
44
+ rawIndex++;
45
+ }
46
+ }
47
+ return visualCol;
48
+ }
49
+ export function wrapLines(buffer, cursor, width, placeholders) {
9
50
  const visualLines = [];
51
+ const rowsOut = [];
10
52
  let cursorVisualRow = 0;
11
53
  let cursorVisualCol = 0;
12
54
  const safeWidth = Math.max(1, width);
@@ -14,10 +56,11 @@ export function wrapLines(buffer, cursor, width, images) {
14
56
  for (let lineIndex = 0; lineIndex < buffer.lines.length; lineIndex++) {
15
57
  const rawLine = buffer.lines[lineIndex];
16
58
  const isCursorLine = lineIndex === cursor.line;
17
- const sentinels = parseSentinels(rawLine);
18
- const rows = getVisualRows(rawLine, safeWidth);
59
+ const blocks = findAtomicBlocks(rawLine, placeholders);
60
+ const rows = getVisualRows(rawLine, safeWidth, placeholders);
19
61
  if (rawLine.length === 0) {
20
62
  visualLines.push('');
63
+ rowsOut.push({ segments: [], visualLength: 0 });
21
64
  if (isCursorLine) {
22
65
  cursorVisualRow = visualRowIndex;
23
66
  cursorVisualCol = 0;
@@ -28,107 +71,81 @@ export function wrapLines(buffer, cursor, width, images) {
28
71
  for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
29
72
  const row = rows[rowIndex];
30
73
  const rowEnd = row.start + row.length;
31
- const chunk = expandRawSegment(rawLine, row.start, rowEnd, sentinels);
32
- visualLines.push(chunk);
74
+ const segments = expandRange(rawLine, row.start, rowEnd, blocks);
75
+ const visualLength = segments.reduce((sum, s) => sum + s.text.length, 0);
76
+ visualLines.push(segments.map((s) => s.text).join(''));
77
+ rowsOut.push({ segments, visualLength });
33
78
  if (isCursorLine) {
34
79
  if (cursor.column >= row.start && cursor.column < rowEnd) {
35
80
  cursorVisualRow = visualRowIndex;
36
- cursorVisualCol = visualColumnForRawColumn(rawLine, row.start, cursor.column, sentinels);
81
+ cursorVisualCol = visualColumnForRawColumn(rawLine, row.start, cursor.column, blocks);
37
82
  }
38
83
  else if (cursor.column === rowEnd && rowIndex === rows.length - 1) {
39
84
  cursorVisualRow = visualRowIndex;
40
- cursorVisualCol = chunk.length;
85
+ cursorVisualCol = visualLength;
41
86
  }
42
87
  }
43
88
  visualRowIndex++;
44
89
  }
45
90
  }
46
- return { visualLines, cursorVisualRow, cursorVisualCol };
91
+ return { visualLines, cursorVisualRow, cursorVisualCol, rows: rowsOut };
47
92
  }
48
- function expandRawSegment(line, start, end, sentinels) {
49
- let expanded = '';
50
- let rawIndex = start;
51
- while (rawIndex < end) {
52
- const sentinel = sentinels.find(s => s.start === rawIndex);
53
- if (sentinel && sentinel.end <= end) {
54
- expanded += getPlaceholderText(sentinel.displayNumber);
55
- rawIndex = sentinel.end;
56
- }
57
- else {
58
- expanded += line[rawIndex];
59
- rawIndex++;
60
- }
61
- }
62
- return expanded;
93
+ function renderSegments(segments, keyPrefix) {
94
+ return segments.map((seg, i) => seg.dim ? (_jsx(Text, { dimColor: true, children: seg.text }, `${keyPrefix}-d-${i}`)) : (_jsx(Text, { children: seg.text }, `${keyPrefix}-t-${i}`)));
63
95
  }
64
- function visualColumnForRawColumn(line, start, column, sentinels) {
65
- let visualCol = 0;
66
- let rawIndex = start;
67
- while (rawIndex < column) {
68
- const sentinel = sentinels.find(s => s.start === rawIndex);
69
- if (sentinel) {
70
- if (column <= sentinel.start) {
71
- return visualCol;
72
- }
73
- visualCol += getPlaceholderText(sentinel.displayNumber).length;
74
- rawIndex = sentinel.end;
75
- }
76
- else {
77
- visualCol += 1;
78
- rawIndex++;
96
+ function sliceSegments(segments, start, end) {
97
+ const out = [];
98
+ let pos = 0;
99
+ for (const seg of segments) {
100
+ const segEnd = pos + seg.text.length;
101
+ if (segEnd <= start) {
102
+ pos = segEnd;
103
+ continue;
79
104
  }
105
+ if (pos >= end)
106
+ break;
107
+ const sliceStart = Math.max(0, start - pos);
108
+ const sliceEnd = Math.min(seg.text.length, end - pos);
109
+ out.push({ text: seg.text.slice(sliceStart, sliceEnd), dim: seg.dim });
110
+ pos = segEnd;
80
111
  }
81
- return visualCol;
112
+ return out;
82
113
  }
83
- function renderLineSegments(line) {
84
- const segments = [];
85
- let lastIndex = 0;
86
- let match;
87
- const re = new RegExp(PLACEHOLDER_PATTERN.source, 'g');
88
- while ((match = re.exec(line)) !== null) {
89
- if (match.index > lastIndex) {
90
- segments.push(_jsx(Text, { children: line.slice(lastIndex, match.index) }, `t-${lastIndex}`));
114
+ function charAtVisualCol(segments, col) {
115
+ let pos = 0;
116
+ for (const seg of segments) {
117
+ if (col < pos + seg.text.length) {
118
+ return { ch: seg.text[col - pos], dim: seg.dim };
91
119
  }
92
- segments.push(_jsx(Text, { dimColor: true, children: match[0] }, `p-${match.index}`));
93
- lastIndex = match.index + match[0].length;
94
- }
95
- if (lastIndex < line.length) {
96
- segments.push(_jsx(Text, { children: line.slice(lastIndex) }, `t-${lastIndex}`));
120
+ pos += seg.text.length;
97
121
  }
98
- return segments;
122
+ return { ch: ' ', dim: false };
99
123
  }
100
- function renderVisualLine(line, isCursorRow, cursorCol, showCursor) {
101
- if (!isCursorRow) {
102
- if (line.length === 0)
103
- return _jsx(Text, { children: " " });
104
- return _jsx(_Fragment, { children: renderLineSegments(line) });
105
- }
106
- if (!showCursor) {
107
- if (line.length === 0)
124
+ function renderVisualRow(row, isCursorRow, cursorCol, showCursor) {
125
+ const { segments, visualLength } = row;
126
+ if (!isCursorRow || !showCursor) {
127
+ if (visualLength === 0)
108
128
  return _jsx(Text, { children: " " });
109
- return _jsx(_Fragment, { children: renderLineSegments(line) });
129
+ return _jsx(_Fragment, { children: renderSegments(segments, 'r') });
110
130
  }
111
- if (line.length === 0) {
131
+ if (visualLength === 0) {
112
132
  return _jsx(Text, { inverse: true, children: " " });
113
133
  }
114
- const before = line.slice(0, cursorCol);
115
- const charUnderCursor = cursorCol < line.length ? line[cursorCol] : ' ';
116
- const after = line.slice(cursorCol + 1);
117
- return (_jsxs(_Fragment, { children: [renderLineSegments(before), _jsx(Text, { inverse: true, children: charUnderCursor }), renderLineSegments(after)] }));
134
+ const before = sliceSegments(segments, 0, cursorCol);
135
+ const under = cursorCol < visualLength
136
+ ? charAtVisualCol(segments, cursorCol)
137
+ : { ch: ' ', dim: false };
138
+ const after = cursorCol < visualLength
139
+ ? sliceSegments(segments, cursorCol + 1, visualLength)
140
+ : [];
141
+ return (_jsxs(_Fragment, { children: [renderSegments(before, 'b'), _jsx(Text, { inverse: true, dimColor: under.dim, children: under.ch }), renderSegments(after, 'a')] }));
118
142
  }
119
- export function TextRenderer({ buffer, cursor, width: propWidth, showCursor = true, placeholderState, images = {}, }) {
143
+ export function TextRenderer({ buffer, cursor, width: propWidth, showCursor = true, placeholderState, }) {
120
144
  const width = useTerminalWidth(propWidth);
121
- // Expand paste placeholder markers to display text before rendering
122
- const hasPlaceholders = placeholderState && placeholderState.placeholders.size > 0;
123
- const displayBuffer = hasPlaceholders
124
- ? { lines: buffer.lines.map(line => getDisplayLine(line, placeholderState.placeholders)) }
125
- : buffer;
126
- const displayCursor = hasPlaceholders && cursor.line < buffer.lines.length
127
- ? { line: cursor.line, column: bufferColToDisplayCol(buffer.lines[cursor.line], cursor.column, placeholderState.placeholders) }
128
- : cursor;
129
- const { visualLines, cursorVisualRow, cursorVisualCol } = wrapLines(displayBuffer, displayCursor, width, images);
130
- return (_jsx(Box, { flexDirection: "column", children: visualLines.map((line, index) => {
145
+ const placeholders = placeholderState?.placeholders;
146
+ const { rows, cursorVisualRow, cursorVisualCol } = wrapLines(buffer, cursor, width, placeholders);
147
+ return (_jsx(Box, { flexDirection: "column", children: rows.map((row, index) => {
131
148
  const isCursorRow = index === cursorVisualRow;
132
- return (_jsx(Box, { children: renderVisualLine(line, isCursorRow, cursorVisualCol, showCursor) }, index));
149
+ return (_jsx(Box, { children: renderVisualRow(row, isCursorRow, cursorVisualCol, showCursor) }, index));
133
150
  }) }));
134
151
  }
@@ -14,7 +14,7 @@ describe('TextRenderer with images', () => {
14
14
  it('renders normal text unchanged when no sentinels', () => {
15
15
  const buffer = { lines: ['hello'] };
16
16
  const cursor = { line: 0, column: 5 };
17
- const result = wrapLines(buffer, cursor, 80, {});
17
+ const result = wrapLines(buffer, cursor, 80);
18
18
  expect(result.visualLines).toEqual(['hello']);
19
19
  expect(result.cursorVisualRow).toBe(0);
20
20
  expect(result.cursorVisualCol).toBe(5);
@@ -22,7 +22,7 @@ describe('TextRenderer with images', () => {
22
22
  it('renders sentinel placeholder text in visual lines', () => {
23
23
  const buffer = { lines: [sentinel1] };
24
24
  const cursor = { line: 0, column: sentinel1.length };
25
- const result = wrapLines(buffer, cursor, 80, images);
25
+ const result = wrapLines(buffer, cursor, 80);
26
26
  expect(result.visualLines).toEqual(['[Pasted Image #1]']);
27
27
  expect(result.cursorVisualRow).toBe(0);
28
28
  expect(result.cursorVisualCol).toBe(17);
@@ -30,13 +30,13 @@ describe('TextRenderer with images', () => {
30
30
  it('renders text around sentinel placeholder', () => {
31
31
  const buffer = { lines: [`hello ${sentinel1} world`] };
32
32
  const cursor = { line: 0, column: 0 };
33
- const result = wrapLines(buffer, cursor, 80, images);
33
+ const result = wrapLines(buffer, cursor, 80);
34
34
  expect(result.visualLines).toEqual(['hello [Pasted Image #1] world']);
35
35
  });
36
36
  it('does not split sentinel placeholder text while wrapping', () => {
37
37
  const buffer = { lines: [`aa ${sentinel1}`] };
38
38
  const cursor = { line: 0, column: `aa ${sentinel1}`.length };
39
- const result = wrapLines(buffer, cursor, 10, images);
39
+ const result = wrapLines(buffer, cursor, 10);
40
40
  expect(result.visualLines).toEqual(['aa', '[Pasted Image #1]']);
41
41
  expect(result.cursorVisualRow).toBe(1);
42
42
  expect(result.cursorVisualCol).toBe('[Pasted Image #1]'.length);
@@ -1,7 +1,8 @@
1
1
  import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
2
- import { createBuffer, insertText as bufferInsertText, deleteChar as bufferDeleteChar, deleteCharForward as bufferDeleteCharForward, insertNewLine as bufferInsertNewLine, moveCursor as bufferMoveCursor, getTextContent, getVisualRows, } from './TextBuffer.js';
3
- import { createPlaceholderState, addPlaceholder, removePlaceholder, getValue, getValueCursorOffset, getCursorFromValueOffset, getDisplayLine, bufferColToDisplayCol, displayColToBufferCol, findPlaceholderAt, findPlaceholderAfter, findPlaceholderBefore, } from './Placeholder.js';
4
- import { createSentinel, parseSentinels, findSentinelAt } from './ImageSentinel.js';
2
+ import { createBuffer, insertText as bufferInsertText, deleteChar as bufferDeleteChar, deleteCharForward as bufferDeleteCharForward, insertNewLine as bufferInsertNewLine, moveCursor as bufferMoveCursor, getTextContent, } from './TextBuffer.js';
3
+ import { createPlaceholderState, addPlaceholder, removePlaceholder, getValue, getValueCursorOffset, getCursorFromValueOffset, } from './Placeholder.js';
4
+ import { createSentinel, parseSentinels } from './ImageSentinel.js';
5
+ import { findAtomicBlockBefore, findAtomicBlockAfter } from './AtomicBlocks.js';
5
6
  import { log } from '../../utils/logger.js';
6
7
  const defaultFormatPlaceholder = (id) => `[Paste text #${id}]`;
7
8
  export function useTextInput({ initialValue = '', width, historyLimit = 100, undoDebounceMs = 200, pasteThreshold, formatPastePlaceholder = defaultFormatPlaceholder, } = {}) {
@@ -80,6 +81,16 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100, und
80
81
  pendingInsertBatchRef.current.startState = undefined;
81
82
  };
82
83
  }, [clearPendingInsertTimer]);
84
+ /** Run a buffer-mutating edit with history bookkeeping (flush batch + push undo). */
85
+ const applyEdit = useCallback((edit) => {
86
+ flushPendingInsertBatch();
87
+ pushToHistory(buffer, cursor);
88
+ const result = edit();
89
+ if (result) {
90
+ setBuffer(result.buffer);
91
+ setCursor(result.cursor);
92
+ }
93
+ }, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
83
94
  const insert = useCallback((char) => {
84
95
  log(`[INSERT] char="${char.replace(/[\x00-\x1F\x7F-￿]/g, c => `\\x${c.charCodeAt(0).toString(16)}`)}" len=${char.length} cursor={line:${cursor.line},col:${cursor.column}} linesBefore=${buffer.lines.length}`);
85
96
  const normalized = char.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\x00/g, '');
@@ -110,159 +121,44 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100, und
110
121
  setBuffer(result.buffer);
111
122
  setCursor(result.cursor);
112
123
  }, [beginOrRefreshInsertBatch, buffer, cursor, flushPendingInsertBatch, pushToHistory, undoDebounceMs, pasteThreshold, placeholderState, formatPastePlaceholder]);
113
- const deleteChar = useCallback(() => {
114
- flushPendingInsertBatch();
115
- pushToHistory(buffer, cursor);
116
- const line = buffer.lines[cursor.line];
117
- // Check if deleting a paste placeholder marker
118
- const placeholder = findPlaceholderBefore(line, cursor.column);
119
- if (placeholder) {
120
- const newLine = line.slice(0, placeholder.start) + line.slice(placeholder.end);
121
- const newLines = [...buffer.lines];
122
- newLines[cursor.line] = newLine;
123
- setBuffer({ lines: newLines });
124
- setCursor({ line: cursor.line, column: placeholder.start });
125
- setPlaceholderState(prev => removePlaceholder(prev, placeholder.id));
124
+ const cleanupBlockRegistry = useCallback((block) => {
125
+ if (!block)
126
126
  return;
127
+ if (block.kind === 'placeholder') {
128
+ setPlaceholderState((prev) => removePlaceholder(prev, block.id));
127
129
  }
128
- // Check if deleting a sentinel (image)
129
- if (cursor.column > 0 && line[cursor.column - 1] === '') {
130
- const sentinel = findSentinelAt(line, cursor.column - 1);
131
- if (sentinel && images[sentinel.id]) {
132
- const newImages = { ...images };
133
- delete newImages[sentinel.id];
134
- setImages(newImages);
135
- }
130
+ else if (block.kind === 'sentinel' && images[block.id]) {
131
+ const next = { ...images };
132
+ delete next[block.id];
133
+ setImages(next);
136
134
  }
137
- const result = bufferDeleteChar(buffer, cursor);
138
- setBuffer(result.buffer);
139
- setCursor(result.cursor);
140
- }, [buffer, cursor, flushPendingInsertBatch, pushToHistory, images, placeholderState]);
135
+ }, [images]);
136
+ const deleteChar = useCallback(() => {
137
+ applyEdit(() => {
138
+ const line = buffer.lines[cursor.line];
139
+ cleanupBlockRegistry(findAtomicBlockBefore(line, cursor.column, placeholderState.placeholders));
140
+ return bufferDeleteChar(buffer, cursor, placeholderState.placeholders);
141
+ });
142
+ }, [applyEdit, buffer, cursor, placeholderState, cleanupBlockRegistry]);
141
143
  const deleteCharForward = useCallback(() => {
142
- flushPendingInsertBatch();
143
- pushToHistory(buffer, cursor);
144
- const line = buffer.lines[cursor.line];
145
- // Check if deleting a paste placeholder marker
146
- const placeholder = findPlaceholderAfter(line, cursor.column);
147
- if (placeholder) {
148
- const newLine = line.slice(0, placeholder.start) + line.slice(placeholder.end);
149
- const newLines = [...buffer.lines];
150
- newLines[cursor.line] = newLine;
151
- setBuffer({ lines: newLines });
152
- setPlaceholderState(prev => removePlaceholder(prev, placeholder.id));
153
- return;
154
- }
155
- // Check if deleting a sentinel (image)
156
- if (cursor.column < line.length && line[cursor.column] === '') {
157
- const sentinel = findSentinelAt(line, cursor.column);
158
- if (sentinel && images[sentinel.id]) {
159
- const newImages = { ...images };
160
- delete newImages[sentinel.id];
161
- setImages(newImages);
162
- }
163
- }
164
- const result = bufferDeleteCharForward(buffer, cursor);
165
- setBuffer(result.buffer);
166
- setCursor(result.cursor);
167
- }, [buffer, cursor, flushPendingInsertBatch, pushToHistory, images, placeholderState]);
144
+ applyEdit(() => {
145
+ const line = buffer.lines[cursor.line];
146
+ cleanupBlockRegistry(findAtomicBlockAfter(line, cursor.column, placeholderState.placeholders));
147
+ return bufferDeleteCharForward(buffer, cursor, placeholderState.placeholders);
148
+ });
149
+ }, [applyEdit, buffer, cursor, placeholderState, cleanupBlockRegistry]);
168
150
  const newLine = useCallback(() => {
169
- flushPendingInsertBatch();
170
- pushToHistory(buffer, cursor);
171
- const result = bufferInsertNewLine(buffer, cursor);
172
- setBuffer(result.buffer);
173
- setCursor(result.cursor);
174
- }, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
151
+ applyEdit(() => bufferInsertNewLine(buffer, cursor));
152
+ }, [applyEdit, buffer, cursor]);
175
153
  const deleteAndNewLine = useCallback(() => {
176
- flushPendingInsertBatch();
177
- pushToHistory(buffer, cursor);
178
- const afterDelete = bufferDeleteChar(buffer, cursor);
179
- const afterNewLine = bufferInsertNewLine(afterDelete.buffer, afterDelete.cursor);
180
- setBuffer(afterNewLine.buffer);
181
- setCursor(afterNewLine.cursor);
182
- }, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
154
+ applyEdit(() => {
155
+ const afterDelete = bufferDeleteChar(buffer, cursor, placeholderState.placeholders);
156
+ return bufferInsertNewLine(afterDelete.buffer, afterDelete.cursor);
157
+ });
158
+ }, [applyEdit, buffer, cursor, placeholderState]);
183
159
  const moveCursor = useCallback((direction) => {
184
160
  flushPendingInsertBatch();
185
- let newCursor = bufferMoveCursor(buffer, cursor, direction, width);
186
- // Handle placeholder markers: skip over them for left/right
187
- if (placeholderState.placeholders.size > 0) {
188
- if (direction === 'left' || direction === 'right') {
189
- const marker = findPlaceholderAt(buffer.lines[newCursor.line], newCursor.column);
190
- if (marker) {
191
- newCursor = {
192
- ...newCursor,
193
- column: direction === 'left' ? marker.start : marker.end,
194
- };
195
- }
196
- }
197
- // For up/down with visual-aware navigation, use display-space conversion
198
- if ((direction === 'up' || direction === 'down') && width !== undefined) {
199
- const { line, column } = newCursor;
200
- const currentLine = buffer.lines[line];
201
- const displayLine = getDisplayLine(currentLine, placeholderState.placeholders);
202
- const displayCol = bufferColToDisplayCol(currentLine, column, placeholderState.placeholders);
203
- const rows = getVisualRows(displayLine, width);
204
- const getVisualPos = (col, str) => {
205
- const r = getVisualRows(str, width);
206
- for (let i = 0; i < r.length; i++) {
207
- const rowEnd = r[i].start + r[i].length;
208
- if (col >= r[i].start && col < rowEnd) {
209
- return { visualRow: i, visualCol: col - r[i].start };
210
- }
211
- if (col === rowEnd && i === r.length - 1) {
212
- return { visualRow: i, visualCol: r[i].length };
213
- }
214
- }
215
- for (let i = 0; i < r.length; i++) {
216
- if (col === r[i].start + r[i].length && i < r.length - 1) {
217
- return { visualRow: i + 1, visualCol: 0 };
218
- }
219
- }
220
- const lastRow = r[r.length - 1];
221
- return { visualRow: r.length - 1, visualCol: lastRow.length };
222
- };
223
- const visualPos = getVisualPos(displayCol, displayLine);
224
- const displayRowCount = rows.length;
225
- if (direction === 'up') {
226
- if (visualPos.visualRow > 0) {
227
- const targetVisRow = visualPos.visualRow - 1;
228
- const targetVisRowLen = targetVisRow < rows.length ? rows[targetVisRow].length : 0;
229
- const targetVisCol = Math.min(visualPos.visualCol, targetVisRowLen);
230
- const targetDispCol = Math.min(rows[targetVisRow].start + targetVisCol, displayLine.length);
231
- const targetBufCol = displayColToBufferCol(currentLine, targetDispCol, placeholderState.placeholders);
232
- newCursor = { line, column: targetBufCol };
233
- }
234
- else if (line > 0) {
235
- const prevLine = buffer.lines[line - 1];
236
- const prevDisplayLine = getDisplayLine(prevLine, placeholderState.placeholders);
237
- const prevRows = getVisualRows(prevDisplayLine, width);
238
- const targetVisRow = prevRows.length - 1;
239
- const targetVisRowLen = targetVisRow >= 0 ? prevRows[targetVisRow].length : 0;
240
- const targetVisCol = Math.min(visualPos.visualCol, targetVisRowLen);
241
- const targetDispCol = targetVisRow >= 0 ? Math.min(prevRows[targetVisRow].start + targetVisCol, prevDisplayLine.length) : 0;
242
- const targetBufCol = displayColToBufferCol(prevLine, targetDispCol, placeholderState.placeholders);
243
- newCursor = { line: line - 1, column: targetBufCol };
244
- }
245
- }
246
- else {
247
- if (visualPos.visualRow < displayRowCount - 1) {
248
- const targetVisRow = visualPos.visualRow + 1;
249
- const targetVisRowLen = targetVisRow < rows.length ? rows[targetVisRow].length : 0;
250
- const targetVisCol = Math.min(visualPos.visualCol, targetVisRowLen);
251
- const targetDispCol = Math.min(rows[targetVisRow].start + targetVisCol, displayLine.length);
252
- const targetBufCol = displayColToBufferCol(currentLine, targetDispCol, placeholderState.placeholders);
253
- newCursor = { line, column: targetBufCol };
254
- }
255
- else if (line < buffer.lines.length - 1) {
256
- const nextLine = buffer.lines[line + 1];
257
- const nextDisplayLine = getDisplayLine(nextLine, placeholderState.placeholders);
258
- const firstRowLen = nextDisplayLine.length > 0 ? (getVisualRows(nextDisplayLine, width)[0]?.length ?? 0) : 0;
259
- const targetVisCol = Math.min(visualPos.visualCol, firstRowLen);
260
- const targetBufCol = displayColToBufferCol(nextLine, targetVisCol, placeholderState.placeholders);
261
- newCursor = { line: line + 1, column: targetBufCol };
262
- }
263
- }
264
- }
265
- }
161
+ const newCursor = bufferMoveCursor(buffer, cursor, direction, width, placeholderState.placeholders);
266
162
  setCursor(newCursor);
267
163
  }, [buffer, cursor, flushPendingInsertBatch, width, placeholderState]);
268
164
  const undo = useCallback(() => {
@@ -304,42 +200,33 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100, und
304
200
  setRedoStack(newRedoStack);
305
201
  }, [buffer, cursor, redoStack, placeholderState, images]);
306
202
  const setText = useCallback((text) => {
307
- flushPendingInsertBatch();
308
- pushToHistory(buffer, cursor);
309
- const newBuffer = createBuffer(text);
310
- setBuffer(newBuffer);
311
- setPlaceholderState(createPlaceholderState());
312
- const lines = text.split('\n');
313
- setCursor({
314
- line: lines.length - 1,
315
- column: lines[lines.length - 1].length,
316
- });
317
- // Clean up orphaned images
318
- const fullText = getTextContent(newBuffer);
319
- const sentinels = parseSentinels(fullText);
320
- const usedIds = new Set(sentinels.map(s => s.id));
321
- setImages((prev) => {
322
- const next = {};
323
- for (const [id, ref] of Object.entries(prev)) {
324
- if (usedIds.has(id)) {
325
- next[id] = ref;
203
+ applyEdit(() => {
204
+ setPlaceholderState(createPlaceholderState());
205
+ const newBuffer = createBuffer(text);
206
+ const lines = text.split('\n');
207
+ const newCursor = { line: lines.length - 1, column: lines[lines.length - 1].length };
208
+ // Clean up orphaned images
209
+ const usedIds = new Set(parseSentinels(getTextContent(newBuffer)).map((s) => s.id));
210
+ setImages((prev) => {
211
+ const next = {};
212
+ for (const [id, ref] of Object.entries(prev)) {
213
+ if (usedIds.has(id))
214
+ next[id] = ref;
326
215
  }
327
- }
328
- return next;
216
+ return next;
217
+ });
218
+ return { buffer: newBuffer, cursor: newCursor };
329
219
  });
330
- }, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
220
+ }, [applyEdit]);
331
221
  const insertImage = useCallback((imageRef) => {
332
- flushPendingInsertBatch();
333
- pushToHistory(buffer, cursor);
334
- const sentinel = createSentinel(imageRef.id, imageRef.displayNumber);
335
- const result = bufferInsertText(buffer, cursor, sentinel);
336
- setBuffer(result.buffer);
337
- setCursor(result.cursor);
338
- setImages((prev) => ({ ...prev, [imageRef.id]: imageRef }));
339
- if (imageRef.displayNumber >= nextDisplayNumberRef.current) {
340
- nextDisplayNumberRef.current = imageRef.displayNumber + 1;
341
- }
342
- }, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
222
+ applyEdit(() => {
223
+ setImages((prev) => ({ ...prev, [imageRef.id]: imageRef }));
224
+ if (imageRef.displayNumber >= nextDisplayNumberRef.current) {
225
+ nextDisplayNumberRef.current = imageRef.displayNumber + 1;
226
+ }
227
+ return bufferInsertText(buffer, cursor, createSentinel(imageRef.id, imageRef.displayNumber));
228
+ });
229
+ }, [applyEdit, buffer, cursor]);
343
230
  const value = useMemo(() => getValue(buffer.lines, placeholderState.placeholders), [buffer.lines, placeholderState.placeholders]);
344
231
  const cursorOffset = useMemo(() => getValueCursorOffset(buffer.lines, cursor, placeholderState.placeholders), [buffer.lines, cursor, placeholderState.placeholders]);
345
232
  const imagesList = useMemo(() => Object.values(images), [images]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ink-prompt",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "A React Ink component for prompts",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",