This PR groups the TUI fixes that restore macOS Terminal usability and clean up the theme/composer regressions: - copy transcript selections on macOS drag-release so Terminal.app users can copy while mouse tracking is enabled - copy composer selections on macOS drag-release; composer selection is internal to TextInput and does not use the global Ink selection bus - keep IDE Cmd+C forwarding setup macOS-only, and make keybinding conflict checks respect simple when-clause overlap/negation - force truecolor before chalk initializes (unless NO_COLOR / FORCE_COLOR / HERMES_TUI_TRUECOLOR opt-outs apply) so the default banner keeps its gold/amber/bronze gradient in Terminal.app - move TUI surfaces onto semantic theme tokens and preserve skin prompt symbols as bare tokens with renderer-owned spacing - render focused placeholders as dim hint text in TTY mode instead of inverse/selected-looking synthetic cursor text
240 lines
5.9 KiB
TypeScript
240 lines
5.9 KiB
TypeScript
import { Box, Text, useInput } from '@hermes/ink'
|
|
import { useState } from 'react'
|
|
|
|
import { isMac } from '../lib/platform.js'
|
|
import type { Theme } from '../theme.js'
|
|
import type { ApprovalReq, ClarifyReq, ConfirmReq } from '../types.js'
|
|
|
|
import { TextInput } from './textInput.js'
|
|
|
|
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
|
|
|
|
export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
|
|
const [sel, setSel] = useState(0)
|
|
|
|
useInput((ch, key) => {
|
|
if (key.upArrow && sel > 0) {
|
|
setSel(s => s - 1)
|
|
}
|
|
|
|
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]!)
|
|
}
|
|
})
|
|
|
|
const rawLines = req.command.split('\n')
|
|
const shown = rawLines.slice(0, CMD_PREVIEW_LINES)
|
|
const overflow = rawLines.length - shown.length
|
|
|
|
return (
|
|
<Box borderColor={t.color.warn} borderStyle="double" flexDirection="column" paddingX={1}>
|
|
<Text bold color={t.color.warn}>
|
|
⚠ approval required · {req.description}
|
|
</Text>
|
|
|
|
<Box flexDirection="column" paddingLeft={1}>
|
|
{shown.map((line, i) => (
|
|
<Text color={t.color.text} key={i} wrap="truncate-end">
|
|
{line || ' '}
|
|
</Text>
|
|
))}
|
|
|
|
{overflow > 0 ? (
|
|
<Text color={t.color.muted}>
|
|
… +{overflow} more line{overflow === 1 ? '' : 's'} (full text above)
|
|
</Text>
|
|
) : null}
|
|
</Box>
|
|
|
|
<Text />
|
|
|
|
{OPTS.map((o, i) => (
|
|
<Text key={o}>
|
|
<Text bold={sel === i} color={sel === i ? t.color.warn : t.color.muted} inverse={sel === i}>
|
|
{sel === i ? '▸ ' : ' '}
|
|
{i + 1}. {LABELS[o]}
|
|
</Text>
|
|
</Text>
|
|
))}
|
|
|
|
<Text color={t.color.muted}>↑/↓ select · Enter confirm · 1-4 quick pick · Ctrl+C deny</Text>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: ClarifyPromptProps) {
|
|
const [sel, setSel] = useState(0)
|
|
const [custom, setCustom] = useState('')
|
|
const [typing, setTyping] = useState(false)
|
|
const choices = req.choices ?? []
|
|
|
|
const heading = (
|
|
<Text bold>
|
|
<Text color={t.color.accent}>ask</Text>
|
|
<Text color={t.color.text}> {req.question}</Text>
|
|
</Text>
|
|
)
|
|
|
|
useInput((ch, key) => {
|
|
if (key.escape) {
|
|
typing && choices.length ? setTyping(false) : onCancel()
|
|
|
|
return
|
|
}
|
|
|
|
if (typing || !choices.length) {
|
|
return
|
|
}
|
|
|
|
if (key.upArrow && sel > 0) {
|
|
setSel(s => s - 1)
|
|
}
|
|
|
|
if (key.downArrow && sel < choices.length) {
|
|
setSel(s => s + 1)
|
|
}
|
|
|
|
if (key.return) {
|
|
sel === choices.length ? setTyping(true) : choices[sel] && onAnswer(choices[sel]!)
|
|
}
|
|
|
|
const n = parseInt(ch)
|
|
|
|
if (n >= 1 && n <= choices.length) {
|
|
onAnswer(choices[n - 1]!)
|
|
}
|
|
})
|
|
|
|
if (typing || !choices.length) {
|
|
return (
|
|
<Box flexDirection="column">
|
|
{heading}
|
|
|
|
<Box>
|
|
<Text color={t.color.label}>{'> '}</Text>
|
|
<TextInput columns={Math.max(20, cols - 6)} onChange={setCustom} onSubmit={onAnswer} value={custom} />
|
|
</Box>
|
|
|
|
<Text color={t.color.muted}>
|
|
Enter send · Esc {choices.length ? 'back' : 'cancel'} ·{' '}
|
|
{isMac ? 'Cmd+C copy · Cmd+V paste · Ctrl+C cancel' : 'Ctrl+C cancel'}
|
|
</Text>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Box flexDirection="column">
|
|
{heading}
|
|
|
|
{[...choices, 'Other (type your answer)'].map((c, i) => (
|
|
<Text key={i}>
|
|
<Text bold={sel === i} color={sel === i ? t.color.label : t.color.muted} inverse={sel === i}>
|
|
{sel === i ? '▸ ' : ' '}
|
|
{i + 1}. {c}
|
|
</Text>
|
|
</Text>
|
|
))}
|
|
|
|
<Text color={t.color.muted}>↑/↓ select · Enter confirm · 1-{choices.length} quick pick · Esc/Ctrl+C cancel</Text>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
export function ConfirmPrompt({ onCancel, onConfirm, req, t }: ConfirmPromptProps) {
|
|
const [sel, setSel] = useState(0)
|
|
|
|
useInput((ch, key) => {
|
|
const lower = ch.toLowerCase()
|
|
|
|
if (key.escape || (key.ctrl && lower === 'c') || lower === 'n') {
|
|
return onCancel()
|
|
}
|
|
|
|
if (lower === 'y') {
|
|
return onConfirm()
|
|
}
|
|
|
|
if (key.upArrow) {
|
|
setSel(0)
|
|
}
|
|
|
|
if (key.downArrow) {
|
|
setSel(1)
|
|
}
|
|
|
|
if (key.return) {
|
|
sel === 0 ? onCancel() : onConfirm()
|
|
}
|
|
})
|
|
|
|
const accent = req.danger ? t.color.error : t.color.warn
|
|
|
|
const rows = [
|
|
{ color: t.color.text, label: req.cancelLabel ?? 'No' },
|
|
{ color: req.danger ? t.color.error : t.color.text, label: req.confirmLabel ?? 'Yes' }
|
|
]
|
|
|
|
return (
|
|
<Box borderColor={accent} borderStyle="double" flexDirection="column" paddingX={1}>
|
|
<Text bold color={accent}>
|
|
{req.danger ? '⚠' : '?'} {req.title}
|
|
</Text>
|
|
|
|
{req.detail ? (
|
|
<Box paddingLeft={1}>
|
|
<Text color={t.color.text} wrap="truncate-end">
|
|
{req.detail}
|
|
</Text>
|
|
</Box>
|
|
) : null}
|
|
|
|
<Text />
|
|
|
|
{rows.map((row, i) => (
|
|
<Text key={row.label}>
|
|
<Text color={sel === i ? accent : t.color.muted}>{sel === i ? '▸ ' : ' '}</Text>
|
|
<Text color={sel === i ? row.color : t.color.muted}>{row.label}</Text>
|
|
</Text>
|
|
))}
|
|
|
|
<Text color={t.color.muted}>↑/↓ select · Enter confirm · Y/N quick · Esc cancel</Text>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
interface ApprovalPromptProps {
|
|
onChoice: (s: string) => void
|
|
req: ApprovalReq
|
|
t: Theme
|
|
}
|
|
|
|
interface ClarifyPromptProps {
|
|
cols?: number
|
|
onAnswer: (s: string) => void
|
|
onCancel: () => void
|
|
req: ClarifyReq
|
|
t: Theme
|
|
}
|
|
|
|
interface ConfirmPromptProps {
|
|
onCancel: () => void
|
|
onConfirm: () => void
|
|
req: ConfirmReq
|
|
t: Theme
|
|
}
|