From c296f79c13c9b7a30f58de4ec1601e872dc636a8 Mon Sep 17 00:00:00 2001 From: clach04 Date: Tue, 25 Aug 2026 17:57:42 -0700 Subject: [PATCH] Fix #95 win32 copy/paste from/to system clipboard - Read/write the Windows clipboard via the Win32 API (CF_UNICODETEXT), replacing clip.exe (unicode-unsafe) - Paste (ctrl+v, ctrl+shift+v, shift+insert) prefers the OS clipboard, falling back to Textual's internal buffer; image paste unchanged - Selection/copy mirrors to the OS clipboard alongside Textual's buffer - TODO notes: Linux clipboard reading unimplemented; mouse capture left on (right-click paste under CMD.exe does not work) - Document keyboard/clipboard behavior in README and welcome screen --- README.md | 17 +++++++ src/kon/ui/app.py | 7 +++ src/kon/ui/clipboard.py | 107 ++++++++++++++++++++++++++++++++++++++-- src/kon/ui/input.py | 11 +++-- src/kon/ui/launch.py | 6 +++ src/kon/ui/welcome.py | 1 + 6 files changed, 142 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9ff81685..647ec770 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,23 @@ Kon is built around a terminal UI that stays simple but practical. | Model switching | Use `/model` to switch interactively | | Session browsing | Use `/resume` to browse prior sessions | +### Keyboard and clipboard + +Kon's TUI captures mouse input, so your terminal's native text selection (e.g. CMD.exe's +mark/copy menu, or drag-select in other terminals) does not select Kon text. Use the +clipboard shortcuts instead: + +| Key | Action | +| --- | --- | +| Mouse selection | Automatically copied to the system clipboard | +| **Ctrl+V** | Paste from the system clipboard into the input (images too) | +| **Ctrl+Shift+V** / **Shift+Insert** | Also paste from the system clipboard | +| **Ctrl+C** | Clear the input, or exit when pressed twice (no selection) | + +Copies inside Kon (mouse selection auto-copies) go to your OS clipboard and can be pasted +into any application; pastes read from the OS clipboard first, falling back to Kon's +internal selection buffer. + ### Slash commands Type `/` at the start of the input box to see available commands. diff --git a/src/kon/ui/app.py b/src/kon/ui/app.py index 5fef7771..1c8210ce 100644 --- a/src/kon/ui/app.py +++ b/src/kon/ui/app.py @@ -42,6 +42,7 @@ from .autocomplete import DEFAULT_COMMANDS, SlashCommand from .blocks import HandoffLinkBlock, LaunchWarning from .chat import ChatLog +from .clipboard import copy_to_clipboard as ui_copy_to_clipboard from .commands import CommandsMixin from .completion_ui import CompletionUIMixin from .floating_list import FloatingList, ListItem @@ -307,6 +308,12 @@ def _sync_runtime_state(self) -> None: # Compatibility hook for mixin/unit-test fakes. Runtime is the source of truth. return None + def copy_to_clipboard(self, text: str) -> None: + # Update Textual's internal clipboard (fallback) and mirror to the OS + # clipboard so copies work outside the terminal. + super().copy_to_clipboard(text) + ui_copy_to_clipboard(text) + @on(events.TextSelected) def _on_text_selected(self) -> None: selection = self.screen.get_selected_text() diff --git a/src/kon/ui/clipboard.py b/src/kon/ui/clipboard.py index e43e2842..f1c29b48 100644 --- a/src/kon/ui/clipboard.py +++ b/src/kon/ui/clipboard.py @@ -9,12 +9,12 @@ def copy_to_clipboard(text: str) -> None: encoded = base64.b64encode(text.encode()).decode() print(f"\033]52;c;{encoded}\a", end="", flush=True) - if sys.platform == "darwin": - _try_run(["pbcopy"], text) + if sys.platform == "win32": + win32_copy_to_clipboard(text) return - if sys.platform == "win32": - _try_run(["clip"], text) + if sys.platform == "darwin": + _try_run(["pbcopy"], text) return if os.environ.get("TERMUX_VERSION") and _try_run(["termux-clipboard-set"], text): @@ -33,6 +33,105 @@ def copy_to_clipboard(text: str) -> None: _try_run(["xsel", "--clipboard", "--input"], text) +# TODO: Linux/Wayland clipboard *reading* is not implemented (wl-paste / xclip -o / +# xsel --output). Paste on Linux falls back to Textual's internal buffer only. + +_CF_UNICODETEXT = 13 +_GMEM_MOVEABLE = 0x0002 + + +def read_system_clipboard() -> str | None: + """Read text from the OS clipboard, or None if unavailable.""" + if sys.platform == "win32": + return win32_read_clipboard() + # TODO: non-Windows reading (pbcopy has no reader; use pbpaste, wl-paste, + # xclip/xsel) is not implemented yet. + return None + + +def _win32_clipboard_functions(): + import ctypes + from ctypes import wintypes + + user32 = ctypes.WinDLL("user32", use_last_error=True) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + + user32.OpenClipboard.argtypes = [wintypes.HWND] + user32.OpenClipboard.restype = wintypes.BOOL + user32.CloseClipboard.argtypes = [] + user32.CloseClipboard.restype = wintypes.BOOL + user32.EmptyClipboard.argtypes = [] + user32.EmptyClipboard.restype = wintypes.BOOL + user32.IsClipboardFormatAvailable.argtypes = [wintypes.UINT] + user32.IsClipboardFormatAvailable.restype = wintypes.BOOL + user32.SetClipboardData.argtypes = [wintypes.UINT, wintypes.HANDLE] + user32.SetClipboardData.restype = wintypes.HANDLE + user32.GetClipboardData.argtypes = [wintypes.UINT] + user32.GetClipboardData.restype = wintypes.HANDLE + kernel32.GlobalAlloc.argtypes = [wintypes.UINT, ctypes.c_size_t] + kernel32.GlobalAlloc.restype = wintypes.HGLOBAL + kernel32.GlobalLock.argtypes = [wintypes.HGLOBAL] + kernel32.GlobalLock.restype = ctypes.c_void_p + kernel32.GlobalUnlock.argtypes = [wintypes.HGLOBAL] + kernel32.GlobalUnlock.restype = wintypes.BOOL + + return user32, kernel32 + + +def win32_read_clipboard() -> str | None: + import ctypes + + user32, kernel32 = _win32_clipboard_functions() + if not user32.OpenClipboard(None): + return None + try: + if not user32.IsClipboardFormatAvailable(_CF_UNICODETEXT): + return None + handle = user32.GetClipboardData(_CF_UNICODETEXT) + if not handle: + return None + ptr = kernel32.GlobalLock(handle) + if not ptr: + return None + try: + return ctypes.wstring_at(ptr) + finally: + kernel32.GlobalUnlock(handle) + finally: + user32.CloseClipboard() + + +def win32_copy_to_clipboard(text: str) -> bool: + """Copy text to the Windows clipboard via the Win32 API (unicode-safe).""" + import ctypes + + user32, kernel32 = _win32_clipboard_functions() + if not user32.OpenClipboard(None): + return False + try: + user32.EmptyClipboard() + buffer = ctypes.create_unicode_buffer(text) + size = ctypes.sizeof(buffer) + handle = kernel32.GlobalAlloc(_GMEM_MOVEABLE, size) + if not handle: + return False + ptr = kernel32.GlobalLock(handle) + if not ptr: + kernel32.GlobalFree(handle) + return False + try: + ctypes.memmove(ptr, buffer, size) + finally: + kernel32.GlobalUnlock(handle) + # On success the system owns the memory; do not free it. + if not user32.SetClipboardData(_CF_UNICODETEXT, handle): + kernel32.GlobalFree(handle) + return False + return True + finally: + user32.CloseClipboard() + + def _is_wayland_session() -> bool: return ( bool(os.environ.get("WAYLAND_DISPLAY")) or os.environ.get("XDG_SESSION_TYPE") == "wayland" diff --git a/src/kon/ui/input.py b/src/kon/ui/input.py index dae39c6a..e4162376 100644 --- a/src/kon/ui/input.py +++ b/src/kon/ui/input.py @@ -30,6 +30,7 @@ SlashCommand, SlashCommandProvider, ) +from .clipboard import read_system_clipboard from .floating_list import ListItem from .image_clipboard import save_clipboard_image from .path_complete import PathComplete @@ -192,7 +193,7 @@ class InputBox(Vertical): """ BINDINGS: ClassVar[list] = [ - Binding("ctrl+v", "paste_clipboard", "Paste", priority=True), + Binding("ctrl+v,ctrl+shift+v,shift+insert", "paste_clipboard", "Paste", priority=True), Binding("enter", "submit", "Send", priority=True), Binding("ctrl+j,shift+enter", "newline", "New line", priority=True), Binding("alt+enter", "steer_submit", "Steer", priority=True), @@ -483,8 +484,12 @@ def action_paste_clipboard(self) -> None: if temporary: path.unlink(missing_ok=True) - textarea = self.query_one("#input-textarea", TextArea) - textarea.action_paste() + system_text = read_system_clipboard() + if system_text: + self.insert(system_text) + return + # Fallback: Textual's internal clipboard (e.g. selection copied in-app). + self.query_one("#input-textarea", TextArea).action_paste() def _strip_skill_markers(self, text: str) -> str: return text.replace(_SKILL_TRIGGER_MARKER, "") diff --git a/src/kon/ui/launch.py b/src/kon/ui/launch.py index dad55a25..2312b2c5 100644 --- a/src/kon/ui/launch.py +++ b/src/kon/ui/launch.py @@ -85,6 +85,12 @@ def run_tui(args: argparse.Namespace, *, extra_tools: list[str] | None) -> None: openai_compat_auth_mode=args.openai_compat_auth, anthropic_compat_auth_mode=args.anthropic_compat_auth, ) + # TODO: Textual captures mouse input (SGR tracking) by default, so native + # terminal selection does not work inside Kon. In particular, under + # CMD.exe/conhost right-click does NOT paste, and CMD's mark/copy menu is + # required for selecting text outside Kon's own selection handling. + # Passing mouse=False to run() would restore native selection/paste at the + # cost of in-TUI mouse support; revisit if requested. app.run() hints = list(app._exit_hints) diff --git a/src/kon/ui/welcome.py b/src/kon/ui/welcome.py index 15b56948..c0e3dee4 100644 --- a/src/kon/ui/welcome.py +++ b/src/kon/ui/welcome.py @@ -11,6 +11,7 @@ (("shift+tab", "permissions"), ("esc", "to interrupt"), ("shift+enter", "add newline")), (("ctrl+c", "clear input"), ("ctrl+c x2", "exit"), ("enter", "queue"), ("alt+enter", "steer")), (("↑/↓", "select queue"), ("ctrl+t", "cycle thinking"), ("ctrl+shift+t", "toggle thinking")), + (("ctrl+v/shift+ins", "paste system clipboard"), ("selection", "auto-copies")), )