fix(tui): restore copy and cut shortcuts

This commit is contained in:
null-runner
2026-07-14 14:50:04 +02:00
committed by Teknium
parent 2108d4bfda
commit be250390db
7 changed files with 187 additions and 39 deletions

View File

@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest'
import { handleInputSelectionClipboard } from '../app/useInputHandlers.js'
const selection = (start = 1, end = 4) => ({
clear: vi.fn(),
collapseToEnd: vi.fn(),
copy: vi.fn(),
cut: vi.fn(),
end,
start,
value: 'hello'
})
describe('handleInputSelectionClipboard', () => {
it('copies an active composer selection', () => {
const active = selection()
expect(handleInputSelectionClipboard(active, 'copy')).toBe(true)
expect(active.copy).toHaveBeenCalledOnce()
expect(active.cut).not.toHaveBeenCalled()
})
it('cuts an active composer selection', () => {
const active = selection()
expect(handleInputSelectionClipboard(active, 'cut')).toBe(true)
expect(active.cut).toHaveBeenCalledOnce()
expect(active.copy).not.toHaveBeenCalled()
})
it('leaves shortcuts available when there is no active selection', () => {
expect(handleInputSelectionClipboard(null, 'copy')).toBe(false)
expect(handleInputSelectionClipboard(selection(2, 2), 'cut')).toBe(false)
})
})

View File

@@ -3,6 +3,8 @@ import { atom } from 'nanostores'
export interface InputSelection {
clear: () => void
collapseToEnd: () => void
copy: () => void
cut: () => void
end: number
start: number
value: string

View File

@@ -35,6 +35,19 @@ const DASHBOARD_NEW_SESSION_MESSAGE = 'starting a fresh dashboard chat...'
export const shouldAllowIdleHotkeyExit = (dashboardTuiMode = DASHBOARD_TUI_MODE) => !dashboardTuiMode
export function handleInputSelectionClipboard(
selection: ReturnType<typeof getInputSelection>,
action: 'copy' | 'cut'
): boolean {
if (!selection || selection.end <= selection.start) {
return false
}
selection[action]()
return true
}
export function handleIdleHotkeyExit(
actions: Pick<InputHandlerActions, 'die' | 'sys'>,
dashboardTuiMode = DASHBOARD_TUI_MODE,
@@ -562,9 +575,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
const inputSel = getInputSelection()
if (inputSel && inputSel.end > inputSel.start) {
inputSel.clear()
if (handleInputSelectionClipboard(inputSel, 'copy')) {
return
}
@@ -575,6 +586,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
}
}
if (isCtrl(key, ch, 'x') && handleInputSelectionClipboard(getInputSelection(), 'cut')) {
return
}
if (isCtrl(key, ch, 'x') && cState.queueEditIdx !== null) {
cActions.removeQueue(cState.queueEditIdx)

View File

@@ -810,6 +810,23 @@ export function TextInput({
setCur(vRef.current.length)
curRef.current = vRef.current.length
},
copy: () => {
const range = selRange()
if (range) {
void writeClipboardText(vRef.current.slice(range.start, range.end))
}
},
cut: () => {
const range = selRange()
if (!range) {
return
}
void writeClipboardText(vRef.current.slice(range.start, range.end))
commit(vRef.current.slice(0, range.start) + vRef.current.slice(range.end), range.start)
},
end: selected?.end ?? curRef.current,
start: selected?.start ?? curRef.current,
value: vRef.current

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { resolvePtyKeyboardShortcut } from './pty-keyboard-shortcuts'
const key = (overrides: Partial<KeyboardEvent> = {}) =>
({
altKey: false,
ctrlKey: false,
key: '',
metaKey: false,
shiftKey: false,
...overrides,
}) as KeyboardEvent
describe('resolvePtyKeyboardShortcut', () => {
it('copies terminal selection with bare Ctrl+C on non-macOS', () => {
expect(
resolvePtyKeyboardShortcut(key({ ctrlKey: true, key: 'c' }), false, true),
).toBe('copy')
})
it('preserves Ctrl+C interrupt when nothing is selected', () => {
expect(
resolvePtyKeyboardShortcut(key({ ctrlKey: true, key: 'c' }), false, false),
).toBe('pass')
})
it('maps Ctrl+Backspace to backward word deletion', () => {
expect(
resolvePtyKeyboardShortcut(
key({ ctrlKey: true, key: 'Backspace' }),
false,
false,
),
).toBe('delete-word-backward')
})
it('maps Ctrl+Delete to forward word deletion', () => {
expect(
resolvePtyKeyboardShortcut(key({ ctrlKey: true, key: 'Delete' }), false, false),
).toBe('delete-word-forward')
})
})

View File

@@ -0,0 +1,42 @@
export type PtyKeyboardShortcut =
| "copy"
| "delete-word-backward"
| "delete-word-forward"
| "pass";
export function resolvePtyKeyboardShortcut(
ev: Pick<KeyboardEvent, "altKey" | "ctrlKey" | "key" | "metaKey" | "shiftKey">,
isMac: boolean,
hasTerminalSelection: boolean,
): PtyKeyboardShortcut {
const key = ev.key.toLowerCase();
const copyPressed = isMac
? ev.metaKey && !ev.ctrlKey
: ev.ctrlKey && !ev.altKey && !ev.metaKey;
if (copyPressed && key === "c" && hasTerminalSelection) {
return "copy";
}
if (
ev.ctrlKey &&
!ev.shiftKey &&
!ev.altKey &&
!ev.metaKey &&
ev.key === "Backspace"
) {
return "delete-word-backward";
}
if (
ev.ctrlKey &&
!ev.shiftKey &&
!ev.altKey &&
!ev.metaKey &&
ev.key === "Delete"
) {
return "delete-word-forward";
}
return "pass";
}

View File

@@ -60,6 +60,7 @@ import {
normalizePtyMobileInput,
shouldTreatInputAsMobileReplacement,
} from "@/lib/pty-mobile-input";
import { resolvePtyKeyboardShortcut } from "@/lib/pty-keyboard-shortcuts";
import {
isViewportPinnedToBottom,
shouldFollowPtyOutput,
@@ -663,13 +664,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
term.attachCustomKeyEventHandler((ev) => {
if (ev.type !== "keydown") return true;
// Copy: Cmd+C on macOS, Ctrl+Shift+C on other platforms. Bare Ctrl+C
// is reserved for SIGINT to the TUI child — matches xterm / gnome-terminal /
// konsole / Windows Terminal. Ctrl+Shift+C only copies if a selection exists;
// without a selection it passes through to the TUI so agents can still
// react to the keypress.
// Copy: Cmd+C on macOS, Ctrl+C or Ctrl+Shift+C elsewhere. Copy only
// when xterm has a selection; without one Ctrl+C still reaches the TUI
// as SIGINT.
// Paste: Cmd+Shift+V on macOS, Ctrl+Shift+V on others.
const copyModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey;
const copyModifier = isMac ? ev.metaKey : ev.ctrlKey;
// Paste on BARE Ctrl+V too (not only Ctrl+Shift+V). Bare Ctrl+V otherwise
// falls through to the TUI, whose server-side clipboard read can't see the
// browser/OS clipboard → "No image found in clipboard". Routing Ctrl+V
@@ -677,22 +676,28 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// image-or-text correctly, like Ctrl+Shift+V.
const pasteModifier = isMac ? ev.metaKey : ev.ctrlKey;
if (copyModifier && ev.key.toLowerCase() === "c") {
const sel = term.getSelection();
if (sel) {
// Direct writeText inside the keydown handler preserves the user
// gesture — async round-trips through OSC 52 can lose activation
// and fail with "Document is not focused".
navigator.clipboard.writeText(sel).catch((err) => {
console.warn("[dashboard clipboard] direct copy failed:", err.message);
});
// Clear xterm.js's highlight after copy (matches gnome-terminal).
term.clearSelection();
ev.preventDefault();
return false;
}
// No selection → fall through so the TUI receives Ctrl+Shift+C
// (or the bare ev if the user used a different modifier).
const terminalSelection = term.getSelection();
const shortcut = resolvePtyKeyboardShortcut(
ev,
isMac,
Boolean(terminalSelection),
);
if (
(shortcut === "copy" ||
(copyModifier && ev.shiftKey && ev.key.toLowerCase() === "c")) &&
terminalSelection
) {
// Direct writeText inside the keydown handler preserves the user
// gesture — async round-trips through OSC 52 can lose activation
// and fail with "Document is not focused".
navigator.clipboard.writeText(terminalSelection).catch((err) => {
console.warn("[dashboard clipboard] direct copy failed:", err.message);
});
// Clear xterm.js's highlight after copy (matches gnome-terminal).
term.clearSelection();
ev.preventDefault();
return false;
}
// Ctrl+Backspace → delete previous word. xterm.js sends bare DEL
@@ -701,13 +706,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// delete-word-backward. (Ctrl+W can't be used in a browser tab — it's a
// reserved shortcut that closes the tab and preventDefault has no effect;
// for Ctrl+W muscle memory use the Electron desktop app.)
if (
ev.ctrlKey &&
!ev.shiftKey &&
!ev.altKey &&
!ev.metaKey &&
ev.key === "Backspace"
) {
if (shortcut === "delete-word-backward") {
ev.preventDefault();
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) ws.send("\x17");
@@ -716,13 +715,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// Ctrl+Delete → delete next word. Mirror of Ctrl+Backspace; sends Alt+d
// (ESC d), the readline / prompt_toolkit kill-word-forward binding.
if (
ev.ctrlKey &&
!ev.shiftKey &&
!ev.altKey &&
!ev.metaKey &&
ev.key === "Delete"
) {
if (shortcut === "delete-word-forward") {
ev.preventDefault();
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) ws.send("\x1bd");