88 matchesKey ,
99 Key ,
1010 SelectList ,
11+ truncateToWidth ,
12+ visibleWidth ,
1113 type SelectItem ,
1214 type TUI ,
1315} from '@earendil-works/pi-tui' ;
@@ -28,6 +30,7 @@ import {
2830import { isPrintableChar , printableChar } from '#/tui/utils/printable-key' ;
2931import { effortColorToken , shortEffortLabel } from '#/tui/utils/thinking-levels' ;
3032
33+ import { findSlashAutocompleteContext , getSlashHighlightRanges } from './slash-autocomplete-context' ;
3134import { WrappingSelectList } from './wrapping-select-list' ;
3235
3336// oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences
@@ -64,8 +67,11 @@ const SHIFT_BIT = 1;
6467
6568interface AutocompleteInternals {
6669 cancelAutocomplete ( ) : void ;
70+ requestAutocomplete ?( options : { force : boolean ; explicitTab : boolean } ) : void ;
6771 readonly autocompleteAbort ?: AbortController ;
6872 readonly autocompleteDebounceTimer ?: ReturnType < typeof setTimeout > ;
73+ readonly autocompletePrefix ?: string ;
74+ readonly autocompleteList ?: { getSelectedItem ( ) : SelectItem | undefined } ;
6975}
7076
7177interface AutocompleteListFactoryInternals {
@@ -136,6 +142,62 @@ function stripSgr(s: string): string {
136142 return s . replace ( ANSI_SGR , '' ) ;
137143}
138144
145+ function findCursorMarkerRange (
146+ line : string ,
147+ ) : { rawStart : number ; rawEnd : number ; visibleStart : number ; currentChar : string | undefined } | null {
148+ const rawStart = line . indexOf ( '\u001B[7m' ) ;
149+ if ( rawStart < 0 ) return null ;
150+ const rawEnd = line . indexOf ( '\u001B[0m' , rawStart ) ;
151+ if ( rawEnd < 0 ) return null ;
152+ const visibleStart = stripSgr ( line . slice ( 0 , rawStart ) ) . length ;
153+ const visible = stripSgr ( line ) ;
154+ return {
155+ rawStart,
156+ rawEnd : rawEnd + '\u001B[0m' . length ,
157+ visibleStart,
158+ currentChar : visible [ visibleStart ] ,
159+ } ;
160+ }
161+
162+ function buildAutocompleteGhostSuffix ( prefix : string , item : SelectItem ) : string | null {
163+ if ( prefix . startsWith ( '/' ) ) {
164+ const typed = prefix . slice ( 1 ) ;
165+ if ( ! item . value . startsWith ( typed ) ) return null ;
166+ return `${ item . value . slice ( typed . length ) } ` ;
167+ }
168+ if ( ! item . value . startsWith ( prefix ) ) return null ;
169+ return item . value . slice ( prefix . length ) ;
170+ }
171+
172+ function insertAutocompleteGhost ( line : string , ghostText : string ) : string | undefined {
173+ if ( ghostText . length === 0 ) return undefined ;
174+ const cursor = findCursorMarkerRange ( line ) ;
175+ if ( cursor === null ) return undefined ;
176+ if ( cursor . currentChar !== undefined && cursor . currentChar !== ' ' && cursor . currentChar !== '\t' ) {
177+ return undefined ;
178+ }
179+
180+ const visible = stripSgr ( line ) ;
181+ const insertStartVisible = cursor . visibleStart + 1 ;
182+ let insertEndVisible = insertStartVisible ;
183+ while ( insertEndVisible < visible . length ) {
184+ const ch = visible [ insertEndVisible ] ;
185+ if ( ch !== ' ' && ch !== '\t' ) break ;
186+ insertEndVisible += 1 ;
187+ }
188+
189+ const availableWidth = insertEndVisible - insertStartVisible ;
190+ if ( availableWidth <= 0 ) return undefined ;
191+ const ghostPlain = truncateToWidth ( ghostText , availableWidth , '' ) ;
192+ const ghostWidth = visibleWidth ( ghostPlain ) ;
193+ if ( ghostWidth <= 0 ) return undefined ;
194+
195+ const rawStart = mapVisibleIdxToRaw ( line , insertStartVisible ) ;
196+ const rawEnd = mapVisibleIdxToRaw ( line , insertStartVisible + ghostWidth ) ;
197+ const ghost = currentTheme . fg ( 'textMuted' , ghostPlain ) ;
198+ return line . slice ( 0 , rawStart ) + ghost + line . slice ( rawEnd ) ;
199+ }
200+
139201export class CustomEditor extends Editor {
140202 public onEscape ?: ( ) => void ;
141203 public onCtrlD ?: ( ) => void ;
@@ -258,29 +320,49 @@ export class CustomEditor extends Editor {
258320 ( this as unknown as AutocompleteInternals ) . cancelAutocomplete ( ) ;
259321 }
260322
323+ private hasMidPromptSlashContext ( ) : boolean {
324+ const { line, col } = this . getCursor ( ) ;
325+ const currentLine = this . getLines ( ) [ line ] ?? '' ;
326+ const context = findSlashAutocompleteContext ( currentLine , col ) ;
327+ return context !== null && currentLine . slice ( 0 , context . commandStart ) . trim ( ) . length > 0 ;
328+ }
329+
330+ private requestMidPromptSlashAutocomplete ( explicitTab : boolean ) : boolean {
331+ if ( ! this . hasMidPromptSlashContext ( ) ) return false ;
332+ const autocomplete = this as unknown as AutocompleteInternals ;
333+ autocomplete . requestAutocomplete ?.( { force : false , explicitTab } ) ;
334+ return autocomplete . requestAutocomplete !== undefined ;
335+ }
336+
261337 override render ( width : number ) : string [ ] {
262338 const lines = super . render ( width ) ;
263339 if ( lines . length < 3 ) return lines ;
264340 const firstContentIdx = 1 ;
265- const text = this . getText ( ) . trimStart ( ) ;
266- if ( text . startsWith ( '/' ) ) {
267- // Paint only the FIRST editor content line; multi-line slash commands
268- // are not a thing in practice.
269- const original = lines [ firstContentIdx ] ;
270- if ( original !== undefined ) {
271- const highlighted = highlightFirstSlashToken ( original , 'primary' ) ;
272- if ( highlighted !== undefined ) {
273- lines [ firstContentIdx ] = highlighted ;
274- }
275- }
276- }
277341 const firstContent = lines [ firstContentIdx ] ;
278342 if ( firstContent !== undefined ) {
279343 const withPrompt = injectPromptSymbol ( firstContent ) ;
280344 if ( withPrompt !== undefined ) {
281345 lines [ firstContentIdx ] = withPrompt ;
282346 }
283347 }
348+
349+ const cursorLineIdx = lines . findIndex ( ( line ) => line . includes ( '\u001B[7m' ) ) ;
350+ if ( cursorLineIdx >= 0 ) {
351+ const cursorLine = lines [ cursorLineIdx ] ;
352+ if ( cursorLine !== undefined ) {
353+ const highlighted = highlightFirstSlashToken ( cursorLine , 'primary' ) ;
354+ const decoratedLine = highlighted ?? cursorLine ;
355+ const autocomplete = this as unknown as AutocompleteInternals ;
356+ const selectedItem = autocomplete . autocompleteList ?. getSelectedItem ( ) ;
357+ const prefix = autocomplete . autocompletePrefix ?? '' ;
358+ const ghostSuffix =
359+ selectedItem === undefined ? null : buildAutocompleteGhostSuffix ( prefix , selectedItem ) ;
360+ const withGhost =
361+ ghostSuffix === null ? undefined : insertAutocompleteGhost ( decoratedLine , ghostSuffix ) ;
362+ lines [ cursorLineIdx ] = withGhost ?? decoratedLine ;
363+ }
364+ }
365+
284366 // `this.borderColor` is pi-tui's per-render paint function. The host may
285367 // overwrite it (e.g. plan-mode / slash-context highlight via
286368 // `editor.borderColor = chalk.hex(primary)`), so we route corners and
@@ -342,6 +424,7 @@ export class CustomEditor extends Editor {
342424 return ;
343425 }
344426 super . handleInput ( data ) ;
427+ if ( ! this . hasAutocompleteActivity ( ) ) this . requestMidPromptSlashAutocomplete ( false ) ;
345428 return ;
346429 }
347430
@@ -431,7 +514,12 @@ export class CustomEditor extends Editor {
431514 return ;
432515 }
433516
517+ if ( matchesKey ( normalized , Key . tab ) && this . requestMidPromptSlashAutocomplete ( true ) ) {
518+ return ;
519+ }
520+
434521 super . handleInput ( normalized ) ;
522+ if ( ! this . hasAutocompleteActivity ( ) ) this . requestMidPromptSlashAutocomplete ( false ) ;
435523 }
436524
437525 private handlePasteKeybinding ( data : string ) : void {
@@ -456,69 +544,17 @@ export class CustomEditor extends Editor {
456544}
457545
458546/**
459- * Return a copy of `line` with the first `/token` coloured using `hex`.
460- * For `/goal next manage`, also colour the command-path tokens.
461- * `line` may already contain SGR escapes (cursor inverse, etc.); we
462- * locate `/` via visible-index math so ANSI pass-through survives.
463- * Returns `undefined` if no token is found.
547+ * Return a copy of `line` with the active slash-command token coloured using
548+ * the current theme, even when the command lives mid-prompt.
464549 */
465550export function highlightFirstSlashToken ( line : string , token : 'primary' ) : string | undefined {
466- const visible = stripSgr ( line ) ;
467- const slashIdx = visible . indexOf ( '/' ) ;
468- if ( slashIdx < 0 ) return undefined ;
469- // Guard: only paint when `/` is the first non-whitespace character
470- // on the line (avoids colouring a mid-sentence slash).
471- for ( let i = 0 ; i < slashIdx ; i ++ ) {
472- if ( visible [ i ] !== ' ' && visible [ i ] !== '\t' ) return undefined ;
473- }
474- // Token ends at the next whitespace (or the visible end).
475- let endVisible = slashIdx + 1 ;
476- while ( endVisible < visible . length ) {
477- const ch = visible [ endVisible ] ;
478- if ( ch === ' ' || ch === '\t' ) break ;
479- endVisible ++ ;
480- }
481- const visibleToken = visible . slice ( slashIdx , endVisible ) ;
482- if ( visibleToken . slice ( 1 ) . includes ( '/' ) ) return undefined ;
483- const ranges = [ { start : slashIdx , end : endVisible } ] ;
484- if ( visibleToken === '/goal' ) {
485- ranges . push ( ...goalCommandPathRanges ( visible , endVisible ) ) ;
486- }
551+ const cursor = findCursorMarkerRange ( line ) ;
552+ if ( cursor === null ) return undefined ;
553+ const ranges = getSlashHighlightRanges ( stripSgr ( line ) , cursor . visibleStart ) ;
554+ if ( ranges . length === 0 ) return undefined ;
487555 return highlightVisibleRanges ( line , ranges , token ) ;
488556}
489557
490- function goalCommandPathRanges (
491- visible : string ,
492- commandEnd : number ,
493- ) : Array < { start : number ; end : number } > {
494- const nextRange = readTokenRange ( visible , commandEnd ) ;
495- if ( nextRange === null || visible . slice ( nextRange . start , nextRange . end ) !== 'next' ) {
496- return [ ] ;
497- }
498- const ranges = [ nextRange ] ;
499- const manageRange = readTokenRange ( visible , nextRange . end ) ;
500- if ( manageRange !== null && visible . slice ( manageRange . start , manageRange . end ) === 'manage' ) {
501- ranges . push ( manageRange ) ;
502- }
503- return ranges ;
504- }
505-
506- function readTokenRange (
507- visible : string ,
508- start : number ,
509- ) : { start : number ; end : number } | null {
510- let tokenStart = start ;
511- while ( tokenStart < visible . length && isTokenSpace ( visible [ tokenStart ] ) ) tokenStart ++ ;
512- if ( tokenStart >= visible . length ) return null ;
513- let tokenEnd = tokenStart ;
514- while ( tokenEnd < visible . length && ! isTokenSpace ( visible [ tokenEnd ] ) ) tokenEnd ++ ;
515- return { start : tokenStart , end : tokenEnd } ;
516- }
517-
518- function isTokenSpace ( ch : string | undefined ) : boolean {
519- return ch === ' ' || ch === '\t' ;
520- }
521-
522558function highlightVisibleRanges (
523559 line : string ,
524560 ranges : Array < { start : number ; end : number } > ,
0 commit comments