ink-prompt 0.2.3 → 0.2.4

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 (61) hide show
  1. package/README.md +2 -2
  2. package/dist/components/MultilineInput/AtomicBlocks.d.ts +10 -20
  3. package/dist/components/MultilineInput/AtomicBlocks.js +33 -42
  4. package/dist/components/MultilineInput/BlockMarker.d.ts +19 -0
  5. package/dist/components/MultilineInput/BlockMarker.js +83 -0
  6. package/dist/components/MultilineInput/BlockRegistry.d.ts +39 -0
  7. package/dist/components/MultilineInput/BlockRegistry.js +236 -0
  8. package/dist/components/MultilineInput/BlockTypes.d.ts +22 -0
  9. package/dist/components/MultilineInput/ImageTypes.d.ts +0 -2
  10. package/dist/components/MultilineInput/ImageTypes.js +1 -2
  11. package/dist/components/MultilineInput/ImageValidator.js +1 -1
  12. package/dist/components/MultilineInput/KeyHandler.d.ts +1 -1
  13. package/dist/components/MultilineInput/TextBuffer.d.ts +5 -31
  14. package/dist/components/MultilineInput/TextBuffer.js +91 -161
  15. package/dist/components/MultilineInput/TextRenderer.d.ts +6 -7
  16. package/dist/components/MultilineInput/TextRenderer.js +7 -7
  17. package/dist/components/MultilineInput/__tests__/BlockMarker.test.js +130 -0
  18. package/dist/components/MultilineInput/__tests__/BlockRegistry.test.js +225 -0
  19. package/dist/components/MultilineInput/__tests__/Placeholder.integration.test.js +44 -65
  20. package/dist/components/MultilineInput/__tests__/TextBuffer_images.test.js +10 -31
  21. package/dist/components/MultilineInput/__tests__/TextRenderer_images.test.js +27 -13
  22. package/dist/components/MultilineInput/__tests__/integration_images.test.js +2 -4
  23. package/dist/components/MultilineInput/__tests__/useTextInput_images.test.js +30 -29
  24. package/dist/components/MultilineInput/index.d.ts +6 -6
  25. package/dist/components/MultilineInput/index.js +2 -11
  26. package/dist/components/MultilineInput/types.d.ts +0 -20
  27. package/dist/components/MultilineInput/useTextInput.d.ts +4 -11
  28. package/dist/components/MultilineInput/useTextInput.js +79 -76
  29. package/package.json +1 -1
  30. package/dist/components/MultilineInput/Placeholder.d.ts +0 -30
  31. package/dist/components/MultilineInput/Placeholder.js +0 -200
  32. package/dist/components/MultilineInput/__tests__/Placeholder.test.js +0 -235
  33. package/dist/examples/examples/basic.js +0 -9
  34. package/dist/examples/src/components/MultilineInput/KeyHandler.d.ts +0 -15
  35. package/dist/examples/src/components/MultilineInput/KeyHandler.js +0 -97
  36. package/dist/examples/src/components/MultilineInput/TextBuffer.d.ts +0 -34
  37. package/dist/examples/src/components/MultilineInput/TextBuffer.js +0 -127
  38. package/dist/examples/src/components/MultilineInput/TextRenderer.d.ts +0 -24
  39. package/dist/examples/src/components/MultilineInput/TextRenderer.js +0 -72
  40. package/dist/examples/src/components/MultilineInput/__tests__/KeyHandler.test.js +0 -115
  41. package/dist/examples/src/components/MultilineInput/__tests__/TextBuffer.test.d.ts +0 -1
  42. package/dist/examples/src/components/MultilineInput/__tests__/TextBuffer.test.js +0 -254
  43. package/dist/examples/src/components/MultilineInput/__tests__/TextRenderer.test.d.ts +0 -1
  44. package/dist/examples/src/components/MultilineInput/__tests__/TextRenderer.test.js +0 -176
  45. package/dist/examples/src/components/MultilineInput/__tests__/integration.test.d.ts +0 -1
  46. package/dist/examples/src/components/MultilineInput/__tests__/integration.test.js +0 -71
  47. package/dist/examples/src/components/MultilineInput/__tests__/useTextInput.test.d.ts +0 -1
  48. package/dist/examples/src/components/MultilineInput/__tests__/useTextInput.test.js +0 -65
  49. package/dist/examples/src/components/MultilineInput/index.d.ts +0 -39
  50. package/dist/examples/src/components/MultilineInput/index.js +0 -82
  51. package/dist/examples/src/components/MultilineInput/types.d.ts +0 -55
  52. package/dist/examples/src/components/MultilineInput/types.js +0 -1
  53. package/dist/examples/src/components/MultilineInput/useTextInput.d.ts +0 -16
  54. package/dist/examples/src/components/MultilineInput/useTextInput.js +0 -82
  55. package/dist/examples/src/hello.test.d.ts +0 -1
  56. package/dist/examples/src/hello.test.js +0 -13
  57. package/dist/examples/src/index.d.ts +0 -2
  58. package/dist/examples/src/index.js +0 -2
  59. /package/dist/components/MultilineInput/{__tests__/Placeholder.test.d.ts → BlockTypes.js} +0 -0
  60. /package/dist/{examples/examples/basic.d.ts → components/MultilineInput/__tests__/BlockMarker.test.d.ts} +0 -0
  61. /package/dist/{examples/src/components/MultilineInput/__tests__/KeyHandler.test.d.ts → components/MultilineInput/__tests__/BlockRegistry.test.d.ts} +0 -0
@@ -2,14 +2,28 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { describe, it, expect } from 'vitest';
3
3
  import { render } from '@testing-library/react';
4
4
  import { TextRenderer, wrapLines } from '../TextRenderer.js';
5
- import { createSentinel } from '../ImageSentinel.js';
5
+ import { createBlockMarker } from '../BlockMarker.js';
6
+ function makeBlockState(entries) {
7
+ const map = new Map();
8
+ for (const e of entries) {
9
+ map.set(e.id, {
10
+ kind: 'image',
11
+ id: e.id,
12
+ displayNumber: e.displayNumber,
13
+ data: '',
14
+ mimeType: 'image/png',
15
+ byteSize: 100,
16
+ });
17
+ }
18
+ return { entries: map, nextPasteNumber: 1, nextImageNumber: 3 };
19
+ }
6
20
  describe('TextRenderer with images', () => {
7
- const sentinel1 = createSentinel('img1', 1);
8
- const sentinel2 = createSentinel('img2', 2);
9
- const images = {
10
- img1: { id: 'img1', data: '', mimeType: 'image/png', byteSize: 100, displayNumber: 1 },
11
- img2: { id: 'img2', data: '', mimeType: 'image/png', byteSize: 100, displayNumber: 2 },
12
- };
21
+ const sentinel1 = createBlockMarker('i', 'img1', 1);
22
+ const sentinel2 = createBlockMarker('i', 'img2', 2);
23
+ const blockState = makeBlockState([
24
+ { id: 'img1', displayNumber: 1 },
25
+ { id: 'img2', displayNumber: 2 },
26
+ ]);
13
27
  describe('wrapLines', () => {
14
28
  it('renders normal text unchanged when no sentinels', () => {
15
29
  const buffer = { lines: ['hello'] };
@@ -46,26 +60,26 @@ describe('TextRenderer with images', () => {
46
60
  it('renders sentinel as dimmed placeholder text', () => {
47
61
  const buffer = { lines: [sentinel1] };
48
62
  const cursor = { line: 0, column: sentinel1.length };
49
- const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor, images: images, showCursor: false }));
63
+ const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor, blockState: blockState, showCursor: false }));
50
64
  expect(container.textContent).toContain('[Pasted Image #1]');
51
65
  });
52
66
  it('renders multiple sentinels', () => {
53
67
  const buffer = { lines: [`${sentinel1} ${sentinel2}`] };
54
68
  const cursor = { line: 0, column: 0 };
55
- const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor, images: images, showCursor: false }));
69
+ const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor, blockState: blockState, showCursor: false }));
56
70
  expect(container.textContent).toContain('[Pasted Image #1]');
57
71
  expect(container.textContent).toContain('[Pasted Image #2]');
58
72
  });
59
73
  it('renders cursor before sentinel', () => {
60
74
  const buffer = { lines: [sentinel1] };
61
- const cursor = { line: 0, column: 0 }; // before sentinel
62
- const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor, images: images, showCursor: true }));
75
+ const cursor = { line: 0, column: 0 };
76
+ const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor, blockState: blockState, showCursor: true }));
63
77
  expect(container.textContent).toContain('[Pasted Image #1]');
64
78
  });
65
79
  it('renders cursor after sentinel', () => {
66
80
  const buffer = { lines: [sentinel1] };
67
- const cursor = { line: 0, column: sentinel1.length }; // after sentinel
68
- const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor, images: images, showCursor: true }));
81
+ const cursor = { line: 0, column: sentinel1.length };
82
+ const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor, blockState: blockState, showCursor: true }));
69
83
  expect(container.textContent).toContain('[Pasted Image #1]');
70
84
  });
71
85
  });
@@ -2,9 +2,9 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { describe, it, expect } from 'vitest';
3
3
  import { render } from '@testing-library/react';
4
4
  import { MultilineInputCore } from '../index.js';
5
- import { createSentinel } from '../ImageSentinel.js';
5
+ import { createBlockMarker } from '../BlockMarker.js';
6
6
  describe('MultilineInputCore with images', () => {
7
- const sentinel1 = createSentinel('img1', 1);
7
+ const sentinel1 = createBlockMarker('i', 'img1', 1);
8
8
  const img1 = {
9
9
  id: 'img1',
10
10
  data: 'base64data',
@@ -26,8 +26,6 @@ describe('MultilineInputCore with images', () => {
26
26
  });
27
27
  describe('submit with images', () => {
28
28
  it('submit passes images when provided via textInput', () => {
29
- // We test that the component renders with images correctly
30
- // The actual submit behavior with images is tested in useTextInput_images tests
31
29
  const { container } = render(_jsx(MultilineInputCore, { value: sentinel1, images: [img1], showCursor: false }));
32
30
  expect(container.textContent).toContain('[Pasted Image #1]');
33
31
  });
@@ -1,25 +1,26 @@
1
1
  import { renderHook, act } from '@testing-library/react';
2
2
  import { describe, it, expect } from 'vitest';
3
3
  import { useTextInput } from '../useTextInput.js';
4
- import { createSentinel } from '../ImageSentinel.js';
4
+ import { createBlockMarker } from '../BlockMarker.js';
5
5
  describe('useTextInput with images', () => {
6
6
  const makeImageRef = (id, displayNumber) => ({
7
7
  id,
8
8
  data: 'base64data',
9
9
  mimeType: 'image/png',
10
10
  byteSize: 100,
11
- displayNumber,
11
+ displayNumber: displayNumber || 1,
12
12
  });
13
13
  describe('insertImage', () => {
14
14
  it('adds sentinel to text and image to map', () => {
15
15
  const { result } = renderHook(() => useTextInput());
16
- const imgRef = makeImageRef('img1', 1);
16
+ const imgRef = makeImageRef('img1');
17
17
  act(() => {
18
18
  result.current.insertImage(imgRef);
19
19
  });
20
- const sentinel = createSentinel('img1', 1);
21
- expect(result.current.value).toBe(sentinel);
22
- expect(result.current.getImages()).toEqual([imgRef]);
20
+ expect(result.current.getImages()).toHaveLength(1);
21
+ expect(result.current.getImages()[0].id).toBe('img1');
22
+ const line = result.current.buffer.lines[0];
23
+ expect(line).toContain(createBlockMarker('i', 'img1', 1));
23
24
  });
24
25
  it('assigns correct displayNumber', () => {
25
26
  const { result } = renderHook(() => useTextInput());
@@ -32,15 +33,25 @@ describe('useTextInput with images', () => {
32
33
  expect(images[0].displayNumber).toBe(1);
33
34
  expect(images[1].displayNumber).toBe(2);
34
35
  });
36
+ it('continues numbering after controlled images are synced', () => {
37
+ const { result } = renderHook(() => useTextInput());
38
+ act(() => {
39
+ result.current.setImages([makeImageRef('img5', 5)]);
40
+ });
41
+ act(() => {
42
+ result.current.insertImage(makeImageRef('img6', 6));
43
+ });
44
+ expect(result.current.getImages().map((img) => img.displayNumber)).toEqual([5, 6]);
45
+ expect(result.current.buffer.lines[0]).toContain(createBlockMarker('i', 'img6', 6));
46
+ });
35
47
  });
36
48
  describe('deleteChar removes image and sentinel', () => {
37
49
  it('removes sentinel and image from map on backspace', () => {
38
50
  const { result } = renderHook(() => useTextInput());
39
- const imgRef = makeImageRef('img1', 1);
51
+ const imgRef = makeImageRef('img1');
40
52
  act(() => { result.current.insertImage(imgRef); });
41
- const sentinel = createSentinel('img1', 1);
42
- // Cursor is at end of sentinel
43
- expect(result.current.cursor.column).toBe(sentinel.length);
53
+ const markerLen = createBlockMarker('i', 'img1', 1).length;
54
+ expect(result.current.cursor.column).toBe(markerLen);
44
55
  act(() => {
45
56
  result.current.delete();
46
57
  });
@@ -51,17 +62,14 @@ describe('useTextInput with images', () => {
51
62
  describe('deleteForward removes image and sentinel', () => {
52
63
  it('removes sentinel and image from map on delete key', () => {
53
64
  const { result } = renderHook(() => useTextInput());
54
- const imgRef = makeImageRef('img1', 1);
65
+ const imgRef = makeImageRef('img1');
55
66
  act(() => { result.current.insertImage(imgRef); });
56
- // Move cursor to before the sentinel
57
67
  act(() => { result.current.moveCursor('left'); });
58
68
  act(() => { result.current.moveCursor('left'); });
59
- // Cursor should now be before the sentinel
60
69
  expect(result.current.cursor.column).toBe(0);
61
70
  act(() => {
62
71
  result.current.deleteForward();
63
72
  });
64
- // Should have deleted the whole sentinel
65
73
  expect(result.current.value).toBe('');
66
74
  expect(result.current.getImages()).toEqual([]);
67
75
  });
@@ -69,7 +77,7 @@ describe('useTextInput with images', () => {
69
77
  describe('undo/redo with images', () => {
70
78
  it('undo restores deleted image', () => {
71
79
  const { result } = renderHook(() => useTextInput({ undoDebounceMs: 0 }));
72
- const imgRef = makeImageRef('img1', 1);
80
+ const imgRef = makeImageRef('img1');
73
81
  act(() => { result.current.insertImage(imgRef); });
74
82
  expect(result.current.getImages()).toHaveLength(1);
75
83
  act(() => { result.current.delete(); });
@@ -80,7 +88,7 @@ describe('useTextInput with images', () => {
80
88
  });
81
89
  it('redo re-applies image insert', () => {
82
90
  const { result } = renderHook(() => useTextInput({ undoDebounceMs: 0 }));
83
- const imgRef = makeImageRef('img1', 1);
91
+ const imgRef = makeImageRef('img1');
84
92
  act(() => { result.current.insertImage(imgRef); });
85
93
  act(() => { result.current.undo(); });
86
94
  expect(result.current.getImages()).toHaveLength(0);
@@ -90,21 +98,14 @@ describe('useTextInput with images', () => {
90
98
  });
91
99
  });
92
100
  describe('setText cleans up orphaned sentinels', () => {
93
- it('removes images whose sentinels are no longer in text', () => {
101
+ it('setText clears block entries', () => {
94
102
  const { result } = renderHook(() => useTextInput());
95
- const img1 = makeImageRef('img1', 1);
96
- const img2 = makeImageRef('img2', 2);
103
+ const img1 = makeImageRef('img1');
97
104
  act(() => { result.current.insertImage(img1); });
98
- act(() => { result.current.insertImage(img2); });
99
- expect(result.current.getImages()).toHaveLength(2);
100
- // setText to a value that only contains img1's sentinel
101
- const sentinel1 = createSentinel('img1', 1);
102
- act(() => {
103
- result.current.setText(sentinel1 + ' extra');
104
- });
105
- const images = result.current.getImages();
106
- expect(images).toHaveLength(1);
107
- expect(images[0].id).toBe('img1');
105
+ expect(result.current.getImages()).toHaveLength(1);
106
+ act(() => { result.current.setText('new text'); });
107
+ expect(result.current.getImages()).toHaveLength(0);
108
+ expect(result.current.value).toBe('new text');
108
109
  });
109
110
  });
110
111
  });
@@ -20,10 +20,10 @@ export interface MultilineInputProps {
20
20
  pasteThreshold?: number;
21
21
  /**
22
22
  * Custom formatter for the placeholder display text.
23
- * Receives the placeholder ID and should return the display string.
24
- * Default: (id) => `[Paste text #${id}]`
23
+ * Receives the display number (1-based) and should return the display string.
24
+ * Default: (n) => `[Paste text #${n}]`
25
25
  */
26
- formatPastePlaceholder?: (id: number) => string;
26
+ formatPastePlaceholder?: (displayNumber: number) => string;
27
27
  images?: ImageRef[];
28
28
  onImagesChange?: (images: ImageRef[]) => void;
29
29
  onPasteError?: (reason: PasteErrorReason) => void;
@@ -51,10 +51,10 @@ export interface MultilineInputCoreProps {
51
51
  pasteThreshold?: number;
52
52
  /**
53
53
  * Custom formatter for the placeholder display text.
54
- * Receives the placeholder ID and should return the display string.
55
- * Default: (id) => `[Paste text #${id}]`
54
+ * Receives the display number (1-based) and should return the display string.
55
+ * Default: (n) => `[Paste text #${n}]`
56
56
  */
57
- formatPastePlaceholder?: (id: number) => string;
57
+ formatPastePlaceholder?: (displayNumber: number) => string;
58
58
  images?: ImageRef[];
59
59
  onImagesChange?: (images: ImageRef[]) => void;
60
60
  }
@@ -7,15 +7,6 @@ import { handleKey } from './KeyHandler.js';
7
7
  import { TextRenderer } from './TextRenderer.js';
8
8
  import { useClipboardPaste } from './useClipboardPaste.js';
9
9
  import { log } from '../../utils/logger.js';
10
- function imagesToRecord(images) {
11
- if (!images || images.length === 0)
12
- return {};
13
- const record = {};
14
- for (const img of images) {
15
- record[img.id] = img;
16
- }
17
- return record;
18
- }
19
10
  export const MultilineInputCore = ({ value, onChange, placeholder, showCursor = true, width = 80, onCursorChange, cursorOverride, undoDebounceMs, pasteThreshold, formatPastePlaceholder, images, onImagesChange, }) => {
20
11
  const textInput = useTextInput({ initialValue: value ?? '', undoDebounceMs, pasteThreshold, formatPastePlaceholder });
21
12
  const isSyncingFromProps = useRef(false);
@@ -67,7 +58,7 @@ export const MultilineInputCore = ({ value, onChange, placeholder, showCursor =
67
58
  if (showPlaceholder) {
68
59
  return _jsx("div", { style: { opacity: 0.5 }, children: placeholder });
69
60
  }
70
- return (_jsx(TextRenderer, { buffer: textInput.buffer, cursor: textInput.cursor, width: width, showCursor: showCursor, placeholderState: textInput.placeholderState, images: imagesToRecord(textInput.images) }));
61
+ return (_jsx(TextRenderer, { buffer: textInput.buffer, cursor: textInput.cursor, width: width, showCursor: showCursor, blockState: textInput.blockState }));
71
62
  };
72
63
  export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCursor = true, width, isActive = true, onCursorChange, cursorOverride, onBoundaryArrow, undoDebounceMs, pasteThreshold, formatPastePlaceholder, images, onImagesChange, onPasteError, enableImagePaste = false, maxImageSizeBytes, maxImageCount, acceptedMimeTypes, }) => {
73
64
  const terminalWidth = useTerminalWidth(width);
@@ -228,5 +219,5 @@ export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCur
228
219
  if (showPlaceholder && !isPasting) {
229
220
  return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: placeholder }) }));
230
221
  }
231
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TextRenderer, { buffer: textInput.buffer, cursor: textInput.cursor, width: terminalWidth, showCursor: showCursor, placeholderState: textInput.placeholderState, images: imagesToRecord(textInput.images) }), isPasting && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "Reading clipboard..." }) }))] }));
222
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TextRenderer, { buffer: textInput.buffer, cursor: textInput.cursor, width: terminalWidth, showCursor: showCursor, blockState: textInput.blockState }), isPasting && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "Reading clipboard..." }) }))] }));
232
223
  };
@@ -33,26 +33,6 @@ export interface WrapResult {
33
33
  /** Column in that visual row where cursor appears */
34
34
  cursorVisualCol: number;
35
35
  }
36
- /**
37
- * Information about a paste placeholder: its identifier, original text, and display label.
38
- */
39
- export interface PlaceholderInfo {
40
- /** Unique placeholder identifier */
41
- id: number;
42
- /** The original pasted text (may contain newlines) */
43
- originalText: string;
44
- /** The display label shown in place of the original text (e.g., "[Paste text #1]") */
45
- displayText: string;
46
- }
47
- /**
48
- * Registry state for paste placeholders.
49
- */
50
- export interface PlaceholderState {
51
- /** Map of placeholder ID to info */
52
- placeholders: Map<number, PlaceholderInfo>;
53
- /** Next auto-incrementing ID */
54
- nextId: number;
55
- }
56
36
  /**
57
37
  * Keyboard key state (mirrors Ink's Key interface)
58
38
  * Defined locally to avoid ESM/CJS import issues with Ink
@@ -1,20 +1,13 @@
1
- import type { Buffer, Cursor, Direction, PlaceholderState } from './types.js';
1
+ import type { Buffer, Cursor, Direction } from './types.js';
2
2
  import type { ImageRef } from './ImageTypes.js';
3
+ import type { BlockState } from './BlockTypes.js';
3
4
  export interface UseTextInputProps {
4
5
  initialValue?: string;
5
6
  width?: number;
6
7
  historyLimit?: number;
7
8
  undoDebounceMs?: number;
8
- /**
9
- * When set, pasted text exceeding this character count is replaced
10
- * with a placeholder for cleaner display.
11
- */
12
9
  pasteThreshold?: number;
13
- /**
14
- * Custom formatter for placeholder display text.
15
- * Default: (id) => `[Paste text #${id}]`
16
- */
17
- formatPastePlaceholder?: (id: number) => string;
10
+ formatPastePlaceholder?: (displayNumber: number) => string;
18
11
  }
19
12
  export interface UseTextInputResult {
20
13
  value: string;
@@ -31,7 +24,7 @@ export interface UseTextInputResult {
31
24
  setText: (text: string) => void;
32
25
  cursorOffset: number;
33
26
  setCursorOffset: (offset: number) => void;
34
- placeholderState: PlaceholderState;
27
+ blockState: BlockState;
35
28
  insertImage: (imageRef: ImageRef) => void;
36
29
  images: ImageRef[];
37
30
  getImages: () => ImageRef[];
@@ -1,10 +1,9 @@
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, } from './TextBuffer.js';
3
- import { createPlaceholderState, addPlaceholder, removePlaceholder, getValue, getValueCursorOffset, getCursorFromValueOffset, } from './Placeholder.js';
4
- import { createSentinel, parseSentinels } from './ImageSentinel.js';
2
+ import { createBuffer, insertText as bufferInsertText, deleteChar as bufferDeleteChar, deleteCharForward as bufferDeleteCharForward, insertNewLine as bufferInsertNewLine, moveCursor as bufferMoveCursor, } from './TextBuffer.js';
3
+ import { createBlockState, createPasteBlockEntry, createImageBlockEntry, removeBlock, getValue, getValueCursorOffset, getCursorFromValueOffset, } from './BlockRegistry.js';
5
4
  import { findAtomicBlockBefore, findAtomicBlockAfter } from './AtomicBlocks.js';
6
5
  import { log } from '../../utils/logger.js';
7
- const defaultFormatPlaceholder = (id) => `[Paste text #${id}]`;
6
+ const defaultFormatPlaceholder = (displayNumber) => `[Paste text #${displayNumber}]`;
8
7
  export function useTextInput({ initialValue = '', width, historyLimit = 100, undoDebounceMs = 200, pasteThreshold, formatPastePlaceholder = defaultFormatPlaceholder, } = {}) {
9
8
  const [buffer, setBuffer] = useState(() => createBuffer(initialValue));
10
9
  const [cursor, setCursor] = useState(() => {
@@ -14,9 +13,7 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100, und
14
13
  column: lines[lines.length - 1].length,
15
14
  };
16
15
  });
17
- const [placeholderState, setPlaceholderState] = useState(() => createPlaceholderState());
18
- const [images, setImages] = useState({});
19
- const nextDisplayNumberRef = useRef(1);
16
+ const [blockState, setBlockState] = useState(() => createBlockState());
20
17
  const [undoStack, setUndoStack] = useState([]);
21
18
  const [redoStack, setRedoStack] = useState([]);
22
19
  const pendingInsertBatchRef = useRef({});
@@ -56,13 +53,12 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100, und
56
53
  pendingInsertBatchRef.current.startState = {
57
54
  buffer: currentBuffer,
58
55
  cursor: currentCursor,
59
- placeholderState,
60
- images,
56
+ blockState,
61
57
  };
62
58
  setRedoStack([]);
63
59
  }
64
60
  schedulePendingInsertCommit();
65
- }, [schedulePendingInsertCommit, placeholderState, images]);
61
+ }, [schedulePendingInsertCommit, blockState]);
66
62
  const flushPendingInsertBatch = useCallback(() => {
67
63
  commitPendingInsertBatch();
68
64
  }, [commitPendingInsertBatch]);
@@ -70,11 +66,10 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100, und
70
66
  appendUndoState({
71
67
  buffer: currentBuffer,
72
68
  cursor: currentCursor,
73
- placeholderState,
74
- images,
69
+ blockState,
75
70
  });
76
71
  setRedoStack([]);
77
- }, [appendUndoState, placeholderState, images]);
72
+ }, [appendUndoState, blockState]);
78
73
  useEffect(() => {
79
74
  return () => {
80
75
  clearPendingInsertTimer();
@@ -92,16 +87,16 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100, und
92
87
  }
93
88
  }, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
94
89
  const insert = useCallback((char) => {
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}`);
90
+ log(`[INSERT] char="${char.replace(/[\x00-\x1F\x7F-\uFFFF]/g, c => `\\x${c.charCodeAt(0).toString(16)}`)}" len=${char.length} cursor={line:${cursor.line},col:${cursor.column}} linesBefore=${buffer.lines.length}`);
96
91
  const normalized = char.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\x00/g, '');
97
92
  if (normalized.length === 0)
98
93
  return;
99
- // Check if this is a paste exceeding the threshold
100
94
  if (pasteThreshold !== undefined && pasteThreshold > 0 && normalized.length > pasteThreshold) {
101
95
  flushPendingInsertBatch();
102
96
  pushToHistory(buffer, cursor);
103
- const { id, marker, state: newPlaceholderState } = addPlaceholder(placeholderState, normalized, formatPastePlaceholder(placeholderState.nextId));
104
- setPlaceholderState(newPlaceholderState);
97
+ const displayText = formatPastePlaceholder(blockState.nextPasteNumber);
98
+ const { marker, state: newBlockState } = createPasteBlockEntry(blockState, normalized, displayText);
99
+ setBlockState(newBlockState);
105
100
  const result = bufferInsertText(buffer, cursor, marker);
106
101
  setBuffer(result.buffer);
107
102
  setCursor(result.cursor);
@@ -120,70 +115,61 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100, und
120
115
  const result = bufferInsertText(buffer, cursor, normalized);
121
116
  setBuffer(result.buffer);
122
117
  setCursor(result.cursor);
123
- }, [beginOrRefreshInsertBatch, buffer, cursor, flushPendingInsertBatch, pushToHistory, undoDebounceMs, pasteThreshold, placeholderState, formatPastePlaceholder]);
118
+ }, [beginOrRefreshInsertBatch, buffer, cursor, flushPendingInsertBatch, pushToHistory, undoDebounceMs, pasteThreshold, blockState, formatPastePlaceholder]);
124
119
  const cleanupBlockRegistry = useCallback((block) => {
125
120
  if (!block)
126
121
  return;
127
- if (block.kind === 'placeholder') {
128
- setPlaceholderState((prev) => removePlaceholder(prev, block.id));
129
- }
130
- else if (block.kind === 'sentinel' && images[block.id]) {
131
- const next = { ...images };
132
- delete next[block.id];
133
- setImages(next);
134
- }
135
- }, [images]);
122
+ setBlockState((prev) => removeBlock(prev, block.id));
123
+ }, []);
136
124
  const deleteChar = useCallback(() => {
137
125
  applyEdit(() => {
138
126
  const line = buffer.lines[cursor.line];
139
- cleanupBlockRegistry(findAtomicBlockBefore(line, cursor.column, placeholderState.placeholders));
140
- return bufferDeleteChar(buffer, cursor, placeholderState.placeholders);
127
+ cleanupBlockRegistry(findAtomicBlockBefore(line, cursor.column, blockState.entries));
128
+ return bufferDeleteChar(buffer, cursor, blockState.entries);
141
129
  });
142
- }, [applyEdit, buffer, cursor, placeholderState, cleanupBlockRegistry]);
130
+ }, [applyEdit, buffer, cursor, blockState, cleanupBlockRegistry]);
143
131
  const deleteCharForward = useCallback(() => {
144
132
  applyEdit(() => {
145
133
  const line = buffer.lines[cursor.line];
146
- cleanupBlockRegistry(findAtomicBlockAfter(line, cursor.column, placeholderState.placeholders));
147
- return bufferDeleteCharForward(buffer, cursor, placeholderState.placeholders);
134
+ cleanupBlockRegistry(findAtomicBlockAfter(line, cursor.column, blockState.entries));
135
+ return bufferDeleteCharForward(buffer, cursor, blockState.entries);
148
136
  });
149
- }, [applyEdit, buffer, cursor, placeholderState, cleanupBlockRegistry]);
137
+ }, [applyEdit, buffer, cursor, blockState, cleanupBlockRegistry]);
150
138
  const newLine = useCallback(() => {
151
139
  applyEdit(() => bufferInsertNewLine(buffer, cursor));
152
140
  }, [applyEdit, buffer, cursor]);
153
141
  const deleteAndNewLine = useCallback(() => {
154
142
  applyEdit(() => {
155
- const afterDelete = bufferDeleteChar(buffer, cursor, placeholderState.placeholders);
143
+ const afterDelete = bufferDeleteChar(buffer, cursor, blockState.entries);
156
144
  return bufferInsertNewLine(afterDelete.buffer, afterDelete.cursor);
157
145
  });
158
- }, [applyEdit, buffer, cursor, placeholderState]);
146
+ }, [applyEdit, buffer, cursor, blockState]);
159
147
  const moveCursor = useCallback((direction) => {
160
148
  flushPendingInsertBatch();
161
- const newCursor = bufferMoveCursor(buffer, cursor, direction, width, placeholderState.placeholders);
149
+ const newCursor = bufferMoveCursor(buffer, cursor, direction, width, blockState.entries);
162
150
  setCursor(newCursor);
163
- }, [buffer, cursor, flushPendingInsertBatch, width, placeholderState]);
151
+ }, [buffer, cursor, flushPendingInsertBatch, width, blockState]);
164
152
  const undo = useCallback(() => {
165
153
  const pendingStartState = pendingInsertBatchRef.current.startState;
166
154
  if (pendingStartState) {
167
155
  clearPendingInsertTimer();
168
156
  pendingInsertBatchRef.current.startState = undefined;
169
- setRedoStack((prev) => [...prev, { buffer, cursor, placeholderState, images }]);
157
+ setRedoStack((prev) => [...prev, { buffer, cursor, blockState }]);
170
158
  setBuffer(pendingStartState.buffer);
171
159
  setCursor(pendingStartState.cursor);
172
- setPlaceholderState(pendingStartState.placeholderState);
173
- setImages(pendingStartState.images);
160
+ setBlockState(pendingStartState.blockState);
174
161
  return;
175
162
  }
176
163
  if (undoStack.length === 0)
177
164
  return;
178
165
  const previousState = undoStack[undoStack.length - 1];
179
166
  const newUndoStack = undoStack.slice(0, -1);
180
- setRedoStack((prev) => [...prev, { buffer, cursor, placeholderState, images }]);
167
+ setRedoStack((prev) => [...prev, { buffer, cursor, blockState }]);
181
168
  setBuffer(previousState.buffer);
182
169
  setCursor(previousState.cursor);
183
- setPlaceholderState(previousState.placeholderState);
184
- setImages(previousState.images);
170
+ setBlockState(previousState.blockState);
185
171
  setUndoStack(newUndoStack);
186
- }, [buffer, clearPendingInsertTimer, cursor, undoStack, placeholderState, images]);
172
+ }, [buffer, clearPendingInsertTimer, cursor, undoStack, blockState]);
187
173
  const redo = useCallback(() => {
188
174
  if (pendingInsertBatchRef.current.startState) {
189
175
  return;
@@ -192,53 +178,70 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100, und
192
178
  return;
193
179
  const nextState = redoStack[redoStack.length - 1];
194
180
  const newRedoStack = redoStack.slice(0, -1);
195
- setUndoStack((prev) => [...prev, { buffer, cursor, placeholderState, images }]);
181
+ setUndoStack((prev) => [...prev, { buffer, cursor, blockState }]);
196
182
  setBuffer(nextState.buffer);
197
183
  setCursor(nextState.cursor);
198
- setPlaceholderState(nextState.placeholderState);
199
- setImages(nextState.images);
184
+ setBlockState(nextState.blockState);
200
185
  setRedoStack(newRedoStack);
201
- }, [buffer, cursor, redoStack, placeholderState, images]);
186
+ }, [buffer, cursor, redoStack, blockState]);
202
187
  const setText = useCallback((text) => {
203
188
  applyEdit(() => {
204
- setPlaceholderState(createPlaceholderState());
189
+ setBlockState(createBlockState());
205
190
  const newBuffer = createBuffer(text);
206
191
  const lines = text.split('\n');
207
192
  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;
215
- }
216
- return next;
217
- });
218
193
  return { buffer: newBuffer, cursor: newCursor };
219
194
  });
220
195
  }, [applyEdit]);
221
196
  const insertImage = useCallback((imageRef) => {
222
197
  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));
198
+ const { marker, state: newBlockState } = createImageBlockEntry(blockState, imageRef, imageRef.id);
199
+ setBlockState(newBlockState);
200
+ return bufferInsertText(buffer, cursor, marker);
228
201
  });
229
- }, [applyEdit, buffer, cursor]);
230
- const value = useMemo(() => getValue(buffer.lines, placeholderState.placeholders), [buffer.lines, placeholderState.placeholders]);
231
- const cursorOffset = useMemo(() => getValueCursorOffset(buffer.lines, cursor, placeholderState.placeholders), [buffer.lines, cursor, placeholderState.placeholders]);
232
- const imagesList = useMemo(() => Object.values(images), [images]);
202
+ }, [applyEdit, buffer, cursor, blockState]);
203
+ const value = useMemo(() => getValue(buffer.lines, blockState.entries), [buffer.lines, blockState.entries]);
204
+ const cursorOffset = useMemo(() => getValueCursorOffset(buffer.lines, cursor, blockState.entries), [buffer.lines, cursor, blockState.entries]);
205
+ const imagesList = useMemo(() => {
206
+ const result = [];
207
+ for (const entry of blockState.entries.values()) {
208
+ if (entry.kind === 'image') {
209
+ result.push({
210
+ id: entry.id,
211
+ data: entry.data,
212
+ mimeType: entry.mimeType,
213
+ byteSize: entry.byteSize,
214
+ displayNumber: entry.displayNumber,
215
+ });
216
+ }
217
+ }
218
+ return result;
219
+ }, [blockState.entries]);
233
220
  const getImagesCallback = useCallback(() => {
234
221
  return imagesList;
235
222
  }, [imagesList]);
236
223
  const setImagesCallback = useCallback((newImages) => {
237
- const map = {};
238
- for (const img of newImages) {
239
- map[img.id] = img;
240
- }
241
- setImages(map);
224
+ setBlockState((prev) => {
225
+ const newEntries = new Map(prev.entries);
226
+ for (const [id, entry] of prev.entries) {
227
+ if (entry.kind === 'image') {
228
+ newEntries.delete(id);
229
+ }
230
+ }
231
+ let nextImageNumber = prev.nextImageNumber;
232
+ for (const img of newImages) {
233
+ nextImageNumber = Math.max(nextImageNumber, img.displayNumber + 1);
234
+ newEntries.set(img.id, {
235
+ kind: 'image',
236
+ id: img.id,
237
+ displayNumber: img.displayNumber,
238
+ data: img.data,
239
+ mimeType: img.mimeType,
240
+ byteSize: img.byteSize,
241
+ });
242
+ }
243
+ return { ...prev, entries: newEntries, nextImageNumber };
244
+ });
242
245
  }, []);
243
246
  return {
244
247
  value,
@@ -256,9 +259,9 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100, und
256
259
  cursorOffset,
257
260
  setCursorOffset: useCallback((offset) => {
258
261
  flushPendingInsertBatch();
259
- setCursor(getCursorFromValueOffset(buffer.lines, offset, placeholderState.placeholders));
260
- }, [buffer.lines, flushPendingInsertBatch, placeholderState.placeholders]),
261
- placeholderState,
262
+ setCursor(getCursorFromValueOffset(buffer.lines, offset, blockState.entries));
263
+ }, [buffer.lines, flushPendingInsertBatch, blockState.entries]),
264
+ blockState,
262
265
  insertImage,
263
266
  images: imagesList,
264
267
  getImages: getImagesCallback,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ink-prompt",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "A React Ink component for prompts",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",