fix(tui): file paste, block-delete, ctrl+c copy, per-session input (#389)
- Pasting non-image files (.docx/.pdf/.py …) was folded as `[Image #N]` because clipboard helper returned any file path. Split into image vs file by extension; non-images now fold as `[File #N]` and expand to the path on submit, same flow as images. - Backspace/Delete used to nibble placeholders one char at a time. Override `action_delete_left/right`: when the caret sits flush against a `[Pasted text #N +X lines]` / `[Image #N]` / `[File #N]` placeholder on the matching side, remove the whole span and drop its dict entry. - Ctrl+C inside the input was clearing the whole field. Root cause: `screen.get_selected_text()` doesn't see TextArea's internal selection, so the handler fell through to the input-reset branch. Fixed by preferring the focused InputArea's `selected_text` before screen-level drag selection. - Input text/history/pastes were globally shared across agent sessions (single InputArea widget). Moved the four state slots (text/history/pastes/paste_counter) onto `AgentSession`, with a `_swap_input_for_session` snapshot/restore on session switch.
This commit is contained in:
1 parent
fc6b5ad309
commit
90a3dff557
1 file changed
+118
-33
+118
-33
@@ -158,6 +158,11 @@ class AgentSession:
|
||||
task_seq: int = 0
|
||||
current_task_id: Optional[int] = None
|
||||
current_display_queue: Optional[queue.Queue] = None
|
||||
# Per-session input box state. Restored into the shared InputArea on session switch.
|
||||
input_text: str = ""
|
||||
input_history: list[str] = field(default_factory=list)
|
||||
input_pastes: dict[int, str] = field(default_factory=dict)
|
||||
input_paste_counter: int = 0
|
||||
buffer: str = ""
|
||||
|
||||
|
||||
@@ -241,31 +246,32 @@ def _read_clipboard_text() -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _save_clipboard_image() -> Optional[str]:
|
||||
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff", ".tif", ".ico"}
|
||||
|
||||
|
||||
def _grab_clipboard_file() -> Optional[tuple[str, bool]]:
|
||||
"""Return (path, is_image) from clipboard. is_image distinguishes image files
|
||||
(rendered inline as `[Image #N]`) from any other file (folded as `[File #N]`)."""
|
||||
try:
|
||||
from PIL import ImageGrab, Image
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
data = ImageGrab.grabclipboard()
|
||||
except Exception:
|
||||
return None
|
||||
if data is None:
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
if isinstance(item, str) and os.path.isfile(item):
|
||||
is_img = os.path.splitext(item)[1].lower() in _IMAGE_EXTS
|
||||
return (item, is_img)
|
||||
return None
|
||||
try:
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
if isinstance(item, str) and os.path.isfile(item):
|
||||
return item
|
||||
return None
|
||||
if isinstance(data, Image.Image):
|
||||
if isinstance(data, Image.Image):
|
||||
try:
|
||||
out_dir = os.path.join(tempfile.gettempdir(), "genericagent_tui_clipboard")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
path = os.path.join(out_dir, f"clipboard_{int(time.time() * 1000)}.png")
|
||||
data.save(path, "PNG")
|
||||
return path
|
||||
except Exception:
|
||||
return None
|
||||
return (path, True)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
@@ -274,6 +280,8 @@ class InputArea(TextArea):
|
||||
# `[Image #N]` is the folded form; expand_placeholders restores the raw path at submit time.
|
||||
# The longer `[Image #N: ...]` form is tolerated for backward compatibility only.
|
||||
_IMAGE_RE = re.compile(r'\[Image #(\d+)(?::[^\]]*)?\]')
|
||||
_FILE_RE = re.compile(r'\[File #(\d+)\]')
|
||||
_PLACEHOLDER_RES = (_PASTE_RE, _IMAGE_RE, _FILE_RE)
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+j", "newline", "Newline", show=False),
|
||||
@@ -313,14 +321,16 @@ class InputArea(TextArea):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _paste_image_from_clipboard(self) -> bool:
|
||||
path = _save_clipboard_image()
|
||||
if not path:
|
||||
def _paste_file_from_clipboard(self) -> bool:
|
||||
result = _grab_clipboard_file()
|
||||
if not result:
|
||||
return False
|
||||
path, is_image = result
|
||||
self._paste_counter += 1
|
||||
sid = self._paste_counter
|
||||
self._pastes[sid] = path
|
||||
self._insert_via_keyboard(f"[Image #{sid}]")
|
||||
marker = f"[Image #{sid}]" if is_image else f"[File #{sid}]"
|
||||
self._insert_via_keyboard(marker)
|
||||
return True
|
||||
|
||||
def _insert_paste_text(self, text: str) -> None:
|
||||
@@ -334,14 +344,52 @@ class InputArea(TextArea):
|
||||
self._insert_via_keyboard(text)
|
||||
|
||||
def action_paste(self) -> None:
|
||||
if self.read_only or self._paste_image_from_clipboard():
|
||||
if self.read_only or self._paste_file_from_clipboard():
|
||||
return
|
||||
text = _read_clipboard_text() or getattr(self.app, "clipboard", "")
|
||||
if text:
|
||||
self._insert_paste_text(text)
|
||||
|
||||
def action_paste_image(self) -> None:
|
||||
self._paste_image_from_clipboard()
|
||||
def action_paste_file(self) -> None:
|
||||
self._paste_file_from_clipboard()
|
||||
|
||||
def _placeholder_adjacent(self, side: str) -> Optional[tuple[int, int, int, int]]:
|
||||
"""Return (row, start_col, end_col, sid) if a placeholder is flush against
|
||||
the caret on the given side ('left' = backspace target, 'right' = delete target)."""
|
||||
if self.selection.start != self.selection.end:
|
||||
return None
|
||||
row, col = self.cursor_location
|
||||
try:
|
||||
line = self.text.split("\n")[row]
|
||||
except IndexError:
|
||||
return None
|
||||
for pat in self._PLACEHOLDER_RES:
|
||||
for m in pat.finditer(line):
|
||||
edge = m.end() if side == "left" else m.start()
|
||||
if edge == col:
|
||||
return (row, m.start(), m.end(), int(m.group(1)))
|
||||
return None
|
||||
|
||||
def _delete_placeholder(self, side: str) -> bool:
|
||||
hit = self._placeholder_adjacent(side)
|
||||
if not hit:
|
||||
return False
|
||||
row, start, end, sid = hit
|
||||
self.delete((row, start), (row, end))
|
||||
self._pastes.pop(sid, None)
|
||||
try:
|
||||
self.app._resize_input(self)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
def action_delete_left(self) -> None:
|
||||
if not self._delete_placeholder("left"):
|
||||
super().action_delete_left()
|
||||
|
||||
def action_delete_right(self) -> None:
|
||||
if not self._delete_placeholder("right"):
|
||||
super().action_delete_right()
|
||||
|
||||
async def _on_click(self, event: events.Click) -> None:
|
||||
if getattr(event, "button", 0) == 3 and not self.read_only:
|
||||
@@ -364,14 +412,12 @@ class InputArea(TextArea):
|
||||
self._HISTORY_MAX = 200
|
||||
|
||||
def expand_placeholders(self, text: str) -> str:
|
||||
def repl_paste(m):
|
||||
def repl(m):
|
||||
sid = int(m.group(1))
|
||||
return self._pastes.get(sid, m.group(0))
|
||||
def repl_image(m):
|
||||
sid = int(m.group(1))
|
||||
return self._pastes.get(sid, m.group(0))
|
||||
text = self._PASTE_RE.sub(repl_paste, text)
|
||||
return self._IMAGE_RE.sub(repl_image, text)
|
||||
for pat in self._PLACEHOLDER_RES:
|
||||
text = pat.sub(repl, text)
|
||||
return text
|
||||
|
||||
# ---- history public API ----
|
||||
def record_history(self, raw_text: str) -> None:
|
||||
@@ -430,7 +476,7 @@ class InputArea(TextArea):
|
||||
# Terminal Ctrl+V in bracketed-paste mode lands here, bypassing action_paste.
|
||||
if self.read_only:
|
||||
return
|
||||
if self._paste_image_from_clipboard():
|
||||
if self._paste_file_from_clipboard():
|
||||
event.stop(); event.prevent_default(); return
|
||||
self._insert_paste_text(event.text)
|
||||
event.stop(); event.prevent_default()
|
||||
@@ -954,6 +1000,17 @@ class GenericAgentTUI(App[None]):
|
||||
def action_handle_ctrl_c(self) -> None:
|
||||
# Two-stage quit: when no task is running, first press clears input and arms;
|
||||
# second press within 2s exits.
|
||||
try:
|
||||
inp = self.query_one("#input", InputArea)
|
||||
except Exception:
|
||||
inp = None
|
||||
# Copy precedence: focused InputArea selection first (screen-level selection
|
||||
# doesn't cover TextArea internals), then screen drag selection.
|
||||
if inp is not None and self.focused is inp and inp.selected_text:
|
||||
try: self.copy_to_clipboard(inp.selected_text)
|
||||
except Exception: pass
|
||||
self._disarm_quit()
|
||||
return
|
||||
try:
|
||||
selected_text = self.screen.get_selected_text()
|
||||
except Exception:
|
||||
@@ -971,10 +1028,6 @@ class GenericAgentTUI(App[None]):
|
||||
if self._quit_armed:
|
||||
self.exit()
|
||||
return
|
||||
try:
|
||||
inp = self.query_one("#input", InputArea)
|
||||
except Exception:
|
||||
inp = None
|
||||
if inp is not None and inp.text:
|
||||
inp.reset()
|
||||
try: self._resize_input(inp)
|
||||
@@ -1717,10 +1770,42 @@ class GenericAgentTUI(App[None]):
|
||||
|
||||
def _refresh_all(self):
|
||||
if not self.is_mounted: return
|
||||
self._swap_input_for_session()
|
||||
self._refresh_topbar()
|
||||
self._refresh_sidebar()
|
||||
self._refresh_messages()
|
||||
|
||||
def _swap_input_for_session(self) -> None:
|
||||
"""Persist the InputArea's text/history/pastes per-session so switching
|
||||
agents doesn't bleed input state across them."""
|
||||
if self.current_id is None:
|
||||
return
|
||||
try:
|
||||
inp = self.query_one("#input", InputArea)
|
||||
except Exception:
|
||||
return
|
||||
prev_id = getattr(self, "_input_owner_id", None)
|
||||
if prev_id == self.current_id:
|
||||
return
|
||||
if prev_id is not None and prev_id in self.sessions:
|
||||
prev = self.sessions[prev_id]
|
||||
prev.input_text = inp.text
|
||||
prev.input_history = inp._input_history
|
||||
prev.input_pastes = inp._pastes
|
||||
prev.input_paste_counter = inp._paste_counter
|
||||
sess = self.current
|
||||
inp._input_history = sess.input_history
|
||||
inp._pastes = sess.input_pastes
|
||||
inp._paste_counter = sess.input_paste_counter
|
||||
inp._history_index = -1
|
||||
inp._history_stash = ""
|
||||
try: inp._suppress_palette_next_change()
|
||||
except Exception: pass
|
||||
inp.text = sess.input_text
|
||||
self._input_owner_id = self.current_id
|
||||
try: self._resize_input(inp)
|
||||
except Exception: pass
|
||||
|
||||
def _refresh_topbar(self):
|
||||
if not self.is_mounted or self.current_id is None: return
|
||||
s = self.current
|
||||
|
||||
Reference in new issue
Block a user