@@ -25,6 +25,8 @@ export interface LlmCallOptions {
2525 timeoutMs ?: number ;
2626 /** Called with each text chunk for streaming */
2727 onChunk ?: ( chunk : string ) => void ;
28+ /** Called when the LLM executes a tool (Read, Grep, etc.) */
29+ onToolUse ?: ( tool : string , summary : string ) => void ;
2830 /** Working directory for CLI-based providers */
2931 cwd ?: string ;
3032 /** Enable tool access (provider-specific) */
@@ -48,7 +50,7 @@ export class ClaudeCliProvider implements LlmProvider {
4850 readonly id = "claude-cli" ;
4951
5052 async call ( opts : LlmCallOptions ) : Promise < string > {
51- const { systemPrompt, history, userMessage, model, timeoutMs = 300_000 , onChunk, cwd, enableTools, allowedTools } = opts ;
53+ const { systemPrompt, history, userMessage, model, timeoutMs = 300_000 , onChunk, onToolUse , cwd, enableTools, allowedTools } = opts ;
5254
5355 const contextLines = history . slice ( - 6 ) . map ( ( m ) => {
5456 const label = m . role === "user" ? "User" : "Manager" ;
@@ -62,12 +64,19 @@ export class ClaudeCliProvider implements LlmProvider {
6264 const env = { ...process . env } ;
6365 delete env . CLAUDECODE ;
6466
67+ // Use stream-json when tools are enabled (allows parsing tool_use events)
68+ const useStreamJson = ! ! enableTools ;
69+
6570 const args = [
6671 "--print" ,
6772 "--system-prompt" , systemPrompt ,
6873 "--model" , model ,
6974 ] ;
7075
76+ if ( useStreamJson ) {
77+ args . push ( "--verbose" , "--output-format" , "stream-json" ) ;
78+ }
79+
7180 if ( enableTools ) {
7281 args . push ( "--permission-mode" , "plan" ) ;
7382 if ( allowedTools ?. length ) {
@@ -98,11 +107,58 @@ export class ClaudeCliProvider implements LlmProvider {
98107
99108 let stdout = "" ;
100109 let stderr = "" ;
110+ let resultText = "" ;
111+ let stdoutBuffer = "" ;
101112
102113 child . stdout ?. on ( "data" , ( chunk : Buffer ) => {
103- const text = chunk . toString ( ) ;
104- stdout += text ;
105- if ( onChunk && text ) onChunk ( text ) ;
114+ const raw = chunk . toString ( ) ;
115+ stdout += raw ;
116+
117+ if ( useStreamJson ) {
118+ // Parse stream-json JSONL for tool_use events and text
119+ stdoutBuffer += raw ;
120+ const lines = stdoutBuffer . split ( "\n" ) ;
121+ stdoutBuffer = lines . pop ( ) ?? "" ;
122+
123+ for ( const line of lines ) {
124+ if ( ! line . trim ( ) ) continue ;
125+ try {
126+ const event = JSON . parse ( line ) as Record < string , unknown > ;
127+
128+ // Extract tool_use events
129+ if ( event . type === "content_block_start" ) {
130+ const block = event . content_block as Record < string , unknown > | undefined ;
131+ if ( block ?. type === "tool_use" && typeof block . name === "string" ) {
132+ onToolUse ?.( block . name , summarizeTool ( block . name , block . input as Record < string , unknown > ) ) ;
133+ }
134+ }
135+ if ( event . type === "assistant" ) {
136+ const content = ( event . message as Record < string , unknown > ) ?. content ;
137+ if ( Array . isArray ( content ) ) {
138+ for ( const b of content ) {
139+ if ( b . type === "tool_use" && typeof b . name === "string" ) {
140+ onToolUse ?.( b . name , summarizeTool ( b . name , b . input as Record < string , unknown > ) ) ;
141+ }
142+ if ( b . type === "text" && typeof b . text === "string" ) {
143+ onChunk ?.( b . text ) ;
144+ }
145+ }
146+ }
147+ }
148+
149+ // Capture final result
150+ if ( event . type === "result" && typeof event . result === "string" ) {
151+ resultText = event . result ;
152+ onChunk ?.( event . result ) ;
153+ }
154+ } catch {
155+ // Not JSON — treat as raw text
156+ onChunk ?.( line ) ;
157+ }
158+ }
159+ } else {
160+ if ( onChunk && raw ) onChunk ( raw ) ;
161+ }
106162 } ) ;
107163 child . stderr ?. on ( "data" , ( chunk : Buffer ) => { stderr += chunk . toString ( ) ; } ) ;
108164
@@ -126,10 +182,12 @@ export class ClaudeCliProvider implements LlmProvider {
126182 ui . debug ( "llm" , `Claude CLI stderr: ${ stderr . trim ( ) . slice ( 0 , 300 ) } ` ) ;
127183 }
128184 if ( code === 0 ) {
129- if ( ! stdout . trim ( ) ) {
185+ // stream-json: use parsed resultText; plain: use raw stdout
186+ const response = useStreamJson ? ( resultText || stdout . trim ( ) ) : stdout . trim ( ) ;
187+ if ( ! response ) {
130188 ui . warn ( `[llm] Claude CLI returned empty response (stderr: ${ stderr . trim ( ) . slice ( 0 , 200 ) || "none" } )` ) ;
131189 }
132- resolve ( stdout . trim ( ) || "(no response)" ) ;
190+ resolve ( response || "(no response)" ) ;
133191 } else {
134192 reject ( new Error ( `Claude CLI exited with code ${ code } : ${ stderr . slice ( 0 , 200 ) } ` ) ) ;
135193 }
@@ -192,6 +250,18 @@ export class OpenAiApiProvider implements LlmProvider {
192250// Factory
193251// ---------------------------------------------------------------------------
194252
253+ /** Brief summary of a tool invocation for UI display */
254+ function summarizeTool ( name : string , input ?: Record < string , unknown > ) : string {
255+ if ( ! input ) return name ;
256+ switch ( name ) {
257+ case "Read" : return input . file_path ? `Read ${ String ( input . file_path ) . split ( "/" ) . slice ( - 2 ) . join ( "/" ) } ` : "Read" ;
258+ case "Grep" : return input . pattern ? `Grep "${ input . pattern } "` : "Grep" ;
259+ case "Glob" : return input . pattern ? `Glob ${ input . pattern } ` : "Glob" ;
260+ case "Bash" : { const cmd = input . command as string | undefined ; return cmd ? `Bash: ${ cmd . slice ( 0 , 50 ) } ` : "Bash" ; }
261+ default : return name ;
262+ }
263+ }
264+
195265/**
196266 * Create the appropriate LLM provider based on configuration.
197267 * If llmBaseUrl + llmApiKey are provided, uses OpenAI-compatible API.
0 commit comments