ink-prompt 0.2.2 → 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 +56 -13
  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
@@ -0,0 +1,225 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createBlockState, createPasteBlockEntry, createImageBlockEntry, removeBlock, getBlock, getDisplayLine, getValue, bufferColToDisplayCol, displayColToBufferCol, getValueCursorOffset, getCursorFromValueOffset, } from '../BlockRegistry.js';
3
+ import { parseBlockMarkers } from '../BlockMarker.js';
4
+ function makeImageRef(overrides = {}) {
5
+ return {
6
+ id: 'img1',
7
+ data: '',
8
+ mimeType: 'image/png',
9
+ byteSize: 100,
10
+ displayNumber: 1,
11
+ ...overrides,
12
+ };
13
+ }
14
+ describe('createBlockState', () => {
15
+ it('creates empty state with counters at 1', () => {
16
+ const state = createBlockState();
17
+ expect(state.entries.size).toBe(0);
18
+ expect(state.nextPasteNumber).toBe(1);
19
+ expect(state.nextImageNumber).toBe(1);
20
+ });
21
+ });
22
+ describe('createPasteBlockEntry', () => {
23
+ it('adds paste block and returns marker', () => {
24
+ const state = createBlockState();
25
+ const { id, marker, state: newState } = createPasteBlockEntry(state, 'original text', '[Paste text #1]');
26
+ expect(id).toBeTruthy();
27
+ expect(marker).toContain('p:');
28
+ expect(marker).toContain(':1');
29
+ expect(newState.entries.size).toBe(1);
30
+ expect(newState.nextPasteNumber).toBe(2);
31
+ expect(newState.nextImageNumber).toBe(1);
32
+ const entry = newState.entries.get(id);
33
+ expect(entry.kind).toBe('paste');
34
+ if (entry.kind === 'paste') {
35
+ expect(entry.originalText).toBe('original text');
36
+ expect(entry.displayText).toBe('[Paste text #1]');
37
+ expect(entry.displayNumber).toBe(1);
38
+ }
39
+ });
40
+ it('increments paste counter', () => {
41
+ const state = createBlockState();
42
+ const { state: s1 } = createPasteBlockEntry(state, 'a', '[#1]');
43
+ const { state: s2 } = createPasteBlockEntry(s1, 'b', '[#2]');
44
+ expect(s2.nextPasteNumber).toBe(3);
45
+ expect(s2.nextImageNumber).toBe(1);
46
+ expect(s2.entries.size).toBe(2);
47
+ });
48
+ });
49
+ describe('createImageBlockEntry', () => {
50
+ it('adds image block and returns marker', () => {
51
+ const state = createBlockState();
52
+ const imgRef = makeImageRef();
53
+ const { id, marker, state: newState } = createImageBlockEntry(state, imgRef);
54
+ expect(id).toBe('img1');
55
+ expect(marker).toContain('i:');
56
+ expect(marker).toContain(':1');
57
+ expect(newState.entries.size).toBe(1);
58
+ expect(newState.nextImageNumber).toBe(2);
59
+ expect(newState.nextPasteNumber).toBe(1);
60
+ const entry = newState.entries.get(id);
61
+ expect(entry.kind).toBe('image');
62
+ if (entry.kind === 'image') {
63
+ expect(entry.data).toBe('');
64
+ expect(entry.mimeType).toBe('image/png');
65
+ expect(entry.byteSize).toBe(100);
66
+ expect(entry.displayNumber).toBe(1);
67
+ }
68
+ });
69
+ it('increments image counter', () => {
70
+ const state = createBlockState();
71
+ const { state: s1 } = createImageBlockEntry(state, makeImageRef({ id: 'a' }));
72
+ const { state: s2 } = createImageBlockEntry(s1, makeImageRef({ id: 'b', displayNumber: 2 }));
73
+ expect(s2.nextImageNumber).toBe(3);
74
+ expect(s2.nextPasteNumber).toBe(1);
75
+ expect(s2.entries.size).toBe(2);
76
+ });
77
+ it('uses image ref displayNumber for marker and counter', () => {
78
+ const state = createBlockState();
79
+ const { marker, state: newState } = createImageBlockEntry(state, makeImageRef({ id: 'img5', displayNumber: 5 }));
80
+ expect(marker).toContain(':5');
81
+ expect(newState.nextImageNumber).toBe(6);
82
+ const entry = newState.entries.get('img5');
83
+ expect(entry.displayNumber).toBe(5);
84
+ });
85
+ it('paste and image counters are independent', () => {
86
+ const state = createBlockState();
87
+ const { state: s1 } = createPasteBlockEntry(state, 'text', '[Paste text #1]');
88
+ const { state: s2 } = createImageBlockEntry(s1, makeImageRef());
89
+ expect(s2.nextPasteNumber).toBe(2);
90
+ expect(s2.nextImageNumber).toBe(2);
91
+ });
92
+ });
93
+ describe('removeBlock', () => {
94
+ it('removes block by id', () => {
95
+ const state = createBlockState();
96
+ const { id, state: s1 } = createPasteBlockEntry(state, 'a', '[#1]');
97
+ expect(s1.entries.size).toBe(1);
98
+ const s2 = removeBlock(s1, id);
99
+ expect(s2.entries.size).toBe(0);
100
+ });
101
+ it('does nothing for non-existent id', () => {
102
+ const state = createBlockState();
103
+ const s1 = removeBlock(state, 'nonexistent');
104
+ expect(s1.entries.size).toBe(0);
105
+ });
106
+ });
107
+ describe('getBlock', () => {
108
+ it('returns entry by id', () => {
109
+ const state = createBlockState();
110
+ const { id, state: s1 } = createPasteBlockEntry(state, 'a', '[#1]');
111
+ const entry = getBlock(s1, id);
112
+ expect(entry).toBeDefined();
113
+ expect(entry.kind).toBe('paste');
114
+ });
115
+ it('returns undefined for non-existent id', () => {
116
+ const state = createBlockState();
117
+ expect(getBlock(state, 'nope')).toBeUndefined();
118
+ });
119
+ });
120
+ describe('getDisplayLine', () => {
121
+ it('expands paste marker to display text', () => {
122
+ const state = createBlockState();
123
+ const { marker, state: s1 } = createPasteBlockEntry(state, 'original', '[Custom #1]');
124
+ const line = `Hello ${marker} world`;
125
+ expect(getDisplayLine(line, s1.entries)).toBe('Hello [Custom #1] world');
126
+ });
127
+ it('expands image marker to display text', () => {
128
+ const state = createBlockState();
129
+ const { marker, state: s1 } = createImageBlockEntry(state, makeImageRef());
130
+ const line = `a${marker}b`;
131
+ expect(getDisplayLine(line, s1.entries)).toBe('a[Pasted Image #1]b');
132
+ });
133
+ it('returns line unchanged if no markers', () => {
134
+ const state = createBlockState();
135
+ expect(getDisplayLine('plain text', state.entries)).toBe('plain text');
136
+ });
137
+ });
138
+ describe('getValue', () => {
139
+ it('expands paste markers to original text', () => {
140
+ const state = createBlockState();
141
+ const { marker, state: s1 } = createPasteBlockEntry(state, 'long original text', '[#1]');
142
+ const lines = [`Hello ${marker} world`];
143
+ expect(getValue(lines, s1.entries)).toBe('Hello long original text world');
144
+ });
145
+ it('leaves image markers as raw markers', () => {
146
+ const state = createBlockState();
147
+ const { marker, state: s1 } = createImageBlockEntry(state, makeImageRef());
148
+ const lines = [`a${marker}b`];
149
+ expect(getValue(lines, s1.entries)).toBe(`a${marker}b`);
150
+ });
151
+ it('handles multiple lines', () => {
152
+ const state = createBlockState();
153
+ const { marker, state: s1 } = createPasteBlockEntry(state, 'long', '[#1]');
154
+ const lines = [`line1 ${marker}`, 'line2'];
155
+ expect(getValue(lines, s1.entries)).toBe('line1 long\nline2');
156
+ });
157
+ it('returns original lines if no entries', () => {
158
+ const state = createBlockState();
159
+ expect(getValue(['hello'], state.entries)).toBe('hello');
160
+ });
161
+ });
162
+ describe('bufferColToDisplayCol', () => {
163
+ it('handles plain text without markers', () => {
164
+ const state = createBlockState();
165
+ expect(bufferColToDisplayCol('hello', 3, state.entries)).toBe(3);
166
+ });
167
+ it('maps column before marker', () => {
168
+ const state = createBlockState();
169
+ const { marker, state: s1 } = createPasteBlockEntry(state, 'x', '[Display]');
170
+ const line = `ab${marker}cd`;
171
+ expect(bufferColToDisplayCol(line, 0, s1.entries)).toBe(0);
172
+ expect(bufferColToDisplayCol(line, 1, s1.entries)).toBe(1);
173
+ expect(bufferColToDisplayCol(line, 2, s1.entries)).toBe(2);
174
+ });
175
+ it('maps column within paste marker to display length', () => {
176
+ const state = createBlockState();
177
+ const { marker, state: s1 } = createPasteBlockEntry(state, 'x', '[Display]');
178
+ const line = `ab${marker}cd`;
179
+ // marker is at buffer positions 2-~15
180
+ const markers = parseBlockMarkers(line);
181
+ const m = markers[0];
182
+ // Any column within the marker should map to display col = 2 + 9 = 11
183
+ expect(bufferColToDisplayCol(line, m.start + 1, s1.entries)).toBe(11);
184
+ expect(bufferColToDisplayCol(line, m.end - 1, s1.entries)).toBe(11);
185
+ });
186
+ });
187
+ describe('getValueCursorOffset', () => {
188
+ it('computes offset in expanded value', () => {
189
+ const state = createBlockState();
190
+ const { marker, state: s1 } = createPasteBlockEntry(state, 'hello', '[#1]');
191
+ const lines = [`${marker} world`];
192
+ // marker expands to 5 chars, so cursor at buffer position 0 maps to value position 0
193
+ expect(getValueCursorOffset(lines, { line: 0, column: 0 }, s1.entries)).toBe(0);
194
+ // Cursor after marker in buffer maps to value position after original text
195
+ const markers = parseBlockMarkers(marker);
196
+ expect(getValueCursorOffset(lines, { line: 0, column: markers[0].end }, s1.entries)).toBe(5);
197
+ expect(getValueCursorOffset(lines, { line: 0, column: markers[0].end + 1 }, s1.entries)).toBe(6);
198
+ });
199
+ });
200
+ describe('getCursorFromValueOffset', () => {
201
+ it('converts value offset back to buffer cursor', () => {
202
+ const state = createBlockState();
203
+ const { marker, state: s1 } = createPasteBlockEntry(state, 'hello', '[#1]');
204
+ const lines = [`${marker} world`];
205
+ const markers = parseBlockMarkers(marker);
206
+ const cursor = getCursorFromValueOffset(lines, 5, s1.entries);
207
+ expect(cursor.line).toBe(0);
208
+ expect(cursor.column).toBe(markers[0].end);
209
+ });
210
+ });
211
+ describe('displayColToBufferCol', () => {
212
+ it('handles plain text without markers', () => {
213
+ const state = createBlockState();
214
+ expect(displayColToBufferCol('hello', 3, state.entries)).toBe(3);
215
+ });
216
+ it('maps display column past marker to buffer column past marker', () => {
217
+ const state = createBlockState();
218
+ const { marker, state: s1 } = createPasteBlockEntry(state, 'x', '[Display]');
219
+ const line = `ab${marker}cd`;
220
+ const markers = parseBlockMarkers(line);
221
+ const displayLen = '[Display]'.length; // 9
222
+ // display position after marker: 2 + 9 = 11 → buffer after marker
223
+ expect(displayColToBufferCol(line, 2 + displayLen, s1.entries)).toBe(markers[0].end);
224
+ });
225
+ });
@@ -1,6 +1,8 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { renderHook, act } from '@testing-library/react';
3
3
  import { useTextInput } from '../useTextInput.js';
4
+ import { parseBlockMarkers } from '../BlockMarker.js';
5
+ import { BLOCK_OPEN } from '../BlockMarker.js';
4
6
  describe('Placeholder integration with useTextInput', () => {
5
7
  const longText = 'x'.repeat(200);
6
8
  it('creates a placeholder when pasting text exceeding pasteThreshold', () => {
@@ -11,13 +13,12 @@ describe('Placeholder integration with useTextInput', () => {
11
13
  act(() => {
12
14
  result.current.insert(longText);
13
15
  });
14
- // Text should contain the placeholder marker
15
- expect(result.current.buffer.lines[0]).toContain('\x00P');
16
- // Value should return the original text
16
+ const line = result.current.buffer.lines[0];
17
+ expect(line).toContain(BLOCK_OPEN);
18
+ expect(line).toContain('p:');
17
19
  expect(result.current.value).toBe(longText);
18
- // Cursor should be after the placeholder
19
20
  const bufLine = result.current.buffer.lines[0];
20
- const markerEnd = bufLine.length; // marker is the only thing on the line
21
+ const markerEnd = bufLine.length;
21
22
  expect(result.current.cursor).toEqual({ line: 0, column: markerEnd });
22
23
  });
23
24
  it('does not create placeholder when pasteThreshold is not set', () => {
@@ -27,7 +28,6 @@ describe('Placeholder integration with useTextInput', () => {
27
28
  act(() => {
28
29
  result.current.insert(longText);
29
30
  });
30
- // Should contain the actual text, not a marker
31
31
  expect(result.current.value).toBe(longText);
32
32
  expect(result.current.buffer.lines[0]).toBe(longText);
33
33
  });
@@ -49,14 +49,12 @@ describe('Placeholder integration with useTextInput', () => {
49
49
  undoDebounceMs: 0,
50
50
  }));
51
51
  act(() => { result.current.insert(longText); });
52
- // Move cursor to end to insert more
53
52
  act(() => { result.current.insert(' '); });
54
53
  act(() => { result.current.insert(longText + longText); });
55
- // Two placeholders should exist
56
54
  const line = result.current.buffer.lines[0];
57
- expect(line.match(/\x00P\d+\x00/g)?.length).toBeGreaterThanOrEqual(2);
58
- // Different IDs
59
- const ids = line.match(/\x00P(\d+)\x00/g).map(s => parseInt(s.match(/\d+/)[0], 10));
55
+ const markers = parseBlockMarkers(line);
56
+ expect(markers.length).toBeGreaterThanOrEqual(2);
57
+ const ids = markers.map(m => m.id);
60
58
  expect(new Set(ids).size).toBe(ids.length);
61
59
  });
62
60
  it('value returns original text with multiple placeholders', () => {
@@ -78,8 +76,6 @@ describe('Placeholder integration with useTextInput', () => {
78
76
  }));
79
77
  act(() => { result.current.insert(longText); });
80
78
  expect(result.current.value).toBe(longText);
81
- expect(result.current.buffer.lines[0]).toContain('\x00P0\x00');
82
- // Backspace should remove the whole placeholder
83
79
  act(() => { result.current.delete(); });
84
80
  expect(result.current.value).toBe('');
85
81
  expect(result.current.buffer.lines[0]).toBe('');
@@ -90,17 +86,13 @@ describe('Placeholder integration with useTextInput', () => {
90
86
  pasteThreshold: 10,
91
87
  undoDebounceMs: 0,
92
88
  }));
93
- // Insert text, then go back to start
94
89
  act(() => { result.current.insert('A'); });
95
90
  act(() => { result.current.insert(longText); });
96
- // Move cursor to start
97
91
  act(() => { result.current.moveCursor('lineStart'); });
98
92
  expect(result.current.cursor).toEqual({ line: 0, column: 0 });
99
- // Forward delete should delete 'A' first
100
93
  act(() => { result.current.deleteForward(); });
101
94
  expect(result.current.value).toBe(longText);
102
95
  expect(result.current.cursor).toEqual({ line: 0, column: 0 });
103
- // Forward delete again should delete the placeholder
104
96
  act(() => { result.current.deleteForward(); });
105
97
  expect(result.current.value).toBe('');
106
98
  expect(result.current.buffer.lines[0]).toBe('');
@@ -113,14 +105,17 @@ describe('Placeholder integration with useTextInput', () => {
113
105
  act(() => { result.current.insert('A'); });
114
106
  act(() => { result.current.insert(longText); });
115
107
  act(() => { result.current.insert('B'); });
116
- // Cursor at end: 'A'(0) + marker(1-4) + 'B'(5) → column 6
117
- expect(result.current.cursor.column).toBe(6);
118
- // Move left to column 5 (valid: before 'B', after marker)
108
+ const line = result.current.buffer.lines[0];
109
+ const markers = parseBlockMarkers(line);
110
+ const marker = markers[0];
111
+ const endCol = marker.end + 1; // 1 for B
112
+ expect(result.current.cursor.column).toBe(endCol);
113
+ // Move left to position after marker (before B)
119
114
  act(() => { result.current.moveCursor('left'); });
120
- expect(result.current.cursor.column).toBe(5);
121
- // Move left again - lands inside marker, skip to marker start (column 1)
115
+ expect(result.current.cursor.column).toBe(marker.end);
116
+ // Move left again - should jump to marker start
122
117
  act(() => { result.current.moveCursor('left'); });
123
- expect(result.current.cursor.column).toBe(1);
118
+ expect(result.current.cursor.column).toBe(marker.start);
124
119
  });
125
120
  it('right arrow jumps over placeholder', () => {
126
121
  const { result } = renderHook(() => useTextInput({
@@ -130,15 +125,17 @@ describe('Placeholder integration with useTextInput', () => {
130
125
  act(() => { result.current.insert('A'); });
131
126
  act(() => { result.current.insert(longText); });
132
127
  act(() => { result.current.insert('B'); });
133
- // Move cursor to start
134
128
  act(() => { result.current.moveCursor('lineStart'); });
135
129
  expect(result.current.cursor.column).toBe(0);
136
- // Move right to column 1 (valid: after 'A', before marker)
130
+ const line = result.current.buffer.lines[0];
131
+ const markers = parseBlockMarkers(line);
132
+ const marker = markers[0];
133
+ // Move right past 'A'
137
134
  act(() => { result.current.moveCursor('right'); });
138
- expect(result.current.cursor.column).toBe(1);
139
- // Move right again - lands inside marker, skip to marker end (column 5)
135
+ expect(result.current.cursor.column).toBe(marker.start);
136
+ // Move right again - should jump to marker end
140
137
  act(() => { result.current.moveCursor('right'); });
141
- expect(result.current.cursor.column).toBe(5);
138
+ expect(result.current.cursor.column).toBe(marker.end);
142
139
  });
143
140
  it('undo restores state before placeholder creation', () => {
144
141
  const { result } = renderHook(() => useTextInput({
@@ -148,11 +145,9 @@ describe('Placeholder integration with useTextInput', () => {
148
145
  act(() => { result.current.insert('A'); });
149
146
  act(() => { result.current.insert(longText); });
150
147
  expect(result.current.value).toBe('A' + longText);
151
- // Undo should remove the paste
152
148
  act(() => { result.current.undo(); });
153
149
  expect(result.current.value).toBe('A');
154
150
  expect(result.current.cursor).toEqual({ line: 0, column: 1 });
155
- // Undo again should remove 'A'
156
151
  act(() => { result.current.undo(); });
157
152
  expect(result.current.value).toBe('');
158
153
  });
@@ -164,13 +159,11 @@ describe('Placeholder integration with useTextInput', () => {
164
159
  act(() => { result.current.insert('A'); });
165
160
  act(() => { result.current.insert(longText); });
166
161
  expect(result.current.value).toBe('A' + longText);
167
- // Undo the placeholder creation
168
162
  act(() => { result.current.undo(); });
169
163
  expect(result.current.value).toBe('A');
170
- // Redo should bring back the placeholder
171
164
  act(() => { result.current.redo(); });
172
165
  expect(result.current.value).toBe('A' + longText);
173
- expect(result.current.buffer.lines[0]).toContain('\x00P');
166
+ expect(result.current.buffer.lines[0]).toContain(BLOCK_OPEN);
174
167
  });
175
168
  it('handles multi-line original text', () => {
176
169
  const { result } = renderHook(() => useTextInput({
@@ -179,10 +172,8 @@ describe('Placeholder integration with useTextInput', () => {
179
172
  }));
180
173
  const multiLineText = 'hello\nworld\nfoo';
181
174
  act(() => { result.current.insert(multiLineText); });
182
- // Value should contain the original multi-line text
183
175
  expect(result.current.value).toBe(multiLineText);
184
- // Buffer should have the marker on a single line
185
- expect(result.current.buffer.lines[0]).toContain('\x00P0\x00');
176
+ expect(result.current.buffer.lines[0]).toContain(BLOCK_OPEN);
186
177
  expect(result.current.buffer.lines.length).toBe(1);
187
178
  });
188
179
  it('typing after placeholder works correctly', () => {
@@ -194,7 +185,7 @@ describe('Placeholder integration with useTextInput', () => {
194
185
  act(() => { result.current.insert(' and more'); });
195
186
  expect(result.current.value).toBe(longText + ' and more');
196
187
  const line = result.current.buffer.lines[0];
197
- expect(line).toContain('\x00P0\x00');
188
+ expect(line).toContain(BLOCK_OPEN);
198
189
  expect(line).toContain(' and more');
199
190
  });
200
191
  it('typing before placeholder works correctly', () => {
@@ -203,12 +194,11 @@ describe('Placeholder integration with useTextInput', () => {
203
194
  undoDebounceMs: 0,
204
195
  }));
205
196
  act(() => { result.current.insert(longText); });
206
- // Move cursor to start
207
197
  act(() => { result.current.moveCursor('lineStart'); });
208
198
  act(() => { result.current.insert('prefix '); });
209
199
  expect(result.current.value).toBe('prefix ' + longText);
210
200
  const line = result.current.buffer.lines[0];
211
- expect(line).toContain('\x00P0\x00');
201
+ expect(line).toContain(BLOCK_OPEN);
212
202
  expect(line).toContain('prefix ');
213
203
  });
214
204
  it('setText clears placeholders', () => {
@@ -217,24 +207,25 @@ describe('Placeholder integration with useTextInput', () => {
217
207
  undoDebounceMs: 0,
218
208
  }));
219
209
  act(() => { result.current.insert(longText); });
220
- expect(result.current.placeholderState.placeholders.size).toBe(1);
210
+ expect(result.current.blockState.entries.size).toBe(1);
221
211
  act(() => { result.current.setText('new text'); });
222
212
  expect(result.current.value).toBe('new text');
223
- expect(result.current.placeholderState.placeholders.size).toBe(0);
213
+ expect(result.current.blockState.entries.size).toBe(0);
224
214
  });
225
215
  it('custom formatPastePlaceholder is used for display text', () => {
226
- const formatter = (id) => `📋Pasted#${id}📋`;
216
+ const formatter = (displayNumber) => `📋Pasted#${displayNumber}📋`;
227
217
  const { result } = renderHook(() => useTextInput({
228
218
  pasteThreshold: 10,
229
219
  formatPastePlaceholder: formatter,
230
220
  undoDebounceMs: 0,
231
221
  }));
232
222
  act(() => { result.current.insert(longText); });
233
- // Value should still have original text
234
223
  expect(result.current.value).toBe(longText);
235
- // The placeholder should have the custom display text
236
- const placeholderInfo = result.current.placeholderState.placeholders.get(0);
237
- expect(placeholderInfo?.displayText).toBe('📋Pasted#0📋');
224
+ const entry = result.current.blockState.entries.values().next().value;
225
+ expect(entry.kind).toBe('paste');
226
+ if (entry.kind === 'paste') {
227
+ expect(entry.displayText).toBe('📋Pasted#1📋');
228
+ }
238
229
  });
239
230
  it('placeholder counter is per useTextInput instance', () => {
240
231
  const { result: r1 } = renderHook(() => useTextInput({
@@ -249,19 +240,17 @@ describe('Placeholder integration with useTextInput', () => {
249
240
  r1.current.insert(longText);
250
241
  r2.current.insert(longText);
251
242
  });
252
- expect(r1.current.placeholderState.placeholders.get(0)).toBeDefined();
253
- expect(r2.current.placeholderState.placeholders.get(0)).toBeDefined();
243
+ expect(r1.current.blockState.entries.size).toBe(1);
244
+ expect(r2.current.blockState.entries.size).toBe(1);
254
245
  });
255
246
  it('strips \x00 from user input so it cannot impersonate a placeholder marker', () => {
256
247
  const { result } = renderHook(() => useTextInput({
257
248
  undoDebounceMs: 0,
258
249
  }));
259
- // Forge what looks like a marker for id 0
260
250
  act(() => { result.current.insert('hello\x00P0\x00world'); });
261
- // \x00 bytes are stripped, leaving plain text — no fake marker in buffer or value
262
251
  expect(result.current.buffer.lines[0]).toBe('helloP0world');
263
252
  expect(result.current.value).toBe('helloP0world');
264
- expect(result.current.placeholderState.placeholders.size).toBe(0);
253
+ expect(result.current.blockState.entries.size).toBe(0);
265
254
  });
266
255
  it('backspace at line start merges with previous line containing placeholder', () => {
267
256
  const { result } = renderHook(() => useTextInput({
@@ -270,19 +259,14 @@ describe('Placeholder integration with useTextInput', () => {
270
259
  }));
271
260
  act(() => { result.current.insert(longText); });
272
261
  act(() => { result.current.insert('\n'); });
273
- act(() => { result.current.insert('abc'); }); // 3 chars, below threshold
274
- // Cursor should be at end of line 1
275
- expect(result.current.cursor).toEqual({ line: 1, column: 3 }); // 'abc' = 3 chars
276
- // Move to start of second line
262
+ act(() => { result.current.insert('abc'); });
263
+ expect(result.current.cursor).toEqual({ line: 1, column: 3 });
277
264
  for (let i = 0; i < 3; i++) {
278
265
  act(() => { result.current.moveCursor('left'); });
279
266
  }
280
267
  expect(result.current.cursor).toEqual({ line: 1, column: 0 });
281
- // Backspace should merge lines
282
268
  act(() => { result.current.delete(); });
283
269
  expect(result.current.cursor.line).toBe(0);
284
- // After merge: "\x00P0\x00abc" = 7 chars, cursor at previousLine.length = 4
285
- expect(result.current.cursor.column).toBe(4);
286
270
  expect(result.current.value).toBe(longText + 'abc');
287
271
  });
288
272
  it('delete at end of line merges with next line containing placeholder', () => {
@@ -290,13 +274,11 @@ describe('Placeholder integration with useTextInput', () => {
290
274
  pasteThreshold: 10,
291
275
  undoDebounceMs: 0,
292
276
  }));
293
- act(() => { result.current.insert('hi'); }); // 2 chars, below threshold
277
+ act(() => { result.current.insert('hi'); });
294
278
  act(() => { result.current.insert('\n'); });
295
279
  act(() => { result.current.insert(longText); });
296
- // Move to end of first line
297
280
  act(() => { result.current.moveCursor('up'); });
298
- expect(result.current.cursor).toEqual({ line: 0, column: 2 }); // end of 'hi'
299
- // Forward delete should merge lines
281
+ expect(result.current.cursor).toEqual({ line: 0, column: 2 });
300
282
  act(() => { result.current.deleteForward(); });
301
283
  expect(result.current.value).toBe('hi' + longText);
302
284
  });
@@ -307,12 +289,9 @@ describe('Placeholder integration with useTextInput', () => {
307
289
  }));
308
290
  act(() => { result.current.insert('A'); });
309
291
  const longLen = longText.length;
310
- // Before paste, cursor offset = 1
311
292
  expect(result.current.cursorOffset).toBe(1);
312
293
  act(() => { result.current.insert(longText); });
313
- // After paste, cursor offset should be 1 + longLen (after the expanded text)
314
294
  expect(result.current.cursorOffset).toBe(1 + longLen);
315
- // Value is A + longText (length 1 + longLen)
316
295
  expect(result.current.value.length).toBe(1 + longLen);
317
296
  });
318
297
  });
@@ -1,39 +1,29 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { createBuffer, insertText, deleteChar, deleteCharForward, moveCursor, getVisualRows, getTextContent, } from '../TextBuffer.js';
3
- import { createSentinel, getPlaceholderText } from '../ImageSentinel.js';
3
+ import { createBlockMarker, getBlockPlaceholderText } from '../BlockMarker.js';
4
4
  describe('TextBuffer with sentinels', () => {
5
- const sentinel1 = createSentinel('img1', 1);
6
- const sentinel2 = createSentinel('img2', 2);
7
- const placeholder1 = getPlaceholderText(1); // "[Pasted Image #1]" (17 chars)
8
- const placeholder2 = getPlaceholderText(2); // "[Pasted Image #2]" (17 chars)
5
+ const sentinel1 = createBlockMarker('i', 'img1', 1);
6
+ const sentinel2 = createBlockMarker('i', 'img2', 2);
7
+ const placeholder1 = getBlockPlaceholderText('i', 1); // "[Pasted Image #1]" (17 chars)
8
+ const placeholder2 = getBlockPlaceholderText('i', 2); // "[Pasted Image #2]" (17 chars)
9
9
  describe('getVisualRows', () => {
10
10
  it('treats sentinel as atomic block with placeholder visual width', () => {
11
11
  const line = `${sentinel1}`;
12
12
  const rows = getVisualRows(line, 20);
13
- // The sentinel takes up placeholder text width (17)
14
13
  expect(rows).toEqual([
15
- { start: 0, length: sentinel1.length }, // whole sentinel as one chunk
14
+ { start: 0, length: sentinel1.length },
16
15
  ]);
17
16
  });
18
17
  it('does not split a sentinel block across visual rows', () => {
19
- // Sentinel (17 visual width) + "abc" (3) = 20 total, use width 20
20
18
  const line = `${sentinel1}abc`;
21
19
  const rows = getVisualRows(line, 20);
22
- // All fits on one row
23
20
  expect(rows.length).toBe(1);
24
21
  expect(rows[0].start).toBe(0);
25
22
  expect(rows[0].length).toBe(line.length);
26
23
  });
27
24
  it('wraps text around sentinel blocks', () => {
28
- // A sentinel in the middle, wrap at a narrow width
29
25
  const line = `a${sentinel1}b`;
30
26
  const rows = getVisualRows(line, 3);
31
- // Each sentinel block is atomic and takes up its full visual width
32
- // "a" (1) + sentinel (5 actual chars but 17 visual width) = 18 > 3, so splits
33
- // The sentinel can't be split so it goes on its own row
34
- // But "a" (1) fits in first row
35
- // Then sentinel (17 visual) might need to be on its own row
36
- // Then "b" (1) on next
37
27
  expect(rows.length).toBeGreaterThanOrEqual(1);
38
28
  });
39
29
  it('renders empty array for empty line', () => {
@@ -44,14 +34,13 @@ describe('TextBuffer with sentinels', () => {
44
34
  describe('moveCursor left with sentinels', () => {
45
35
  it('jumps over sentinel when cursor is right after it', () => {
46
36
  const buffer = createBuffer(`hello${sentinel1}world`);
47
- const cursor = { line: 0, column: `hello${sentinel1}`.length }; // right after sentinel
37
+ const cursor = { line: 0, column: `hello${sentinel1}`.length };
48
38
  const result = moveCursor(buffer, cursor, 'left');
49
- // Should jump to before the sentinel opener (after "hello")
50
39
  expect(result).toEqual({ line: 0, column: 5 });
51
40
  });
52
41
  it('normal left movement when not adjacent to sentinel', () => {
53
42
  const buffer = createBuffer(`${sentinel1}world`);
54
- const cursor = { line: 0, column: `${sentinel1}world`.length }; // end of "world"
43
+ const cursor = { line: 0, column: `${sentinel1}world`.length };
55
44
  const result = moveCursor(buffer, cursor, 'left');
56
45
  expect(result).toEqual({ line: 0, column: `${sentinel1}worl`.length });
57
46
  });
@@ -59,9 +48,8 @@ describe('TextBuffer with sentinels', () => {
59
48
  describe('moveCursor right with sentinels', () => {
60
49
  it('jumps over sentinel when cursor is right before it', () => {
61
50
  const buffer = createBuffer(`hello${sentinel1}world`);
62
- const cursor = { line: 0, column: 5 }; // right before sentinel
51
+ const cursor = { line: 0, column: 5 };
63
52
  const result = moveCursor(buffer, cursor, 'right');
64
- // Should jump to after the sentinel closer
65
53
  expect(result).toEqual({ line: 0, column: `hello${sentinel1}`.length });
66
54
  });
67
55
  it('normal right movement when not adjacent to sentinel', () => {
@@ -74,18 +62,12 @@ describe('TextBuffer with sentinels', () => {
74
62
  describe('deleteChar (backspace) with sentinels', () => {
75
63
  it('removes whole sentinel block when deleting at closer', () => {
76
64
  const buffer = createBuffer(`hello${sentinel1}world`);
77
- // Use getTextContent to find offset
78
- // Cursor: right after sentinel (at end of sentinel block)
79
65
  const cursor = { line: 0, column: `hello${sentinel1}`.length };
80
66
  const result = deleteChar(buffer, cursor);
81
67
  expect(getTextContent(result.buffer)).toBe('helloworld');
82
68
  expect(result.cursor).toEqual({ line: 0, column: 5 });
83
69
  });
84
70
  it('normal backspace when not near sentinel', () => {
85
- const buffer = createBuffer(`hello${sentinel1}`);
86
- const cursor = { line: 0, column: `hello${sentinel1}`.length };
87
- // This is right after sentinel, so the first backspace removes the sentinel
88
- // Just test a normal backspace on regular text
89
71
  const normalBuffer = createBuffer('hello');
90
72
  const normalCursor = { line: 0, column: 5 };
91
73
  const result = deleteChar(normalBuffer, normalCursor);
@@ -95,7 +77,7 @@ describe('TextBuffer with sentinels', () => {
95
77
  describe('deleteCharForward (delete key) with sentinels', () => {
96
78
  it('removes whole sentinel block when cursor is at opener', () => {
97
79
  const buffer = createBuffer(`hello${sentinel1}world`);
98
- const cursor = { line: 0, column: 5 }; // at sentinel opener
80
+ const cursor = { line: 0, column: 5 };
99
81
  const result = deleteCharForward(buffer, cursor);
100
82
  expect(getTextContent(result.buffer)).toBe('helloworld');
101
83
  expect(result.cursor).toEqual({ line: 0, column: 5 });
@@ -124,12 +106,9 @@ describe('TextBuffer with sentinels', () => {
124
106
  describe('multiple sentinels', () => {
125
107
  it('handles two sentinels with text between', () => {
126
108
  const buffer = createBuffer(`${sentinel1}text${sentinel2}`);
127
- // Cursor right after sentinel2
128
109
  const cursor = { line: 0, column: `${sentinel1}text${sentinel2}`.length };
129
- // Move left should jump over sentinel2
130
110
  const leftOnce = moveCursor(buffer, cursor, 'left');
131
111
  expect(leftOnce).toEqual({ line: 0, column: `${sentinel1}text`.length });
132
- // Move left again should be normal (inside "text")
133
112
  const leftTwice = moveCursor(buffer, leftOnce, 'left');
134
113
  expect(leftTwice).toEqual({ line: 0, column: `${sentinel1}tex`.length });
135
114
  });