diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 9c6305ded..e1170b106 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -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) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 2fd4f49e8..ff5e2cbf9 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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) diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 7daa876ac..c9f90b6f9 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -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() + } } } diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 46d34a70a..62b40f619 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -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 } }) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index b34ee54be..d14536624 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -26,6 +26,7 @@ export interface StateSetter { } 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 diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index df2814277..be25dcbbe 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -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) { diff --git a/ui-tui/src/app/turnStore.ts b/ui-tui/src/app/turnStore.ts index d84633c94..9b7e04db7 100644 --- a/ui-tui/src/app/turnStore.ts +++ b/ui-tui/src/app/turnStore.ts @@ -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 diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index 3b40e7246..fe3cec573 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -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 = Record>( + gw: GatewayClient, + method: string, + params: Record = {} +): Promise => { + try { + return asRpcResult(await gw.request(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('voice.toggle', { action: 'status' }).then(r => setVoiceEnabled(!!r?.enabled)) - rpc('config.get', { key: 'mtime' }).then(r => { + quietRpc(gw, 'voice.toggle', { action: 'status' }).then(r => setVoiceEnabled(!!r?.enabled)) + quietRpc(gw, 'config.get', { key: 'mtime' }).then(r => { mtimeRef.current = Number(r?.mtime ?? 0) }) - rpc('config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete)) - }, [rpc, setBellOnComplete, setVoiceEnabled, sid]) + quietRpc(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('config.get', { key: 'mtime' }).then(r => { + quietRpc(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('reload.mcp', { session_id: sid }).then( + quietRpc(gw, 'reload.mcp', { session_id: sid }).then( r => r && turnController.pushActivity('MCP reloaded after config change') ) - rpc('config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete)) + quietRpc(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 diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 71ecab6ac..70000b73c 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -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('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) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index cfc471e0a..df4a0b3b8 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -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 || diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index ca0edfea3..dcadb8226 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -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({ /> )} - @@ -254,6 +251,14 @@ export const AppLayout = memo(function AppLayout({ + + diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 509a990cd..23187cf3f 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -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) { + const overlay = useStore($overlayState) + const ui = useStore($uiState) + + if (overlay.approval) { + return ( + + + + ) + } + + if (overlay.clarify) { + return ( + + onClarifyAnswer('')} + req={overlay.clarify} + t={ui.theme} + /> + + ) + } + + if (overlay.sudo) { + return ( + + + + ) + } + + if (overlay.secret) { + return ( + + + + ) + } + + return null +} + +export function FloatingOverlays({ cols, compIdx, completions, - onApprovalChoice, - onClarifyAnswer, onModelSelect, onPickerSelect, - onSecretSubmit, - onSudoSubmit, pagerPageSize -}: AppOverlaysProps) { +}: Pick) { 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 ( - {overlay.clarify && ( - - onClarifyAnswer('')} - req={overlay.clarify} - t={ui.theme} - /> - - )} - - {overlay.approval && ( - - - - )} - - {overlay.sudo && ( - - - - )} - - {overlay.secret && ( - - - - )} - {overlay.picker && ( { 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 ( - + - ! DANGEROUS COMMAND: {req.description} + ⚠ approval required · {req.description} - {req.command} + {req.command} {OPTS.map((o, i) => ( {sel === i ? '▸ ' : ' '} - [{o[0]}] {LABELS[o]} + {i + 1}. {LABELS[o]} ))} - ↑/↓ select · Enter confirm · o/s/a/d quick pick + ↑/↓ select · Enter confirm · 1-4 quick pick · Ctrl+C deny ) } diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 482537376..2e7791e25 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -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) const fwdDel = useFwdDelete(focus) const termFocus = useTerminalFocus() const curRef = useRef(cur) + const selRef = useRef(null) const vRef = useRef(value) const self = useRef(false) const pasteBuf = useRef('') + const pasteEnd = useRef(null) const pasteTimer = useRef | 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 diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 25f208081..958333d6e 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -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 ? ( + + + · {outcome} + + + ) : null} ) })