Merge branch 'main' into bb/gui

This commit is contained in:
emozilla
2026-05-16 00:13:51 -04:00
13 changed files with 512 additions and 38 deletions
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { approvalAction } from '../components/prompts.js'
describe('approvalAction — pure key dispatch for ApprovalPrompt', () => {
it('maps Esc to deny — parity with global Ctrl+C cancellation', () => {
expect(approvalAction('', { escape: true }, 0)).toEqual({ kind: 'choose', choice: 'deny' })
expect(approvalAction('', { escape: true }, 2)).toEqual({ kind: 'choose', choice: 'deny' })
})
it('maps number keys 1..4 to once/session/always/deny in registration order', () => {
expect(approvalAction('1', {}, 0)).toEqual({ kind: 'choose', choice: 'once' })
expect(approvalAction('2', {}, 0)).toEqual({ kind: 'choose', choice: 'session' })
expect(approvalAction('3', {}, 0)).toEqual({ kind: 'choose', choice: 'always' })
expect(approvalAction('4', {}, 0)).toEqual({ kind: 'choose', choice: 'deny' })
})
it('ignores out-of-range numbers', () => {
expect(approvalAction('0', {}, 1)).toEqual({ kind: 'noop' })
expect(approvalAction('5', {}, 1)).toEqual({ kind: 'noop' })
expect(approvalAction('9', {}, 1)).toEqual({ kind: 'noop' })
})
it('confirms the current selection on Enter', () => {
expect(approvalAction('', { return: true }, 0)).toEqual({ kind: 'choose', choice: 'once' })
expect(approvalAction('', { return: true }, 3)).toEqual({ kind: 'choose', choice: 'deny' })
})
it('moves selection up/down within bounds', () => {
expect(approvalAction('', { upArrow: true }, 2)).toEqual({ kind: 'move', delta: -1 })
expect(approvalAction('', { downArrow: true }, 1)).toEqual({ kind: 'move', delta: 1 })
})
it('clamps selection movement at the edges', () => {
expect(approvalAction('', { upArrow: true }, 0)).toEqual({ kind: 'noop' })
expect(approvalAction('', { downArrow: true }, 3)).toEqual({ kind: 'noop' })
})
it('Esc beats numeric/return — denying is always the first interpretation', () => {
// If a terminal somehow delivers Esc + a digit in the same event, deny
// wins. Documents the precedence so a future refactor doesn't flip it.
expect(approvalAction('1', { escape: true }, 0)).toEqual({ kind: 'choose', choice: 'deny' })
expect(approvalAction('', { escape: true, return: true }, 1)).toEqual({ kind: 'choose', choice: 'deny' })
})
it('returns noop for unrelated keystrokes (printable letters etc.)', () => {
expect(approvalAction('a', {}, 0)).toEqual({ kind: 'noop' })
expect(approvalAction(' ', {}, 0)).toEqual({ kind: 'noop' })
})
})
+41 -1
View File
@@ -1,6 +1,46 @@
import { describe, expect, it, vi } from 'vitest'
import { applyVoiceRecordResponse } from '../app/useInputHandlers.js'
import { applyVoiceRecordResponse, shouldFallThroughForScroll } from '../app/useInputHandlers.js'
const baseKey = {
downArrow: false,
pageDown: false,
pageUp: false,
shift: false,
upArrow: false,
wheelDown: false,
wheelUp: false
}
describe('shouldFallThroughForScroll — keep transcript scrolling alive during prompt overlays', () => {
it('falls through for wheel scrolls', () => {
expect(shouldFallThroughForScroll({ ...baseKey, wheelUp: true })).toBe(true)
expect(shouldFallThroughForScroll({ ...baseKey, wheelDown: true })).toBe(true)
})
it('falls through for PageUp / PageDown', () => {
expect(shouldFallThroughForScroll({ ...baseKey, pageUp: true })).toBe(true)
expect(shouldFallThroughForScroll({ ...baseKey, pageDown: true })).toBe(true)
})
it('falls through for Shift+ArrowUp / Shift+ArrowDown', () => {
expect(shouldFallThroughForScroll({ ...baseKey, shift: true, upArrow: true })).toBe(true)
expect(shouldFallThroughForScroll({ ...baseKey, shift: true, downArrow: true })).toBe(true)
})
it('does NOT fall through for plain arrows — those drive in-prompt selection', () => {
expect(shouldFallThroughForScroll({ ...baseKey, upArrow: true })).toBe(false)
expect(shouldFallThroughForScroll({ ...baseKey, downArrow: true })).toBe(false)
})
it('does NOT fall through for plain Shift — without an arrow it is a no-op', () => {
expect(shouldFallThroughForScroll({ ...baseKey, shift: true })).toBe(false)
})
it('does NOT fall through for unrelated state (no scroll keys held)', () => {
expect(shouldFallThroughForScroll(baseKey)).toBe(false)
})
})
describe('applyVoiceRecordResponse', () => {
it('reverts optimistic REC state when the gateway reports voice busy', () => {
+55 -2
View File
@@ -23,6 +23,42 @@ import { getUiState } from './uiStore.js'
const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
/**
* Approval / clarify / confirm overlays mount their own `useInput` handlers
* for the in-prompt keys (arrows, numbers, Enter, sometimes Esc). The global
* input handler used to early-return for any other key while one of those
* overlays was up, which silently disabled transcript scrolling — the user
* couldn't read context above the prompt that the prompt itself was asking
* about. Returns true when the key is a transcript-scroll input that should
* fall through to the global scroll handlers even while a prompt is active.
*
* Modifier-held wheel (precision mode) is included — a user who wants to
* scroll a single line at a time during a prompt expects it to work.
*/
export function shouldFallThroughForScroll(key: {
downArrow: boolean
pageDown: boolean
pageUp: boolean
shift: boolean
upArrow: boolean
wheelDown: boolean
wheelUp: boolean
}): boolean {
if (key.wheelUp || key.wheelDown) {
return true
}
if (key.pageUp || key.pageDown) {
return true
}
if (key.shift && (key.upArrow || key.downArrow)) {
return true
}
return false
}
export function applyVoiceRecordResponse(
response: null | VoiceRecordResponse,
starting: boolean,
@@ -224,7 +260,18 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
// handlers must receive keystrokes (arrow keys, numbers, Enter). Only
// intercept Ctrl+C here so the user can deny/dismiss — all other keys
// fall through to the component-level handlers.
if (overlay.approval || overlay.clarify || overlay.confirm) {
//
// Scroll inputs (wheel / PageUp / PageDown / Shift+↑↓) are special:
// they must reach the transcript scroll handlers below even with a
// prompt up. Long-thread context the prompt is asking about often
// lives above the visible viewport, and being unable to read it while
// answering felt like the prompt had locked the entire UI. Explicitly
// skip the prompt-overlay early-return for scroll keys so they fall
// through to the wheel / PageUp / Shift+arrow handlers below.
const promptOverlay = overlay.approval || overlay.clarify || overlay.confirm
const fallThroughForScroll = promptOverlay && shouldFallThroughForScroll(key)
if (promptOverlay && !fallThroughForScroll) {
if (isCtrl(key, ch, 'c')) {
cancelOverlayFromCtrlC()
}
@@ -298,7 +345,13 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
patchOverlayState({ picker: false })
}
return
// When a prompt overlay is up and the user pressed a scroll key, fall
// through to the global scroll handlers below instead of returning.
// Otherwise nothing above this comment matched, and there's nothing
// useful to do for an arbitrary key while blocked.
if (!fallThroughForScroll) {
return
}
}
if (cState.completions.length && cState.input && cState.historyIdx === null && (key.upArrow || key.downArrow)) {
+55 -18
View File
@@ -11,28 +11,65 @@ const OPTS = ['once', 'session', 'always', 'deny'] as const
const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const
const CMD_PREVIEW_LINES = 10
type ApprovalKey = {
downArrow?: boolean
escape?: boolean
return?: boolean
upArrow?: boolean
}
type ApprovalAction =
| { kind: 'choose'; choice: (typeof OPTS)[number] }
| { kind: 'move'; delta: -1 | 1 }
| { kind: 'noop' }
/**
* Pure key-dispatch for the approval prompt — exported so the regression
* matrix (Esc, Ctrl+C-equivalent, number keys, Enter, ↑↓) is testable
* without mounting React + Ink + a fake stdin. The component just maps the
* action onto its own state setters.
*
* Esc and number keys both terminate the prompt; Esc maps to deny (parity
* with the global Ctrl+C handler that already calls cancelOverlayFromCtrlC
* for approvals). Numbers 1..OPTS.length pick the labelled choice. Enter
* confirms the current selection. ↑/↓ moves the selection within bounds.
*/
export function approvalAction(ch: string, key: ApprovalKey, sel: number): ApprovalAction {
if (key.escape) {
return { kind: 'choose', choice: 'deny' }
}
const n = parseInt(ch, 10)
if (n >= 1 && n <= OPTS.length) {
return { kind: 'choose', choice: OPTS[n - 1]! }
}
if (key.return) {
return { kind: 'choose', choice: OPTS[sel]! }
}
if (key.upArrow && sel > 0) {
return { kind: 'move', delta: -1 }
}
if (key.downArrow && sel < OPTS.length - 1) {
return { kind: 'move', delta: 1 }
}
return { kind: 'noop' }
}
export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
const [sel, setSel] = useState(0)
useInput((ch, key) => {
if (key.upArrow && sel > 0) {
setSel(s => s - 1)
}
const action = approvalAction(ch, key, sel)
if (key.downArrow && sel < OPTS.length - 1) {
setSel(s => s + 1)
}
const n = parseInt(ch, 10)
if (n >= 1 && n <= OPTS.length) {
onChoice(OPTS[n - 1]!)
return
}
if (key.return) {
onChoice(OPTS[sel]!)
if (action.kind === 'choose') {
onChoice(action.choice)
} else if (action.kind === 'move') {
setSel(s => s + action.delta)
}
})
@@ -71,7 +108,7 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
</Text>
))}
<Text color={t.color.muted}>/ select · Enter confirm · 1-4 quick pick · Ctrl+C deny</Text>
<Text color={t.color.muted}>/ select · Enter confirm · 1-4 quick pick · Esc/Ctrl+C deny</Text>
</Box>
)
}