Skip to content

Commit beec95c

Browse files
authored
Update core.py
1 parent 18ab1cd commit beec95c

1 file changed

Lines changed: 140 additions & 7 deletions

File tree

‎python_agent_harness/tui/core.py‎

Lines changed: 140 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,14 @@
2424

2525
from .. import config
2626
from ..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
2835
from ..session import Session
2936
from .commands import CommandMixin
3037
from .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

Comments
 (0)