11import { appendFileSync } from "node:fs" ;
22import { basename } from "node:path" ;
33import type { AgentEvent , AgentMessage } from "@earendil-works/pi-agent-core" ;
4+ import type { ImageContent } from "@earendil-works/pi-ai" ;
45import {
56 type AutocompleteItem ,
67 CombinedAutocompleteProvider ,
@@ -24,6 +25,7 @@ import { runPlanFlow } from "../plan/run-flow.js";
2425import type { ChatState , ToolExecution } from "../types.js" ;
2526import { EMPTY_USAGE } from "../types.js" ;
2627import { buildAttachmentPrompt , collectAttachments } from "../ui/attachments.js" ;
28+ import { type ClipboardImage , readClipboardImage } from "../ui/clipboard-image.js" ;
2729import { HistoryStore } from "../ui/history-store.js" ;
2830import { notifyTurnComplete } from "../ui/notify.js" ;
2931import { runShellEscape } from "../ui/shell-escape.js" ;
@@ -109,6 +111,8 @@ export class App extends Container {
109111 /** Keyboard copy mode: registry of transcript copy boxes + the Ctrl-O picker. */
110112 private readonly copyRegistry = new CopyRegistry ( ) ;
111113 private copyPickerOverlay : { handle : OverlayHandle ; component : CopyPickerOverlay } | undefined ;
114+ /** Images pulled off the clipboard with Ctrl-V, attached to the next prompt. */
115+ private pendingImages : ClipboardImage [ ] = [ ] ;
112116
113117 constructor ( ) {
114118 super ( ) ;
@@ -369,6 +373,23 @@ export class App extends Container {
369373 this . tui ?. requestRender ( ) ;
370374 }
371375
376+ /** Pull an image off the clipboard and stage it for the next prompt. */
377+ private async attachClipboardImage ( ) : Promise < void > {
378+ try {
379+ const image = await readClipboardImage ( ) ;
380+ if ( ! image ) {
381+ this . statusBar . note ( "No image on the clipboard (copy one first; needs pngpaste / wl-paste / xclip)." ) ;
382+ } else {
383+ this . pendingImages . push ( image ) ;
384+ const kb = Math . round ( ( image . data . length * 3 ) / 4 / 1024 ) ;
385+ this . statusBar . note ( `📎 image attached (${ kb } KB) — send a message to include it.` ) ;
386+ }
387+ } catch {
388+ this . statusBar . note ( "Couldn't read the clipboard image." ) ;
389+ }
390+ this . tui ?. requestRender ( ) ;
391+ }
392+
372393 /** Dynamic terminal title: cwd basename + a ● marker while a turn runs. */
373394 private setTitle ( working : boolean ) : void {
374395 const dir = basename ( this . bundle . toolContext . cwd ) || "codebase" ;
@@ -423,6 +444,12 @@ export class App extends Container {
423444 this . showCopyPickerOverlay ( ) ;
424445 return { consume : true } ;
425446 }
447+ // Ctrl-V attaches an image from the system clipboard to the next
448+ // prompt (text paste arrives via bracketed paste, not Ctrl-V).
449+ if ( data === "\x16" && this . inputBar ) {
450+ void this . attachClipboardImage ( ) ;
451+ return { consume : true } ;
452+ }
426453 // Ghost suggestion: Tab on an empty editor accepts it; any other
427454 // keystroke dismisses it (and still reaches the editor).
428455 const ghost = this . suggestionLine . get ( ) ;
@@ -534,7 +561,8 @@ export class App extends Container {
534561
535562 private async handleSubmitInner ( text : string ) : Promise < void > {
536563 const trimmed = text . trim ( ) ;
537- if ( ! trimmed ) return ;
564+ // Allow an image-only submit (Ctrl-V then Enter with no text).
565+ if ( ! trimmed && this . pendingImages . length === 0 ) return ;
538566
539567 // Slash commands and `!cmd` shell escapes bypass the agent and the
540568 // type-ahead queue. They run immediately so the user never has to
@@ -558,6 +586,10 @@ export class App extends Container {
558586 // Claude-Code "type while it works" behavior. The message also lands
559587 // in the transcript so the user sees what they steered with.
560588 if ( this . busy ) {
589+ if ( ! trimmed ) {
590+ this . statusBar . note ( "Finish the current turn before sending an image." ) ;
591+ return ;
592+ }
561593 const userMsg : AgentMessage = { role : "user" , content : trimmed , timestamp : Date . now ( ) } ;
562594 this . messages . push ( userMsg ) ;
563595 this . transcript . appendUserMessage ( trimmed ) ;
@@ -585,10 +617,12 @@ export class App extends Container {
585617 this . statusBar . note ( `Attached: ${ attachments . map ( ( a ) => a . relPath ) . join ( ", " ) } ` ) ;
586618 }
587619
588- const userMsg : AgentMessage = { role : "user" , content : trimmed , timestamp : Date . now ( ) } ;
620+ const imageCount = this . pendingImages . length ;
621+ const display = trimmed || ( imageCount > 0 ? `📎 ${ imageCount } image${ imageCount === 1 ? "" : "s" } ` : "" ) ;
622+ const userMsg : AgentMessage = { role : "user" , content : display , timestamp : Date . now ( ) } ;
589623 this . messages . push ( userMsg ) ;
590- this . transcript . appendUserMessage ( trimmed ) ;
591- this . persistHistory ( trimmed ) ;
624+ this . transcript . appendUserMessage ( display ) ;
625+ if ( trimmed ) this . persistHistory ( trimmed ) ;
592626
593627 // Glue-router classification: plan-style requests run through the
594628 // plan flow (Q&A → reviewable plan → agent); everything else
@@ -631,8 +665,11 @@ export class App extends Container {
631665 // stderr as a status-bar note. A real error (agent throws before
632666 // reaching agent_start) surfaces as an ErrorCard — otherwise the
633667 // user just sees their prompt land with no response.
668+ // Attach + clear any clipboard images staged with Ctrl-V.
669+ const images = this . pendingImages . length > 0 ? this . pendingImages : undefined ;
670+ this . pendingImages = [ ] ;
634671 this . bundle
635- . submitUserPrompt ( promptText )
672+ . submitUserPrompt ( promptText , images )
636673 . then ( ( result ) => {
637674 if ( ! result . submitted && result . reason ) {
638675 this . statusBar . note ( `Prompt blocked by hook: ${ result . reason } ` ) ;
0 commit comments