mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
c20546feac
Clear the chat input with <kbd>esc</kbd>+<kbd>esc</kbd> (undoable via <kbd>ctrl+z</kbd>) or the new on-screen `[ X ]`/`[ COPY ]` buttons. --- Adds quick ways to discard or copy a chat-input draft when you change your mind, instead of selecting all and deleting. - <kbd>esc + esc</kbd> clears the draft: the first <kbd>Esc</kbd> (when there is nothing else to interrupt) arms a pending clear and shows a hint; a second <kbd>Esc</kbd> within the window clears it. - <kbd>ctrl+z</kbd> to undo: the clear is an undoable `TextArea` edit (via `discard_text`), so a mistaken clear can be restored. - **`[ X ]` and `[ COPY ]` buttons** on the right of the input row provide discoverable mouse alternatives — `[ X ]` clears (undoable), `[ COPY ]` copies the draft to the clipboard. This addresses the request for buttons so these operations don't require memorizing shortcuts. The buttons use fixed widths because `width: auto` collapsed to 0 next to the `1fr` text area. Made by [Open SWE](https://openswe.vercel.app) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
218 lines
6.8 KiB
Python
218 lines
6.8 KiB
Python
"""Clipboard utilities."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import logging
|
|
import os
|
|
import pathlib
|
|
from typing import TYPE_CHECKING, Literal
|
|
|
|
from textual.dom import NoScreen
|
|
|
|
from deepagents_code.config import get_glyphs
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
from textual.app import App
|
|
|
|
_PREVIEW_MAX_LENGTH = 40
|
|
|
|
|
|
def _copy_osc52(text: str) -> None:
|
|
"""Copy text using OSC 52 escape sequence (works over SSH/tmux)."""
|
|
encoded = base64.b64encode(text.encode("utf-8")).decode("ascii")
|
|
osc52_seq = f"\033]52;c;{encoded}\a"
|
|
if os.environ.get("TMUX"):
|
|
osc52_seq = f"\033Ptmux;\033{osc52_seq}\033\\"
|
|
|
|
with pathlib.Path("/dev/tty").open("w", encoding="utf-8") as tty:
|
|
tty.write(osc52_seq)
|
|
tty.flush()
|
|
|
|
|
|
def _shorten_preview(texts: list[str]) -> str:
|
|
"""Shorten text for notification preview.
|
|
|
|
Returns:
|
|
Shortened preview text suitable for notification display.
|
|
"""
|
|
glyphs = get_glyphs()
|
|
dense_text = glyphs.newline.join(texts).replace("\n", glyphs.newline)
|
|
if len(dense_text) > _PREVIEW_MAX_LENGTH:
|
|
return f"{dense_text[: _PREVIEW_MAX_LENGTH - 1]}{glyphs.ellipsis}"
|
|
return dense_text
|
|
|
|
|
|
def copy_text_to_clipboard(app: App, text: str) -> tuple[bool, str | None]:
|
|
"""Copy text to the system clipboard.
|
|
|
|
Args:
|
|
app: The active Textual app, used for the app clipboard backend.
|
|
text: Text to copy.
|
|
|
|
Returns:
|
|
Tuple of `(success, error_message)`.
|
|
|
|
`success` is `True` when one backend completed without raising.
|
|
`error_message` is `None` on success and the last backend's error
|
|
string when every backend failed, suitable for surfacing to the
|
|
user so they can self-diagnose missing clipboard support.
|
|
"""
|
|
# Backend order: pyperclip first (most reliable when installed), then
|
|
# Textual's app clipboard, then OSC 52 as a last resort for SSH/remote
|
|
# sessions where no local clipboard is reachable.
|
|
copy_methods: list[Callable[[str], object]] = [app.copy_to_clipboard]
|
|
|
|
try:
|
|
import pyperclip
|
|
|
|
copy_methods.insert(0, pyperclip.copy)
|
|
except ImportError:
|
|
pass
|
|
|
|
copy_methods.append(_copy_osc52)
|
|
|
|
last_error: str | None = None
|
|
for copy_fn in copy_methods:
|
|
try:
|
|
copy_fn(text)
|
|
# ValueError covers UnicodeEncodeError from the `text.encode("utf-8")`
|
|
# done by both `_copy_osc52` and `App.copy_to_clipboard` on draft text
|
|
# containing lone surrogates, so a bad code point degrades to the next
|
|
# backend instead of escaping the (success, error) contract and crashing
|
|
# the caller.
|
|
except (OSError, RuntimeError, TypeError, ValueError) as e:
|
|
last_error = str(e) or type(e).__name__
|
|
logger.debug(
|
|
"Clipboard copy method %s failed: %s",
|
|
getattr(copy_fn, "__name__", repr(copy_fn)),
|
|
e,
|
|
exc_info=True,
|
|
)
|
|
continue
|
|
else:
|
|
return True, None
|
|
|
|
return False, last_error
|
|
|
|
|
|
def copy_text_with_feedback(
|
|
app: App,
|
|
text: str,
|
|
*,
|
|
failure_noun: Literal["input", "selection"],
|
|
success_message: str | None = None,
|
|
) -> bool:
|
|
"""Copy text to the clipboard and surface the outcome as a toast.
|
|
|
|
Centralizes the copy-then-notify pattern shared by the Ctrl+C and
|
|
`[ COPY ]` paths so the success/failure messaging stays consistent.
|
|
|
|
Args:
|
|
app: The active Textual app, used for the clipboard backend and toasts.
|
|
text: Text to copy.
|
|
failure_noun: Noun used in the warning toast (e.g. `"input"`,
|
|
`"selection"`).
|
|
success_message: Toast shown on success. When `None`, success is silent
|
|
(used by the selection-copy path, which has no visible draft to
|
|
confirm).
|
|
|
|
Returns:
|
|
`True` when the copy succeeded.
|
|
"""
|
|
success, error = copy_text_to_clipboard(app, text)
|
|
if success:
|
|
if success_message is not None:
|
|
app.notify(success_message, timeout=3, markup=False)
|
|
else:
|
|
# markup=False so the dynamic error string is never parsed as markup.
|
|
app.notify(
|
|
f"Failed to copy {failure_noun}: {error}"
|
|
if error
|
|
else f"Failed to copy {failure_noun} - no clipboard method available",
|
|
severity="warning",
|
|
timeout=3,
|
|
markup=False,
|
|
)
|
|
return success
|
|
|
|
|
|
def copy_selection_to_clipboard(app: App) -> None:
|
|
"""Copy selected text from app widgets to clipboard.
|
|
|
|
This queries all widgets for their text_selection and copies
|
|
any selected text to the system clipboard.
|
|
"""
|
|
selected_texts = []
|
|
|
|
for widget in app.query("*"):
|
|
# Skip detached widgets before touching `text_selection` — the
|
|
# property reads `widget.screen.selections`, which raises `NoScreen`
|
|
# for transient toasts mid-mount. `hasattr` is not a safe precheck
|
|
# because it would itself invoke the property and propagate
|
|
# `NoScreen`. The try/except handles lifecycle races between the
|
|
# `is_attached` check and the property read.
|
|
if not getattr(widget, "is_attached", False):
|
|
continue
|
|
|
|
try:
|
|
selection = widget.text_selection
|
|
except (NoScreen, AttributeError) as e:
|
|
logger.debug(
|
|
"Skipping widget %s during selection copy: %s",
|
|
type(widget).__name__,
|
|
e,
|
|
)
|
|
continue
|
|
|
|
if not selection:
|
|
continue
|
|
|
|
try:
|
|
result = widget.get_selection(selection)
|
|
except (AttributeError, TypeError, ValueError, IndexError) as e:
|
|
logger.debug(
|
|
"Failed to get selection from widget %s: %s",
|
|
type(widget).__name__,
|
|
e,
|
|
exc_info=True,
|
|
)
|
|
continue
|
|
|
|
if not result:
|
|
continue
|
|
|
|
selected_text, _ = result
|
|
if selected_text.strip():
|
|
selected_texts.append(selected_text)
|
|
|
|
if not selected_texts:
|
|
return
|
|
|
|
combined_text = "\n".join(selected_texts)
|
|
|
|
success, _ = copy_text_to_clipboard(app, combined_text)
|
|
if success:
|
|
# Use markup=False to prevent copied text from being parsed as Rich markup
|
|
app.notify(
|
|
f'"{_shorten_preview(selected_texts)}" copied',
|
|
severity="information",
|
|
timeout=2,
|
|
markup=False,
|
|
)
|
|
return
|
|
|
|
# If all methods fail, still notify but warn. markup=False guards against
|
|
# this string ever growing dynamic content (e.g., the backend error reason)
|
|
# that could contain bracket characters.
|
|
app.notify(
|
|
"Failed to copy - no clipboard method available",
|
|
severity="warning",
|
|
timeout=3,
|
|
markup=False,
|
|
)
|