2424
2525from .. import config
2626from ..agent import run_agent_loop
27- from ..models import Message
27+ from ..attachments import (
28+ AttachmentError ,
29+ ParsedAttachment ,
30+ load_clipboard_image ,
31+ parse_at_references ,
32+ strip_clipboard_markers ,
33+ )
34+ from ..models import ImagePart , Message , TextPart
2835from ..session import Session
2936from .commands import CommandMixin
3037from .input import InputMixin , SlashCompleter , UiQuestion , _history_path , _make_prompt_session
@@ -70,13 +77,23 @@ def __init__(self, session: Session, console: Console | None = None) -> None:
7077 # wall-clock start of the current run, used to report the total
7178 # time spent once the run finishes
7279 self ._run_start : float | None = None
80+ # Pre-built user message (with @file attachments) passed from
81+ # _start_agent to _run_agent
82+ self ._pending_user_msg : Message | None = None
7383 # Discovered agent profiles (name -> prompt file path) from the
7484 # prompts/agents/ directory; refreshed on each /agent call so files
7585 # added at runtime are picked up.
7686 self ._discovered_agents : dict [str , str ] = {}
87+ # Clipboard images captured on paste, pending until the next
88+ # submit. The paste handler appends the temp-file path here
89+ # (out-of-band, not via an @path text token, so a temp path with
90+ # spaces isn't truncated by the @file parser); _submit_text
91+ # drains and attaches them.
92+ self ._pending_clipboard_images : list [str ] = []
7793 self .prompt_session = _make_prompt_session (
7894 FileHistory (_history_path ()),
7995 SlashCompleter (lambda : str (self .session .project_dir )),
96+ on_image_paste = self ._pending_clipboard_images .append ,
8097 )
8198
8299 session .on_delta = self ._on_delta
@@ -178,7 +195,8 @@ def run(self) -> None:
178195 "Ctrl-C cancels the current execution (the app stays open); "
179196 "Ctrl-D or /exit quits.\n "
180197 "Type a message — Enter for a new line, Esc then Enter "
181- "(or Alt+Enter) to submit. Up/Down recall history.\n \n "
198+ "(or Alt+Enter) to submit. Up/Down recall history.\n "
199+ "Use @path to attach files (e.g. @screenshot.png, @README.md).\n \n "
182200 "[dim]Type [bold]/help[/bold] for the full command reference.[/dim]"
183201 ),
184202 title = "[bold cyan]python-agent-harness — interactive AI coding agent[/bold cyan]" ,
@@ -203,6 +221,13 @@ def run(self) -> None:
203221 if not text .strip ():
204222 continue
205223 if text .startswith ("/" ):
224+ # A slash command isn't a normal submit: discard any
225+ # clipboard images captured on paste so they don't
226+ # silently attach to a later message. Cleared in
227+ # place (not reassigned) so the paste callback bound
228+ # to this list keeps targeting it. Files remain
229+ # tracked for cleanup on session close.
230+ self ._pending_clipboard_images .clear ()
206231 if self ._handle_slash (text ):
207232 break
208233 continue
@@ -211,9 +236,34 @@ def run(self) -> None:
211236 # stray Ctrl-C outside input/execution: stay in the app
212237 self .console .print ("[dim]cancelled — Ctrl-D or /exit to quit[/dim]" )
213238
239+ def _drain_clipboard_images (self , text : str ) -> tuple [list [ParsedAttachment ], str ]:
240+ """Consume clipboard images captured on paste since the last submit.
241+
242+ Returns ``(attachments, cleaned_text)``: each pending temp-file
243+ path is validated (same checks as ``@file`` images) into a
244+ ``ParsedAttachment``; validation errors are shown to the user and
245+ dropped. Only the markers for the *actually pending* paths are
246+ stripped (so text a user literally typed that resembles a marker
247+ is left intact). The pending list is cleared in place — never
248+ reassigned — so the paste callback bound to it keeps working.
249+ """
250+ pending = list (self ._pending_clipboard_images )
251+ self ._pending_clipboard_images .clear ()
252+ if not pending :
253+ return [], text
254+ cleaned = strip_clipboard_markers (text , pending )
255+ atts : list [ParsedAttachment ] = []
256+ for path in pending :
257+ result = load_clipboard_image (path )
258+ if isinstance (result , AttachmentError ):
259+ self .console .print (f"[red]clipboard image: { result .message } [/red]" )
260+ continue
261+ atts .append (result )
262+ return atts , cleaned
263+
214264 def _start_agent (
215265 self ,
216- text : str ,
266+ text : str | Message ,
217267 system : str | None = None ,
218268 restore : Callable [[], None ] | None = None ,
219269 ) -> None :
@@ -222,7 +272,81 @@ def _start_agent(
222272 SYSTEM overrides the session's system prompt for this run only.
223273 RESTORE (if given) runs when the run finishes — used by the
224274 slash commands to put back state they borrowed (e.g. project_dir).
275+
276+ TEXT may be a plain string (parsed for @file references here) or
277+ a pre-built Message (slash commands that already parsed their
278+ kickoff's @file references pass the Message directly).
225279 """
280+ # Parse @file references before submitting: images become
281+ # ImagePart attachments, text files become TextPart attachments.
282+ # The @path token is stripped from the text (the content lives
283+ # in the attachment), and validation errors are shown to the user.
284+ if isinstance (text , Message ):
285+ user_msg = text
286+ cleaned_text = text .text ()
287+ attachments : list [Any ] = []
288+ errors : list [Any ] = []
289+ display_text = cleaned_text .strip () or "(attachment)"
290+ else :
291+ cleaned_text , attachments , errors = parse_at_references (
292+ text , str (self .session .project_dir )
293+ )
294+ for err in errors :
295+ self .console .print (f"[red]@{ err .path } : { err .message } [/red]" )
296+
297+ # Drain clipboard images captured on paste since the last
298+ # submit and append them as attachments. They are validated
299+ # through the same path as @file images (size, PNG signature)
300+ # and their "[image #...]" markers are stripped from the text.
301+ clip_atts , cleaned_text = self ._drain_clipboard_images (cleaned_text )
302+ attachments = attachments + clip_atts
303+
304+ if errors and not attachments and not cleaned_text .strip ():
305+ # All references failed and nothing else to send
306+ return
307+
308+ # Build the user message: multimodal if there are attachments,
309+ # plain text otherwise (preserving the existing text-only path).
310+ if attachments :
311+ parts : list [Any ] = []
312+ if cleaned_text .strip ():
313+ parts .append (TextPart (text = cleaned_text ))
314+ for att in attachments :
315+ parts .append (att .part )
316+ user_msg = Message (role = "user" , content = parts )
317+ # The round_user_text is the display text (without image data)
318+ display_text = (
319+ cleaned_text .strip ()
320+ or f"({ len (attachments )} image attachment{ 's' if len (attachments ) != 1 else '' } )"
321+ )
322+ else :
323+ user_msg = Message (role = "user" , content = cleaned_text )
324+ display_text = cleaned_text
325+
326+ # Warn when the active model cannot see images: the attachment
327+ # will be silently stripped before the request is sent (see
328+ # Client._payload), so the user should know the image never
329+ # reaches the model.
330+ if attachments and any (isinstance (a .part , ImagePart ) for a in attachments ):
331+ from ..config import get_model_info
332+
333+ # A malformed image_input_models config must not break the
334+ # submit path; fall back to the built-in table on error.
335+ try :
336+ supports_images = get_model_info (
337+ self .session .model , config_path = self .session .config_path
338+ ).supports_image_input
339+ except Exception : # noqa: BLE001 - config error must not block input
340+ supports_images = get_model_info (self .session .model ).supports_image_input
341+ if not supports_images :
342+ self .console .print (
343+ f"[yellow]warning: model { self .session .model } does not support "
344+ "image input — image attachment(s) will be ignored[/yellow]"
345+ )
346+
347+ # Store for _run_agent to pick up (avoids re-parsing there)
348+ self ._pending_user_msg = user_msg
349+
226350 self .stream_text = ""
227351 self .status = " running"
228352 self ._current_tool = ""
@@ -232,7 +356,7 @@ def _start_agent(
232356 # the run so it points just past the previous round's messages —
233357 # the new user message will be the first mirrored row.
234358 self .round_start = len (self .session .last_messages or [])
235- self .round_user_text = text
359+ self .round_user_text = display_text
236360 self ._round_times .append (time .time ())
237361 self ._run_start = time .time ()
238362 # keep the persisted metadata in sync so auto-save /save
@@ -256,7 +380,7 @@ def _start_agent(
256380 self ._restore = restore
257381 self .agent_running = True
258382 worker = threading .Thread (
259- target = self ._run_agent , args = (text , seq , system , restore ), daemon = True
383+ target = self ._run_agent , args = (user_msg , seq , system , restore ), daemon = True
260384 )
261385 worker .start ()
262386 cancelled = False
@@ -360,13 +484,22 @@ def _flush(self) -> None:
360484
361485 def _run_agent (
362486 self ,
363- text : str ,
487+ text : str | Message ,
364488 seq : int ,
365489 system : str | None = None ,
366490 restore : Callable [[], None ] | None = None ,
367491 ) -> None :
368492 try :
369- self .conversation_history .append (Message (role = "user" , content = text ))
493+ # The user message was already built in _start_agent (with
494+ # @file attachments parsed into ImagePart/TextPart). We
495+ # reconstruct it here from the round_user_text for text-only
496+ # messages, or use the pre-built message stored on the instance.
497+ user_msg = getattr (self , "_pending_user_msg" , None )
498+ if user_msg is None :
499+ user_msg = text if isinstance (text , Message ) else Message (role = "user" , content = text )
500+ else :
501+ self ._pending_user_msg = None
502+ self .conversation_history .append (user_msg )
370503 run_agent_loop (
371504 self .session ,
372505 messages = list (self .conversation_history ),
0 commit comments