@@ -229,6 +229,11 @@ export function createSampleGameDevConsoleIntegration(options = {}) {
229229 let consoleScrollOffset = 0 ;
230230 let consoleTypingMode = true ;
231231 let consoleCommandHistory = [ ] ;
232+ let consoleAutocompleteState = {
233+ basePrefix : "" ,
234+ matches : [ ] ,
235+ tokenStart : 0
236+ } ;
232237 const keyboardEventTarget = getKeyboardEventTarget ( ) ;
233238 const commandRegistry = createDevConsoleCommandRegistry ( {
234239 packs : [
@@ -249,6 +254,11 @@ export function createSampleGameDevConsoleIntegration(options = {}) {
249254 consoleOutputHistory = [ ] ;
250255 consoleScrollOffset = 0 ;
251256 consoleTypingMode = true ;
257+ consoleAutocompleteState = {
258+ basePrefix : "" ,
259+ matches : [ ] ,
260+ tokenStart : 0
261+ } ;
252262 }
253263
254264 function normalizeRuntimeDelegationResult ( commandName , execution ) {
@@ -308,6 +318,54 @@ export function createSampleGameDevConsoleIntegration(options = {}) {
308318 source . forEach ( ( line ) => pushConsoleOutputLine ( line ) ) ;
309319 }
310320
321+ function resetConsoleAutocompleteState ( ) {
322+ consoleAutocompleteState = {
323+ basePrefix : "" ,
324+ matches : [ ] ,
325+ tokenStart : 0
326+ } ;
327+ }
328+
329+ function getCommandRegistryNames ( ) {
330+ const names = new Set ( [ "help" , "status" ] ) ;
331+ if ( typeof commandRegistry ?. listCommands === "function" ) {
332+ const commands = commandRegistry . listCommands ( ) ;
333+ const source = Array . isArray ( commands ) ? commands : [ ] ;
334+ source . forEach ( ( entry ) => {
335+ const name = sanitizeText ( typeof entry === "string" ? entry : entry ?. name ) ;
336+ if ( name ) {
337+ names . add ( name ) ;
338+ }
339+ } ) ;
340+ }
341+ return Array . from ( names ) . sort ( ( left , right ) => left . localeCompare ( right ) ) ;
342+ }
343+
344+ function findLongestCommonPrefix ( values ) {
345+ const source = Array . isArray ( values ) ? values . filter ( Boolean ) : [ ] ;
346+ if ( source . length === 0 ) {
347+ return "" ;
348+ }
349+ if ( source . length === 1 ) {
350+ return source [ 0 ] ;
351+ }
352+
353+ let prefix = source [ 0 ] ;
354+ for ( let index = 1 ; index < source . length ; index += 1 ) {
355+ const candidate = source [ index ] ;
356+ let prefixIndex = 0 ;
357+ const maxLength = Math . min ( prefix . length , candidate . length ) ;
358+ while ( prefixIndex < maxLength && prefix [ prefixIndex ] === candidate [ prefixIndex ] ) {
359+ prefixIndex += 1 ;
360+ }
361+ prefix = prefix . slice ( 0 , prefixIndex ) ;
362+ if ( ! prefix ) {
363+ break ;
364+ }
365+ }
366+ return prefix ;
367+ }
368+
311369 function captureCommandHistory ( command ) {
312370 if ( ! command ) {
313371 return ;
@@ -319,6 +377,7 @@ export function createSampleGameDevConsoleIntegration(options = {}) {
319377 }
320378 consoleHistoryCursor = - 1 ;
321379 consoleScrollOffset = 0 ;
380+ resetConsoleAutocompleteState ( ) ;
322381 }
323382
324383 function clampConsoleCursor ( ) {
@@ -336,6 +395,7 @@ export function createSampleGameDevConsoleIntegration(options = {}) {
336395 } else {
337396 clampConsoleCursor ( ) ;
338397 }
398+ resetConsoleAutocompleteState ( ) ;
339399 }
340400
341401 function insertConsoleText ( text ) {
@@ -408,6 +468,99 @@ export function createSampleGameDevConsoleIntegration(options = {}) {
408468 return `${ before } |${ after } ` ;
409469 }
410470
471+ function replaceConsoleRange ( startIndex , endIndex , replacement ) {
472+ const safeStart = Math . max ( 0 , Math . min ( startIndex , consoleInputBuffer . length ) ) ;
473+ const safeEnd = Math . max ( safeStart , Math . min ( endIndex , consoleInputBuffer . length ) ) ;
474+ const before = consoleInputBuffer . slice ( 0 , safeStart ) ;
475+ const after = consoleInputBuffer . slice ( safeEnd ) ;
476+ const next = `${ before } ${ replacement } ${ after } ` ;
477+ setConsoleInputBuffer ( next , false ) ;
478+ consoleCursorIndex = safeStart + String ( replacement ?? "" ) . length ;
479+ clampConsoleCursor ( ) ;
480+ }
481+
482+ function getCommandTokenAtCursor ( ) {
483+ const beforeCursor = consoleInputBuffer . slice ( 0 , consoleCursorIndex ) ;
484+ const afterCursor = consoleInputBuffer . slice ( consoleCursorIndex ) ;
485+ const start = beforeCursor . lastIndexOf ( " " ) + 1 ;
486+ const nextSpaceOffset = afterCursor . indexOf ( " " ) ;
487+ const end = nextSpaceOffset >= 0
488+ ? consoleCursorIndex + nextSpaceOffset
489+ : consoleInputBuffer . length ;
490+ const token = consoleInputBuffer . slice ( start , end ) ;
491+ return {
492+ start,
493+ end,
494+ token : sanitizeText ( token )
495+ } ;
496+ }
497+
498+ function autocompleteConsoleInput ( ) {
499+ const tokenInfo = getCommandTokenAtCursor ( ) ;
500+ if ( tokenInfo . start !== 0 ) {
501+ return false ;
502+ }
503+
504+ const commandPrefix = tokenInfo . token . toLowerCase ( ) ;
505+ const names = getCommandRegistryNames ( ) ;
506+ if ( ! commandPrefix ) {
507+ return false ;
508+ }
509+
510+ const matched = names . filter ( ( name ) => name . toLowerCase ( ) . startsWith ( commandPrefix ) ) ;
511+ if ( matched . length === 0 ) {
512+ return false ;
513+ }
514+
515+ if ( matched . length === 1 ) {
516+ replaceConsoleRange ( tokenInfo . start , tokenInfo . end , matched [ 0 ] ) ;
517+ resetConsoleAutocompleteState ( ) ;
518+ return true ;
519+ }
520+
521+ const currentAutocompleteMatches = Array . isArray ( consoleAutocompleteState . matches )
522+ ? consoleAutocompleteState . matches
523+ : [ ] ;
524+ const cycleMatches = currentAutocompleteMatches . length > 1
525+ && consoleAutocompleteState . tokenStart === tokenInfo . start
526+ && tokenInfo . token . toLowerCase ( ) . startsWith ( sanitizeText ( consoleAutocompleteState . basePrefix ) . toLowerCase ( ) )
527+ && currentAutocompleteMatches . every ( ( name ) => matched . includes ( name ) )
528+ && matched . every ( ( name ) => currentAutocompleteMatches . includes ( name ) ) ;
529+
530+ if ( cycleMatches ) {
531+ const currentIndex = currentAutocompleteMatches . indexOf ( tokenInfo . token ) ;
532+ const nextIndex = currentIndex >= 0
533+ ? ( currentIndex + 1 ) % currentAutocompleteMatches . length
534+ : 0 ;
535+ replaceConsoleRange ( tokenInfo . start , tokenInfo . end , currentAutocompleteMatches [ nextIndex ] ) ;
536+ consoleAutocompleteState = {
537+ basePrefix : sanitizeText ( consoleAutocompleteState . basePrefix ) || tokenInfo . token ,
538+ matches : currentAutocompleteMatches . slice ( ) ,
539+ tokenStart : tokenInfo . start
540+ } ;
541+ return true ;
542+ }
543+
544+ const commonPrefix = findLongestCommonPrefix ( matched ) ;
545+ if ( commonPrefix . length > tokenInfo . token . length ) {
546+ replaceConsoleRange ( tokenInfo . start , tokenInfo . end , commonPrefix ) ;
547+ consoleAutocompleteState = {
548+ basePrefix : commonPrefix ,
549+ matches : matched . slice ( ) ,
550+ tokenStart : tokenInfo . start
551+ } ;
552+ return true ;
553+ }
554+
555+ replaceConsoleRange ( tokenInfo . start , tokenInfo . end , matched [ 0 ] ) ;
556+ consoleAutocompleteState = {
557+ basePrefix : tokenInfo . token ,
558+ matches : matched . slice ( ) ,
559+ tokenStart : tokenInfo . start
560+ } ;
561+ return true ;
562+ }
563+
411564 function appendExecutionToConsole ( command , execution ) {
412565 if ( ! command ) {
413566 return ;
@@ -496,6 +649,12 @@ export function createSampleGameDevConsoleIntegration(options = {}) {
496649 return ;
497650 }
498651
652+ if ( code === "Tab" ) {
653+ autocompleteConsoleInput ( ) ;
654+ event . preventDefault ( ) ;
655+ return ;
656+ }
657+
499658 if ( code === "Enter" ) {
500659 consoleTypingMode = true ;
501660 submitConsoleInput ( ) ;
@@ -531,13 +690,15 @@ export function createSampleGameDevConsoleIntegration(options = {}) {
531690 if ( code === "ArrowLeft" ) {
532691 consoleTypingMode = true ;
533692 consoleCursorIndex = Math . max ( 0 , consoleCursorIndex - 1 ) ;
693+ resetConsoleAutocompleteState ( ) ;
534694 event . preventDefault ( ) ;
535695 return ;
536696 }
537697
538698 if ( code === "ArrowRight" ) {
539699 consoleTypingMode = true ;
540700 consoleCursorIndex = Math . min ( consoleInputBuffer . length , consoleCursorIndex + 1 ) ;
701+ resetConsoleAutocompleteState ( ) ;
541702 event . preventDefault ( ) ;
542703 return ;
543704 }
@@ -551,6 +712,7 @@ export function createSampleGameDevConsoleIntegration(options = {}) {
551712 } else {
552713 scrollConsoleHistory ( 1 ) ;
553714 }
715+ resetConsoleAutocompleteState ( ) ;
554716 event . preventDefault ( ) ;
555717 return ;
556718 }
@@ -564,11 +726,7 @@ export function createSampleGameDevConsoleIntegration(options = {}) {
564726 } else {
565727 scrollConsoleHistory ( - 1 ) ;
566728 }
567- event . preventDefault ( ) ;
568- return ;
569- }
570-
571- if ( code === "Tab" ) {
729+ resetConsoleAutocompleteState ( ) ;
572730 event . preventDefault ( ) ;
573731 return ;
574732 }
@@ -754,7 +912,7 @@ export function createSampleGameDevConsoleIntegration(options = {}) {
754912
755913 const commandHint = "Shift+` console | Ctrl+Shift+` overlay | Ctrl+Shift+R reload" ;
756914 const inputHint = consoleTypingMode
757- ? "Typing: Left/Right cursor | Up/Down history | Shift+Up/Down scroll | Backspace/Delete | Esc mode"
915+ ? "Typing: Tab autocomplete | Left/Right cursor | Up/Down history | Shift+Up/Down scroll | Backspace/Delete | Esc mode"
758916 : "Scroll: Up/Down output | Esc mode" ;
759917 const statusHint = `mode: ${ consoleTypingMode ? "typing" : "scroll" } | scroll: ${ consoleScrollOffset } | last: ${ lastCommandBinding || "none" } | status: ${ sanitizeText ( lastCommandExecution ?. status ) || "idle" } | history: ${ consoleCommandHistory . length } ` ;
760918
0 commit comments