diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index e623700d8..964311fb7 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -155,7 +155,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ cli_only=True, aliases=("gateway",)), CommandDef("copy", "Copy the last assistant response to clipboard", "Info", cli_only=True, args_hint="[number]"), - CommandDef("paste", "Attach clipboard image or manage text paste shelf", "Info", + CommandDef("paste", "Attach clipboard image from your clipboard", "Info", cli_only=True), CommandDef("image", "Attach a local image file for your next prompt", "Info", cli_only=True, args_hint=""), diff --git a/tui_gateway/entry.py b/tui_gateway/entry.py index 9284ba28e..a9667528d 100644 --- a/tui_gateway/entry.py +++ b/tui_gateway/entry.py @@ -5,6 +5,8 @@ import sys from tui_gateway.server import handle_request, resolve_skin, write_json signal.signal(signal.SIGPIPE, signal.SIG_DFL) +signal.signal(signal.SIGINT, signal.SIG_IGN) + def main(): if not write_json({ diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 86f3617e2..78cac4f88 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1499,6 +1499,42 @@ def _(rid, params: dict) -> dict: except Exception as e: return _err(rid, 5001, str(e)) + if key == "details_mode": + nv = str(value or "").strip().lower() + allowed_dm = frozenset({"hidden", "collapsed", "expanded"}) + if nv not in allowed_dm: + return _err(rid, 4002, f"unknown details_mode: {value}") + _write_config_key("display.details_mode", nv) + return _ok(rid, {"key": key, "value": nv}) + + if key == "thinking_mode": + nv = str(value or "").strip().lower() + allowed_tm = frozenset({"collapsed", "truncated", "full"}) + if nv not in allowed_tm: + return _err(rid, 4002, f"unknown thinking_mode: {value}") + _write_config_key("display.thinking_mode", nv) + # Backward compatibility bridge: keep details_mode aligned. + _write_config_key("display.details_mode", "expanded" if nv == "full" else "collapsed") + return _ok(rid, {"key": key, "value": nv}) + + if key in ("compact", "statusbar"): + raw = str(value or "").strip().lower() + cfg0 = _load_cfg() + d0 = cfg0.get("display") if isinstance(cfg0.get("display"), dict) else {} + def_key = "tui_compact" if key == "compact" else "tui_statusbar" + cur_b = bool(d0.get(def_key, False if key == "compact" else True)) + if raw in ("", "toggle"): + nv_b = not cur_b + elif raw == "on": + nv_b = True + elif raw == "off": + nv_b = False + else: + return _err(rid, 4002, f"unknown {key} value: {value}") + _write_config_key(f"display.{def_key}", nv_b) + out = "on" if nv_b else "off" + return _ok(rid, {"key": key, "value": out}) + if key in ("prompt", "personality", "skin"): try: cfg = _load_cfg() @@ -1562,6 +1598,27 @@ def _(rid, params: dict) -> dict: effort = str(cfg.get("agent", {}).get("reasoning_effort", "medium") or "medium") display = "show" if bool(cfg.get("display", {}).get("show_reasoning", False)) else "hide" return _ok(rid, {"value": effort, "display": display}) + if key == "details_mode": + allowed_dm = frozenset({"hidden", "collapsed", "expanded"}) + raw = str(_load_cfg().get("display", {}).get("details_mode", "collapsed") or "collapsed").strip().lower() + nv = raw if raw in allowed_dm else "collapsed" + return _ok(rid, {"value": nv}) + if key == "thinking_mode": + allowed_tm = frozenset({"collapsed", "truncated", "full"}) + cfg = _load_cfg() + raw = str(cfg.get("display", {}).get("thinking_mode", "") or "").strip().lower() + if raw in allowed_tm: + nv = raw + else: + dm = str(cfg.get("display", {}).get("details_mode", "collapsed") or "collapsed").strip().lower() + nv = "full" if dm == "expanded" else "collapsed" + return _ok(rid, {"value": nv}) + if key == "compact": + on = bool(_load_cfg().get("display", {}).get("tui_compact", False)) + return _ok(rid, {"value": "on" if on else "off"}) + if key == "statusbar": + on = bool(_load_cfg().get("display", {}).get("tui_statusbar", True)) + return _ok(rid, {"value": "on" if on else "off"}) if key == "mtime": cfg_path = _hermes_home / "config.yaml" try: diff --git a/ui-tui/README.md b/ui-tui/README.md index 0f4f14e3e..19d162e6d 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -150,10 +150,7 @@ Notes: - Queued drafts keep their original `!cmd` and `{!cmd}` text while you edit them. Shell commands and interpolation run when the queued item is actually sent. - If you load a queued item into the input and resubmit plain text, that queue item is replaced, removed from the queue preview, and promoted to send next. If the agent is still busy, the edited item is moved to the front of the queue and sent after the current run completes. - Completion requests are debounced by 60 ms. Input starting with `/` uses `complete.slash`. A trailing token that starts with `./`, `../`, `~/`, `/`, or `@` uses `complete.path`. -- Text pastes are captured into a local paste shelf and inserted as `[[paste:]]` tokens. Nothing is newline-flattened. -- Small pastes default to `excerpt` mode. Larger pastes default to `attach` mode. -- Very large paste references trigger a confirmation prompt before send. -- Pasted content is scanned for obvious secret patterns before send and redacted in the outbound payload. +- Text pastes are inserted inline directly into the draft. Nothing is newline-flattened. - `Ctrl+G` writes the current draft, including any multiline buffer, to a temp file, temporarily swaps screen buffers, launches `$EDITOR`, then restores the TUI and submits the saved text if the editor exits cleanly. - Input history is stored in `~/.hermes/.hermes_history` or under `HERMES_HOME`. @@ -192,6 +189,7 @@ The local slash handler covers the built-ins that need direct client behavior: - `/resume` - `/copy` - `/paste` +- `/details` - `/logs` - `/statusbar`, `/sb` - `/queue` @@ -202,7 +200,8 @@ Notes: - `/copy` sends the selected assistant response through OSC 52. - `/paste` with no args asks the gateway for clipboard image attachment state. -- `/paste list|mode|drop|clear` manages text paste-shelf items. +- `/paste` does not manage text paste entries; text paste is inline-only. +- `/details [hidden|collapsed|expanded|cycle]` controls thinking/tool-detail visibility. - `/statusbar` toggles the status rule on/off. Anything else falls through to: diff --git a/ui-tui/packages/hermes-ink/src/utils/fullscreen.ts b/ui-tui/packages/hermes-ink/src/utils/fullscreen.ts index 7ce9e8758..523a43102 100644 --- a/ui-tui/packages/hermes-ink/src/utils/fullscreen.ts +++ b/ui-tui/packages/hermes-ink/src/utils/fullscreen.ts @@ -1,3 +1,3 @@ export function isMouseClicksDisabled(): boolean { - return false + return /^(1|true|yes|on)$/.test((process.env.HERMES_TUI_DISABLE_MOUSE_CLICKS ?? '').trim().toLowerCase()) } diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 6e69ba42c..af8f60786 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -3,14 +3,24 @@ import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Box, Text, useApp, useInput, useStdout } from '@hermes/ink' -import { useCallback, useEffect, useRef, useState } from 'react' +import { + AlternateScreen, + Box, + NoSelect, + ScrollBox, + Text, + useApp, + useHasSelection, + useInput, + useSelection, + useStdout +} from '@hermes/ink' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Banner, Panel, SessionPanel } from './components/branding.js' import { MaskedPrompt } from './components/maskedPrompt.js' import { MessageLine } from './components/messageLine.js' import { ModelPicker } from './components/modelPicker.js' -import { PasteShelf } from './components/pasteShelf.js' import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' import { QueuedMessages } from './components/queuedMessages.js' import { SessionPicker } from './components/sessionPicker.js' @@ -21,17 +31,15 @@ import { type GatewayClient, type GatewayEvent } from './gatewayClient.js' import { useCompletion } from './hooks/useCompletion.js' import { useInputHistory } from './hooks/useInputHistory.js' import { useQueue } from './hooks/useQueue.js' +import { useVirtualHistory } from './hooks/useVirtualHistory.js' import { writeOsc52Clipboard } from './lib/osc52.js' import { asRpcResult, rpcErrorMessage } from './lib/rpc.js' import { buildToolTrailLine, - compactPreview, - estimateTokensRough, fmtK, hasInterpolation, isToolTrailResultLine, isTransientTrailLine, - pasteTokenLabel, pick, sameToolTrailGroup, stripTrailingPasteNewlines, @@ -43,82 +51,46 @@ import type { ActivityItem, ApprovalReq, ClarifyReq, + DetailsMode, Msg, PanelSection, - PasteMode, - PendingPaste, SecretReq, SessionInfo, SlashCatalog, SudoReq, - ThinkingMode, Usage } from './types.js' // ── Constants ──────────────────────────────────────────────────────── const PLACEHOLDER = pick(PLACEHOLDERS) -const PASTE_TOKEN_RE = /\[\[paste:(\d+)(?:[^\n]*?)\]\]/g const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim() -const LARGE_PASTE = { chars: 8000, lines: 80 } -const EXCERPT = { chars: 1200, lines: 14 } const MAX_HISTORY = 800 const REASONING_PULSE_MS = 700 +const WHEEL_SCROLL_STEP = 3 +const MOUSE_TRACKING = !/^(1|true|yes|on)$/.test((process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim().toLowerCase()) -const SECRET_PATTERNS = [ - /AKIA[0-9A-Z]{16}/g, - /AIza[0-9A-Za-z-_]{30,}/g, - /gh[pousr]_[A-Za-z0-9]{20,}/g, - /sk-[A-Za-z0-9]{20,}/g, - /sk-ant-[A-Za-z0-9-]{20,}/g, - /xox[baprs]-[A-Za-z0-9-]{10,}/g, - /\b(?:api[_-]?key|token|secret)\b\s*[:=]\s*["']?[A-Za-z0-9_-]{12,}/gi -] +const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded'] + +const parseDetailsMode = (v: unknown): DetailsMode | null => { + const s = typeof v === 'string' ? v.trim().toLowerCase() : '' + return DETAILS_MODES.includes(s as DetailsMode) ? (s as DetailsMode) : null +} + +const resolveDetailsMode = (d: any): DetailsMode => + parseDetailsMode(d?.details_mode) + ?? ({ full: 'expanded' as const, collapsed: 'collapsed' as const, truncated: 'collapsed' as const }[ + String(d?.thinking_mode ?? '').trim().toLowerCase() + ] ?? 'collapsed') + +const nextDetailsMode = (m: DetailsMode): DetailsMode => + DETAILS_MODES[(DETAILS_MODES.indexOf(m) + 1) % DETAILS_MODES.length]! // ── Pure helpers ───────────────────────────────────────────────────── const introMsg = (info: SessionInfo): Msg => ({ role: 'system', text: '', kind: 'intro', info }) -const classifyPaste = (text: string): PendingPaste['kind'] => { - if (/error|warn|traceback|exception|stack|debug|\[\d{2}:\d{2}:\d{2}\]/i.test(text)) { - return 'log' - } - - if ( - /```|function\s+\w+|class\s+\w+|import\s+.+from|const\s+\w+\s*=|def\s+\w+\(|<\w+/.test(text) || - text.split('\n').filter(l => /[{}()[\];<>]/.test(l)).length >= 3 - ) { - return 'code' - } - - return 'text' -} - -const redactSecrets = (text: string) => { - let redactions = 0 - - const cleaned = SECRET_PATTERNS.reduce( - (t, pat) => - t.replace(pat, val => { - redactions++ - - return val.includes(':') || val.includes('=') - ? `${val.split(/[:=]/)[0]}: [REDACTED_SECRET]` - : '[REDACTED_SECRET]' - }), - text - ) - - return { redactions, text: cleaned } -} - -const stripTokens = (text: string, re: RegExp) => - text - .replace(re, '') - .replace(/\s{2,}/g, ' ') - .trim() - const imageTokenMeta = (info: { height?: number; token_estimate?: number; width?: number } | null | undefined) => { const dims = info?.width && info?.height ? `${info.width}x${info.height}` : '' @@ -366,7 +338,6 @@ export function App({ gw }: { gw: GatewayClient }) { const [reasoningStreaming, setReasoningStreaming] = useState(false) const [statusBar, setStatusBar] = useState(true) const [lastUserMsg, setLastUserMsg] = useState('') - const [pastes, setPastes] = useState([]) const [streaming, setStreaming] = useState('') const [turnTrail, setTurnTrail] = useState([]) const [bgTasks, setBgTasks] = useState>(new Set()) @@ -378,21 +349,19 @@ export function App({ gw }: { gw: GatewayClient }) { const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) const [bellOnComplete, setBellOnComplete] = useState(false) const [clockNow, setClockNow] = useState(() => Date.now()) - const [thinkingMode, setThinkingMode] = useState('truncated') + const [detailsMode, setDetailsMode] = useState('collapsed') // ── Refs ───────────────────────────────────────────────────────── const activityIdRef = useRef(0) const toolCompleteRibbonRef = useRef<{ label: string; line: string } | null>(null) const buf = useRef('') - const inflightPasteIdsRef = useRef([]) const interruptedRef = useRef(false) const reasoningRef = useRef('') const slashRef = useRef<(cmd: string) => boolean>(() => false) const lastEmptyAt = useRef(0) const lastStatusNoteRef = useRef('') const protocolWarnedRef = useRef(false) - const pasteCounterRef = useRef(0) const colsRef = useRef(cols) const turnToolsRef = useRef([]) const persistedToolLabelsRef = useRef>(new Set()) @@ -400,6 +369,7 @@ export function App({ gw }: { gw: GatewayClient }) { const statusTimerRef = useRef | null>(null) const busyRef = useRef(busy) const sidRef = useRef(sid) + const scrollRef = useRef(null) const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {}) const configMtimeRef = useRef(0) colsRef.current = cols @@ -409,6 +379,9 @@ export function App({ gw }: { gw: GatewayClient }) { // ── Hooks ──────────────────────────────────────────────────────── + const hasSelection = useHasSelection() + const selection = useSelection() + const { queueRef, queueEditRef, queuedDisplay, queueEditIdx, enqueue, dequeue, replaceQ, setQueueEdit, syncQueue } = useQueue() @@ -453,7 +426,16 @@ export function App({ gw }: { gw: GatewayClient }) { const empty = !messages.length const isBlocked = blocked() - const hasAnyThinking = Boolean(reasoning.trim() || historyItems.some(m => m.thinking?.trim())) + const virtualRows = useMemo( + () => + historyItems.map((msg, index) => ({ + index, + key: `${index}:${msg.role}:${msg.kind ?? ''}:${msg.text.slice(0, 40)}`, + msg + })), + [historyItems] + ) + const virtualHistory = useVirtualHistory(scrollRef, virtualRows) // ── Resize RPC ─────────────────────────────────────────────────── @@ -612,7 +594,11 @@ export function App({ gw }: { gw: GatewayClient }) { configMtimeRef.current = Number(r?.mtime ?? 0) }) rpc('config.get', { key: 'full' }).then((r: any) => { - setBellOnComplete(!!r?.config?.display?.bell_on_complete) + const display = r?.config?.display ?? {} + setBellOnComplete(!!display?.bell_on_complete) + setCompact(!!display?.tui_compact) + setStatusBar(display?.tui_statusbar !== false) + setDetailsMode(resolveDetailsMode(display)) }) }, [rpc, sid]) @@ -635,7 +621,11 @@ export function App({ gw }: { gw: GatewayClient }) { pushActivity('MCP reloaded after config change') }) rpc('config.get', { key: 'full' }).then((cfg: any) => { - setBellOnComplete(!!cfg?.config?.display?.bell_on_complete) + const display = cfg?.config?.display ?? {} + setBellOnComplete(!!display?.bell_on_complete) + setCompact(!!display?.tui_compact) + setStatusBar(display?.tui_statusbar !== false) + setDetailsMode(resolveDetailsMode(display)) }) } else if (!configMtimeRef.current && next) { configMtimeRef.current = next @@ -681,7 +671,6 @@ export function App({ gw }: { gw: GatewayClient }) { setInfo(null) setHistoryItems([]) setMessages([]) - setPastes([]) setActivity([]) setBgTasks(new Set()) setUsage(ZERO) @@ -825,55 +814,6 @@ export function App({ gw }: { gw: GatewayClient }) { // ── Paste pipeline ─────────────────────────────────────────────── - const resolvePasteTokens = useCallback( - (text: string) => { - const byId = new Map(pastes.map(p => [p.id, p])) - const missingIds = new Set() - const usedIds = new Set() - let redactions = 0 - - const resolved = text.replace(PASTE_TOKEN_RE, (_m, rawId: string) => { - const id = parseInt(rawId, 10) - const paste = byId.get(id) - - if (!paste) { - missingIds.add(id) - - return `[missing paste:${id}]` - } - - usedIds.add(id) - const cleaned = redactSecrets(paste.text) - redactions += cleaned.redactions - - if (paste.mode === 'inline') { - return cleaned.text - } - - const lang = paste.kind === 'code' ? 'text' : '' - const lines = cleaned.text.split('\n') - - if (paste.mode === 'excerpt') { - let excerpt = lines.slice(0, EXCERPT.lines).join('\n') - - if (excerpt.length > EXCERPT.chars) { - excerpt = excerpt.slice(0, EXCERPT.chars).trimEnd() + '…' - } - - const truncated = lines.length > EXCERPT.lines || cleaned.text.length > excerpt.length - const tail = truncated ? `\n…[paste #${id} truncated]` : '' - - return `[paste #${id} excerpt]\n\`\`\`${lang}\n${excerpt}${tail}\n\`\`\`` - } - - return `[paste #${id} attached · ${paste.lineCount} lines]\n\`\`\`${lang}\n${cleaned.text}\n\`\`\`` - }) - - return { missingIds: [...missingIds], redactions, text: resolved, usedIds: [...usedIds] } - }, - [pastes] - ) - const paste = useCallback( (quiet = false) => rpc('clipboard.paste', { session_id: sid }).then((r: any) => { @@ -911,70 +851,23 @@ export function App({ gw }: { gw: GatewayClient }) { return null } - const lineCount = cleanedText.split('\n').length - - if (cleanedText.length < LARGE_PASTE.chars && lineCount < LARGE_PASTE.lines) { - return { - cursor: cursor + cleanedText.length, - value: value.slice(0, cursor) + cleanedText + value.slice(cursor) - } + return { + cursor: cursor + cleanedText.length, + value: value.slice(0, cursor) + cleanedText + value.slice(cursor) } - - pasteCounterRef.current++ - const id = pasteCounterRef.current - const mode: PasteMode = 'attach' - const charCount = cleanedText.length - const tokenCount = estimateTokensRough(cleanedText) - const token = pasteTokenLabel({ charCount, id, lineCount, text: cleanedText, tokenCount }) - const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : '' - const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : '' - const insert = `${lead}${token}${tail}` - - setPastes(prev => - [ - ...prev, - { - charCount, - createdAt: Date.now(), - id, - kind: classifyPaste(cleanedText), - lineCount, - mode, - text: cleanedText, - tokenCount - } - ].slice(-24) - ) - - pushActivity(`captured ${lineCount} lines · ${fmtK(tokenCount)} tok as #${id} (${mode})`) - - return { cursor: cursor + insert.length, value: value.slice(0, cursor) + insert + value.slice(cursor) } }, - [paste, pushActivity] + [paste] ) // ── Send ───────────────────────────────────────────────────────── const send = (text: string) => { - const payload = resolvePasteTokens(text) - - if (payload.missingIds.length) { - pushActivity(`missing paste token(s): ${payload.missingIds.join(', ')}`, 'warn') - - return - } - - if (payload.redactions > 0) { - pushActivity(`redacted ${payload.redactions} secret-like value(s)`, 'warn') - } - const startSubmit = (displayText: string, submitText: string) => { if (statusTimerRef.current) { clearTimeout(statusTimerRef.current) statusTimerRef.current = null } - inflightPasteIdsRef.current = payload.usedIds setLastUserMsg(text) appendMessage({ role: 'user', text: displayText }) setBusy(true) @@ -983,7 +876,6 @@ export function App({ gw }: { gw: GatewayClient }) { interruptedRef.current = false gw.request('prompt.submit', { session_id: sid, text: submitText }).catch((e: Error) => { - inflightPasteIdsRef.current = [] sys(`error: ${e.message}`) setStatus('ready') setBusy(false) @@ -1000,14 +892,14 @@ export function App({ gw }: { gw: GatewayClient }) { pushActivity(`detected file: ${r.name}`) } - startSubmit(r.text || text, r.text || payload.text) + startSubmit(r.text || text, r.text || text) return } - startSubmit(text, payload.text) + startSubmit(text, text) }) - .catch(() => startSubmit(text, payload.text)) + .catch(() => startSubmit(text, text)) } const shellExec = (cmd: string) => { @@ -1148,14 +1040,6 @@ export function App({ gw }: { gw: GatewayClient }) { return } - const { missingIds } = resolvePasteTokens(full) - - if (missingIds.length) { - pushActivity(`missing paste token(s): ${missingIds.join(', ')}`, 'warn') - - return - } - clearInput() const editIdx = queueEditRef.current @@ -1198,7 +1082,7 @@ export function App({ gw }: { gw: GatewayClient }) { send(full) }, // eslint-disable-next-line react-hooks/exhaustive-deps - [appendMessage, busy, enqueue, gw, pushHistory, resolvePasteTokens, sid] + [appendMessage, busy, enqueue, gw, pushHistory, sid] ) // ── Input handling ─────────────────────────────────────────────── @@ -1273,6 +1157,23 @@ export function App({ gw }: { gw: GatewayClient }) { return } + if (key.wheelUp) { + scrollRef.current?.scrollBy(-WHEEL_SCROLL_STEP) + return + } + + if (key.wheelDown) { + scrollRef.current?.scrollBy(WHEEL_SCROLL_STEP) + return + } + + if (key.pageUp || key.pageDown) { + const viewport = scrollRef.current?.getViewportHeight() ?? Math.max(6, (stdout?.rows ?? 24) - 8) + const step = Math.max(4, viewport - 2) + scrollRef.current?.scrollBy(key.pageUp ? -step : step) + return + } + if (key.tab && completions.length) { const row = completions[compIdx] @@ -1351,6 +1252,11 @@ export function App({ gw }: { gw: GatewayClient }) { statusTimerRef.current = null setStatus('ready') }, 1500) + } else if (hasSelection) { + const copied = selection.copySelection() + if (copied) { + sys('copied selection') + } } else if (input || inputBuf.length) { clearIn() } else { @@ -1375,16 +1281,6 @@ export function App({ gw }: { gw: GatewayClient }) { return } - if (ctrl(key, ch, 't')) { - if (hasAnyThinking) { - setThinkingMode(mode => (mode === 'collapsed' ? 'truncated' : mode === 'truncated' ? 'full' : 'collapsed')) - } else { - sys('no thinking available') - } - - return - } - if (ctrl(key, ch, 'b')) { if (voiceRecording) { setVoiceRecording(false) @@ -1747,11 +1643,6 @@ export function App({ gw }: { gw: GatewayClient }) { setReasoning('') setStreaming('') - if (inflightPasteIdsRef.current.length) { - setPastes(prev => prev.filter(paste => !inflightPasteIdsRef.current.includes(paste.id))) - inflightPasteIdsRef.current = [] - } - if (!wasInterrupted) { appendMessage({ role: 'assistant', @@ -1790,7 +1681,6 @@ export function App({ gw }: { gw: GatewayClient }) { } case 'error': - inflightPasteIdsRef.current = [] idle() setReasoning('') turnToolsRef.current = [] @@ -1869,6 +1759,11 @@ export function App({ gw }: { gw: GatewayClient }) { sections.push({ text: `${catalog.skillCount} skill commands available — /skills to browse` }) } + sections.push({ + title: 'TUI', + rows: [['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode']] + }) + sections.push({ title: 'Hotkeys', rows: HOTKEYS }) panel('Commands', sections) @@ -1929,6 +1824,7 @@ export function App({ gw }: { gw: GatewayClient }) { const mode = arg.trim().toLowerCase() setCompact(current => { const next = mode === 'on' ? true : mode === 'off' ? false : !current + rpc('config.set', { key: 'compact', value: next ? 'on' : 'off' }).catch(() => {}) queueMicrotask(() => sys(`compact ${next ? 'on' : 'off'}`)) return next @@ -1936,7 +1832,46 @@ export function App({ gw }: { gw: GatewayClient }) { } return true + + case 'details': + + case 'detail': + if (!arg) { + rpc('config.get', { key: 'details_mode' }) + .then((r: any) => { + const mode = parseDetailsMode(r?.value) ?? detailsMode + setDetailsMode(mode) + sys(`details: ${mode}`) + }) + .catch(() => sys(`details: ${detailsMode}`)) + + return true + } + + { + const mode = arg.trim().toLowerCase() + if (!['hidden', 'collapsed', 'expanded', 'cycle', 'toggle'].includes(mode)) { + sys('usage: /details [hidden|collapsed|expanded|cycle]') + return true + } + + const next = mode === 'cycle' || mode === 'toggle' ? nextDetailsMode(detailsMode) : (mode as DetailsMode) + setDetailsMode(next) + rpc('config.set', { key: 'details_mode', value: next }).catch(() => {}) + sys(`details: ${next}`) + } + + return true + case 'copy': { + if (!arg && hasSelection) { + const copied = selection.copySelection() + if (copied) { + sys('copied selection') + return true + } + } + const all = messages.filter(m => m.role === 'assistant') if (arg && Number.isNaN(parseInt(arg, 10))) { @@ -1966,71 +1901,7 @@ export function App({ gw }: { gw: GatewayClient }) { return true } - if (arg === 'list') { - if (!pastes.length) { - sys('no text pastes') - } else { - panel('Paste Shelf', [ - { - rows: pastes.map( - p => - [ - `#${p.id} ${p.mode}`, - `${p.lineCount}L · ${p.kind} · ${compactPreview(p.text, 60) || '(empty)'}` - ] as [string, string] - ) - } - ]) - } - - return true - } - - if (arg === 'clear') { - setPastes([]) - setInput(v => stripTokens(v, PASTE_TOKEN_RE)) - setInputBuf(prev => prev.map(l => stripTokens(l, PASTE_TOKEN_RE)).filter(Boolean)) - pushActivity('cleared paste shelf') - - return true - } - - if (arg.startsWith('drop ')) { - const id = parseInt(arg.split(/\s+/)[1] ?? '-1', 10) - - if (!id || !pastes.some(p => p.id === id)) { - sys('usage: /paste drop ') - - return true - } - - const re = new RegExp(`\\s*\\[\\[paste:${id}(?:[^\\n]*?)\\]\\]\\s*`, 'g') - setPastes(prev => prev.filter(p => p.id !== id)) - setInput(v => stripTokens(v, re)) - setInputBuf(prev => prev.map(l => stripTokens(l, re)).filter(Boolean)) - pushActivity(`dropped paste #${id}`) - - return true - } - - if (arg.startsWith('mode ')) { - const [, rawId, rawMode] = arg.split(/\s+/) - const id = parseInt(rawId ?? '-1', 10) - const mode = rawMode as PasteMode - - if (!id || !['attach', 'excerpt', 'inline'].includes(mode) || !pastes.some(p => p.id === id)) { - sys('usage: /paste mode ') - - return true - } - - setPastes(prev => prev.map(p => (p.id === id ? { ...p, mode } : p))) - pushActivity(`paste #${id} mode → ${mode}`) - - return true - } - - sys('usage: /paste [list|mode |drop |clear]') + sys('usage: /paste') return true case 'logs': { @@ -2043,8 +1914,15 @@ export function App({ gw }: { gw: GatewayClient }) { case 'statusbar': case 'sb': + if (arg && !['on', 'off', 'toggle'].includes(arg.trim().toLowerCase())) { + sys('usage: /statusbar [on|off|toggle]') + return true + } + setStatusBar(current => { - const next = !current + const mode = arg.trim().toLowerCase() + const next = mode === 'on' ? true : mode === 'off' ? false : !current + rpc('config.set', { key: 'statusbar', value: next ? 'on' : 'off' }).catch(() => {}) queueMicrotask(() => sys(`status bar ${next ? 'on' : 'off'}`)) return next @@ -2843,18 +2721,20 @@ export function App({ gw }: { gw: GatewayClient }) { [ catalog, compact, + detailsMode, guardBusySessionSwitch, gw, + hasSelection, lastUserMsg, maybeWarn, messages, newSession, page, panel, - pastes, pushActivity, rpc, resetVisibleHistory, + selection, send, sid, statusBar, @@ -2943,249 +2823,266 @@ export function App({ gw }: { gw: GatewayClient }) { const voiceLabel = voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}` const hasReasoning = Boolean(reasoning.trim()) - - const showProgressArea = Boolean(busy || tools.length || turnTrail.length || hasReasoning) + const showProgressArea = + detailsMode === 'hidden' + ? activity.some(i => i.tone !== 'info') + : Boolean(busy || tools.length || turnTrail.length || hasReasoning || activity.length) const showStreamingArea = Boolean(streaming) + const visibleHistory = virtualRows.slice(virtualHistory.start, virtualHistory.end) // ── Render ─────────────────────────────────────────────────────── return ( - - {historyItems.map((m, i) => ( - - {m.kind === 'intro' && m.info ? ( - - - - - ) : m.kind === 'panel' && m.panelData ? ( - - ) : ( - - )} - - ))} + + + + + {virtualHistory.topSpacer > 0 ? : null} - - {showProgressArea && ( - - - - )} + {visibleHistory.map(row => ( + + {row.msg.kind === 'intro' && row.msg.info ? ( + + + + + ) : row.msg.kind === 'panel' && row.msg.panelData ? ( + + ) : ( + + )} + + ))} - {showStreamingArea && ( - - - - )} + {virtualHistory.bottomSpacer > 0 ? : null} - {clarify && ( - - answerClarify('')} - req={clarify} - t={theme} - /> - - )} - - {approval && ( - - { - rpc('approval.respond', { choice, session_id: sid }).then(r => { - if (!r) { - return - } - - setApproval(null) - sys(choice === 'deny' ? 'denied' : `approved (${choice})`) - setStatus('running…') - }) - }} - req={approval} - t={theme} - /> - - )} - - {sudo && ( - - { - rpc('sudo.respond', { request_id: sudo.requestId, password: pw }).then(r => { - if (!r) { - return - } - - setSudo(null) - setStatus('running…') - }) - }} - t={theme} - /> - - )} - - {secret && ( - - { - rpc('secret.respond', { request_id: secret.requestId, value: val }).then(r => { - if (!r) { - return - } - - setSecret(null) - setStatus('running…') - }) - }} - sub={`for ${secret.envVar}`} - t={theme} - /> - - )} - - {picker && ( - - setPicker(false)} onSelect={resumeById} t={theme} /> - - )} - - {modelPicker && ( - - setModelPicker(false)} - onSelect={value => { - setModelPicker(false) - slash(`/model ${value}`) - }} - sessionId={sid} - t={theme} - /> - - )} - - - - - - {bgTasks.size > 0 && ( - - {bgTasks.size} background {bgTasks.size === 1 ? 'task' : 'tasks'} running - - )} - - - - {statusBar && ( - - )} - - {pager && ( - - {pager.title && ( - - - {pager.title} - + {showStreamingArea && ( + + )} - - {pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => ( - {line} - ))} - - - - {pager.offset + pagerPageSize < pager.lines.length - ? `Enter/Space for more · q to close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length})` - : `end · q to close (${pager.lines.length} lines)`} - - - )} - - {!isBlocked && ( - - {inputBuf.map((line, i) => ( - - - {i === 0 ? `${theme.brand.prompt} ` : ' '} - - - {line || ' '} - - ))} + + + {showProgressArea && ( - - - {inputBuf.length ? ' ' : `${theme.brand.prompt} `} - - - - - - )} + )} - {!!completions.length && ( - - {completions.slice(Math.max(0, compIdx - 8), compIdx + 8).map((item, i) => { - const active = Math.max(0, compIdx - 8) + i === compIdx + {clarify && ( + + answerClarify('')} + req={clarify} + t={theme} + /> + + )} - return ( - - - {item.display} + {approval && ( + + { + rpc('approval.respond', { choice, session_id: sid }).then(r => { + if (!r) { + return + } + + setApproval(null) + sys(choice === 'deny' ? 'denied' : `approved (${choice})`) + setStatus('running…') + }) + }} + req={approval} + t={theme} + /> + + )} + + {sudo && ( + + { + rpc('sudo.respond', { request_id: sudo.requestId, password: pw }).then(r => { + if (!r) { + return + } + + setSudo(null) + setStatus('running…') + }) + }} + t={theme} + /> + + )} + + {secret && ( + + { + rpc('secret.respond', { request_id: secret.requestId, value: val }).then(r => { + if (!r) { + return + } + + setSecret(null) + setStatus('running…') + }) + }} + sub={`for ${secret.envVar}`} + t={theme} + /> + + )} + + {picker && ( + + setPicker(false)} onSelect={resumeById} t={theme} /> + + )} + + {modelPicker && ( + + setModelPicker(false)} + onSelect={value => { + setModelPicker(false) + slash(`/model ${value}`) + }} + sessionId={sid} + t={theme} + /> + + )} + + + + {bgTasks.size > 0 && ( + + {bgTasks.size} background {bgTasks.size === 1 ? 'task' : 'tasks'} running + + )} + + + + {statusBar && ( + + )} + + {pager && ( + + {pager.title && ( + + + {pager.title} - {item.meta ? {item.meta} : null} - - ) - })} - - )} + + )} - {!empty && !sid && ⚕ {status}} + {pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => ( + {line} + ))} + + + + {pager.offset + pagerPageSize < pager.lines.length + ? `Enter/Space for more · q to close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length})` + : `end · q to close (${pager.lines.length} lines)`} + + + + )} + + {!isBlocked && ( + + {inputBuf.map((line, i) => ( + + + {i === 0 ? `${theme.brand.prompt} ` : ' '} + + + {line || ' '} + + ))} + + + + + {inputBuf.length ? ' ' : `${theme.brand.prompt} `} + + + + + + + )} + + {!!completions.length && ( + + {completions.slice(Math.max(0, compIdx - 8), compIdx + 8).map((item, i) => { + const active = Math.max(0, compIdx - 8) + i === compIdx + + return ( + + + {item.display} + + {item.meta ? {item.meta} : null} + + ) + })} + + )} + + {!empty && !sid && ⚕ {status}} + - + ) } diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 2ab0e2272..0403135de 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -1,42 +1,39 @@ -import { Ansi, Box, Text } from '@hermes/ink' +import { Ansi, Box, NoSelect, Text } from '@hermes/ink' import { memo } from 'react' import { LONG_MSG, ROLE } from '../constants.js' -import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi, thinkingPreview, userDisplay } from '../lib/text.js' +import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi, userDisplay } from '../lib/text.js' import type { Theme } from '../theme.js' -import type { Msg, ThinkingMode } from '../types.js' - +import type { DetailsMode, Msg } from '../types.js' import { Md } from './markdown.js' import { ToolTrail } from './thinking.js' export const MessageLine = memo(function MessageLine({ cols, compact, - thinkingMode = 'truncated', + detailsMode = 'collapsed', msg, t }: { cols: number compact?: boolean - thinkingMode?: ThinkingMode + detailsMode?: DetailsMode msg: Msg t: Theme }) { if (msg.kind === 'trail' && msg.tools?.length) { - return ( + return detailsMode === 'hidden' ? null : ( - + ) } if (msg.role === 'tool') { - const preview = compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) - return ( - {preview || '(empty tool result)'} + {compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) || '(empty tool result)'} ) @@ -44,33 +41,19 @@ export const MessageLine = memo(function MessageLine({ const { body, glyph, prefix } = ROLE[msg.role](t) const thinking = msg.thinking?.replace(/\n/g, ' ').trim() ?? '' - const preview = thinkingPreview(thinking, thinkingMode, Math.min(96, Math.max(32, cols - 18))) - const showThinkingPreview = Boolean(preview && !msg.tools?.length) + const showDetails = detailsMode !== 'hidden' && (Boolean(msg.tools?.length) || Boolean(thinking)) const content = (() => { - if (msg.kind === 'slash') { - return {msg.text} - } - - if (msg.role !== 'user' && hasAnsi(msg.text)) { - return {msg.text} - } - - if (msg.role === 'assistant') { - return - } + if (msg.kind === 'slash') return {msg.text} + if (msg.role !== 'user' && hasAnsi(msg.text)) return {msg.text} + if (msg.role === 'assistant') return if (msg.role === 'user' && msg.text.length > LONG_MSG && isPasteBackedText(msg.text)) { const [head, ...rest] = userDisplay(msg.text).split('[long message]') - return ( {head} - - - [long message] - - + [long message] {rest.join('')} ) @@ -85,25 +68,16 @@ export const MessageLine = memo(function MessageLine({ marginBottom={msg.role === 'user' ? 1 : 0} marginTop={msg.role === 'user' || msg.kind === 'slash' ? 1 : 0} > - {msg.tools?.length ? ( + {showDetails && ( - + - ) : null} - - {showThinkingPreview && ( - - {'└ '} - {preview} - )} - - - {glyph}{' '} - - + + {glyph}{' '} + {content} diff --git a/ui-tui/src/components/pasteShelf.tsx b/ui-tui/src/components/pasteShelf.tsx deleted file mode 100644 index ca5b93485..000000000 --- a/ui-tui/src/components/pasteShelf.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Box, Text } from '@hermes/ink' - -import { compactPreview, fmtK } from '../lib/text.js' -import type { Theme } from '../theme.js' -import type { PendingPaste } from '../types.js' - -const TOKEN_RE = /\[\[paste:(\d+)(?:[^\n]*?)\]\]/g - -const modeLabel = { - attach: 'attach', - excerpt: 'excerpt', - inline: 'inline' -} as const - -export function PasteShelf({ draft, pastes, t }: { draft: string; pastes: PendingPaste[]; t: Theme }) { - if (!pastes.length) { - return null - } - - const inDraft = new Set() - - for (const m of draft.matchAll(TOKEN_RE)) { - inDraft.add(parseInt(m[1] ?? '-1', 10)) - } - - return ( - - Paste shelf ({pastes.length}) - {pastes.slice(-4).map(paste => ( - - #{paste.id} {modeLabel[paste.mode]} · {paste.lineCount}L · {fmtK(paste.tokenCount)} tok ·{' '} - {fmtK(paste.charCount)} chars · {paste.kind} - {inDraft.has(paste.id) ? · in draft : ''} - {' · '} - {compactPreview(paste.text, 44) || '(empty)'} - - ))} - {pastes.length > 4 && ( - - …and {pastes.length - 4} more - - )} - - /paste mode {''} {''} · /paste drop {''} · /paste clear - - - ) -} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index edc586e93..8df818811 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -156,6 +156,55 @@ function cursorLayout(value: string, cursor: number, cols: number) { return { column: col, line } } +function offsetFromPosition(value: string, row: number, col: number, cols: number) { + if (!value.length) { + return 0 + } + + const targetRow = Math.max(0, Math.floor(row)) + const targetCol = Math.max(0, Math.floor(col)) + const w = Math.max(1, cols - 1) + + let line = 0 + let column = 0 + let lastOffset = 0 + + for (const { segment, index } of seg().segment(value)) { + lastOffset = index + + if (segment === '\n') { + if (line === targetRow) { + return index + } + line++ + column = 0 + continue + } + + const sw = Math.max(1, stringWidth(segment)) + + if (column + sw > w) { + if (line === targetRow) { + return index + } + line++ + column = 0 + } + + if (line === targetRow && targetCol <= column + Math.max(0, sw - 1)) { + return index + } + + column += sw + } + + if (targetRow >= line) { + return value.length + } + + return lastOffset +} + // ── Render value with inverse-video cursor ─────────────────────────── function renderWithCursor(value: string, cursor: number) { @@ -283,6 +332,13 @@ export function TextInput({ return renderWithCursor(display, cur) }, [cur, display, focus, placeholder]) + const clickCursor = (e: { localRow?: number; localCol?: number }) => { + if (!focus) return + const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns) + setCur(next) + curRef.current = next + } + // ── Sync external value changes ────────────────────────────────── useEffect(() => { @@ -512,7 +568,7 @@ export function TextInput({ // ── Render ─────────────────────────────────────────────────────── return ( - + {rendered} ) diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 1ed03f810..b24766ab3 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -4,6 +4,7 @@ import spinners, { type BrailleSpinnerName } from 'unicode-animations' import { FACES, VERBS } from '../constants.js' import { + compactPreview, formatToolCall, parseToolTrailResultLine, pick, @@ -12,23 +13,21 @@ import { toolTrailLabel } from '../lib/text.js' import type { Theme } from '../theme.js' -import type { ActiveTool, ActivityItem, ThinkingMode } from '../types.js' +import type { ActiveTool, ActivityItem, DetailsMode, ThinkingMode } from '../types.js' const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse'] const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle'] const fmtElapsed = (ms: number) => { const sec = Math.max(0, ms) / 1000 - return sec < 10 ? `${sec.toFixed(1)}s` : `${Math.round(sec)}s` } -// ── Spinner ────────────────────────────────────────────────────────── +// ── Primitives ─────────────────────────────────────────────────────── export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) { const [spin] = useState(() => { const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)] - return { ...raw, frames: raw.frames.map(f => [...f][0] ?? '⠀') } }) @@ -36,15 +35,12 @@ export function Spinner({ color, variant = 'think' }: { color: string; variant?: useEffect(() => { const id = setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval) - return () => clearInterval(id) }, [spin]) return {spin.frames[frame]} } -// ── Detail row ─────────────────────────────────────────────────────── - type DetailRow = { color: string; content: ReactNode; dimColor?: boolean; key: string } function Detail({ color, content, dimColor }: DetailRow) { @@ -56,54 +52,47 @@ function Detail({ color, content, dimColor }: DetailRow) { ) } -// ── Streaming cursor ───────────────────────────────────────────────── - -function StreamCursor({ - color, - dimColor, - streaming = false, - visible = false -}: { - color: string - dimColor?: boolean - streaming?: boolean - visible?: boolean +function StreamCursor({ color, dimColor, streaming = false, visible = false }: { + color: string; dimColor?: boolean; streaming?: boolean; visible?: boolean }) { const [on, setOn] = useState(true) useEffect(() => { const id = setInterval(() => setOn(v => !v), 420) - return () => clearInterval(id) }, []) - return visible ? ( - - {streaming && on ? '▍' : ' '} - - ) : null + return visible ? {streaming && on ? '▍' : ' '} : null } -// ── Thinking (pre-tool fallback) ───────────────────────────────────── +function Chevron({ count, onClick, open, summary, t, title, tone = 'dim' }: { + count?: number; onClick: () => void; open: boolean; summary?: string + t: Theme; title: string; tone?: 'dim' | 'error' | 'warn' +}) { + const color = tone === 'error' ? t.color.error : tone === 'warn' ? t.color.warn : t.color.dim + + return ( + + + {open ? '▾ ' : '▸ '} + {title}{typeof count === 'number' ? ` (${count})` : ''} + {summary ? · {summary} : null} + + + ) +} + +// ── Thinking ───────────────────────────────────────────────────────── export const Thinking = memo(function Thinking({ - active = false, - mode = 'truncated', - reasoning, - streaming = false, - t + active = false, mode = 'truncated', reasoning, streaming = false, t }: { - active?: boolean - mode?: ThinkingMode - reasoning: string - streaming?: boolean - t: Theme + active?: boolean; mode?: ThinkingMode; reasoning: string; streaming?: boolean; t: Theme }) { const [tick, setTick] = useState(0) useEffect(() => { const id = setInterval(() => setTick(v => v + 1), 1100) - return () => clearInterval(id) }, []) @@ -132,58 +121,44 @@ export const Thinking = memo(function Thinking({ ) }) -// ── ToolTrail (canonical progress block) ───────────────────────────── +// ── ToolTrail ──────────────────────────────────────────────────────── type Group = { color: string; content: ReactNode; details: DetailRow[]; key: string } export const ToolTrail = memo(function ToolTrail({ - busy = false, - thinkingMode = 'truncated', - reasoningActive = false, - reasoning = '', - reasoningStreaming = false, - t, - tools = [], - trail = [], - activity = [] + busy = false, detailsMode = 'collapsed', reasoningActive = false, + reasoning = '', reasoningStreaming = false, t, + tools = [], trail = [], activity = [] }: { - busy?: boolean - thinkingMode?: ThinkingMode - reasoningActive?: boolean - reasoning?: string - reasoningStreaming?: boolean - t: Theme - tools?: ActiveTool[] - trail?: string[] - activity?: ActivityItem[] + busy?: boolean; detailsMode?: DetailsMode; reasoningActive?: boolean + reasoning?: string; reasoningStreaming?: boolean; t: Theme + tools?: ActiveTool[]; trail?: string[]; activity?: ActivityItem[] }) { const [now, setNow] = useState(() => Date.now()) + const [openThinking, setOpenThinking] = useState(false) + const [openTools, setOpenTools] = useState(false) + const [openMeta, setOpenMeta] = useState(false) useEffect(() => { - if (!tools.length) { - return - } - + if (!tools.length) return const id = setInterval(() => setNow(Date.now()), 200) - return () => clearInterval(id) }, [tools.length]) - const reasoningTail = thinkingPreview(reasoning, thinkingMode, THINKING_COT_MAX) + useEffect(() => { + if (detailsMode === 'expanded') { setOpenThinking(true); setOpenTools(true); setOpenMeta(true) } + if (detailsMode === 'hidden') { setOpenThinking(false); setOpenTools(false); setOpenMeta(false) } + }, [detailsMode]) - if (!busy && !trail.length && !tools.length && !activity.length && !reasoningTail && !reasoningActive) { - return null - } + const cot = thinkingPreview(reasoning, 'full', THINKING_COT_MAX) + + if (!busy && !trail.length && !tools.length && !activity.length && !cot && !reasoningActive) return null + + // ── Build groups + meta ──────────────────────────────────────── const groups: Group[] = [] const meta: DetailRow[] = [] - - const detail = (row: DetailRow) => { - const g = groups.at(-1) - g ? g.details.push(row) : meta.push(row) - } - - // ── trail → groups + details ──────────────────────────────────── + const pushDetail = (row: DetailRow) => (groups.at(-1)?.details ?? meta).push(row) for (const [i, line] of trail.entries()) { const parsed = parseToolTrailResultLine(line) @@ -192,19 +167,12 @@ export const ToolTrail = memo(function ToolTrail({ groups.push({ color: parsed.mark === '✗' ? t.color.error : t.color.cornsilk, content: parsed.detail ? parsed.call : `${parsed.call} ${parsed.mark}`, - details: [], - key: `tr-${i}` + details: [], key: `tr-${i}` + }) + if (parsed.detail) pushDetail({ + color: parsed.mark === '✗' ? t.color.error : t.color.dim, + content: parsed.detail, dimColor: parsed.mark !== '✗', key: `tr-${i}-d` }) - - if (parsed.detail) { - detail({ - color: parsed.mark === '✗' ? t.color.error : t.color.dim, - content: parsed.detail, - dimColor: parsed.mark !== '✗', - key: `tr-${i}-d` - }) - } - continue } @@ -215,112 +183,134 @@ export const ToolTrail = memo(function ToolTrail({ details: [{ color: t.color.dim, content: 'drafting...', dimColor: true, key: `tr-${i}-d` }], key: `tr-${i}` }) - continue } if (line === 'analyzing tool output…') { - detail({ - color: t.color.dim, - content: groups.length ? ( - <> - {line} - - ) : ( - line - ), - dimColor: true, - key: `tr-${i}` + pushDetail({ + color: t.color.dim, dimColor: true, key: `tr-${i}`, + content: groups.length + ? <> {line} + : line }) - continue } meta.push({ color: t.color.dim, content: line, dimColor: true, key: `tr-${i}` }) } - // ── live tools → groups ───────────────────────────────────────── - for (const tool of tools) { groups.push({ - color: t.color.cornsilk, + color: t.color.cornsilk, key: tool.id, details: [], content: ( <> {formatToolCall(tool.name, tool.context || '')} {tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''} - ), - details: [], - key: tool.id + ) }) } - if (reasoningTail && groups.length) { - detail({ - color: t.color.dim, - content: ( - <> - {reasoningTail} - - - ), - dimColor: true, - key: 'cot' + if (cot && groups.length) { + pushDetail({ + color: t.color.dim, dimColor: true, key: 'cot', + content: <>{cot} }) - } else if (reasoningActive && groups.length && thinkingMode === 'collapsed') { - detail({ - color: t.color.dim, - content: , - dimColor: true, - key: 'cot' + } else if (reasoningActive && groups.length) { + pushDetail({ + color: t.color.dim, dimColor: true, key: 'cot', + content: }) } - // ── activity → meta ───────────────────────────────────────────── - for (const item of activity.slice(-4)) { const glyph = item.tone === 'error' ? '✗' : item.tone === 'warn' ? '!' : '·' const color = item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim - meta.push({ color, content: `${glyph} ${item.text}`, dimColor: item.tone === 'info', key: `a-${item.id}` }) } - // ── render ────────────────────────────────────────────────────── + // ── Derived ──────────────────────────────────────────────────── + + const hasTools = groups.length > 0 + const hasMeta = meta.length > 0 + const hasThinking = !hasTools && (busy || !!cot || reasoningActive) + + // ── Hidden: errors/warnings only ────────────────────────────── + + if (detailsMode === 'hidden') { + const alerts = activity.filter(i => i.tone !== 'info').slice(-2) + return alerts.length ? ( + + {alerts.map(i => ( + + {i.tone === 'error' ? '✗' : '!'} {i.text} + + ))} + + ) : null + } + + // ── Shared render fragments ──────────────────────────────────── + + const thinkingBlock = hasThinking ? ( + busy + ? + : cot + ? + : } dimColor key="cot" /> + ) : null + + const toolBlock = hasTools ? groups.map(g => ( + + + + {g.content} + + {g.details.map(d => )} + + )) : null + + const metaBlock = hasMeta ? meta.map((row, i) => ( + + {i === meta.length - 1 ? '└ ' : '├ '} + {row.content} + + )) : null + + // ── Expanded: flat, no accordions ────────────────────────────── + + if (detailsMode === 'expanded') { + return {thinkingBlock}{toolBlock}{metaBlock} + } + + // ── Collapsed: clickable accordions ──────────────────────────── + + const metaTone: 'dim' | 'error' | 'warn' = + activity.some(i => i.tone === 'error') ? 'error' + : activity.some(i => i.tone === 'warn') ? 'warn' : 'dim' return ( - {busy && !groups.length && ( - - )} - {!busy && !groups.length && reasoningTail && ( - + {hasThinking && ( + <> + setOpenThinking(v => !v)} open={openThinking} summary={cot ? compactPreview(cot, 56) : busy ? 'running…' : ''} t={t} title="Thinking" /> + {openThinking && thinkingBlock} + )} - {groups.map(g => ( - - - - {g.content} - + {hasTools && ( + <> + setOpenTools(v => !v)} open={openTools} t={t} title="Tool calls" /> + {openTools && toolBlock} + + )} - {g.details.map(d => ( - - ))} - - ))} - - {meta.map((row, i) => ( - - {i === meta.length - 1 ? '└ ' : '├ '} - {row.content} - - ))} + {hasMeta && ( + <> + setOpenMeta(v => !v)} open={openMeta} t={t} title="Activity" tone={metaTone} /> + {openMeta && metaBlock} + + )} ) }) diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index 9e7cac999..9e8cb5a2b 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -24,7 +24,6 @@ export const HOTKEYS: [string, string][] = [ ['Ctrl+D', 'exit'], ['Ctrl+G', 'open $EDITOR for prompt'], ['Ctrl+L', 'new session (clear)'], - ['Ctrl+T', 'cycle thinking detail'], ['Alt+V / /paste', 'paste clipboard image'], ['Tab', 'apply completion'], ['↑/↓', 'completions / queue edit / history'], diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index 2c98c64e0..a35f3c417 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -1,6 +1,6 @@ import { type ChildProcess, spawn } from 'node:child_process' import { EventEmitter } from 'node:events' -import { resolve } from 'node:path' +import { delimiter, resolve } from 'node:path' import { createInterface } from 'node:readline' const MAX_GATEWAY_LOG_LINES = 200 @@ -55,6 +55,9 @@ export class GatewayClient extends EventEmitter { const root = process.env.HERMES_PYTHON_SRC_ROOT ?? resolve(import.meta.dirname, '../../') const python = process.env.HERMES_PYTHON ?? resolve(root, 'venv/bin/python') const cwd = process.env.HERMES_CWD || root + const env = { ...process.env } + const pyPath = (env.PYTHONPATH ?? '').trim() + env.PYTHONPATH = pyPath ? `${root}${delimiter}${pyPath}` : root this.ready = false this.pendingExit = undefined this.stdoutRl?.close() @@ -81,6 +84,7 @@ export class GatewayClient extends EventEmitter { this.proc = spawn(python, ['-m', 'tui_gateway.entry'], { cwd, + env, stdio: ['pipe', 'pipe', 'pipe'] }) diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts new file mode 100644 index 000000000..877fde5d7 --- /dev/null +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -0,0 +1,117 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type RefObject } from 'react' + +import type { ScrollBoxHandle } from '@hermes/ink' + +const ESTIMATE = 4 +const OVERSCAN = 40 +const MAX_MOUNTED = 260 +const COLD_START = 40 +const QUANTUM = 8 + +const upperBound = (arr: number[], target: number) => { + let lo = 0, hi = arr.length + while (lo < hi) { + const mid = (lo + hi) >> 1 + arr[mid]! <= target ? lo = mid + 1 : hi = mid + } + return lo +} + +export function useVirtualHistory( + scrollRef: RefObject, + items: readonly { key: string }[], + { estimate = ESTIMATE, overscan = OVERSCAN, maxMounted = MAX_MOUNTED, coldStartCount = COLD_START } = {} +) { + const nodes = useRef(new Map()) + const heights = useRef(new Map()) + const refs = useRef(new Map void>()) + const [ver, setVer] = useState(0) + + useSyncExternalStore( + useCallback( + (cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => () => {}), + [scrollRef] + ), + () => { + const s = scrollRef.current + if (!s) return NaN + const b = Math.floor(s.getScrollTop() / QUANTUM) + return s.isSticky() ? -b - 1 : b + }, + () => NaN + ) + + useEffect(() => { + const keep = new Set(items.map(i => i.key)) + let dirty = false + for (const k of heights.current.keys()) { + if (!keep.has(k)) { + heights.current.delete(k) + nodes.current.delete(k) + refs.current.delete(k) + dirty = true + } + } + if (dirty) setVer(v => v + 1) + }, [items]) + + const offsets = useMemo(() => { + const out = new Array(items.length + 1).fill(0) + for (let i = 0; i < items.length; i++) + out[i + 1] = out[i]! + Math.max(1, Math.floor(heights.current.get(items[i]!.key) ?? estimate)) + return out + }, [estimate, items, ver]) + + const total = offsets[items.length] ?? 0 + const top = Math.max(0, scrollRef.current?.getScrollTop() ?? 0) + const vp = Math.max(0, scrollRef.current?.getViewportHeight() ?? 0) + const sticky = scrollRef.current?.isSticky() ?? true + + let start = 0, end = items.length + + if (items.length > 0) { + if (vp <= 0) { + start = Math.max(0, items.length - coldStartCount) + } else { + start = Math.max(0, Math.min(items.length - 1, upperBound(offsets, Math.max(0, top - overscan)) - 1)) + end = Math.max(start + 1, Math.min(items.length, upperBound(offsets, top + vp + overscan))) + } + } + + if (end - start > maxMounted) { + sticky + ? (start = Math.max(0, end - maxMounted)) + : (end = Math.min(items.length, start + maxMounted)) + } + + const measureRef = useCallback((key: string) => { + let fn = refs.current.get(key) + if (!fn) { + fn = (el: any) => el ? nodes.current.set(key, el) : nodes.current.delete(key) + refs.current.set(key, fn) + } + return fn + }, []) + + useLayoutEffect(() => { + let dirty = false + for (let i = start; i < end; i++) { + const k = items[i]?.key + if (!k) continue + const h = Math.ceil(nodes.current.get(k)?.yogaNode?.getComputedHeight?.() ?? 0) + if (h > 0 && heights.current.get(k) !== h) { + heights.current.set(k, h) + dirty = true + } + } + if (dirty) setVer(v => v + 1) + }, [end, items, start]) + + return { + start, + end, + topSpacer: offsets[start] ?? 0, + bottomSpacer: Math.max(0, total - (offsets[end] ?? total)), + measureRef + } +} diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index e8d94e64c..aac00d667 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -33,6 +33,7 @@ export interface Msg { } export type Role = 'assistant' | 'system' | 'tool' | 'user' +export type DetailsMode = 'hidden' | 'collapsed' | 'expanded' export type ThinkingMode = 'collapsed' | 'truncated' | 'full' export interface SessionInfo { @@ -78,20 +79,6 @@ export interface PanelSection { title?: string } -export type PasteKind = 'code' | 'log' | 'text' -export type PasteMode = 'attach' | 'excerpt' | 'inline' - -export interface PendingPaste { - charCount: number - createdAt: number - id: number - kind: PasteKind - lineCount: number - mode: PasteMode - text: string - tokenCount: number -} - export interface SlashCatalog { canon: Record categories: SlashCategory[] diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 81faab32e..d6ecb7f61 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -17,6 +17,8 @@ declare module '@hermes/ink' { readonly tab: boolean readonly pageUp: boolean readonly pageDown: boolean + readonly wheelUp: boolean + readonly wheelDown: boolean readonly home: boolean readonly end: boolean readonly [key: string]: boolean @@ -44,8 +46,21 @@ declare module '@hermes/ink' { readonly cleanup: () => void } + export type ScrollBoxHandle = { + readonly scrollTo: (y: number) => void + readonly scrollBy: (dy: number) => void + readonly scrollToBottom: () => void + readonly getScrollTop: () => number + readonly getViewportHeight: () => number + readonly isSticky: () => boolean + readonly subscribe: (listener: () => void) => () => void + } + export const Box: React.ComponentType + export const AlternateScreen: React.ComponentType export const Ansi: React.ComponentType + export const NoSelect: React.ComponentType + export const ScrollBox: React.ComponentType export const Text: React.ComponentType export const TextInput: React.ComponentType export const stringWidth: (s: string) => number @@ -54,6 +69,20 @@ declare module '@hermes/ink' { export function useApp(): { readonly exit: (error?: Error) => void } export function useInput(handler: InputHandler, options?: { readonly isActive?: boolean }): void + export function useSelection(): { + readonly copySelection: () => string + readonly copySelectionNoClear: () => string + readonly clearSelection: () => void + readonly hasSelection: () => boolean + readonly getState: () => unknown + readonly subscribe: (cb: () => void) => () => void + readonly shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void + readonly shiftSelection: (dRow: number, minRow: number, maxRow: number) => void + readonly moveFocus: (move: unknown) => void + readonly captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void + readonly setSelectionBgColor: (color: string) => void + } + export function useHasSelection(): boolean export function useStdout(): { readonly stdout?: NodeJS.WriteStream } export function useTerminalFocus(): boolean export function useDeclaredCursor(args: {