ink-prompt 0.1.8 → 0.1.9

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 (34) hide show
  1. package/dist/components/MultilineInput/__tests__/useTextInput.test.js +74 -6
  2. package/dist/components/MultilineInput/index.d.ts +12 -0
  3. package/dist/components/MultilineInput/index.js +4 -4
  4. package/dist/components/MultilineInput/useTextInput.d.ts +6 -1
  5. package/dist/components/MultilineInput/useTextInput.js +85 -16
  6. package/dist/examples/examples/basic.d.ts +1 -0
  7. package/dist/examples/examples/basic.js +9 -0
  8. package/dist/examples/src/components/MultilineInput/KeyHandler.d.ts +15 -0
  9. package/dist/examples/src/components/MultilineInput/KeyHandler.js +97 -0
  10. package/dist/examples/src/components/MultilineInput/TextBuffer.d.ts +34 -0
  11. package/dist/examples/src/components/MultilineInput/TextBuffer.js +127 -0
  12. package/dist/examples/src/components/MultilineInput/TextRenderer.d.ts +24 -0
  13. package/dist/examples/src/components/MultilineInput/TextRenderer.js +72 -0
  14. package/dist/examples/src/components/MultilineInput/__tests__/KeyHandler.test.d.ts +1 -0
  15. package/dist/examples/src/components/MultilineInput/__tests__/KeyHandler.test.js +115 -0
  16. package/dist/examples/src/components/MultilineInput/__tests__/TextBuffer.test.d.ts +1 -0
  17. package/dist/examples/src/components/MultilineInput/__tests__/TextBuffer.test.js +254 -0
  18. package/dist/examples/src/components/MultilineInput/__tests__/TextRenderer.test.d.ts +1 -0
  19. package/dist/examples/src/components/MultilineInput/__tests__/TextRenderer.test.js +176 -0
  20. package/dist/examples/src/components/MultilineInput/__tests__/integration.test.d.ts +1 -0
  21. package/dist/examples/src/components/MultilineInput/__tests__/integration.test.js +71 -0
  22. package/dist/examples/src/components/MultilineInput/__tests__/useTextInput.test.d.ts +1 -0
  23. package/dist/examples/src/components/MultilineInput/__tests__/useTextInput.test.js +65 -0
  24. package/dist/examples/src/components/MultilineInput/index.d.ts +39 -0
  25. package/dist/examples/src/components/MultilineInput/index.js +82 -0
  26. package/dist/examples/src/components/MultilineInput/types.d.ts +55 -0
  27. package/dist/examples/src/components/MultilineInput/types.js +1 -0
  28. package/dist/examples/src/components/MultilineInput/useTextInput.d.ts +16 -0
  29. package/dist/examples/src/components/MultilineInput/useTextInput.js +82 -0
  30. package/dist/examples/src/hello.test.d.ts +1 -0
  31. package/dist/examples/src/hello.test.js +13 -0
  32. package/dist/examples/src/index.d.ts +2 -0
  33. package/dist/examples/src/index.js +2 -0
  34. package/package.json +3 -3
@@ -1,5 +1,5 @@
1
1
  import { renderHook, act } from '@testing-library/react';
2
- import { describe, it, expect } from 'vitest';
2
+ import { describe, it, expect, vi } from 'vitest';
3
3
  import { useTextInput } from '../useTextInput.js';
4
4
  describe('useTextInput', () => {
5
5
  it('should initialize with empty buffer', () => {
@@ -62,6 +62,74 @@ describe('useTextInput', () => {
62
62
  });
63
63
  expect(result.current.value).toBe('a');
64
64
  });
65
+ it('should batch consecutive inserts into one undo step when undoDebounceMs is set', () => {
66
+ vi.useFakeTimers();
67
+ const { result } = renderHook(() => useTextInput({ undoDebounceMs: 200 }));
68
+ act(() => {
69
+ result.current.insert('a');
70
+ });
71
+ act(() => {
72
+ result.current.insert('b');
73
+ });
74
+ act(() => {
75
+ result.current.insert('c');
76
+ });
77
+ expect(result.current.value).toBe('abc');
78
+ act(() => {
79
+ vi.advanceTimersByTime(200);
80
+ });
81
+ act(() => {
82
+ result.current.undo();
83
+ });
84
+ expect(result.current.value).toBe('');
85
+ act(() => {
86
+ result.current.redo();
87
+ });
88
+ expect(result.current.value).toBe('abc');
89
+ vi.useRealTimers();
90
+ });
91
+ it('should undo a pending debounced batch without waiting for commit', () => {
92
+ vi.useFakeTimers();
93
+ const { result } = renderHook(() => useTextInput({ undoDebounceMs: 200 }));
94
+ act(() => {
95
+ result.current.insert('a');
96
+ });
97
+ act(() => {
98
+ result.current.insert('b');
99
+ });
100
+ expect(result.current.value).toBe('ab');
101
+ act(() => {
102
+ result.current.undo();
103
+ });
104
+ expect(result.current.value).toBe('');
105
+ vi.useRealTimers();
106
+ });
107
+ it('should create separate undo steps when there is an idle gap', () => {
108
+ vi.useFakeTimers();
109
+ const { result } = renderHook(() => useTextInput({ undoDebounceMs: 200 }));
110
+ act(() => {
111
+ result.current.insert('a');
112
+ });
113
+ act(() => {
114
+ vi.advanceTimersByTime(200);
115
+ });
116
+ act(() => {
117
+ result.current.insert('b');
118
+ });
119
+ act(() => {
120
+ vi.advanceTimersByTime(200);
121
+ });
122
+ expect(result.current.value).toBe('ab');
123
+ act(() => {
124
+ result.current.undo();
125
+ });
126
+ expect(result.current.value).toBe('a');
127
+ act(() => {
128
+ result.current.undo();
129
+ });
130
+ expect(result.current.value).toBe('');
131
+ vi.useRealTimers();
132
+ });
65
133
  it('should remove backslash when deleted at end of line', () => {
66
134
  const { result } = renderHook(() => useTextInput({ initialValue: 'hello\\' }));
67
135
  // Cursor should be at end: { line: 0, column: 6 } (after the backslash)
@@ -171,7 +239,7 @@ describe('useTextInput', () => {
171
239
  });
172
240
  describe('history limit', () => {
173
241
  it('should use default history limit of 100', () => {
174
- const { result } = renderHook(() => useTextInput());
242
+ const { result } = renderHook(() => useTextInput({ undoDebounceMs: 0 }));
175
243
  // Insert 5 characters separately
176
244
  for (let i = 0; i < 5; i++) {
177
245
  act(() => {
@@ -195,7 +263,7 @@ describe('useTextInput', () => {
195
263
  expect(result.current.value).toBe(valueBefore);
196
264
  });
197
265
  it('should respect custom history limit', () => {
198
- const { result } = renderHook(() => useTextInput({ historyLimit: 3 }));
266
+ const { result } = renderHook(() => useTextInput({ historyLimit: 3, undoDebounceMs: 0 }));
199
267
  // Insert 5 characters
200
268
  for (let i = 0; i < 5; i++) {
201
269
  act(() => {
@@ -219,7 +287,7 @@ describe('useTextInput', () => {
219
287
  expect(result.current.value).toBe(valueBefore);
220
288
  });
221
289
  it('should trim oldest history when limit exceeded', () => {
222
- const { result } = renderHook(() => useTextInput({ historyLimit: 3 }));
290
+ const { result } = renderHook(() => useTextInput({ historyLimit: 3, undoDebounceMs: 0 }));
223
291
  // Insert 5 characters
224
292
  for (let i = 0; i < 5; i++) {
225
293
  act(() => {
@@ -243,7 +311,7 @@ describe('useTextInput', () => {
243
311
  expect(result.current.value).toBe(valueBefore);
244
312
  });
245
313
  it('should clear redo stack when new edit happens', () => {
246
- const { result } = renderHook(() => useTextInput({ historyLimit: 5 }));
314
+ const { result } = renderHook(() => useTextInput({ historyLimit: 5, undoDebounceMs: 0 }));
247
315
  act(() => {
248
316
  result.current.insert('a');
249
317
  });
@@ -282,7 +350,7 @@ describe('useTextInput', () => {
282
350
  expect(result.current.value).toBe(valueBefore);
283
351
  });
284
352
  it('should prevent excessive memory use with bounded history', () => {
285
- const { result } = renderHook(() => useTextInput({ historyLimit: 10 }));
353
+ const { result } = renderHook(() => useTextInput({ historyLimit: 10, undoDebounceMs: 0 }));
286
354
  // Insert 30 characters
287
355
  for (let i = 0; i < 30; i++) {
288
356
  act(() => {
@@ -52,6 +52,12 @@ export interface MultilineInputProps {
52
52
  * - 'right': cursor is at end of text (after last character)
53
53
  */
54
54
  onBoundaryArrow?: (direction: 'up' | 'down' | 'left' | 'right') => void;
55
+ /**
56
+ * Batches consecutive single-character inserts into a single undo step.
57
+ * The batch is committed after this many milliseconds of inactivity (default: 200).
58
+ * Set to 0 to disable batching (undo will be per edit again).
59
+ */
60
+ undoDebounceMs?: number;
55
61
  }
56
62
  /**
57
63
  * Props for the core component (without Ink-specific hooks)
@@ -105,6 +111,12 @@ export interface MultilineInputCoreProps {
105
111
  * - 'right': cursor is at end of text (after last character)
106
112
  */
107
113
  onBoundaryArrow?: (direction: 'up' | 'down' | 'left' | 'right') => void;
114
+ /**
115
+ * Batches consecutive single-character inserts into a single undo step.
116
+ * The batch is committed after this many milliseconds of inactivity (default: 200).
117
+ * Set to 0 to disable batching (undo will be per edit again).
118
+ */
119
+ undoDebounceMs?: number;
108
120
  }
109
121
  /**
110
122
  * Core rendering component that can be tested without Ink runtime.
@@ -11,8 +11,8 @@ import { log } from '../../utils/logger.js';
11
11
  * Core rendering component that can be tested without Ink runtime.
12
12
  * Does not include useInput/useStdout hooks.
13
13
  */
14
- export const MultilineInputCore = ({ value, onChange, placeholder, showCursor = true, width = 80, onCursorChange, cursorOverride, }) => {
15
- const textInput = useTextInput({ initialValue: value ?? '' });
14
+ export const MultilineInputCore = ({ value, onChange, placeholder, showCursor = true, width = 80, onCursorChange, cursorOverride, undoDebounceMs, }) => {
15
+ const textInput = useTextInput({ initialValue: value ?? '', undoDebounceMs });
16
16
  // Track whether a value change is from syncing props (not user input)
17
17
  const isSyncingFromProps = useRef(false);
18
18
  // Handle cursor override
@@ -66,7 +66,7 @@ export const MultilineInputCore = ({ value, onChange, placeholder, showCursor =
66
66
  * Full MultilineInput with Ink keyboard handling.
67
67
  * This component uses Ink-specific hooks and must be rendered in an Ink context.
68
68
  */
69
- export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCursor = true, width, isActive = true, onCursorChange, cursorOverride, onBoundaryArrow, }) => {
69
+ export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCursor = true, width, isActive = true, onCursorChange, cursorOverride, onBoundaryArrow, undoDebounceMs, }) => {
70
70
  // Get terminal width from Ink (with resize support) if not provided
71
71
  const terminalWidth = useTerminalWidth(width);
72
72
  // Track raw input for detecting Home/End keys
@@ -84,7 +84,7 @@ export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCur
84
84
  stdin.off('data', handleData);
85
85
  };
86
86
  }, [stdin, isActive]);
87
- const textInput = useTextInput({ initialValue: value ?? '', width: terminalWidth });
87
+ const textInput = useTextInput({ initialValue: value ?? '', width: terminalWidth, undoDebounceMs });
88
88
  // Handle cursor override
89
89
  useEffect(() => {
90
90
  if (cursorOverride !== undefined) {
@@ -5,6 +5,11 @@ export interface UseTextInputProps {
5
5
  width?: number;
6
6
  /** Maximum number of history entries to keep (default: 100) */
7
7
  historyLimit?: number;
8
+ /**
9
+ * When > 0, consecutive single-character inserts are batched into a single undo step.
10
+ * A batch is committed after this many milliseconds of inactivity (default: 200).
11
+ */
12
+ undoDebounceMs?: number;
8
13
  }
9
14
  export interface UseTextInputResult {
10
15
  value: string;
@@ -21,4 +26,4 @@ export interface UseTextInputResult {
21
26
  cursorOffset: number;
22
27
  setCursorOffset: (offset: number) => void;
23
28
  }
24
- export declare function useTextInput({ initialValue, width, historyLimit }?: UseTextInputProps): UseTextInputResult;
29
+ export declare function useTextInput({ initialValue, width, historyLimit, undoDebounceMs, }?: UseTextInputProps): UseTextInputResult;
@@ -1,7 +1,7 @@
1
- import { useState, useCallback } from 'react';
1
+ import { useState, useCallback, useEffect, useRef } from 'react';
2
2
  import { createBuffer, insertText as bufferInsertText, deleteChar as bufferDeleteChar, deleteCharForward as bufferDeleteCharForward, insertNewLine as bufferInsertNewLine, moveCursor as bufferMoveCursor, getTextContent, getOffset, getCursor, } from './TextBuffer.js';
3
3
  import { log } from '../../utils/logger.js';
4
- export function useTextInput({ initialValue = '', width, historyLimit = 100 } = {}) {
4
+ export function useTextInput({ initialValue = '', width, historyLimit = 100, undoDebounceMs = 200, } = {}) {
5
5
  const [buffer, setBuffer] = useState(() => createBuffer(initialValue));
6
6
  const [cursor, setCursor] = useState(() => {
7
7
  const lines = initialValue.split('\n');
@@ -12,46 +12,100 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100 } =
12
12
  });
13
13
  const [undoStack, setUndoStack] = useState([]);
14
14
  const [redoStack, setRedoStack] = useState([]);
15
- const pushToHistory = useCallback((currentBuffer, currentCursor) => {
15
+ const pendingInsertBatchRef = useRef({});
16
+ const clearPendingInsertTimer = useCallback(() => {
17
+ if (pendingInsertBatchRef.current.timerId) {
18
+ clearTimeout(pendingInsertBatchRef.current.timerId);
19
+ pendingInsertBatchRef.current.timerId = undefined;
20
+ }
21
+ }, []);
22
+ const appendUndoState = useCallback((state) => {
16
23
  setUndoStack((prev) => {
17
- const newStack = [...prev, { buffer: currentBuffer, cursor: currentCursor }];
18
- // Trim stack if it exceeds history limit
24
+ const newStack = [...prev, state];
19
25
  if (newStack.length > historyLimit) {
20
26
  return newStack.slice(-historyLimit);
21
27
  }
22
28
  return newStack;
23
29
  });
24
- setRedoStack([]);
25
30
  }, [historyLimit]);
31
+ const commitPendingInsertBatch = useCallback(() => {
32
+ const startState = pendingInsertBatchRef.current.startState;
33
+ if (!startState)
34
+ return;
35
+ clearPendingInsertTimer();
36
+ pendingInsertBatchRef.current.startState = undefined;
37
+ appendUndoState(startState);
38
+ }, [appendUndoState, clearPendingInsertTimer]);
39
+ const schedulePendingInsertCommit = useCallback(() => {
40
+ if (undoDebounceMs <= 0)
41
+ return;
42
+ clearPendingInsertTimer();
43
+ pendingInsertBatchRef.current.timerId = setTimeout(() => {
44
+ commitPendingInsertBatch();
45
+ }, undoDebounceMs);
46
+ }, [clearPendingInsertTimer, commitPendingInsertBatch, undoDebounceMs]);
47
+ const beginOrRefreshInsertBatch = useCallback((currentBuffer, currentCursor) => {
48
+ if (!pendingInsertBatchRef.current.startState) {
49
+ pendingInsertBatchRef.current.startState = { buffer: currentBuffer, cursor: currentCursor };
50
+ setRedoStack([]);
51
+ }
52
+ schedulePendingInsertCommit();
53
+ }, [schedulePendingInsertCommit]);
54
+ const flushPendingInsertBatch = useCallback(() => {
55
+ commitPendingInsertBatch();
56
+ }, [commitPendingInsertBatch]);
57
+ const pushToHistory = useCallback((currentBuffer, currentCursor) => {
58
+ appendUndoState({ buffer: currentBuffer, cursor: currentCursor });
59
+ setRedoStack([]);
60
+ }, [appendUndoState]);
61
+ useEffect(() => {
62
+ return () => {
63
+ clearPendingInsertTimer();
64
+ pendingInsertBatchRef.current.startState = undefined;
65
+ };
66
+ }, [clearPendingInsertTimer]);
26
67
  const insert = useCallback((char) => {
27
68
  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}`);
28
69
  // Normalize line endings: \r\n → \n, \r → \n (handles Windows, Unix, and old Mac)
29
70
  const normalized = char.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
30
- pushToHistory(buffer, cursor);
71
+ const canBatchInsert = undoDebounceMs > 0 &&
72
+ normalized.length === 1 &&
73
+ normalized !== '\n';
74
+ if (canBatchInsert) {
75
+ beginOrRefreshInsertBatch(buffer, cursor);
76
+ }
77
+ else {
78
+ flushPendingInsertBatch();
79
+ pushToHistory(buffer, cursor);
80
+ }
31
81
  // TextBuffer now handles multi-line insertion internally
32
82
  const result = bufferInsertText(buffer, cursor, normalized);
33
83
  setBuffer(result.buffer);
34
84
  setCursor(result.cursor);
35
- }, [buffer, cursor, pushToHistory]);
85
+ }, [beginOrRefreshInsertBatch, buffer, cursor, flushPendingInsertBatch, pushToHistory, undoDebounceMs]);
36
86
  const deleteChar = useCallback(() => {
87
+ flushPendingInsertBatch();
37
88
  pushToHistory(buffer, cursor);
38
89
  const result = bufferDeleteChar(buffer, cursor);
39
90
  setBuffer(result.buffer);
40
91
  setCursor(result.cursor);
41
- }, [buffer, cursor, pushToHistory]);
92
+ }, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
42
93
  const deleteCharForward = useCallback(() => {
94
+ flushPendingInsertBatch();
43
95
  pushToHistory(buffer, cursor);
44
96
  const result = bufferDeleteCharForward(buffer, cursor);
45
97
  setBuffer(result.buffer);
46
98
  setCursor(result.cursor);
47
- }, [buffer, cursor, pushToHistory]);
99
+ }, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
48
100
  const newLine = useCallback(() => {
101
+ flushPendingInsertBatch();
49
102
  pushToHistory(buffer, cursor);
50
103
  const result = bufferInsertNewLine(buffer, cursor);
51
104
  setBuffer(result.buffer);
52
105
  setCursor(result.cursor);
53
- }, [buffer, cursor, pushToHistory]);
106
+ }, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
54
107
  const deleteAndNewLine = useCallback(() => {
108
+ flushPendingInsertBatch();
55
109
  pushToHistory(buffer, cursor);
56
110
  // First delete the character before cursor (the backslash)
57
111
  const afterDelete = bufferDeleteChar(buffer, cursor);
@@ -59,12 +113,22 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100 } =
59
113
  const afterNewLine = bufferInsertNewLine(afterDelete.buffer, afterDelete.cursor);
60
114
  setBuffer(afterNewLine.buffer);
61
115
  setCursor(afterNewLine.cursor);
62
- }, [buffer, cursor, pushToHistory]);
116
+ }, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
63
117
  const moveCursor = useCallback((direction) => {
118
+ flushPendingInsertBatch();
64
119
  const newCursor = bufferMoveCursor(buffer, cursor, direction, width);
65
120
  setCursor(newCursor);
66
- }, [buffer, cursor, width]);
121
+ }, [buffer, cursor, flushPendingInsertBatch, width]);
67
122
  const undo = useCallback(() => {
123
+ const pendingStartState = pendingInsertBatchRef.current.startState;
124
+ if (pendingStartState) {
125
+ clearPendingInsertTimer();
126
+ pendingInsertBatchRef.current.startState = undefined;
127
+ setRedoStack((prev) => [...prev, { buffer, cursor }]);
128
+ setBuffer(pendingStartState.buffer);
129
+ setCursor(pendingStartState.cursor);
130
+ return;
131
+ }
68
132
  if (undoStack.length === 0)
69
133
  return;
70
134
  const previousState = undoStack[undoStack.length - 1];
@@ -73,8 +137,11 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100 } =
73
137
  setBuffer(previousState.buffer);
74
138
  setCursor(previousState.cursor);
75
139
  setUndoStack(newUndoStack);
76
- }, [buffer, cursor, undoStack]);
140
+ }, [buffer, clearPendingInsertTimer, cursor, undoStack]);
77
141
  const redo = useCallback(() => {
142
+ if (pendingInsertBatchRef.current.startState) {
143
+ return;
144
+ }
78
145
  if (redoStack.length === 0)
79
146
  return;
80
147
  const nextState = redoStack[redoStack.length - 1];
@@ -85,6 +152,7 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100 } =
85
152
  setRedoStack(newRedoStack);
86
153
  }, [buffer, cursor, redoStack]);
87
154
  const setText = useCallback((text) => {
155
+ flushPendingInsertBatch();
88
156
  pushToHistory(buffer, cursor);
89
157
  const newBuffer = createBuffer(text);
90
158
  setBuffer(newBuffer);
@@ -94,7 +162,7 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100 } =
94
162
  line: lines.length - 1,
95
163
  column: lines[lines.length - 1].length,
96
164
  });
97
- }, [buffer, cursor, pushToHistory]);
165
+ }, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
98
166
  return {
99
167
  value: getTextContent(buffer),
100
168
  cursor,
@@ -109,7 +177,8 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100 } =
109
177
  setText,
110
178
  cursorOffset: getOffset(buffer, cursor),
111
179
  setCursorOffset: useCallback((offset) => {
180
+ flushPendingInsertBatch();
112
181
  setCursor(getCursor(buffer, offset));
113
- }, [buffer]),
182
+ }, [buffer, flushPendingInsertBatch]),
114
183
  };
115
184
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ import { render } from 'ink';
3
+ import { MultilineInput } from '../src/index.js';
4
+ const App = () => {
5
+ return (React.createElement(MultilineInput
6
+ // Add props here
7
+ , null));
8
+ };
9
+ render(React.createElement(App, null));
@@ -0,0 +1,15 @@
1
+ import { type Key, type Buffer, type Cursor } from './types';
2
+ import { type UseTextInputResult } from './useTextInput';
3
+ export interface KeyHandlerActions extends Omit<UseTextInputResult, 'value' | 'cursor'> {
4
+ submit: () => void;
5
+ }
6
+ /**
7
+ * Handles keyboard input and maps it to text input actions.
8
+ *
9
+ * @param key - The Ink key object
10
+ * @param input - The input string (if any)
11
+ * @param buffer - The current text buffer
12
+ * @param actions - The actions available to modify the state
13
+ * @param cursor - The current cursor position (optional, but required for some logic like backslash check)
14
+ */
15
+ export declare function handleKey(key: Partial<Key>, input: string, buffer: Buffer, actions: KeyHandlerActions, cursor?: Cursor): void;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Handles keyboard input and maps it to text input actions.
3
+ *
4
+ * @param key - The Ink key object
5
+ * @param input - The input string (if any)
6
+ * @param buffer - The current text buffer
7
+ * @param actions - The actions available to modify the state
8
+ * @param cursor - The current cursor position (optional, but required for some logic like backslash check)
9
+ */
10
+ export function handleKey(key, input, buffer, actions, cursor) {
11
+ // Navigation
12
+ if (key.upArrow) {
13
+ actions.moveCursor('up');
14
+ return;
15
+ }
16
+ if (key.downArrow) {
17
+ actions.moveCursor('down');
18
+ return;
19
+ }
20
+ if (key.leftArrow) {
21
+ actions.moveCursor('left');
22
+ return;
23
+ }
24
+ if (key.rightArrow) {
25
+ actions.moveCursor('right');
26
+ return;
27
+ }
28
+ // Home/End (Ink might not provide these directly in all environments, but if it does)
29
+ // We check for 'home' and 'end' properties if they exist on the key object,
30
+ // or specific sequences if we were parsing raw input, but here we assume Ink's Key object.
31
+ // Note: Ink's Key interface might not have home/end in all versions, but we'll assume it does or we extend it.
32
+ // If not, we might need to check specific input sequences, but for now let's trust the test/types.
33
+ if (key.home) {
34
+ actions.moveCursor('lineStart');
35
+ return;
36
+ }
37
+ if (key.end) {
38
+ actions.moveCursor('lineEnd');
39
+ return;
40
+ }
41
+ // History
42
+ if (key.ctrl) {
43
+ if (input === 'z') {
44
+ actions.undo();
45
+ return;
46
+ }
47
+ if (input === 'y') {
48
+ actions.redo();
49
+ return;
50
+ }
51
+ if (input === 'j') {
52
+ actions.newLine();
53
+ return;
54
+ }
55
+ }
56
+ // Editing
57
+ if (key.backspace) {
58
+ actions.delete();
59
+ return;
60
+ }
61
+ if (key.delete) {
62
+ // Currently mapped to delete (backspace behavior) as per requirements/tests,
63
+ // but usually delete is forward.
64
+ // The plan said "Delete (delete at cursor)", which usually means forward.
65
+ // But our useTextInput only has `delete` (which is backspace).
66
+ // For now, we map it to `delete` as per the test "handles Delete".
67
+ actions.delete();
68
+ return;
69
+ }
70
+ // Submission / New Line
71
+ if (key.return) {
72
+ if (cursor) {
73
+ const currentLine = buffer.lines[cursor.line];
74
+ // Check if line ends with backslash AND cursor is at the end (or we just check the line content?)
75
+ // Requirement: "Line ending with \ + Enter continues to next line"
76
+ // Usually this implies the user typed '\' then Enter.
77
+ // We should probably check if the character *before* the cursor is '\' if we want to be precise,
78
+ // or just if the line ends with '\'.
79
+ // Let's assume "line ends with \" means the last char of the line is '\'.
80
+ if (currentLine.endsWith('\\')) {
81
+ actions.delete(); // Remove the backslash
82
+ actions.newLine(); // Insert newline
83
+ return;
84
+ }
85
+ }
86
+ actions.submit();
87
+ return;
88
+ }
89
+ // Text Insertion
90
+ // Ignore control keys if they don't have a specific handler above
91
+ if (key.ctrl || key.meta) {
92
+ return;
93
+ }
94
+ if (input) {
95
+ actions.insert(input);
96
+ }
97
+ }
@@ -0,0 +1,34 @@
1
+ import type { Buffer, Cursor, Direction } from './types';
2
+ /**
3
+ * Create a new buffer from optional initial text
4
+ */
5
+ export declare function createBuffer(text?: string): Buffer;
6
+ /**
7
+ * Insert a character at the cursor position
8
+ */
9
+ export declare function insertChar(buffer: Buffer, cursor: Cursor, char: string): {
10
+ buffer: Buffer;
11
+ cursor: Cursor;
12
+ };
13
+ /**
14
+ * Delete character before cursor (backspace)
15
+ */
16
+ export declare function deleteChar(buffer: Buffer, cursor: Cursor): {
17
+ buffer: Buffer;
18
+ cursor: Cursor;
19
+ };
20
+ /**
21
+ * Insert a new line at cursor position (splits current line)
22
+ */
23
+ export declare function insertNewLine(buffer: Buffer, cursor: Cursor): {
24
+ buffer: Buffer;
25
+ cursor: Cursor;
26
+ };
27
+ /**
28
+ * Move cursor in specified direction with bounds checking
29
+ */
30
+ export declare function moveCursor(buffer: Buffer, cursor: Cursor, direction: Direction): Cursor;
31
+ /**
32
+ * Get the full text content from buffer (lines joined with newlines)
33
+ */
34
+ export declare function getTextContent(buffer: Buffer): string;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Create a new buffer from optional initial text
3
+ */
4
+ export function createBuffer(text) {
5
+ if (!text) {
6
+ return { lines: [''] };
7
+ }
8
+ return { lines: text.split('\n') };
9
+ }
10
+ /**
11
+ * Insert a character at the cursor position
12
+ */
13
+ export function insertChar(buffer, cursor, char) {
14
+ const { line, column } = cursor;
15
+ const currentLine = buffer.lines[line];
16
+ const newLine = currentLine.slice(0, column) + char + currentLine.slice(column);
17
+ const newLines = [...buffer.lines];
18
+ newLines[line] = newLine;
19
+ return {
20
+ buffer: { lines: newLines },
21
+ cursor: { line, column: column + 1 },
22
+ };
23
+ }
24
+ /**
25
+ * Delete character before cursor (backspace)
26
+ */
27
+ export function deleteChar(buffer, cursor) {
28
+ const { line, column } = cursor;
29
+ // At the very start of the buffer - nothing to delete
30
+ if (line === 0 && column === 0) {
31
+ return { buffer, cursor };
32
+ }
33
+ // At the start of a line - merge with previous line
34
+ if (column === 0) {
35
+ const previousLine = buffer.lines[line - 1];
36
+ const currentLine = buffer.lines[line];
37
+ const mergedLine = previousLine + currentLine;
38
+ const newLines = [...buffer.lines];
39
+ newLines[line - 1] = mergedLine;
40
+ newLines.splice(line, 1);
41
+ return {
42
+ buffer: { lines: newLines },
43
+ cursor: { line: line - 1, column: previousLine.length },
44
+ };
45
+ }
46
+ // Delete character within the line
47
+ const currentLine = buffer.lines[line];
48
+ const newLine = currentLine.slice(0, column - 1) + currentLine.slice(column);
49
+ const newLines = [...buffer.lines];
50
+ newLines[line] = newLine;
51
+ return {
52
+ buffer: { lines: newLines },
53
+ cursor: { line, column: column - 1 },
54
+ };
55
+ }
56
+ /**
57
+ * Insert a new line at cursor position (splits current line)
58
+ */
59
+ export function insertNewLine(buffer, cursor) {
60
+ const { line, column } = cursor;
61
+ const currentLine = buffer.lines[line];
62
+ const beforeCursor = currentLine.slice(0, column);
63
+ const afterCursor = currentLine.slice(column);
64
+ const newLines = [...buffer.lines];
65
+ newLines[line] = beforeCursor;
66
+ newLines.splice(line + 1, 0, afterCursor);
67
+ return {
68
+ buffer: { lines: newLines },
69
+ cursor: { line: line + 1, column: 0 },
70
+ };
71
+ }
72
+ /**
73
+ * Move cursor in specified direction with bounds checking
74
+ */
75
+ export function moveCursor(buffer, cursor, direction) {
76
+ const { line, column } = cursor;
77
+ const currentLine = buffer.lines[line];
78
+ const lineCount = buffer.lines.length;
79
+ switch (direction) {
80
+ case 'left':
81
+ if (column > 0) {
82
+ return { line, column: column - 1 };
83
+ }
84
+ // Wrap to end of previous line
85
+ if (line > 0) {
86
+ return { line: line - 1, column: buffer.lines[line - 1].length };
87
+ }
88
+ return cursor;
89
+ case 'right':
90
+ if (column < currentLine.length) {
91
+ return { line, column: column + 1 };
92
+ }
93
+ // Wrap to start of next line
94
+ if (line < lineCount - 1) {
95
+ return { line: line + 1, column: 0 };
96
+ }
97
+ return cursor;
98
+ case 'up':
99
+ if (line > 0) {
100
+ const targetLine = buffer.lines[line - 1];
101
+ return { line: line - 1, column: Math.min(column, targetLine.length) };
102
+ }
103
+ return cursor;
104
+ case 'down':
105
+ if (line < lineCount - 1) {
106
+ const targetLine = buffer.lines[line + 1];
107
+ return { line: line + 1, column: Math.min(column, targetLine.length) };
108
+ }
109
+ return cursor;
110
+ case 'lineStart':
111
+ return { line, column: 0 };
112
+ case 'lineEnd':
113
+ return { line, column: currentLine.length };
114
+ default:
115
+ return cursor;
116
+ }
117
+ }
118
+ /**
119
+ * Get the full text content from buffer (lines joined with newlines)
120
+ */
121
+ export function getTextContent(buffer) {
122
+ // Single empty line is considered empty buffer
123
+ if (buffer.lines.length === 1 && buffer.lines[0] === '') {
124
+ return '';
125
+ }
126
+ return buffer.lines.join('\n');
127
+ }