@@ -7,6 +7,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
77const ENTER = '\r' ;
88const ESCAPE = '\x1B' ;
99const BACKSPACE = '\x7f' ;
10+ const LEFT = '\x1B[D' ;
11+ const RIGHT = '\x1B[C' ;
1012
1113afterEach ( ( ) => vi . restoreAllMocks ( ) ) ;
1214
@@ -84,20 +86,38 @@ function TextInputHarness({
8486 onSubmit,
8587 onCancel,
8688 onChange,
89+ onUpArrow,
90+ onDownArrow,
91+ isActive,
8792} : {
8893 initialValue ?: string ;
8994 onSubmit ?: ( value : string ) => void ;
9095 onCancel ?: ( ) => void ;
9196 onChange ?: ( value : string ) => void ;
97+ onUpArrow ?: ( ) => void ;
98+ onDownArrow ?: ( ) => void ;
99+ isActive ?: boolean ;
92100} ) {
93- const { value, cursor } = useTextInput ( { initialValue, onSubmit, onCancel, onChange } ) ;
101+ const { value, cursor } = useTextInput ( {
102+ initialValue,
103+ onSubmit,
104+ onCancel,
105+ onChange,
106+ onUpArrow,
107+ onDownArrow,
108+ isActive,
109+ } ) ;
94110 return (
95111 < Text >
96112 val:[{ value } ] cur:{ cursor }
97113 </ Text >
98114 ) ;
99115}
100116
117+ function delay ( ms = 50 ) {
118+ return new Promise ( resolve => setTimeout ( resolve , ms ) ) ;
119+ }
120+
101121describe ( 'useTextInput hook' , ( ) => {
102122 it ( 'starts with initial value and cursor at end' , ( ) => {
103123 const { lastFrame } = render ( < TextInputHarness initialValue = "hello" /> ) ;
@@ -116,9 +136,9 @@ describe('useTextInput hook', () => {
116136 it ( 'accepts character input' , async ( ) => {
117137 const { lastFrame, stdin } = render ( < TextInputHarness /> ) ;
118138
119- await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
139+ await delay ( ) ;
120140 stdin . write ( 'a' ) ;
121- await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
141+ await delay ( ) ;
122142
123143 expect ( lastFrame ( ) ) . toContain ( 'val:[a]' ) ;
124144 expect ( lastFrame ( ) ) . toContain ( 'cur:1' ) ;
@@ -127,10 +147,10 @@ describe('useTextInput hook', () => {
127147 it ( 'accepts multiple characters' , async ( ) => {
128148 const { lastFrame, stdin } = render ( < TextInputHarness /> ) ;
129149
130- await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
150+ await delay ( ) ;
131151 stdin . write ( 'h' ) ;
132152 stdin . write ( 'i' ) ;
133- await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
153+ await delay ( ) ;
134154
135155 expect ( lastFrame ( ) ) . toContain ( 'val:[hi]' ) ;
136156 expect ( lastFrame ( ) ) . toContain ( 'cur:2' ) ;
@@ -139,21 +159,32 @@ describe('useTextInput hook', () => {
139159 it ( 'handles backspace' , async ( ) => {
140160 const { lastFrame, stdin } = render ( < TextInputHarness initialValue = "abc" /> ) ;
141161
142- await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
162+ await delay ( ) ;
143163 stdin . write ( BACKSPACE ) ;
144- await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
164+ await delay ( ) ;
145165
146166 expect ( lastFrame ( ) ) . toContain ( 'val:[ab]' ) ;
147167 expect ( lastFrame ( ) ) . toContain ( 'cur:2' ) ;
148168 } ) ;
149169
150- it ( 'calls onSubmit on Enter' , async ( ) => {
170+ it ( 'backspace at start does nothing' , async ( ) => {
171+ const { lastFrame, stdin } = render ( < TextInputHarness initialValue = "" /> ) ;
172+
173+ await delay ( ) ;
174+ stdin . write ( BACKSPACE ) ;
175+ await delay ( ) ;
176+
177+ expect ( lastFrame ( ) ) . toContain ( 'val:[]' ) ;
178+ expect ( lastFrame ( ) ) . toContain ( 'cur:0' ) ;
179+ } ) ;
180+
181+ it ( 'calls onSubmit on Enter with current text' , async ( ) => {
151182 const onSubmit = vi . fn ( ) ;
152183 const { stdin } = render ( < TextInputHarness initialValue = "test" onSubmit = { onSubmit } /> ) ;
153184
154- await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
185+ await delay ( ) ;
155186 stdin . write ( ENTER ) ;
156- await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
187+ await delay ( ) ;
157188
158189 expect ( onSubmit ) . toHaveBeenCalledWith ( 'test' ) ;
159190 } ) ;
@@ -162,19 +193,19 @@ describe('useTextInput hook', () => {
162193 const onCancel = vi . fn ( ) ;
163194 const { stdin } = render ( < TextInputHarness onCancel = { onCancel } /> ) ;
164195
165- await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
196+ await delay ( ) ;
166197 stdin . write ( ESCAPE ) ;
167- await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
198+ await delay ( ) ;
168199
169200 expect ( onCancel ) . toHaveBeenCalledTimes ( 1 ) ;
170201 } ) ;
171202
172203 it ( 'moves cursor left with arrow key' , async ( ) => {
173204 const { lastFrame, stdin } = render ( < TextInputHarness initialValue = "abc" /> ) ;
174205
175- await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
176- stdin . write ( '\x1B[D' ) ; // left arrow
177- await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
206+ await delay ( ) ;
207+ stdin . write ( LEFT ) ;
208+ await delay ( ) ;
178209
179210 expect ( lastFrame ( ) ) . toContain ( 'val:[abc]' ) ;
180211 expect ( lastFrame ( ) ) . toContain ( 'cur:2' ) ;
@@ -183,29 +214,47 @@ describe('useTextInput hook', () => {
183214 it ( 'moves cursor right with arrow key' , async ( ) => {
184215 const { lastFrame, stdin } = render ( < TextInputHarness initialValue = "abc" /> ) ;
185216
186- // Move left first, then right
187- await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
188- stdin . write ( '\x1B[D' ) ; // left
189- stdin . write ( '\x1B[D' ) ; // left
190- await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
191-
217+ await delay ( ) ;
218+ stdin . write ( LEFT ) ;
219+ stdin . write ( LEFT ) ;
220+ await delay ( ) ;
192221 expect ( lastFrame ( ) ) . toContain ( 'cur:1' ) ;
193222
194- stdin . write ( '\x1B[C' ) ; // right arrow
195- await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
223+ stdin . write ( RIGHT ) ;
224+ await delay ( ) ;
225+ expect ( lastFrame ( ) ) . toContain ( 'cur:2' ) ;
226+ } ) ;
227+
228+ it ( 'cursor does not go below 0' , async ( ) => {
229+ const { lastFrame, stdin } = render ( < TextInputHarness initialValue = "ab" /> ) ;
230+
231+ await delay ( ) ;
232+ stdin . write ( LEFT ) ;
233+ stdin . write ( LEFT ) ;
234+ stdin . write ( LEFT ) ; // try to go past 0
235+ await delay ( ) ;
236+
237+ expect ( lastFrame ( ) ) . toContain ( 'cur:0' ) ;
238+ } ) ;
239+
240+ it ( 'cursor does not go past text length' , async ( ) => {
241+ const { lastFrame, stdin } = render ( < TextInputHarness initialValue = "ab" /> ) ;
242+
243+ await delay ( ) ;
244+ stdin . write ( RIGHT ) ; // already at end (2)
245+ await delay ( ) ;
196246
197247 expect ( lastFrame ( ) ) . toContain ( 'cur:2' ) ;
198248 } ) ;
199249
200- it ( 'inserts character at cursor position' , async ( ) => {
250+ it ( 'inserts character at cursor position (middle of text) ' , async ( ) => {
201251 const { lastFrame, stdin } = render ( < TextInputHarness initialValue = "ac" /> ) ;
202252
203- // Move cursor left once (between a and c), then insert b
204- await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
205- stdin . write ( '\x1B[D' ) ; // left, cursor now at 1
206- await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
253+ await delay ( ) ;
254+ stdin . write ( LEFT ) ; // cursor at 1
255+ await delay ( ) ;
207256 stdin . write ( 'b' ) ;
208- await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
257+ await delay ( ) ;
209258
210259 expect ( lastFrame ( ) ) . toContain ( 'val:[abc]' ) ;
211260 expect ( lastFrame ( ) ) . toContain ( 'cur:2' ) ;
@@ -215,10 +264,106 @@ describe('useTextInput hook', () => {
215264 const onChange = vi . fn ( ) ;
216265 const { stdin } = render ( < TextInputHarness onChange = { onChange } /> ) ;
217266
218- await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
267+ await delay ( ) ;
219268 stdin . write ( 'x' ) ;
220- await new Promise ( resolve => setTimeout ( resolve , 100 ) ) ;
269+ await delay ( 100 ) ;
221270
222271 expect ( onChange ) . toHaveBeenCalledWith ( 'x' ) ;
223272 } ) ;
273+
274+ it ( 'calls onUpArrow on up arrow key' , async ( ) => {
275+ const onUpArrow = vi . fn ( ) ;
276+ const { stdin } = render ( < TextInputHarness onUpArrow = { onUpArrow } /> ) ;
277+
278+ await delay ( ) ;
279+ stdin . write ( '\x1B[A' ) ; // up arrow
280+ await delay ( ) ;
281+
282+ expect ( onUpArrow ) . toHaveBeenCalledTimes ( 1 ) ;
283+ } ) ;
284+
285+ it ( 'calls onDownArrow on down arrow key' , async ( ) => {
286+ const onDownArrow = vi . fn ( ) ;
287+ const { stdin } = render ( < TextInputHarness onDownArrow = { onDownArrow } /> ) ;
288+
289+ await delay ( ) ;
290+ stdin . write ( '\x1B[B' ) ; // down arrow
291+ await delay ( ) ;
292+
293+ expect ( onDownArrow ) . toHaveBeenCalledTimes ( 1 ) ;
294+ } ) ;
295+ } ) ;
296+
297+ describe ( 'useTextInput keyboard shortcuts' , ( ) => {
298+ it ( 'Ctrl+A moves cursor to start' , async ( ) => {
299+ const { lastFrame, stdin } = render ( < TextInputHarness initialValue = "hello world" /> ) ;
300+
301+ await delay ( ) ;
302+ stdin . write ( '\x01' ) ; // Ctrl+A
303+ await delay ( ) ;
304+
305+ expect ( lastFrame ( ) ) . toContain ( 'val:[hello world]' ) ;
306+ expect ( lastFrame ( ) ) . toContain ( 'cur:0' ) ;
307+ } ) ;
308+
309+ it ( 'Ctrl+E moves cursor to end' , async ( ) => {
310+ const { lastFrame, stdin } = render ( < TextInputHarness initialValue = "hello world" /> ) ;
311+
312+ // Move to start first, then Ctrl+E
313+ await delay ( ) ;
314+ stdin . write ( '\x01' ) ; // Ctrl+A → cursor:0
315+ await delay ( ) ;
316+ stdin . write ( '\x05' ) ; // Ctrl+E
317+ await delay ( ) ;
318+
319+ expect ( lastFrame ( ) ) . toContain ( 'cur:11' ) ;
320+ } ) ;
321+
322+ it ( 'Ctrl+W deletes previous word' , async ( ) => {
323+ const { lastFrame, stdin } = render ( < TextInputHarness initialValue = "hello world" /> ) ;
324+
325+ await delay ( ) ;
326+ stdin . write ( '\x17' ) ; // Ctrl+W
327+ await delay ( ) ;
328+
329+ expect ( lastFrame ( ) ) . toContain ( 'val:[hello ]' ) ;
330+ expect ( lastFrame ( ) ) . toContain ( 'cur:6' ) ;
331+ } ) ;
332+
333+ it ( 'Ctrl+U deletes from cursor to start' , async ( ) => {
334+ const { lastFrame, stdin } = render ( < TextInputHarness initialValue = "hello world" /> ) ;
335+
336+ // Move cursor to middle first
337+ await delay ( ) ;
338+ stdin . write ( LEFT ) ;
339+ stdin . write ( LEFT ) ;
340+ stdin . write ( LEFT ) ;
341+ stdin . write ( LEFT ) ;
342+ stdin . write ( LEFT ) ; // cursor at 6
343+ await delay ( ) ;
344+ stdin . write ( '\x15' ) ; // Ctrl+U
345+ await delay ( ) ;
346+
347+ expect ( lastFrame ( ) ) . toContain ( 'val:[world]' ) ;
348+ expect ( lastFrame ( ) ) . toContain ( 'cur:0' ) ;
349+ } ) ;
350+
351+ it ( 'Ctrl+K deletes from cursor to end' , async ( ) => {
352+ const { lastFrame, stdin } = render ( < TextInputHarness initialValue = "hello world" /> ) ;
353+
354+ // Move cursor to position 5
355+ await delay ( ) ;
356+ stdin . write ( LEFT ) ;
357+ stdin . write ( LEFT ) ;
358+ stdin . write ( LEFT ) ;
359+ stdin . write ( LEFT ) ;
360+ stdin . write ( LEFT ) ;
361+ stdin . write ( LEFT ) ; // cursor at 5
362+ await delay ( ) ;
363+ stdin . write ( '\x0B' ) ; // Ctrl+K
364+ await delay ( ) ;
365+
366+ expect ( lastFrame ( ) ) . toContain ( 'val:[hello]' ) ;
367+ expect ( lastFrame ( ) ) . toContain ( 'cur:5' ) ;
368+ } ) ;
224369} ) ;
0 commit comments