Merge branch 'main' into bb/gui
This commit is contained in:
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user