fix(tui): restore copy and cut shortcuts
This commit is contained in:
36
ui-tui/src/__tests__/inputSelectionClipboard.test.ts
Normal file
36
ui-tui/src/__tests__/inputSelectionClipboard.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
43
web/src/lib/pty-keyboard-shortcuts.test.ts
Normal file
43
web/src/lib/pty-keyboard-shortcuts.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
42
web/src/lib/pty-keyboard-shortcuts.ts
Normal file
42
web/src/lib/pty-keyboard-shortcuts.ts
Normal 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";
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user