1717from rich .live import Live
1818
1919from .. import config
20+ from ..attachments import image_placeholder , parse_at_references , reattach_images
2021from ..commands import find_command
21- from ..models import Message
22+ from ..models import ImagePart , Message , TextPart
2223from ..persistence import (
2324 SessionPersistence ,
2425 escape_role_headers ,
@@ -57,7 +58,7 @@ class CommandMixin:
5758
5859 def _start_agent (
5960 self ,
60- text : str ,
61+ text : str | Message ,
6162 system : str | None = None ,
6263 restore : Callable [[], None ] | None = None ,
6364 ) -> None : ...
@@ -258,14 +259,39 @@ def _restore() -> None:
258259
259260 restore = _restore
260261 self .console .print (f"[cyan]/{ name } : { kickoff .strip ()} [/cyan]" )
261- self ._start_agent (kickoff , system = system , restore = restore )
262+ # Parse @file references in the kickoff (e.g. "/review @diff.patch"
263+ # or "/explain @client.py"): images become ImagePart attachments,
264+ # text files become TextPart attachments, and the @path token is
265+ # stripped from the text. Validation errors are shown to the user.
266+ cleaned_kickoff , attachments , errors = parse_at_references (
267+ kickoff , str (self .session .project_dir )
268+ )
269+ for err in errors :
270+ self .console .print (f"[red]@{ err .path } : { err .message } [/red]" )
271+ if attachments :
272+ parts : list [Any ] = []
273+ if cleaned_kickoff .strip ():
274+ parts .append (TextPart (text = cleaned_kickoff ))
275+ for att in attachments :
276+ parts .append (att .part )
277+ kickoff_msg = Message (role = "user" , content = parts )
278+ else :
279+ kickoff_msg = Message (role = "user" , content = cleaned_kickoff )
280+ self ._start_agent (kickoff_msg , system = system , restore = restore )
262281
263282 def _conversation_text (self ) -> str :
264283 msgs = self .session .last_messages or []
265284 parts = []
266285 for m in msgs :
267286 # escaped: see persistence.escape_role_headers
268287 body = escape_role_headers (m .text ())
288+ # Mark messages that contained image attachments (see
289+ # Session._conversation_text for details)
290+ if isinstance (m .content , list ):
291+ image_count = sum (1 for p in m .content if isinstance (p , ImagePart ))
292+ if image_count :
293+ paths = [p .path for p in m .content if isinstance (p , ImagePart ) and p .path ]
294+ body = f"{ image_placeholder (image_count , paths )} \n { body } "
269295 if body :
270296 parts .append (f"**{ m .role } **: { body } " )
271297 return "\n \n " .join (parts )
@@ -740,6 +766,25 @@ def _parse_saved_body(body: str) -> list[Message]:
740766 current_role : str | None = None
741767 current_lines : list [str ] = []
742768
769+ def _flush (role : str , lines : list [str ]) -> None :
770+ content = "\n " .join (lines ).strip ()
771+ if not content :
772+ return
773+ # A leading image-attachment placeholder (written on save)
774+ # is re-attached when the file still exists, so the restored
775+ # message is multimodal again; otherwise it stays as text.
776+ new_content , parts = reattach_images (content )
777+ if parts :
778+ remainder = new_content .split ("\n " , 1 )
779+ rest_text = remainder [1 ].strip () if len (remainder ) > 1 else ""
780+ content_parts : list = []
781+ if rest_text :
782+ content_parts .append (TextPart (text = rest_text ))
783+ content_parts .extend (parts )
784+ messages .append (Message (role = role , content = content_parts ))
785+ else :
786+ messages .append (Message (role = role , content = content ))
787+
743788 for line in body .splitlines ():
744789 # Check for a role header: **user**: ... or **assistant**: ...
745790 header = split_role_header (line )
@@ -749,19 +794,15 @@ def _parse_saved_body(body: str) -> list[Message]:
749794 # tool_call_id/name; system blocks would duplicate the
750795 # live system prompt the client prepends per request)
751796 if current_role is not None and current_role not in ("tool" , "system" ):
752- content = "\n " .join (current_lines ).strip ()
753- if content :
754- messages .append (Message (role = current_role , content = content ))
797+ _flush (current_role , current_lines )
755798 current_role = role
756799 current_lines = [unescape_role_header (rest )]
757800 continue
758801 current_lines .append (unescape_role_header (line ))
759802
760803 # Don't forget the last block (tool/system blocks dropped, see above)
761804 if current_role is not None and current_role not in ("tool" , "system" ):
762- content = "\n " .join (current_lines ).strip ()
763- if content :
764- messages .append (Message (role = current_role , content = content ))
805+ _flush (current_role , current_lines )
765806
766807 return messages
767808
0 commit comments