fix(tui): Ctrl+C with input selection actually preserves input (lift handler to app level)

Previous fix in 9dbf1ec6 handled Ctrl+C inside textInput but the APP-level
useInputHandlers fires the same keypress in a separate React hook and ran
clearIn() regardless. Net effect: the OSC 52 copy succeeded but the input
wiped right after, so Brooklyn only noticed the wipe.

Lift the selection-aware Ctrl+C to a single place by threading input
selection state through a new nanostore (src/app/inputSelectionStore.ts).
textInput syncs its derived `selected` range + a clear() callback to the
store on every selection change, and the app-level Ctrl+C handler reads
the store before its clear/interrupt/die chain:

  - terminal-level selection (scrollback) → copy, existing behavior
  - in-input selection present → copy + clear selection, preserve input
  - input has text, no selection → clearIn(), existing behavior
  - empty + busy → interrupt turn
  - empty + idle → die

textInput no longer has its own Ctrl+C block; keypress falls through to
app-level like it did before 9dbf1ec6.
This commit is contained in:
Brooklyn Nicholson 2026-04-18 16:28:51 -05:00
parent bfac5d039d
commit fb06bc67de
3 changed files with 59 additions and 15 deletions

View file

@ -0,0 +1,14 @@
import { atom } from 'nanostores'
export interface InputSelection {
clear: () => void
end: number
start: number
value: string
}
export const $inputSelection = atom<InputSelection | null>(null)
export const setInputSelection = (next: InputSelection | null) => $inputSelection.set(next)
export const getInputSelection = () => $inputSelection.get()

View file

@ -8,6 +8,9 @@ import type {
VoiceRecordResponse
} from '../gatewayTypes.js'
import { writeOsc52Clipboard } from '../lib/osc52.js'
import { getInputSelection } from './inputSelectionStore.js'
import type { InputHandlerContext, InputHandlerResult } from './interfaces.js'
import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js'
import { turnController } from './turnController.js'
@ -247,6 +250,15 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
return copySelection()
}
const inputSel = getInputSelection()
if (inputSel && inputSel.end > inputSel.start) {
writeOsc52Clipboard(inputSel.value.slice(inputSel.start, inputSel.end))
inputSel.clear()
return
}
if (live.busy && live.sid) {
return turnController.interruptTurn({
appendMessage: actions.appendMessage,

View file

@ -2,7 +2,7 @@ import type { InputEvent, Key } from '@hermes/ink'
import * as Ink from '@hermes/ink'
import { useEffect, useMemo, useRef, useState } from 'react'
import { writeOsc52Clipboard } from '../lib/osc52.js'
import { setInputSelection } from '../app/inputSelectionStore.js'
type InkExt = typeof Ink & {
stringWidth: (s: string) => number
@ -353,6 +353,28 @@ export function TextInput({
}
}, [value])
useEffect(() => {
if (!focus) {
return
}
if (selected) {
setInputSelection({
clear: () => {
selRef.current = null
setSel(null)
},
end: selected.end,
start: selected.start,
value: vRef.current
})
} else {
setInputSelection(null)
}
return () => setInputSelection(null)
}, [focus, selected])
useEffect(
() => () => {
if (pasteTimer.current) {
@ -470,20 +492,16 @@ export function TextInput({
return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
}
if (k.ctrl && inp === 'c') {
const range = selRange()
if (range) {
writeOsc52Clipboard(vRef.current.slice(range.start, range.end))
clearSel()
return
}
return
}
if (k.upArrow || k.downArrow || k.tab || (k.shift && k.tab) || k.pageUp || k.pageDown || k.escape) {
if (
k.upArrow ||
k.downArrow ||
(k.ctrl && inp === 'c') ||
k.tab ||
(k.shift && k.tab) ||
k.pageUp ||
k.pageDown ||
k.escape
) {
return
}