99//
1010// Delegation model: the CLI runs its own agent loop (tools, permissions,
1111// context) in the current working directory. This provider forwards the
12- // user's prompt, streams Claude's text back, and renders the CLI's tool
13- // activity as one-line notices. It never emits `ToolUse` blocks, so the
12+ // user's prompt (with any pasted images as standard Anthropic image blocks),
13+ // streams Claude's text back, and renders the CLI's tool activity as
14+ // one-line notices. It never emits `ToolUse` blocks, so the
1415// query loop treats every turn as self-contained (`end_turn`).
1516//
1617// Session continuity: the CLI's `session_id` (from the `init` envelope) is
@@ -105,6 +106,27 @@ fn last_user_text(request: &ProviderRequest) -> String {
105106 . unwrap_or_default ( )
106107}
107108
109+ /// Image blocks from the latest user message, forwarded to the CLI alongside
110+ /// the text prompt. Images from earlier turns are not re-sent: on a resumed
111+ /// session the CLI already has them, and on a flattened restart they are gone
112+ /// with the rest of the non-text content.
113+ fn last_user_images ( request : & ProviderRequest ) -> Vec < ContentBlock > {
114+ request
115+ . messages
116+ . iter ( )
117+ . rev ( )
118+ . find ( |message| matches ! ( message. role, Role :: User ) )
119+ . map ( |message| match & message. content {
120+ MessageContent :: Blocks ( blocks) => blocks
121+ . iter ( )
122+ . filter ( |block| matches ! ( block, ContentBlock :: Image { .. } ) )
123+ . cloned ( )
124+ . collect ( ) ,
125+ MessageContent :: Text ( _) => Vec :: new ( ) ,
126+ } )
127+ . unwrap_or_default ( )
128+ }
129+
108130/// Flatten the whole transcript into one prompt for sessions that cannot be
109131/// resumed (the CLI has no matching session to `--resume`).
110132fn flattened_transcript ( request : & ProviderRequest ) -> String {
@@ -167,13 +189,22 @@ fn build_args(model: &str, resume: Option<&str>) -> Vec<String> {
167189 args
168190}
169191
170- /// The single stream-json stdin line carrying the user prompt.
171- fn stdin_line ( prompt : & str ) -> String {
192+ /// The single stream-json stdin line carrying the user prompt. Image blocks
193+ /// ride along in the same Anthropic wire shape the API takes, placed before
194+ /// the text to match the direct `anthropic.rs` path.
195+ fn stdin_line ( prompt : & str , images : & [ ContentBlock ] ) -> String {
196+ let mut content: Vec < Value > = images
197+ . iter ( )
198+ . filter_map ( |block| serde_json:: to_value ( block) . ok ( ) )
199+ . collect ( ) ;
200+ if !prompt. is_empty ( ) || content. is_empty ( ) {
201+ content. push ( json ! ( { "type" : "text" , "text" : prompt} ) ) ;
202+ }
172203 let envelope = json ! ( {
173204 "type" : "user" ,
174205 "message" : {
175206 "role" : "user" ,
176- "content" : [ { "type" : "text" , "text" : prompt } ] ,
207+ "content" : content ,
177208 } ,
178209 } ) ;
179210 format ! ( "{}\n " , envelope)
@@ -481,6 +512,7 @@ impl ClaudeCliProvider {
481512 & self ,
482513 model : & str ,
483514 prompt : & str ,
515+ images : & [ ContentBlock ] ,
484516 resume : Option < & str > ,
485517 ) -> Result < SpawnOutcome , ProviderError > {
486518 let mut command = Command :: new ( self . binary_or_default ( ) ) ;
@@ -498,7 +530,7 @@ impl ClaudeCliProvider {
498530 status : None ,
499531 body : None ,
500532 } ) ?;
501- let payload = stdin_line ( prompt) ;
533+ let payload = stdin_line ( prompt, images ) ;
502534 if let Err ( e) = stdin. write_all ( payload. as_bytes ( ) ) . await {
503535 warn ! ( error = %e, "failed writing prompt to claude CLI stdin" ) ;
504536 }
@@ -565,9 +597,15 @@ impl ClaudeCliProvider {
565597 None
566598 } ;
567599
600+ let images = last_user_images ( request) ;
568601 if let Some ( session_id) = resume_session {
569602 match self
570- . spawn_turn ( & request. model , & last_user_text ( request) , Some ( & session_id) )
603+ . spawn_turn (
604+ & request. model ,
605+ & last_user_text ( request) ,
606+ & images,
607+ Some ( & session_id) ,
608+ )
571609 . await ?
572610 {
573611 SpawnOutcome :: Turn ( turn) => return Ok ( * turn) ,
@@ -584,7 +622,10 @@ impl ClaudeCliProvider {
584622 } else {
585623 last_user_text ( request)
586624 } ;
587- match self . spawn_turn ( & request. model , & prompt, None ) . await ? {
625+ match self
626+ . spawn_turn ( & request. model , & prompt, & images, None )
627+ . await ?
628+ {
588629 SpawnOutcome :: Turn ( turn) => Ok ( * turn) ,
589630 SpawnOutcome :: ExitedEarly { stderr } => {
590631 let detail = collect_stderr ( & stderr) . await ;
@@ -762,7 +803,9 @@ impl LlmProvider for ClaudeCliProvider {
762803 // The CLI runs its own tools; Coven Code's tool loop stays out.
763804 tool_calling : false ,
764805 thinking : false ,
765- image_input : false ,
806+ // Pasted images are forwarded on stdin as standard Anthropic
807+ // image blocks; the CLI accepts them like the API does.
808+ image_input : true ,
766809 pdf_input : false ,
767810 audio_input : false ,
768811 video_input : false ,
@@ -820,13 +863,85 @@ mod tests {
820863
821864 #[ test]
822865 fn stdin_line_is_a_stream_json_user_envelope ( ) {
823- let line = stdin_line ( "hello" ) ;
866+ let line = stdin_line ( "hello" , & [ ] ) ;
824867 let parsed: Value = serde_json:: from_str ( line. trim ( ) ) . expect ( "valid JSON" ) ;
825868 assert_eq ! ( parsed[ "type" ] , "user" ) ;
826869 assert_eq ! ( parsed[ "message" ] [ "content" ] [ 0 ] [ "text" ] , "hello" ) ;
827870 assert ! ( line. ends_with( '\n' ) ) ;
828871 }
829872
873+ fn png_block ( data : & str ) -> ContentBlock {
874+ ContentBlock :: Image {
875+ source : claurst_core:: types:: ImageSource {
876+ source_type : "base64" . to_string ( ) ,
877+ media_type : Some ( "image/png" . to_string ( ) ) ,
878+ data : Some ( data. to_string ( ) ) ,
879+ url : None ,
880+ } ,
881+ }
882+ }
883+
884+ #[ test]
885+ fn stdin_line_places_image_blocks_before_the_text ( ) {
886+ let line = stdin_line ( "what is this?" , & [ png_block ( "aGk=" ) ] ) ;
887+ let parsed: Value = serde_json:: from_str ( line. trim ( ) ) . expect ( "valid JSON" ) ;
888+ let content = parsed[ "message" ] [ "content" ]
889+ . as_array ( )
890+ . expect ( "content array" ) ;
891+ assert_eq ! ( content. len( ) , 2 ) ;
892+ assert_eq ! ( content[ 0 ] [ "type" ] , "image" ) ;
893+ assert_eq ! ( content[ 0 ] [ "source" ] [ "type" ] , "base64" ) ;
894+ assert_eq ! ( content[ 0 ] [ "source" ] [ "media_type" ] , "image/png" ) ;
895+ assert_eq ! ( content[ 0 ] [ "source" ] [ "data" ] , "aGk=" ) ;
896+ assert_eq ! ( content[ 1 ] [ "type" ] , "text" ) ;
897+ assert_eq ! ( content[ 1 ] [ "text" ] , "what is this?" ) ;
898+ }
899+
900+ #[ test]
901+ fn stdin_line_omits_the_text_block_for_image_only_prompts ( ) {
902+ let line = stdin_line ( "" , & [ png_block ( "aGk=" ) ] ) ;
903+ let parsed: Value = serde_json:: from_str ( line. trim ( ) ) . expect ( "valid JSON" ) ;
904+ let content = parsed[ "message" ] [ "content" ]
905+ . as_array ( )
906+ . expect ( "content array" ) ;
907+ assert_eq ! ( content. len( ) , 1 ) ;
908+ assert_eq ! ( content[ 0 ] [ "type" ] , "image" ) ;
909+ }
910+
911+ #[ test]
912+ fn last_user_images_reads_only_the_latest_user_message ( ) {
913+ let request = request_with ( vec ! [
914+ Message :: user_blocks( vec![
915+ png_block( "b2xk" ) ,
916+ ContentBlock :: Text {
917+ text: "earlier" . to_string( ) ,
918+ } ,
919+ ] ) ,
920+ Message {
921+ role: Role :: Assistant ,
922+ content: MessageContent :: Text ( "reply" . to_string( ) ) ,
923+ uuid: None ,
924+ cost: None ,
925+ snapshot_patch: None ,
926+ } ,
927+ Message :: user_blocks( vec![
928+ png_block( "bmV3" ) ,
929+ ContentBlock :: Text {
930+ text: "latest" . to_string( ) ,
931+ } ,
932+ ] ) ,
933+ ] ) ;
934+ let images = last_user_images ( & request) ;
935+ assert_eq ! ( images. len( ) , 1 ) ;
936+ assert ! ( matches!(
937+ & images[ 0 ] ,
938+ ContentBlock :: Image { source } if source. data. as_deref( ) == Some ( "bmV3" )
939+ ) ) ;
940+
941+ let text_only = request_with ( vec ! [ Message :: user( "no images" ) ] ) ;
942+ assert ! ( last_user_images( & text_only) . is_empty( ) ) ;
943+ }
944+
830945 #[ test]
831946 fn last_user_text_reads_text_and_blocks ( ) {
832947 let request = request_with ( vec ! [
0 commit comments