diff --git a/ui-tui/src/__tests__/clipboard.test.ts b/ui-tui/src/__tests__/clipboard.test.ts index a7d2bde468..b073861d1c 100644 --- a/ui-tui/src/__tests__/clipboard.test.ts +++ b/ui-tui/src/__tests__/clipboard.test.ts @@ -3,27 +3,23 @@ import { describe, expect, it, vi } from 'vitest' import { readClipboardText } from '../lib/clipboard.js' describe('readClipboardText', () => { - it('does nothing off macOS', () => { + it('does nothing off macOS', async () => { const run = vi.fn() - expect(readClipboardText('linux', run)).toBeNull() + await expect(readClipboardText('linux', run)).resolves.toBeNull() expect(run).not.toHaveBeenCalled() }) - it('reads text from pbpaste on macOS', () => { - const run = vi.fn().mockReturnValue({ status: 0, stdout: 'hello world\n' }) + it('reads text from pbpaste on macOS', async () => { + const run = vi.fn().mockResolvedValue({ stdout: 'hello world\n' }) - expect(readClipboardText('darwin', run)).toBe('hello world\n') - expect(run).toHaveBeenCalledWith( - 'pbpaste', - [], - expect.objectContaining({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) - ) + await expect(readClipboardText('darwin', run)).resolves.toBe('hello world\n') + expect(run).toHaveBeenCalledWith('pbpaste', [], expect.objectContaining({ encoding: 'utf8', windowsHide: true })) }) - it('returns null when pbpaste fails', () => { - const run = vi.fn().mockReturnValue({ status: 1, stdout: '' }) + it('returns null when pbpaste fails', async () => { + const run = vi.fn().mockRejectedValue(new Error('pbpaste failed')) - expect(readClipboardText('darwin', run)).toBeNull() + await expect(readClipboardText('darwin', run)).resolves.toBeNull() }) }) diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index eea3002911..2473a49bf2 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -241,7 +241,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return } - if (isAction(key, ch, 'c') || (key.ctrl && key.shift && ch.toLowerCase() === 'c')) { + if (isAction(key, ch, 'c')) { if (terminal.hasSelection) { return copySelection() } @@ -286,11 +286,11 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return actions.die() } - if (isAction(key, ch, 'd') || isCtrl(key, ch, 'd')) { + if (isAction(key, ch, 'd')) { return actions.die() } - if (isAction(key, ch, 'l') || isCtrl(key, ch, 'l')) { + if (isAction(key, ch, 'l')) { if (actions.guardBusySessionSwitch()) { return } @@ -300,11 +300,11 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return actions.newSession() } - if (isAction(key, ch, 'b') || isCtrl(key, ch, 'b')) { + if (isAction(key, ch, 'b')) { return voice.recording ? voiceStop() : voiceStart() } - if (isAction(key, ch, 'g') || isCtrl(key, ch, 'g')) { + if (isAction(key, ch, 'g')) { return cActions.openEditor() } @@ -323,7 +323,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return } - if ((isAction(key, ch, 'k') || isCtrl(key, ch, 'k')) && cRefs.queueRef.current.length && live.sid) { + if (isAction(key, ch, 'k') && cRefs.queueRef.current.length && live.sid) { const next = cActions.dequeue() if (next) { diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 95f50d182d..ebe3a97c2b 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { setInputSelection } from '../app/inputSelectionStore.js' import { readClipboardText } from '../lib/clipboard.js' -import { isMac } from '../lib/platform.js' +import { isActionMod, isMac } from '../lib/platform.js' import { writeOsc52Clipboard } from '../lib/osc52.js' type InkExt = typeof Ink & { @@ -514,11 +514,11 @@ export function TextInput({ } if (allowClipboardHotkeys) { - const text = readClipboardText() - - if (text) { - return pastePlainText(text) - } + void readClipboardText().then(text => { + if (text) { + pastePlainText(text) + } + }) } return @@ -557,26 +557,26 @@ export function TextInput({ let c = curRef.current let v = vRef.current - const mod = k.ctrl || k.meta + const mod = isActionMod(k) const range = selRange() const delFwd = k.delete || fwdDel.current - if ((k.ctrl || k.meta) && inp === 'z') { + if (mod && inp === 'z') { return swap(undo, redo) } - if (((k.ctrl || k.meta) && inp === 'y') || ((k.ctrl || k.meta) && k.shift && inp === 'z')) { + if ((mod && inp === 'y') || (mod && k.shift && inp === 'z')) { return swap(redo, undo) } - if ((k.ctrl || k.meta) && inp === 'a') { + if (mod && inp === 'a') { return selectAll() } if (k.home) { clearSel() c = 0 - } else if (k.end || (k.ctrl && inp === 'e') || (k.meta && inp === 'e')) { + } else if (k.end || (mod && inp === 'e')) { clearSel() c = v.length } else if (k.leftArrow) { @@ -595,10 +595,10 @@ export function TextInput({ clearSel() c = mod ? wordRight(v, c) : nextPos(v, c) } - } else if ((k.ctrl || k.meta) && inp === 'b') { + } else if (mod && inp === 'b') { clearSel() c = wordLeft(v, c) - } else if ((k.ctrl || k.meta) && inp === 'f') { + } else if (mod && inp === 'f') { clearSel() c = wordRight(v, c) } else if (range && (k.backspace || delFwd)) { @@ -621,7 +621,7 @@ export function TextInput({ } else { v = v.slice(0, c) + v.slice(nextPos(v, c)) } - } else if ((k.ctrl || k.meta) && inp === 'w') { + } else if (mod && inp === 'w') { if (range) { v = v.slice(0, range.start) + v.slice(range.end) c = range.start @@ -633,7 +633,7 @@ export function TextInput({ } else { return } - } else if ((k.ctrl || k.meta) && inp === 'u') { + } else if (mod && inp === 'u') { if (range) { v = v.slice(0, range.start) + v.slice(range.end) c = range.start @@ -641,7 +641,7 @@ export function TextInput({ v = v.slice(c) c = 0 } - } else if ((k.ctrl || k.meta) && inp === 'k') { + } else if (mod && inp === 'k') { if (range) { v = v.slice(0, range.start) + v.slice(range.end) c = range.start diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts index 292b7b86f3..3d1bb011b6 100644 --- a/ui-tui/src/content/hotkeys.ts +++ b/ui-tui/src/content/hotkeys.ts @@ -1,21 +1,28 @@ import { isMac } from '../lib/platform.js' -const mod = isMac ? 'Cmd' : 'Ctrl' -const pasteMod = isMac ? 'Cmd' : 'Alt' +const action = isMac ? 'Cmd' : 'Ctrl' +const paste = isMac ? 'Cmd' : 'Alt' export const HOTKEYS: [string, string][] = [ - [mod + '+C / ' + mod + '+Shift+C', 'copy selection'], - [mod + '+D', 'exit'], - [mod + '+G', 'open $EDITOR for prompt'], - [mod + '+L', 'new session (clear)'], - [pasteMod + '+V / /paste', 'paste clipboard image'], + ...( + isMac + ? ([ + ['Cmd+C', 'copy selection'], + ['Ctrl+C', 'interrupt / clear draft / exit'] + ] as [string, string][]) + : ([['Ctrl+C', 'copy selection / interrupt / clear draft / exit']] as [string, string][]) + ), + [action + '+D', 'exit'], + [action + '+G', 'open $EDITOR for prompt'], + [action + '+L', 'new session (clear)'], + [paste + '+V / /paste', 'paste clipboard image'], ['Tab', 'apply completion'], ['↑/↓', 'completions / queue edit / history'], - [mod + '+A/E', 'home / end of line'], - [mod + '+Z / ' + mod + '+Y', 'undo / redo input edits'], - [mod + '+W', 'delete word'], - [mod + '+U/K', 'delete to start / end'], - [mod + '+←/→', 'jump word'], + [action + '+A/E', 'home / end of line'], + [action + '+Z / ' + action + '+Y', 'undo / redo input edits'], + [action + '+W', 'delete word'], + [action + '+U/K', 'delete to start / end'], + [action + '+←/→', 'jump word'], ['Home/End', 'start / end of line'], ['Shift+Enter / Alt+Enter', 'insert newline'], ['\\+Enter', 'multi-line continuation (fallback)'], diff --git a/ui-tui/src/lib/clipboard.ts b/ui-tui/src/lib/clipboard.ts index 5260e2f4c1..79bbbb11a3 100644 --- a/ui-tui/src/lib/clipboard.ts +++ b/ui-tui/src/lib/clipboard.ts @@ -1,9 +1,7 @@ -import { spawnSync, type SpawnSyncOptions } from 'node:child_process' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' -const DEFAULT_SPAWN_OPTS: SpawnSyncOptions = { - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf8' -} +const execFileAsync = promisify(execFile) /** * Read plain text from the system clipboard. @@ -12,19 +10,19 @@ const DEFAULT_SPAWN_OPTS: SpawnSyncOptions = { * null for now; the TUI's text-paste hotkeys are primarily targeted at the * macOS clarify/input flow. */ -export function readClipboardText( +export async function readClipboardText( platform: NodeJS.Platform = process.platform, - run = spawnSync -): string | null { + run: typeof execFileAsync = execFileAsync +): Promise { if (platform !== 'darwin') { return null } - const result = run('pbpaste', [], DEFAULT_SPAWN_OPTS) + try { + const result = await run('pbpaste', [], { encoding: 'utf8', windowsHide: true }) - if (result.status !== 0 || typeof result.stdout !== 'string') { + return typeof result.stdout === 'string' ? result.stdout : null + } catch { return null } - - return result.stdout } diff --git a/ui-tui/src/lib/platform.ts b/ui-tui/src/lib/platform.ts index d052324ad6..8995351a1a 100644 --- a/ui-tui/src/lib/platform.ts +++ b/ui-tui/src/lib/platform.ts @@ -1,18 +1,14 @@ /** Platform-aware keybinding helpers. * * On macOS the "action" modifier is Cmd (key.meta in Ink), on other platforms - * it is Ctrl. Ctrl+C is ALWAYS the interrupt key regardless of platform — - * it must never be remapped to copy. + * it is Ctrl. Ctrl+C is ALWAYS the interrupt key regardless of platform — it + * must never be remapped to copy. */ export const isMac = process.platform === 'darwin' -/** The display label for the action modifier key. */ -export const modLabel = isMac ? '⌘' : 'Ctrl' - /** True when the platform action-modifier is pressed (Cmd on macOS, Ctrl elsewhere). */ -export const isActionMod = (key: { ctrl: boolean; meta: boolean }): boolean => - isMac ? key.meta : key.ctrl +export const isActionMod = (key: { ctrl: boolean; meta: boolean }): boolean => (isMac ? key.meta : key.ctrl) /** Match action-modifier + a single character (case-insensitive). */ export const isAction = (key: { ctrl: boolean; meta: boolean }, ch: string, target: string): boolean =>