fix(tui): approval flow + input ergonomics + selection perf
- tui_gateway: route approvals through gateway callback (HERMES_GATEWAY_SESSION/ HERMES_EXEC_ASK) so dangerous commands emit approval.request instead of silently falling through the CLI input() path and auto-denying - approval UX: dedicated PromptZone between transcript and composer, safer defaults (sel=0, numeric quick-picks, no Esc=deny), activity trail line, outcome footer under the cost row - text input: Ctrl+A select-all, real forward Delete, Ctrl+W always consumed (fixes Ctrl+Backspace at cursor 0 inserting literal w) - hermes-ink selection: swap synchronous onRender() for throttled scheduleRender() on drag, and only notify React subscribers on presence change — no more per-cell paint/subscribe spam - useConfigSync: silence config.get polling failures instead of surfacing 'error: timeout: config.get' in the transcript
This commit is contained in:
@ -117,6 +117,18 @@ def test_config_set_yolo_toggles_session_scope():
|
||||
server._sessions.clear()
|
||||
|
||||
|
||||
def test_enable_gateway_prompts_sets_gateway_env(monkeypatch):
|
||||
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
||||
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
||||
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
||||
|
||||
server._enable_gateway_prompts()
|
||||
|
||||
assert server.os.environ["HERMES_GATEWAY_SESSION"] == "1"
|
||||
assert server.os.environ["HERMES_EXEC_ASK"] == "1"
|
||||
assert server.os.environ["HERMES_INTERACTIVE"] == "1"
|
||||
|
||||
|
||||
def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
||||
agent = types.SimpleNamespace(reasoning_config=None)
|
||||
|
||||
@ -284,6 +284,13 @@ def _clear_session_context(tokens: list) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _enable_gateway_prompts() -> None:
|
||||
"""Route approvals through gateway callbacks instead of CLI input()."""
|
||||
os.environ["HERMES_GATEWAY_SESSION"] = "1"
|
||||
os.environ["HERMES_EXEC_ASK"] = "1"
|
||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||
|
||||
|
||||
# ── Blocking prompt factory ──────────────────────────────────────────
|
||||
|
||||
def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str:
|
||||
@ -1043,7 +1050,7 @@ def _(rid, params: dict) -> dict:
|
||||
sid = uuid.uuid4().hex[:8]
|
||||
key = _new_session_key()
|
||||
cols = int(params.get("cols", 80))
|
||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||
_enable_gateway_prompts()
|
||||
|
||||
ready = threading.Event()
|
||||
|
||||
@ -1149,7 +1156,7 @@ def _(rid, params: dict) -> dict:
|
||||
else:
|
||||
return _err(rid, 4007, "session not found")
|
||||
sid = uuid.uuid4().hex[:8]
|
||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||
_enable_gateway_prompts()
|
||||
try:
|
||||
db.reopen_session(target)
|
||||
history = db.get_messages_as_conversation(target)
|
||||
|
||||
@ -202,6 +202,7 @@ export default class Ink {
|
||||
// Fired alongside the terminal repaint whenever the selection mutates
|
||||
// so UI (e.g. footer hints) can react to selection appearing/clearing.
|
||||
private readonly selectionListeners = new Set<() => void>()
|
||||
private selectionWasActive = false
|
||||
// DOM nodes currently under the pointer (mode-1003 motion). Held here
|
||||
// so App.tsx's handleMouseEvent is stateless — dispatchHover diffs
|
||||
// against this set and mutates it in place.
|
||||
@ -1506,10 +1507,16 @@ export default class Ink {
|
||||
return () => this.selectionListeners.delete(cb)
|
||||
}
|
||||
private notifySelectionChange(): void {
|
||||
this.onRender()
|
||||
this.scheduleRender()
|
||||
|
||||
for (const cb of this.selectionListeners) {
|
||||
cb()
|
||||
const active = hasSelection(this.selection)
|
||||
|
||||
if (active !== this.selectionWasActive) {
|
||||
this.selectionWasActive = active
|
||||
|
||||
for (const cb of this.selectionListeners) {
|
||||
cb()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -301,12 +301,15 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
||||
setStatus('waiting for input…')
|
||||
|
||||
return
|
||||
case 'approval.request': {
|
||||
const description = String(ev.payload.description ?? 'dangerous command')
|
||||
|
||||
case 'approval.request':
|
||||
patchOverlayState({ approval: { command: ev.payload.command, description: ev.payload.description } })
|
||||
patchOverlayState({ approval: { command: String(ev.payload.command ?? ''), description } })
|
||||
turnController.pushActivity(`approval needed · ${description}`, 'warn')
|
||||
setStatus('approval needed')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
case 'sudo.request':
|
||||
patchOverlayState({ sudo: { requestId: ev.payload.request_id } })
|
||||
|
||||
@ -26,6 +26,7 @@ export interface StateSetter<T> {
|
||||
}
|
||||
|
||||
export interface SelectionApi {
|
||||
clearSelection: () => void
|
||||
copySelection: () => string
|
||||
}
|
||||
|
||||
@ -275,6 +276,7 @@ export interface AppLayoutComposerProps {
|
||||
|
||||
export interface AppLayoutProgressProps {
|
||||
activity: ActivityItem[]
|
||||
outcome: string
|
||||
reasoning: string
|
||||
reasoningActive: boolean
|
||||
reasoningStreaming: boolean
|
||||
|
||||
@ -91,7 +91,7 @@ class TurnController {
|
||||
this.idle()
|
||||
this.clearReasoning()
|
||||
this.turnTools = []
|
||||
patchTurnState({ activity: [] })
|
||||
patchTurnState({ activity: [], outcome: '' })
|
||||
patchUiState({ status: 'interrupted' })
|
||||
this.clearStatusTimer()
|
||||
|
||||
@ -176,7 +176,7 @@ class TurnController {
|
||||
this.turnTools = []
|
||||
this.persistedToolLabels.clear()
|
||||
this.bufRef = ''
|
||||
patchTurnState({ activity: [] })
|
||||
patchTurnState({ activity: [], outcome: '' })
|
||||
|
||||
return { finalText, savedReasoning, savedReasoningTokens, savedTools, savedToolTokens, wasInterrupted }
|
||||
}
|
||||
@ -271,7 +271,7 @@ class TurnController {
|
||||
this.turnTools = []
|
||||
this.toolTokenAcc = 0
|
||||
this.persistedToolLabels.clear()
|
||||
patchTurnState({ activity: [] })
|
||||
patchTurnState({ activity: [], outcome: '' })
|
||||
}
|
||||
|
||||
fullReset() {
|
||||
@ -312,7 +312,7 @@ class TurnController {
|
||||
this.toolTokenAcc = 0
|
||||
this.persistedToolLabels.clear()
|
||||
patchUiState({ busy: true })
|
||||
patchTurnState({ activity: [], subagents: [], toolTokens: 0, tools: [], turnTrail: [] })
|
||||
patchTurnState({ activity: [], outcome: '', subagents: [], toolTokens: 0, tools: [], turnTrail: [] })
|
||||
}
|
||||
|
||||
upsertSubagent(p: SubagentEventPayload, patch: (current: SubagentProgress) => Partial<SubagentProgress>) {
|
||||
|
||||
@ -4,6 +4,7 @@ import type { ActiveTool, ActivityItem, SubagentProgress } from '../types.js'
|
||||
|
||||
const buildTurnState = (): TurnState => ({
|
||||
activity: [],
|
||||
outcome: '',
|
||||
reasoning: '',
|
||||
reasoningActive: false,
|
||||
reasoningStreaming: false,
|
||||
@ -26,6 +27,7 @@ export const resetTurnState = () => $turnState.set(buildTurnState())
|
||||
|
||||
export interface TurnState {
|
||||
activity: ActivityItem[]
|
||||
outcome: string
|
||||
reasoning: string
|
||||
reasoningActive: boolean
|
||||
reasoningStreaming: boolean
|
||||
|
||||
@ -1,19 +1,32 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { resolveDetailsMode } from '../domain/details.js'
|
||||
import type { GatewayClient } from '../gatewayClient.js'
|
||||
import type {
|
||||
ConfigFullResponse,
|
||||
ConfigMtimeResponse,
|
||||
ReloadMcpResponse,
|
||||
VoiceToggleResponse
|
||||
} from '../gatewayTypes.js'
|
||||
import { asRpcResult } from '../lib/rpc.js'
|
||||
|
||||
import type { GatewayRpc } from './interfaces.js'
|
||||
import { turnController } from './turnController.js'
|
||||
import { patchUiState } from './uiStore.js'
|
||||
|
||||
const MTIME_POLL_MS = 5000
|
||||
|
||||
const quietRpc = async <T extends Record<string, any> = Record<string, any>>(
|
||||
gw: GatewayClient,
|
||||
method: string,
|
||||
params: Record<string, unknown> = {}
|
||||
): Promise<null | T> => {
|
||||
try {
|
||||
return asRpcResult<T>(await gw.request<T>(method, params))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolean) => void) => {
|
||||
const d = cfg?.config?.display ?? {}
|
||||
|
||||
@ -25,7 +38,7 @@ const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolean) => v
|
||||
})
|
||||
}
|
||||
|
||||
export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }: UseConfigSyncOptions) {
|
||||
export function useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid }: UseConfigSyncOptions) {
|
||||
const mtimeRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
@ -33,12 +46,12 @@ export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }:
|
||||
return
|
||||
}
|
||||
|
||||
rpc<VoiceToggleResponse>('voice.toggle', { action: 'status' }).then(r => setVoiceEnabled(!!r?.enabled))
|
||||
rpc<ConfigMtimeResponse>('config.get', { key: 'mtime' }).then(r => {
|
||||
quietRpc<VoiceToggleResponse>(gw, 'voice.toggle', { action: 'status' }).then(r => setVoiceEnabled(!!r?.enabled))
|
||||
quietRpc<ConfigMtimeResponse>(gw, 'config.get', { key: 'mtime' }).then(r => {
|
||||
mtimeRef.current = Number(r?.mtime ?? 0)
|
||||
})
|
||||
rpc<ConfigFullResponse>('config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete))
|
||||
}, [rpc, setBellOnComplete, setVoiceEnabled, sid])
|
||||
quietRpc<ConfigFullResponse>(gw, 'config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete))
|
||||
}, [gw, setBellOnComplete, setVoiceEnabled, sid])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sid) {
|
||||
@ -46,7 +59,7 @@ export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }:
|
||||
}
|
||||
|
||||
const id = setInterval(() => {
|
||||
rpc<ConfigMtimeResponse>('config.get', { key: 'mtime' }).then(r => {
|
||||
quietRpc<ConfigMtimeResponse>(gw, 'config.get', { key: 'mtime' }).then(r => {
|
||||
const next = Number(r?.mtime ?? 0)
|
||||
|
||||
if (!mtimeRef.current) {
|
||||
@ -63,19 +76,19 @@ export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }:
|
||||
|
||||
mtimeRef.current = next
|
||||
|
||||
rpc<ReloadMcpResponse>('reload.mcp', { session_id: sid }).then(
|
||||
quietRpc<ReloadMcpResponse>(gw, 'reload.mcp', { session_id: sid }).then(
|
||||
r => r && turnController.pushActivity('MCP reloaded after config change')
|
||||
)
|
||||
rpc<ConfigFullResponse>('config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete))
|
||||
quietRpc<ConfigFullResponse>(gw, 'config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete))
|
||||
})
|
||||
}, MTIME_POLL_MS)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [rpc, setBellOnComplete, sid])
|
||||
}, [gw, setBellOnComplete, sid])
|
||||
}
|
||||
|
||||
export interface UseConfigSyncOptions {
|
||||
rpc: GatewayRpc
|
||||
gw: GatewayClient
|
||||
setBellOnComplete: (v: boolean) => void
|
||||
setVoiceEnabled: (v: boolean) => void
|
||||
sid: null | string
|
||||
|
||||
@ -11,6 +11,7 @@ import type {
|
||||
import type { InputHandlerContext, InputHandlerResult } from './interfaces.js'
|
||||
import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js'
|
||||
import { turnController } from './turnController.js'
|
||||
import { patchTurnState } from './turnStore.js'
|
||||
import { getUiState, patchUiState } from './uiStore.js'
|
||||
|
||||
const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
|
||||
@ -24,11 +25,17 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
const pagerPageSize = Math.max(5, (terminal.stdout?.rows ?? 24) - 6)
|
||||
|
||||
const copySelection = () => {
|
||||
if (terminal.selection.copySelection()) {
|
||||
actions.sys('copied selection')
|
||||
const text = terminal.selection.copySelection()
|
||||
|
||||
if (text) {
|
||||
actions.sys(`copied ${text.length} chars`)
|
||||
}
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
terminal.selection.clearSelection()
|
||||
}
|
||||
|
||||
const cancelOverlayFromCtrlC = () => {
|
||||
if (overlay.clarify) {
|
||||
return actions.answerClarify('')
|
||||
@ -37,7 +44,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
if (overlay.approval) {
|
||||
return gateway
|
||||
.rpc<ApprovalRespondResponse>('approval.respond', { choice: 'deny', session_id: getUiState().sid })
|
||||
.then(r => r && (patchOverlayState({ approval: null }), actions.sys('denied')))
|
||||
.then(r => r && (patchOverlayState({ approval: null }), patchTurnState({ outcome: 'denied' })))
|
||||
}
|
||||
|
||||
if (overlay.sudo) {
|
||||
@ -215,6 +222,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
return copySelection()
|
||||
}
|
||||
|
||||
if (key.escape && terminal.hasSelection) {
|
||||
return clearSelection()
|
||||
}
|
||||
|
||||
if (key.upArrow && !cState.inputBuf.length) {
|
||||
cycleQueue(1) || cycleHistory(-1)
|
||||
|
||||
|
||||
@ -280,7 +280,7 @@ export function useMainApp(gw: GatewayClient) {
|
||||
sys
|
||||
})
|
||||
|
||||
useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid: ui.sid })
|
||||
useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid: ui.sid })
|
||||
|
||||
useEffect(() => {
|
||||
if (!ui.sid || !stdout) {
|
||||
@ -516,10 +516,10 @@ export function useMainApp(gw: GatewayClient) {
|
||||
(choice: string) =>
|
||||
respondWith('approval.respond', { choice, session_id: ui.sid }, () => {
|
||||
patchOverlayState({ approval: null })
|
||||
sys(choice === 'deny' ? 'denied' : `approved (${choice})`)
|
||||
patchTurnState({ outcome: choice === 'deny' ? 'denied' : `approved (${choice})` })
|
||||
patchUiState({ status: 'running…' })
|
||||
}),
|
||||
[respondWith, sys, ui.sid]
|
||||
[respondWith, ui.sid]
|
||||
)
|
||||
|
||||
const answerSudo = useCallback(
|
||||
@ -562,6 +562,7 @@ export function useMainApp(gw: GatewayClient) {
|
||||
? turn.activity.some(item => item.tone !== 'info')
|
||||
: Boolean(
|
||||
ui.busy ||
|
||||
turn.outcome ||
|
||||
turn.subagents.length ||
|
||||
turn.tools.length ||
|
||||
turn.turnTrail.length ||
|
||||
|
||||
@ -10,7 +10,7 @@ import type { Theme } from '../theme.js'
|
||||
import type { DetailsMode } from '../types.js'
|
||||
|
||||
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
|
||||
import { AppOverlays } from './appOverlays.js'
|
||||
import { FloatingOverlays, PromptZone } from './appOverlays.js'
|
||||
import { Banner, Panel, SessionPanel } from './branding.js'
|
||||
import { MessageLine } from './messageLine.js'
|
||||
import { QueuedMessages } from './queuedMessages.js'
|
||||
@ -37,6 +37,7 @@ const StreamingAssistant = memo(function StreamingAssistant({
|
||||
activity={progress.activity}
|
||||
busy={busy}
|
||||
detailsMode={detailsMode}
|
||||
outcome={progress.outcome}
|
||||
reasoning={progress.reasoning}
|
||||
reasoningActive={progress.reasoningActive}
|
||||
reasoningStreaming={progress.reasoningStreaming}
|
||||
@ -179,16 +180,12 @@ const ComposerPane = memo(function ComposerPane({
|
||||
/>
|
||||
)}
|
||||
|
||||
<AppOverlays
|
||||
<FloatingOverlays
|
||||
cols={composer.cols}
|
||||
compIdx={composer.compIdx}
|
||||
completions={composer.completions}
|
||||
onApprovalChoice={actions.answerApproval}
|
||||
onClarifyAnswer={actions.answerClarify}
|
||||
onModelSelect={actions.onModelSelect}
|
||||
onPickerSelect={actions.resumeById}
|
||||
onSecretSubmit={actions.answerSecret}
|
||||
onSudoSubmit={actions.answerSudo}
|
||||
pagerPageSize={composer.pagerPageSize}
|
||||
/>
|
||||
</Box>
|
||||
@ -254,6 +251,14 @@ export const AppLayout = memo(function AppLayout({
|
||||
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} />
|
||||
</Box>
|
||||
|
||||
<PromptZone
|
||||
cols={composer.cols}
|
||||
onApprovalChoice={actions.answerApproval}
|
||||
onClarifyAnswer={actions.answerClarify}
|
||||
onSecretSubmit={actions.answerSecret}
|
||||
onSudoSubmit={actions.answerSudo}
|
||||
/>
|
||||
|
||||
<ComposerPane actions={actions} composer={composer} status={status} />
|
||||
</Box>
|
||||
</AlternateScreen>
|
||||
|
||||
@ -12,31 +12,77 @@ import { ModelPicker } from './modelPicker.js'
|
||||
import { ApprovalPrompt, ClarifyPrompt } from './prompts.js'
|
||||
import { SessionPicker } from './sessionPicker.js'
|
||||
|
||||
export function AppOverlays({
|
||||
export function PromptZone({
|
||||
cols,
|
||||
onApprovalChoice,
|
||||
onClarifyAnswer,
|
||||
onSecretSubmit,
|
||||
onSudoSubmit
|
||||
}: Pick<AppOverlaysProps, 'cols' | 'onApprovalChoice' | 'onClarifyAnswer' | 'onSecretSubmit' | 'onSudoSubmit'>) {
|
||||
const overlay = useStore($overlayState)
|
||||
const ui = useStore($uiState)
|
||||
|
||||
if (overlay.approval) {
|
||||
return (
|
||||
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
|
||||
<ApprovalPrompt onChoice={onApprovalChoice} req={overlay.approval} t={ui.theme} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (overlay.clarify) {
|
||||
return (
|
||||
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
|
||||
<ClarifyPrompt
|
||||
cols={cols}
|
||||
onAnswer={onClarifyAnswer}
|
||||
onCancel={() => onClarifyAnswer('')}
|
||||
req={overlay.clarify}
|
||||
t={ui.theme}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (overlay.sudo) {
|
||||
return (
|
||||
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
|
||||
<MaskedPrompt cols={cols} icon="🔐" label="sudo password required" onSubmit={onSudoSubmit} t={ui.theme} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (overlay.secret) {
|
||||
return (
|
||||
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
|
||||
<MaskedPrompt
|
||||
cols={cols}
|
||||
icon="🔑"
|
||||
label={overlay.secret.prompt}
|
||||
onSubmit={onSecretSubmit}
|
||||
sub={`for ${overlay.secret.envVar}`}
|
||||
t={ui.theme}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function FloatingOverlays({
|
||||
cols,
|
||||
compIdx,
|
||||
completions,
|
||||
onApprovalChoice,
|
||||
onClarifyAnswer,
|
||||
onModelSelect,
|
||||
onPickerSelect,
|
||||
onSecretSubmit,
|
||||
onSudoSubmit,
|
||||
pagerPageSize
|
||||
}: AppOverlaysProps) {
|
||||
}: Pick<AppOverlaysProps, 'cols' | 'compIdx' | 'completions' | 'onModelSelect' | 'onPickerSelect' | 'pagerPageSize'>) {
|
||||
const { gw } = useGateway()
|
||||
const overlay = useStore($overlayState)
|
||||
const ui = useStore($uiState)
|
||||
|
||||
const hasAny =
|
||||
overlay.approval ||
|
||||
overlay.clarify ||
|
||||
overlay.modelPicker ||
|
||||
overlay.pager ||
|
||||
overlay.picker ||
|
||||
overlay.secret ||
|
||||
overlay.sudo ||
|
||||
completions.length
|
||||
const hasAny = overlay.modelPicker || overlay.pager || overlay.picker || completions.length
|
||||
|
||||
if (!hasAny) {
|
||||
return null
|
||||
@ -46,43 +92,6 @@ export function AppOverlays({
|
||||
|
||||
return (
|
||||
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
|
||||
{overlay.clarify && (
|
||||
<FloatBox color={ui.theme.color.bronze}>
|
||||
<ClarifyPrompt
|
||||
cols={cols}
|
||||
onAnswer={onClarifyAnswer}
|
||||
onCancel={() => onClarifyAnswer('')}
|
||||
req={overlay.clarify}
|
||||
t={ui.theme}
|
||||
/>
|
||||
</FloatBox>
|
||||
)}
|
||||
|
||||
{overlay.approval && (
|
||||
<FloatBox color={ui.theme.color.bronze}>
|
||||
<ApprovalPrompt onChoice={onApprovalChoice} req={overlay.approval} t={ui.theme} />
|
||||
</FloatBox>
|
||||
)}
|
||||
|
||||
{overlay.sudo && (
|
||||
<FloatBox color={ui.theme.color.bronze}>
|
||||
<MaskedPrompt cols={cols} icon="🔐" label="sudo password required" onSubmit={onSudoSubmit} t={ui.theme} />
|
||||
</FloatBox>
|
||||
)}
|
||||
|
||||
{overlay.secret && (
|
||||
<FloatBox color={ui.theme.color.bronze}>
|
||||
<MaskedPrompt
|
||||
cols={cols}
|
||||
icon="🔑"
|
||||
label={overlay.secret.prompt}
|
||||
onSubmit={onSecretSubmit}
|
||||
sub={`for ${overlay.secret.envVar}`}
|
||||
t={ui.theme}
|
||||
/>
|
||||
</FloatBox>
|
||||
)}
|
||||
|
||||
{overlay.picker && (
|
||||
<FloatBox color={ui.theme.color.bronze}>
|
||||
<SessionPicker
|
||||
|
||||
@ -10,57 +10,49 @@ const OPTS = ['once', 'session', 'always', 'deny'] as const
|
||||
const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const
|
||||
|
||||
export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
|
||||
const [sel, setSel] = useState(3)
|
||||
const [sel, setSel] = useState(0)
|
||||
|
||||
useInput((ch, key) => {
|
||||
if (key.upArrow && sel > 0) {
|
||||
setSel(s => s - 1)
|
||||
}
|
||||
|
||||
if (key.downArrow && sel < 3) {
|
||||
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 (ch === 'o') {
|
||||
onChoice('once')
|
||||
}
|
||||
|
||||
if (ch === 's') {
|
||||
onChoice('session')
|
||||
}
|
||||
|
||||
if (ch === 'a') {
|
||||
onChoice('always')
|
||||
}
|
||||
|
||||
if (ch === 'd' || key.escape) {
|
||||
onChoice('deny')
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box borderColor={t.color.warn} borderStyle="double" flexDirection="column" paddingX={1}>
|
||||
<Text bold color={t.color.warn}>
|
||||
! DANGEROUS COMMAND: {req.description}
|
||||
⚠ approval required · {req.description}
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim}> {req.command}</Text>
|
||||
<Text color={t.color.cornsilk}> {req.command}</Text>
|
||||
<Text />
|
||||
|
||||
{OPTS.map((o, i) => (
|
||||
<Text key={o}>
|
||||
<Text color={sel === i ? t.color.warn : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
|
||||
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>
|
||||
[{o[0]}] {LABELS[o]}
|
||||
{i + 1}. {LABELS[o]}
|
||||
</Text>
|
||||
</Text>
|
||||
))}
|
||||
|
||||
<Text color={t.color.dim}>↑/↓ select · Enter confirm · o/s/a/d quick pick</Text>
|
||||
<Text color={t.color.dim}>↑/↓ select · Enter confirm · 1-4 quick pick · Ctrl+C deny</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import * as Ink from '@hermes/ink'
|
||||
import type { InputEvent, Key } from '@hermes/ink'
|
||||
import * as Ink from '@hermes/ink'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
type InkExt = typeof Ink & {
|
||||
@ -240,6 +240,14 @@ function renderWithCursor(value: string, cursor: number) {
|
||||
return done ? out : out + invert(' ')
|
||||
}
|
||||
|
||||
function renderWithSelection(value: string, start: number, end: number) {
|
||||
if (start >= end) {
|
||||
return value
|
||||
}
|
||||
|
||||
return value.slice(0, start) + invert(value.slice(start, end) || ' ') + value.slice(end)
|
||||
}
|
||||
|
||||
function useFwdDelete(active: boolean) {
|
||||
const ref = useRef(false)
|
||||
const { inputEmitter: ee } = useStdin()
|
||||
@ -274,13 +282,16 @@ export function TextInput({
|
||||
focus = true
|
||||
}: TextInputProps) {
|
||||
const [cur, setCur] = useState(value.length)
|
||||
const [sel, setSel] = useState<null | { end: number; start: number }>(null)
|
||||
const fwdDel = useFwdDelete(focus)
|
||||
const termFocus = useTerminalFocus()
|
||||
|
||||
const curRef = useRef(cur)
|
||||
const selRef = useRef<null | { end: number; start: number }>(null)
|
||||
const vRef = useRef(value)
|
||||
const self = useRef(false)
|
||||
const pasteBuf = useRef('')
|
||||
const pasteEnd = useRef<null | number>(null)
|
||||
const pasteTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pastePos = useRef(0)
|
||||
const undo = useRef<{ cursor: number; value: string }[]>([])
|
||||
@ -296,12 +307,15 @@ export function TextInput({
|
||||
const raw = self.current ? vRef.current : value
|
||||
const display = mask ? raw.replace(/[^\n]/g, mask[0] ?? '*') : raw
|
||||
|
||||
const selected =
|
||||
sel && sel.start !== sel.end ? { end: Math.max(sel.start, sel.end), start: Math.min(sel.start, sel.end) } : null
|
||||
|
||||
const layout = useMemo(() => cursorLayout(display, cur, columns), [columns, cur, display])
|
||||
|
||||
const boxRef = useDeclaredCursor({
|
||||
line: layout.line,
|
||||
column: layout.column,
|
||||
active: focus && termFocus
|
||||
active: focus && termFocus && !selected
|
||||
})
|
||||
|
||||
const rendered = useMemo(() => {
|
||||
@ -313,15 +327,21 @@ export function TextInput({
|
||||
return invert(placeholder[0] ?? ' ') + dim(placeholder.slice(1))
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
return renderWithSelection(display, selected.start, selected.end)
|
||||
}
|
||||
|
||||
return renderWithCursor(display, cur)
|
||||
}, [cur, display, focus, placeholder])
|
||||
}, [cur, display, focus, placeholder, selected])
|
||||
|
||||
useEffect(() => {
|
||||
if (self.current) {
|
||||
self.current = false
|
||||
} else {
|
||||
setCur(value.length)
|
||||
setSel(null)
|
||||
curRef.current = value.length
|
||||
selRef.current = null
|
||||
vRef.current = value
|
||||
undo.current = []
|
||||
redo.current = []
|
||||
@ -341,6 +361,11 @@ export function TextInput({
|
||||
const prev = vRef.current
|
||||
const c = snapPos(next, nextCur)
|
||||
|
||||
if (selRef.current) {
|
||||
selRef.current = null
|
||||
setSel(null)
|
||||
}
|
||||
|
||||
if (track && next !== prev) {
|
||||
undo.current.push({ cursor: curRef.current, value: prev })
|
||||
|
||||
@ -385,7 +410,9 @@ export function TextInput({
|
||||
const flushPaste = () => {
|
||||
const text = pasteBuf.current
|
||||
const at = pastePos.current
|
||||
const end = pasteEnd.current ?? at
|
||||
pasteBuf.current = ''
|
||||
pasteEnd.current = null
|
||||
pasteTimer.current = null
|
||||
|
||||
if (!text) {
|
||||
@ -393,10 +420,41 @@ export function TextInput({
|
||||
}
|
||||
|
||||
if (!emitPaste({ cursor: at, text, value: vRef.current }) && PRINTABLE.test(text)) {
|
||||
commit(vRef.current.slice(0, at) + text + vRef.current.slice(at), at + text.length)
|
||||
commit(vRef.current.slice(0, at) + text + vRef.current.slice(end), at + text.length)
|
||||
}
|
||||
}
|
||||
|
||||
const clearSel = () => {
|
||||
if (!selRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
selRef.current = null
|
||||
setSel(null)
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
const end = vRef.current.length
|
||||
|
||||
if (!end) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = { end, start: 0 }
|
||||
selRef.current = next
|
||||
setSel(next)
|
||||
setCur(end)
|
||||
curRef.current = end
|
||||
}
|
||||
|
||||
const selRange = () => {
|
||||
const range = selRef.current
|
||||
|
||||
return range && range.start !== range.end
|
||||
? { end: Math.max(range.start, range.end), start: Math.min(range.start, range.end) }
|
||||
: null
|
||||
}
|
||||
|
||||
const ins = (v: string, c: number, s: string) => v.slice(0, c) + s + v.slice(c)
|
||||
|
||||
useInput(
|
||||
@ -431,6 +489,8 @@ export function TextInput({
|
||||
let c = curRef.current
|
||||
let v = vRef.current
|
||||
const mod = k.ctrl || k.meta
|
||||
const range = selRange()
|
||||
const delFwd = k.delete || fwdDel.current
|
||||
|
||||
if (k.ctrl && inp === 'z') {
|
||||
return swap(undo, redo)
|
||||
@ -440,19 +500,42 @@ export function TextInput({
|
||||
return swap(redo, undo)
|
||||
}
|
||||
|
||||
if (k.home || (k.ctrl && inp === 'a')) {
|
||||
if (k.ctrl && inp === 'a') {
|
||||
return selectAll()
|
||||
}
|
||||
|
||||
if (k.home) {
|
||||
clearSel()
|
||||
c = 0
|
||||
} else if (k.end || (k.ctrl && inp === 'e')) {
|
||||
clearSel()
|
||||
c = v.length
|
||||
} else if (k.leftArrow) {
|
||||
c = mod ? wordLeft(v, c) : prevPos(v, c)
|
||||
if (range && !mod) {
|
||||
clearSel()
|
||||
c = range.start
|
||||
} else {
|
||||
clearSel()
|
||||
c = mod ? wordLeft(v, c) : prevPos(v, c)
|
||||
}
|
||||
} else if (k.rightArrow) {
|
||||
c = mod ? wordRight(v, c) : nextPos(v, c)
|
||||
if (range && !mod) {
|
||||
clearSel()
|
||||
c = range.end
|
||||
} else {
|
||||
clearSel()
|
||||
c = mod ? wordRight(v, c) : nextPos(v, c)
|
||||
}
|
||||
} else if (k.meta && inp === 'b') {
|
||||
clearSel()
|
||||
c = wordLeft(v, c)
|
||||
} else if (k.meta && inp === 'f') {
|
||||
clearSel()
|
||||
c = wordRight(v, c)
|
||||
} else if ((k.backspace || k.delete) && !fwdDel.current && c > 0) {
|
||||
} else if (range && (k.backspace || delFwd)) {
|
||||
v = v.slice(0, range.start) + v.slice(range.end)
|
||||
c = range.start
|
||||
} else if (k.backspace && c > 0) {
|
||||
if (mod) {
|
||||
const t = wordLeft(v, c)
|
||||
v = v.slice(0, t) + v.slice(c)
|
||||
@ -462,22 +545,40 @@ export function TextInput({
|
||||
v = v.slice(0, t) + v.slice(c)
|
||||
c = t
|
||||
}
|
||||
} else if (k.delete && fwdDel.current && c < v.length) {
|
||||
} else if (delFwd && c < v.length) {
|
||||
if (mod) {
|
||||
const t = wordRight(v, c)
|
||||
v = v.slice(0, c) + v.slice(t)
|
||||
} else {
|
||||
v = v.slice(0, c) + v.slice(nextPos(v, c))
|
||||
}
|
||||
} else if (k.ctrl && inp === 'w' && c > 0) {
|
||||
const t = wordLeft(v, c)
|
||||
v = v.slice(0, t) + v.slice(c)
|
||||
c = t
|
||||
} else if (k.ctrl && inp === 'w') {
|
||||
if (range) {
|
||||
v = v.slice(0, range.start) + v.slice(range.end)
|
||||
c = range.start
|
||||
} else if (c > 0) {
|
||||
clearSel()
|
||||
const t = wordLeft(v, c)
|
||||
v = v.slice(0, t) + v.slice(c)
|
||||
c = t
|
||||
} else {
|
||||
return
|
||||
}
|
||||
} else if (k.ctrl && inp === 'u') {
|
||||
v = v.slice(c)
|
||||
c = 0
|
||||
if (range) {
|
||||
v = v.slice(0, range.start) + v.slice(range.end)
|
||||
c = range.start
|
||||
} else {
|
||||
v = v.slice(c)
|
||||
c = 0
|
||||
}
|
||||
} else if (k.ctrl && inp === 'k') {
|
||||
v = v.slice(0, c)
|
||||
if (range) {
|
||||
v = v.slice(0, range.start) + v.slice(range.end)
|
||||
c = range.start
|
||||
} else {
|
||||
v = v.slice(0, c)
|
||||
}
|
||||
} else if (inp.length > 0) {
|
||||
const bracketed = inp.includes('[200~')
|
||||
const text = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
@ -496,7 +597,8 @@ export function TextInput({
|
||||
|
||||
if (text.length > 1 || text.includes('\n')) {
|
||||
if (!pasteBuf.current) {
|
||||
pastePos.current = c
|
||||
pastePos.current = range ? range.start : c
|
||||
pasteEnd.current = range ? range.end : pastePos.current
|
||||
}
|
||||
|
||||
pasteBuf.current += text
|
||||
@ -511,8 +613,13 @@ export function TextInput({
|
||||
}
|
||||
|
||||
if (PRINTABLE.test(text)) {
|
||||
v = v.slice(0, c) + text + v.slice(c)
|
||||
c += text.length
|
||||
if (range) {
|
||||
v = v.slice(0, range.start) + text + v.slice(range.end)
|
||||
c = range.start + text.length
|
||||
} else {
|
||||
v = v.slice(0, c) + text + v.slice(c)
|
||||
c += text.length
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
@ -532,6 +639,7 @@ export function TextInput({
|
||||
return
|
||||
}
|
||||
|
||||
clearSel()
|
||||
const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns)
|
||||
setCur(next)
|
||||
curRef.current = next
|
||||
|
||||
@ -537,6 +537,7 @@ interface Group {
|
||||
export const ToolTrail = memo(function ToolTrail({
|
||||
busy = false,
|
||||
detailsMode = 'collapsed',
|
||||
outcome = '',
|
||||
reasoningActive = false,
|
||||
reasoning = '',
|
||||
reasoningTokens,
|
||||
@ -550,6 +551,7 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
}: {
|
||||
busy?: boolean
|
||||
detailsMode?: DetailsMode
|
||||
outcome?: string
|
||||
reasoningActive?: boolean
|
||||
reasoning?: string
|
||||
reasoningTokens?: number
|
||||
@ -596,7 +598,16 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
|
||||
const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning])
|
||||
|
||||
if (!busy && !trail.length && !tools.length && !subagents.length && !activity.length && !cot && !reasoningActive) {
|
||||
if (
|
||||
!busy &&
|
||||
!trail.length &&
|
||||
!tools.length &&
|
||||
!subagents.length &&
|
||||
!activity.length &&
|
||||
!cot &&
|
||||
!reasoningActive &&
|
||||
!outcome
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
@ -961,6 +972,13 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
t={t}
|
||||
/>
|
||||
) : null}
|
||||
{outcome ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={t.color.dim} dim>
|
||||
· {outcome}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user