Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions src/kon/ui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
107 changes: 103 additions & 4 deletions src/kon/ui/clipboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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"
Expand Down
11 changes: 8 additions & 3 deletions src/kon/ui/input.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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, "")
Expand Down
6 changes: 6 additions & 0 deletions src/kon/ui/launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/kon/ui/welcome.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
)


Expand Down
Loading