33 * Copyright 2025 Autohand AI LLC
44 * SPDX-License-Identifier: Apache-2.0
55 */
6- import fs from 'fs-extra' ;
7- import path from 'node:path' ;
8- import { execFile } from 'node:child_process' ;
9- import { promisify } from 'node:util' ;
10- import { parseShellCommand } from '../../ui/shellCommand.js' ;
11- import { runWithConcurrency } from '../../utils/parallel.js' ;
6+ import { getPrimaryShellCommandSuggestion , parseShellCommand } from '../../ui/shellCommand.js' ;
127import type { AgentRuntime , LLMMessage } from '../../types.js' ;
138import type { LLMProvider } from '../../providers/LLMProvider.js' ;
149
15- const execFileAsync = promisify ( execFile ) ;
16-
1710interface ShellSuggestionConversation {
1811 history ( ) : LLMMessage [ ] ;
1912}
@@ -59,14 +52,10 @@ export function normalizeShellSuggestionFromLlm(raw: string, partialInput: strin
5952}
6053
6154export class ShellSuggestionProvider {
62- private abortController : AbortController | null = null ;
63- private packageContextCache : { value : string ; expiresAt : number } | null = null ;
64-
6555 constructor ( private readonly options : ShellSuggestionProviderOptions ) { }
6656
6757 abort ( ) : void {
68- this . abortController ?. abort ( ) ;
69- this . abortController = null ;
58+ // Shell autocomplete is local and deterministic; no in-flight model work to abort.
7059 }
7160
7261 async resolve ( inputLine : string ) : Promise < string | null > {
@@ -80,140 +69,9 @@ export class ShellSuggestionProvider {
8069 return null ;
8170 }
8271
83- this . abortController ?. abort ( ) ;
84- const controller = new AbortController ( ) ;
85- this . abortController = controller ;
86- const timeout = setTimeout ( ( ) => controller . abort ( ) , 1800 ) ;
87-
88- try {
89- const [ packageContext , gitStatus ] = await runWithConcurrency ( [
90- { label : 'package_context' , run : async ( ) => this . getPackageContext ( ) } ,
91- { label : 'git_status' , run : async ( ) => this . getGitStatus ( ) } ,
92- ] , this . options . getParallelismLimit ( ) ) ;
93-
94- const recentHistory = this . options . conversation
95- . history ( )
96- . slice ( - 6 )
97- . map ( ( message ) => {
98- const content = String ( message . content ?? '' )
99- . replace ( / \s + / g, ' ' )
100- . trim ( )
101- . slice ( 0 , 220 ) ;
102- return `${ message . role } : ${ content } ` ;
103- } )
104- . filter ( Boolean )
105- . join ( '\n' ) ;
106-
107- const completion = await this . options . getLlm ( ) . complete ( {
108- messages : [
109- {
110- role : 'system' ,
111- content : [
112- 'You are a shell autocomplete engine for a coding CLI.' ,
113- 'Return exactly ONE shell command completion for the current partial command.' ,
114- 'Output only the command line, no quotes and no markdown.' ,
115- 'Must start with "! " and should extend the current partial input.' ,
116- 'Prefer commands valid for this repo package manager and scripts.' ,
117- ] . join ( ' ' ) ,
118- } ,
119- {
120- role : 'user' ,
121- content : [
122- `Current partial input: ${ trimmedInput } ` ,
123- packageContext ? `Package/dependency context:\n${ packageContext } ` : 'Package/dependency context: unavailable' ,
124- gitStatus ? `Uncommitted changes context:\n${ gitStatus } ` : 'Uncommitted changes context: unavailable' ,
125- recentHistory ? `Recent chat context:\n${ recentHistory } ` : 'Recent chat context: unavailable' ,
126- ] . join ( '\n\n' ) ,
127- } ,
128- ] ,
129- maxTokens : 80 ,
130- temperature : 0.1 ,
131- signal : controller . signal ,
132- } ) ;
133-
134- if ( controller . signal . aborted ) {
135- return null ;
136- }
137-
138- return normalizeShellSuggestionFromLlm ( completion . content , trimmedInput ) ;
139- } catch {
140- return null ;
141- } finally {
142- clearTimeout ( timeout ) ;
143- if ( this . abortController === controller ) {
144- this . abortController = null ;
145- }
146- }
147- }
148-
149- private async getGitStatus ( ) : Promise < string > {
150- try {
151- const { stdout } = await execFileAsync (
152- 'git' ,
153- [ 'status' , '--short' , '--branch' ] ,
154- { cwd : this . options . runtime . workspaceRoot , encoding : 'utf8' , timeout : 1200 } ,
155- ) ;
156- return String ( stdout || '' ) . trim ( ) . slice ( 0 , 1200 ) ;
157- } catch {
158- return '' ;
159- }
160- }
161-
162- private async getPackageContext ( ) : Promise < string > {
163- const now = Date . now ( ) ;
164- if ( this . packageContextCache && this . packageContextCache . expiresAt > now ) {
165- return this . packageContextCache . value ;
166- }
167-
168- const root = this . options . runtime . workspaceRoot ;
169- const lines : string [ ] = [ ] ;
170- const existenceChecks = [
171- { label : 'bun.lockb' , paths : [ 'bun.lockb' , 'bun.lock' ] , manager : 'bun' } ,
172- { label : 'pnpm-lock.yaml' , paths : [ 'pnpm-lock.yaml' ] , manager : 'pnpm' } ,
173- { label : 'yarn.lock' , paths : [ 'yarn.lock' ] , manager : 'yarn' } ,
174- { label : 'package-lock.json' , paths : [ 'package-lock.json' ] , manager : 'npm' } ,
175- { label : 'python-lockfiles' , paths : [ 'pyproject.toml' , 'requirements.txt' , 'Pipfile' ] , manager : 'python' } ,
176- { label : 'Cargo.toml' , paths : [ 'Cargo.toml' ] , manager : 'cargo' } ,
177- { label : 'go.mod' , paths : [ 'go.mod' ] , manager : 'go' } ,
178- ] as const ;
179-
180- const managerChecks = await runWithConcurrency (
181- existenceChecks . map ( ( { label, paths, manager } ) => ( {
182- label,
183- run : async ( ) => ( {
184- manager,
185- present : ( await Promise . all ( paths . map ( ( rel ) => fs . pathExists ( path . join ( root , rel ) ) ) ) ) . some ( Boolean ) ,
186- } ) ,
187- } ) ) ,
188- this . options . getParallelismLimit ( ) ,
189- ) ;
190-
191- const managers = managerChecks
192- . filter ( ( entry ) => entry . present )
193- . map ( ( entry ) => entry . manager ) ;
194-
195- if ( managers . length > 0 ) {
196- lines . push ( `Detected package managers: ${ Array . from ( new Set ( managers ) ) . join ( ', ' ) } ` ) ;
197- }
198-
199- try {
200- const packageJsonPath = path . join ( root , 'package.json' ) ;
201- if ( await fs . pathExists ( packageJsonPath ) ) {
202- const pkg = await fs . readJson ( packageJsonPath ) as { scripts ?: Record < string , string > } ;
203- const scripts = Object . keys ( pkg . scripts ?? { } ) ;
204- if ( scripts . length > 0 ) {
205- lines . push ( `package.json scripts: ${ scripts . slice ( 0 , 20 ) . join ( ', ' ) } ` ) ;
206- }
207- }
208- } catch {
209- // best effort
210- }
211-
212- const value = lines . join ( '\n' ) ;
213- this . packageContextCache = {
214- value,
215- expiresAt : now + 30_000 ,
216- } ;
217- return value ;
72+ const suggestion = getPrimaryShellCommandSuggestion ( trimmedInput , {
73+ cwd : this . options . runtime . workspaceRoot ,
74+ } ) ;
75+ return suggestion && suggestion !== trimmedInput ? suggestion : null ;
21876 }
21977}
0 commit comments