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