@@ -58,6 +58,44 @@ export type PromptRef = {
5858const PLACEHOLDERS = [ "Fix a TODO in the codebase" , "What is the tech stack of this project?" , "Fix broken tests" ]
5959const SHELL_PLACEHOLDERS = [ "ls -la" , "git status" , "pwd" ]
6060
61+ // Word characters are [A-Za-z0-9] only, matching Readline's isalnum() and
62+ // Emacs' word syntax class. Underscore and punctuation are non-word chars.
63+ function isWordChar ( ch : string ) : boolean {
64+ return / [ A - Z a - z 0 - 9 ] / . test ( ch )
65+ }
66+
67+ function getWordBoundariesForTransformation ( text : string , cursorOffset : number ) : { start : number ; end : number } | null {
68+ if ( text . length === 0 ) return null
69+
70+ const effectiveOffset = Math . min ( cursorOffset , text . length )
71+
72+ // Readline/Emacs forward-word semantics: skip non-word chars, then advance
73+ // through word chars. Returns null if no next word exists (no fallback to
74+ // previous word — upcase-word/downcase-word at end of buffer is a no-op).
75+ let pos = effectiveOffset
76+ while ( pos < text . length && ! isWordChar ( text [ pos ] ) ) pos ++
77+
78+ if ( pos >= text . length ) return null
79+
80+ const start = pos
81+ while ( pos < text . length && isWordChar ( text [ pos ] ) ) pos ++
82+ return { start, end : pos }
83+ }
84+
85+ function lowercaseWord ( text : string , start : number , end : number ) : string {
86+ return text . slice ( 0 , start ) + text . slice ( start , end ) . toLowerCase ( ) + text . slice ( end )
87+ }
88+
89+ function uppercaseWord ( text : string , start : number , end : number ) : string {
90+ return text . slice ( 0 , start ) + text . slice ( start , end ) . toUpperCase ( ) + text . slice ( end )
91+ }
92+
93+ function capitalizeWord ( text : string , start : number , end : number ) : string {
94+ const segment = text . slice ( start , end )
95+ const capitalized = segment . charAt ( 0 ) . toUpperCase ( ) + segment . slice ( 1 ) . toLowerCase ( )
96+ return text . slice ( 0 , start ) + capitalized + text . slice ( end )
97+ }
98+
6199export function Prompt ( props : PromptProps ) {
62100 let input : TextareaRenderable
63101 let anchor : BoxRenderable
@@ -126,6 +164,7 @@ export function Prompt(props: PromptProps) {
126164 extmarkToPartIndex : Map < number , number >
127165 interrupt : number
128166 placeholder : number
167+ killBuffer : string
129168 } > ( {
130169 placeholder : Math . floor ( Math . random ( ) * PLACEHOLDERS . length ) ,
131170 prompt : {
@@ -135,6 +174,7 @@ export function Prompt(props: PromptProps) {
135174 mode : "normal" ,
136175 extmarkToPartIndex : new Map ( ) ,
137176 interrupt : 0 ,
177+ killBuffer : "" ,
138178 } )
139179
140180 createEffect (
@@ -909,6 +949,138 @@ export function Prompt(props: PromptProps) {
909949 if ( keybind . match ( "history_next" , e ) && input . visualCursor . visualRow === input . height - 1 )
910950 input . cursorOffset = input . plainText . length
911951 }
952+ if (
953+ ( keybind as { match : ( key : string , evt : unknown ) => boolean } ) . match ( "input_delete_to_line_end" , e )
954+ ) {
955+ const text = input . plainText
956+ const cursorOffset = input . cursorOffset
957+ const textToEnd = text . slice ( cursorOffset )
958+ setStore ( "killBuffer" , textToEnd )
959+ }
960+ if (
961+ ( keybind as { match : ( key : string , evt : unknown ) => boolean } ) . match ( "input_transpose_characters" , e )
962+ ) {
963+ const text = input . plainText
964+ const cursorOffset = input . cursorOffset
965+
966+ let char1Pos : number , char2Pos : number , newCursorOffset : number
967+
968+ if ( text . length < 2 ) {
969+ return
970+ } else if ( cursorOffset === 0 ) {
971+ char1Pos = 0
972+ char2Pos = 1
973+ newCursorOffset = 1
974+ } else if ( cursorOffset === text . length ) {
975+ char1Pos = text . length - 2
976+ char2Pos = text . length - 1
977+ newCursorOffset = cursorOffset
978+ } else {
979+ char1Pos = cursorOffset - 1
980+ char2Pos = cursorOffset
981+ newCursorOffset = cursorOffset + 1
982+ }
983+
984+ const char1 = text [ char1Pos ]
985+ const char2 = text [ char2Pos ]
986+ const newText =
987+ text . slice ( 0 , char1Pos ) +
988+ char2 +
989+ text . slice ( char1Pos + 1 , char2Pos ) +
990+ char1 +
991+ text . slice ( char2Pos + 1 )
992+ input . setText ( newText )
993+ input . cursorOffset = newCursorOffset
994+ setStore ( "prompt" , "input" , newText )
995+ e . preventDefault ( )
996+ return
997+ }
998+ if (
999+ ( keybind as { match : ( key : string , evt : unknown ) => boolean } ) . match ( "input_delete_word_forward" , e )
1000+ ) {
1001+ const text = input . plainText
1002+ const cursorOffset = input . cursorOffset
1003+ const boundaries = getWordBoundariesForTransformation ( text , cursorOffset )
1004+ if ( boundaries ) {
1005+ setStore ( "killBuffer" , text . slice ( boundaries . start , boundaries . end ) )
1006+ }
1007+ }
1008+ if (
1009+ ( keybind as { match : ( key : string , evt : unknown ) => boolean } ) . match ( "input_delete_word_backward" , e )
1010+ ) {
1011+ const text = input . plainText
1012+ const cursorOffset = input . cursorOffset
1013+ let start = cursorOffset
1014+ while ( start > 0 && ! isWordChar ( text [ start - 1 ] ) ) start --
1015+ while ( start > 0 && isWordChar ( text [ start - 1 ] ) ) start --
1016+ setStore ( "killBuffer" , text . slice ( start , cursorOffset ) )
1017+ }
1018+ if (
1019+ ( keybind as { match : ( key : string , evt : unknown ) => boolean } ) . match ( "input_lowercase_word" , e ) ||
1020+ ( keybind as { match : ( key : string , evt : unknown ) => boolean } ) . match ( "input_uppercase_word" , e ) ||
1021+ ( keybind as { match : ( key : string , evt : unknown ) => boolean } ) . match ( "input_capitalize_word" , e )
1022+ ) {
1023+ const text = input . plainText
1024+ const cursorOffset = input . cursorOffset
1025+ const selection = input . getSelection ( )
1026+ const hasSelection = selection !== null
1027+
1028+ let start : number , end : number
1029+
1030+ if ( hasSelection && selection ) {
1031+ start = selection . start
1032+ end = selection . end
1033+ } else {
1034+ const boundaries = getWordBoundariesForTransformation ( text , cursorOffset )
1035+ if ( ! boundaries ) {
1036+ e . preventDefault ( )
1037+ return
1038+ }
1039+ start = boundaries . start
1040+ end = boundaries . end
1041+ }
1042+
1043+ let newText : string
1044+ if ( ( keybind as { match : ( key : string , evt : unknown ) => boolean } ) . match ( "input_lowercase_word" , e ) ) {
1045+ newText = lowercaseWord ( text , start , end )
1046+ } else if (
1047+ ( keybind as { match : ( key : string , evt : unknown ) => boolean } ) . match ( "input_uppercase_word" , e )
1048+ ) {
1049+ newText = uppercaseWord ( text , start , end )
1050+ } else {
1051+ newText = capitalizeWord ( text , start , end )
1052+ }
1053+
1054+ input . setText ( newText )
1055+ input . cursorOffset = end
1056+ setStore ( "prompt" , "input" , newText )
1057+ e . preventDefault ( )
1058+ return
1059+ }
1060+ if ( ( keybind as { match : ( key : string , evt : unknown ) => boolean } ) . match ( "input_yank" , e ) ) {
1061+ if ( store . killBuffer ) {
1062+ input . insertText ( store . killBuffer )
1063+ setStore ( "prompt" , "input" , input . plainText )
1064+ e . preventDefault ( )
1065+ return
1066+ }
1067+ }
1068+ if (
1069+ ( keybind as { match : ( key : string , evt : unknown ) => boolean } ) . match ( "input_transpose_characters" , e )
1070+ ) {
1071+ const text = input . plainText
1072+ const cursorOffset = input . cursorOffset
1073+ if ( cursorOffset >= 2 ) {
1074+ const before = text . slice ( cursorOffset - 2 , cursorOffset - 1 )
1075+ const current = text . slice ( cursorOffset - 1 , cursorOffset )
1076+ const newText = text . slice ( 0 , cursorOffset - 2 ) + current + before + text . slice ( cursorOffset )
1077+ input . setText ( newText )
1078+ input . cursorOffset = cursorOffset
1079+ setStore ( "prompt" , "input" , newText )
1080+ e . preventDefault ( )
1081+ }
1082+ return
1083+ }
9121084 } }
9131085 onSubmit = { submit }
9141086 onPaste = { async ( event : PasteEvent ) => {
0 commit comments