1+ import { Text , useInput } from 'ink' ;
12import { render } from 'ink-testing-library' ;
3+ import { useRef , useState } from 'react' ;
24
3- import { KEY } from '../../constants' ;
5+ import { COMMAND , KEY } from '../../constants' ;
46import { tick } from '../../utils/test' ;
7+
8+ vi . mock ( '@inkjs/ui' , ( ) => ( {
9+ TextInput : ( {
10+ isDisabled,
11+ onChange,
12+ onSubmit,
13+ } : {
14+ isDisabled ?: boolean ;
15+ onChange ?: ( value : string ) => void ;
16+ onSubmit ?: ( value : string ) => void ;
17+ } ) => {
18+ const [ value , setValue ] = useState ( '' ) ;
19+ const valueRef = useRef ( '' ) ;
20+
21+ useInput ( ( input , key ) => {
22+ if ( isDisabled ) {
23+ return ;
24+ }
25+
26+ if ( key . return ) {
27+ onSubmit ?.( value ) ;
28+ return ;
29+ }
30+
31+ if ( key . backspace || key . delete ) {
32+ const nextValue = valueRef . current . slice ( 0 , - 1 ) ;
33+ valueRef . current = nextValue ;
34+ onChange ?.( nextValue ) ;
35+ setValue ( nextValue ) ;
36+ return ;
37+ }
38+
39+ if ( ! input ) {
40+ return ;
41+ }
42+
43+ const nextValue = valueRef . current + input ;
44+ valueRef . current = nextValue ;
45+ onChange ?.( nextValue ) ;
46+ setValue ( nextValue ) ;
47+ } ) ;
48+
49+ return < Text > { value } </ Text > ;
50+ } ,
51+ } ) ) ;
52+
53+ vi . mock ( './CommandMenu' , ( ) => ( {
54+ CommandMenu : ( {
55+ input,
56+ onSubmit,
57+ } : {
58+ input : string ;
59+ onSubmit : ( value : string ) => void ;
60+ } ) => {
61+ const normalizedInput = input . trim ( ) . toLowerCase ( ) ;
62+ const options =
63+ normalizedInput === '/unknown'
64+ ? [
65+ {
66+ label : '/unknown - invalid command' ,
67+ value : '/unknown' ,
68+ } ,
69+ ]
70+ : COMMAND . LIST . filter ( ( { name } ) =>
71+ name . toLowerCase ( ) . startsWith ( normalizedInput ) ,
72+ ) . map ( ( { name, description } ) => ( {
73+ label : `${ name } - ${ description } ` ,
74+ value : name ,
75+ } ) ) ;
76+
77+ useInput ( ( _ , key ) => {
78+ if ( key . return && options [ 0 ] ) {
79+ onSubmit ( options [ 0 ] . value ) ;
80+ }
81+ } ) ;
82+
83+ if ( ! options . length ) {
84+ return null ;
85+ }
86+
87+ return (
88+ < >
89+ { options . map ( ( { label, value } ) => (
90+ < Text key = { value } > { label } </ Text >
91+ ) ) }
92+ </ >
93+ ) ;
94+ } ,
95+ } ) ) ;
96+
597import { Input } from './Input' ;
698
799describe ( 'Input' , ( ) => {
@@ -17,11 +109,24 @@ describe('Input', () => {
17109 expect ( lastFrame ( ) ) . not . toContain ( '/model' ) ;
18110 } ) ;
19111
20- it ( 'shows inline command suggestion when typing /' , async ( ) => {
112+ it ( 'shows command list below the input when typing /' , async ( ) => {
21113 const { lastFrame, stdin } = render ( < Input onSubmit = { vi . fn ( ) } /> ) ;
22114 stdin . write ( '/' ) ;
23115 await tick ( ) ;
116+ expect ( lastFrame ( ) ) . toContain ( '/clear - clear the current session' ) ;
24117 expect ( lastFrame ( ) ) . toContain ( '/clear' ) ;
118+ expect ( lastFrame ( ) ) . toContain ( '/model - switch the model' ) ;
119+ } ) ;
120+
121+ it ( 'filters the command list to matching slash commands' , async ( ) => {
122+ const { lastFrame, stdin } = render ( < Input onSubmit = { vi . fn ( ) } /> ) ;
123+ stdin . write ( '/' ) ;
124+ await tick ( ) ;
125+ stdin . write ( 'm' ) ;
126+ await tick ( ) ;
127+
128+ expect ( lastFrame ( ) ) . toContain ( '/model - switch the model' ) ;
129+ expect ( lastFrame ( ) ) . not . toContain ( '/clear - clear the current session' ) ;
25130 } ) ;
26131
27132 it ( 'submits typed text on Enter' , async ( ) => {
@@ -36,7 +141,7 @@ describe('Input', () => {
36141 expect ( onSubmit ) . toHaveBeenCalledWith ( 'hi' ) ;
37142 } ) ;
38143
39- it ( 'submits completed slash command on Enter when suggestion is visible' , async ( ) => {
144+ it ( 'submits first matching slash command on Enter when list is visible' , async ( ) => {
40145 const onSubmit = vi . fn ( ) ;
41146 const { stdin } = render ( < Input onSubmit = { onSubmit } /> ) ;
42147 stdin . write ( '/' ) ;
@@ -46,6 +151,30 @@ describe('Input', () => {
46151 expect ( onSubmit ) . toHaveBeenCalledWith ( '/clear' ) ;
47152 } ) ;
48153
154+ it ( 'ignores slash command submissions that are not in the command list' , async ( ) => {
155+ const onSubmit = vi . fn ( ) ;
156+ const { stdin } = render ( < Input onSubmit = { onSubmit } /> ) ;
157+ stdin . write ( '/' ) ;
158+ await tick ( ) ;
159+ stdin . write ( 'u' ) ;
160+ await tick ( ) ;
161+ stdin . write ( 'n' ) ;
162+ await tick ( ) ;
163+ stdin . write ( 'k' ) ;
164+ await tick ( ) ;
165+ stdin . write ( 'n' ) ;
166+ await tick ( ) ;
167+ stdin . write ( 'o' ) ;
168+ await tick ( ) ;
169+ stdin . write ( 'w' ) ;
170+ await tick ( ) ;
171+ stdin . write ( 'n' ) ;
172+ await tick ( ) ;
173+ stdin . write ( KEY . ENTER ) ;
174+ await tick ( ) ;
175+ expect ( onSubmit ) . not . toHaveBeenCalled ( ) ;
176+ } ) ;
177+
49178 it ( 'does not submit blank input' , async ( ) => {
50179 const onSubmit = vi . fn ( ) ;
51180 const { stdin } = render ( < Input onSubmit = { onSubmit } /> ) ;
@@ -61,7 +190,7 @@ describe('Input', () => {
61190 stdin . write ( 'i' ) ;
62191 await tick ( ) ;
63192 stdin . write ( KEY . ENTER ) ;
64- await tick ( ) ;
193+ await tick ( 10 ) ;
65194 expect ( lastFrame ( ) ) . not . toContain ( 'hi' ) ;
66195 } ) ;
67196
@@ -73,10 +202,10 @@ describe('Input', () => {
73202 await tick ( ) ;
74203 stdin . write ( KEY . BACKSPACE ) ;
75204 await tick ( ) ;
76- expect ( lastFrame ( ) ) . toContain ( '/clear' ) ;
205+ expect ( lastFrame ( ) ) . toContain ( '/clear - clear the current session ' ) ;
77206 stdin . write ( KEY . BACKSPACE ) ;
78207 await tick ( ) ;
79- expect ( lastFrame ( ) ) . not . toContain ( '/clear' ) ;
208+ expect ( lastFrame ( ) ) . not . toContain ( '/clear - clear the current session ' ) ;
80209 } ) ;
81210
82211 it ( 'does not accept input when disabled' , async ( ) => {
0 commit comments