mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: add clicky handles
This commit is contained in:
parent
1b573b7b21
commit
6d6b3b03ac
15 changed files with 819 additions and 756 deletions
|
|
@ -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>"),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
})
|
||||
|
||||
|
|
|
|||
117
ui-tui/src/hooks/useVirtualHistory.ts
Normal file
117
ui-tui/src/hooks/useVirtualHistory.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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[]
|
||||
|
|
|
|||
29
ui-tui/src/types/hermes-ink.d.ts
vendored
29
ui-tui/src/types/hermes-ink.d.ts
vendored
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue