feat: add clicky handles

This commit is contained in:
Brooklyn Nicholson 2026-04-13 21:20:55 -05:00
parent 1b573b7b21
commit 6d6b3b03ac
15 changed files with 819 additions and 756 deletions

View file

@ -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="<path>"),

View file

@ -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({

View file

@ -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:

View file

@ -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:<id>]]` 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:

View file

@ -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())
}

File diff suppressed because it is too large Load diff

View file

@ -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 : (
<Box flexDirection="column" marginTop={1}>
<ToolTrail t={t} thinkingMode={thinkingMode} trail={msg.tools} />
<ToolTrail detailsMode={detailsMode} t={t} trail={msg.tools} />
</Box>
)
}
if (msg.role === 'tool') {
const preview = compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14))
return (
<Box alignSelf="flex-start" borderColor={t.color.dim} borderStyle="round" marginLeft={3} paddingX={1}>
<Text color={t.color.dim} wrap="truncate-end">
{preview || '(empty tool result)'}
{compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) || '(empty tool result)'}
</Text>
</Box>
)
@ -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 <Text color={t.color.dim}>{msg.text}</Text>
}
if (msg.role !== 'user' && hasAnsi(msg.text)) {
return <Ansi>{msg.text}</Ansi>
}
if (msg.role === 'assistant') {
return <Md compact={compact} t={t} text={msg.text} />
}
if (msg.kind === 'slash') return <Text color={t.color.dim}>{msg.text}</Text>
if (msg.role !== 'user' && hasAnsi(msg.text)) return <Ansi>{msg.text}</Ansi>
if (msg.role === 'assistant') return <Md compact={compact} t={t} text={msg.text} />
if (msg.role === 'user' && msg.text.length > LONG_MSG && isPasteBackedText(msg.text)) {
const [head, ...rest] = userDisplay(msg.text).split('[long message]')
return (
<Text color={body}>
{head}
<Text color={t.color.dim} dimColor>
[long message]
</Text>
<Text color={t.color.dim} dimColor>[long message]</Text>
{rest.join('')}
</Text>
)
@ -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 && (
<Box flexDirection="column" marginBottom={1}>
<ToolTrail reasoning={thinking} t={t} thinkingMode={thinkingMode} trail={msg.tools} />
<ToolTrail detailsMode={detailsMode} reasoning={thinking} t={t} trail={msg.tools} />
</Box>
) : null}
{showThinkingPreview && (
<Text color={t.color.dim} dimColor {...(thinkingMode !== 'full' ? { wrap: 'truncate-end' as const } : {})}>
{'└ '}
{preview}
</Text>
)}
<Box>
<Box flexShrink={0} width={3}>
<Text bold={msg.role === 'user'} color={prefix}>
{glyph}{' '}
</Text>
</Box>
<NoSelect flexShrink={0} fromLeftEdge width={3}>
<Text bold={msg.role === 'user'} color={prefix}>{glyph}{' '}</Text>
</NoSelect>
<Box width={Math.max(20, cols - 5)}>{content}</Box>
</Box>

View file

@ -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<number>()
for (const m of draft.matchAll(TOKEN_RE)) {
inDraft.add(parseInt(m[1] ?? '-1', 10))
}
return (
<Box borderColor={t.color.bronze} borderStyle="round" flexDirection="column" marginTop={1} paddingX={1}>
<Text color={t.color.amber}>Paste shelf ({pastes.length})</Text>
{pastes.slice(-4).map(paste => (
<Text color={t.color.dim} key={paste.id}>
#{paste.id} {modeLabel[paste.mode]} · {paste.lineCount}L · {fmtK(paste.tokenCount)} tok ·{' '}
{fmtK(paste.charCount)} chars · {paste.kind}
{inDraft.has(paste.id) ? <Text color={t.color.label}> · in draft</Text> : ''}
{' · '}
<Text color={t.color.cornsilk}>{compactPreview(paste.text, 44) || '(empty)'}</Text>
</Text>
))}
{pastes.length > 4 && (
<Text color={t.color.dim} dimColor>
and {pastes.length - 4} more
</Text>
)}
<Text color={t.color.dim} dimColor>
/paste mode {'<id>'} {'<attach|excerpt|inline>'} · /paste drop {'<id>'} · /paste clear
</Text>
</Box>
)
}

View file

@ -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 (
<Box ref={boxRef}>
<Box ref={boxRef} onClick={clickCursor}>
<Text wrap="wrap">{rendered}</Text>
</Box>
)

View file

@ -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 <Text color={color}>{spin.frames[frame]}</Text>
}
// ── 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 ? (
<Text color={color} dimColor={dimColor}>
{streaming && on ? '▍' : ' '}
</Text>
) : null
return visible ? <Text color={color} dimColor={dimColor}>{streaming && on ? '▍' : ' '}</Text> : 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 (
<Box onClick={onClick}>
<Text color={color} dimColor={tone === 'dim'}>
<Text color={t.color.amber}>{open ? '▾ ' : '▸ '}</Text>
{title}{typeof count === 'number' ? ` (${count})` : ''}
{summary ? <Text color={t.color.dim}> · {summary}</Text> : null}
</Text>
</Box>
)
}
// ── 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 ? (
<>
<Spinner color={t.color.amber} variant="think" /> {line}
</>
) : (
line
),
dimColor: true,
key: `tr-${i}`
pushDetail({
color: t.color.dim, dimColor: true, key: `tr-${i}`,
content: groups.length
? <><Spinner color={t.color.amber} variant="think" /> {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: (
<>
<Spinner color={t.color.amber} variant="tool" /> {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}
<StreamCursor color={t.color.dim} dimColor streaming={reasoningStreaming} visible={reasoningActive} />
</>
),
dimColor: true,
key: 'cot'
if (cot && groups.length) {
pushDetail({
color: t.color.dim, dimColor: true, key: 'cot',
content: <>{cot}<StreamCursor color={t.color.dim} dimColor streaming={reasoningStreaming} visible={reasoningActive} /></>
})
} else if (reasoningActive && groups.length && thinkingMode === 'collapsed') {
detail({
color: t.color.dim,
content: <StreamCursor color={t.color.dim} dimColor streaming={reasoningStreaming} visible={reasoningActive} />,
dimColor: true,
key: 'cot'
} else if (reasoningActive && groups.length) {
pushDetail({
color: t.color.dim, dimColor: true, key: 'cot',
content: <StreamCursor color={t.color.dim} dimColor streaming={reasoningStreaming} visible={reasoningActive} />
})
}
// ── 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 ? (
<Box flexDirection="column">
{alerts.map(i => (
<Text color={i.tone === 'error' ? t.color.error : t.color.warn} key={`ha-${i.id}`}>
{i.tone === 'error' ? '✗' : '!'} {i.text}
</Text>
))}
</Box>
) : null
}
// ── Shared render fragments ────────────────────────────────────
const thinkingBlock = hasThinking ? (
busy
? <Thinking active={reasoningActive} mode="full" reasoning={reasoning} streaming={reasoningStreaming} t={t} />
: cot
? <Detail color={t.color.dim} content={cot} dimColor key="cot" />
: <Detail color={t.color.dim} content={<StreamCursor color={t.color.dim} dimColor streaming={reasoningStreaming} visible={reasoningActive} />} dimColor key="cot" />
) : null
const toolBlock = hasTools ? groups.map(g => (
<Box flexDirection="column" key={g.key}>
<Text color={g.color}>
<Text color={t.color.amber}> </Text>
{g.content}
</Text>
{g.details.map(d => <Detail {...d} key={d.key} />)}
</Box>
)) : null
const metaBlock = hasMeta ? meta.map((row, i) => (
<Text color={row.color} dimColor={row.dimColor} key={row.key}>
<Text dimColor>{i === meta.length - 1 ? '└ ' : '├ '}</Text>
{row.content}
</Text>
)) : null
// ── Expanded: flat, no accordions ──────────────────────────────
if (detailsMode === 'expanded') {
return <Box flexDirection="column">{thinkingBlock}{toolBlock}{metaBlock}</Box>
}
// ── Collapsed: clickable accordions ────────────────────────────
const metaTone: 'dim' | 'error' | 'warn' =
activity.some(i => i.tone === 'error') ? 'error'
: activity.some(i => i.tone === 'warn') ? 'warn' : 'dim'
return (
<Box flexDirection="column">
{busy && !groups.length && (
<Thinking
active={reasoningActive}
mode={thinkingMode}
reasoning={reasoning}
streaming={reasoningStreaming}
t={t}
/>
)}
{!busy && !groups.length && reasoningTail && (
<Detail color={t.color.dim} content={reasoningTail} dimColor key="cot" />
{hasThinking && (
<>
<Chevron onClick={() => setOpenThinking(v => !v)} open={openThinking} summary={cot ? compactPreview(cot, 56) : busy ? 'running…' : ''} t={t} title="Thinking" />
{openThinking && thinkingBlock}
</>
)}
{groups.map(g => (
<Box flexDirection="column" key={g.key}>
<Text color={g.color}>
<Text color={t.color.amber}> </Text>
{g.content}
</Text>
{hasTools && (
<>
<Chevron count={groups.length} onClick={() => setOpenTools(v => !v)} open={openTools} t={t} title="Tool calls" />
{openTools && toolBlock}
</>
)}
{g.details.map(d => (
<Detail {...d} key={d.key} />
))}
</Box>
))}
{meta.map((row, i) => (
<Text color={row.color} dimColor={row.dimColor} key={row.key}>
<Text dimColor>{i === meta.length - 1 ? '└ ' : '├ '}</Text>
{row.content}
</Text>
))}
{hasMeta && (
<>
<Chevron count={meta.length} onClick={() => setOpenMeta(v => !v)} open={openMeta} t={t} title="Activity" tone={metaTone} />
{openMeta && metaBlock}
</>
)}
</Box>
)
})

View file

@ -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'],

View file

@ -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']
})

View file

@ -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<ScrollBoxHandle | null>,
items: readonly { key: string }[],
{ estimate = ESTIMATE, overscan = OVERSCAN, maxMounted = MAX_MOUNTED, coldStartCount = COLD_START } = {}
) {
const nodes = useRef(new Map<string, any>())
const heights = useRef(new Map<string, number>())
const refs = useRef(new Map<string, (el: any) => 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<number>(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
}
}

View file

@ -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<string, string>
categories: SlashCategory[]

View file

@ -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<any>
export const AlternateScreen: React.ComponentType<any>
export const Ansi: React.ComponentType<any>
export const NoSelect: React.ComponentType<any>
export const ScrollBox: React.ComponentType<any>
export const Text: React.ComponentType<any>
export const TextInput: React.ComponentType<any>
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: {