Merge pull request #12263 from NousResearch/bb/tui-audit-followup

fix(tui): TUI v2 audit follow-up — registry, overlays, paste, reasoning, hyperlinks
This commit is contained in:
Teknium
2026-04-18 14:40:16 -07:00
committed by GitHub
33 changed files with 1222 additions and 44 deletions

View File

@@ -363,6 +363,28 @@ def test_image_attach_appends_local_image(monkeypatch):
assert len(server._sessions["sid"]["attached_images"]) == 1
def test_commands_catalog_surfaces_quick_commands(monkeypatch):
monkeypatch.setattr(server, "_load_cfg", lambda: {"quick_commands": {
"build": {"type": "exec", "command": "npm run build"},
"git": {"type": "alias", "target": "/shell git"},
"notes": {"type": "exec", "command": "cat NOTES.md", "description": "Open design notes"},
}})
resp = server.handle_request({"id": "1", "method": "commands.catalog", "params": {}})
pairs = dict(resp["result"]["pairs"])
assert "npm run build" in pairs["/build"]
assert pairs["/git"].startswith("alias →")
assert pairs["/notes"] == "Open design notes"
user_cat = next(c for c in resp["result"]["categories"] if c["name"] == "User commands")
user_pairs = dict(user_cat["pairs"])
assert set(user_pairs) == {"/build", "/git", "/notes"}
assert resp["result"]["canon"]["/build"] == "/build"
assert resp["result"]["canon"]["/notes"] == "/notes"
def test_command_dispatch_exec_nonzero_surfaces_error(monkeypatch):
monkeypatch.setattr(server, "_load_cfg", lambda: {"quick_commands": {"boom": {"type": "exec", "command": "boom"}}})
monkeypatch.setattr(
@@ -509,3 +531,18 @@ def test_session_steer_errors_when_agent_has_no_steer_method():
assert "error" in resp, resp
assert resp["error"]["code"] == 4010
def test_session_info_includes_mcp_servers(monkeypatch):
fake_status = [
{"name": "github", "transport": "http", "tools": 12, "connected": True},
{"name": "filesystem", "transport": "stdio", "tools": 4, "connected": True},
{"name": "broken", "transport": "stdio", "tools": 0, "connected": False},
]
fake_mod = types.ModuleType("tools.mcp_tool")
fake_mod.get_mcp_status = lambda: fake_status
monkeypatch.setitem(sys.modules, "tools.mcp_tool", fake_mod)
info = server._session_info(types.SimpleNamespace(tools=[], model=""))
assert info["mcp_servers"] == fake_status

View File

@@ -588,6 +588,11 @@ def _session_info(agent) -> dict:
info["skills"] = get_available_skills()
except Exception:
pass
try:
from tools.mcp_tool import get_mcp_status
info["mcp_servers"] = get_mcp_status()
except Exception:
info["mcp_servers"] = []
try:
from hermes_cli.banner import get_update_result
from hermes_cli.config import recommended_update_command
@@ -1994,8 +1999,35 @@ def _(rid, params: dict) -> dict:
cat_order.append(cat)
cat_map[cat].append([name, desc])
skill_count = 0
warning = ""
try:
qcmds = _load_cfg().get("quick_commands", {}) or {}
if isinstance(qcmds, dict) and qcmds:
bucket = "User commands"
if bucket not in cat_map:
cat_map[bucket] = []
cat_order.append(bucket)
for qname, qc in sorted(qcmds.items()):
if not isinstance(qc, dict):
continue
key = f"/{qname}"
canon[key.lower()] = key
qtype = qc.get("type", "")
if qtype == "exec":
default_desc = f"exec: {qc.get('command', '')}"
elif qtype == "alias":
default_desc = f"alias → {qc.get('target', '')}"
else:
default_desc = qtype or "quick command"
qdesc = str(qc.get("description") or default_desc)
qdesc = qdesc[:120] + ("" if len(qdesc) > 120 else "")
all_pairs.append([key, qdesc])
cat_map[bucket].append([key, qdesc])
except Exception as e:
if not warning:
warning = f"quick_commands discovery unavailable: {e}"
skill_count = 0
try:
from agent.skill_commands import scan_skill_commands
for k, info in sorted(scan_skill_commands().items()):

View File

@@ -4,7 +4,7 @@ import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js'
import { resetOverlayState } from '../app/overlayStore.js'
import { turnController } from '../app/turnController.js'
import { resetTurnState } from '../app/turnStore.js'
import { resetUiState } from '../app/uiStore.js'
import { patchUiState, resetUiState } from '../app/uiStore.js'
import { estimateTokensRough } from '../lib/text.js'
import type { Msg } from '../types.js'
@@ -47,6 +47,7 @@ describe('createGatewayEventHandler', () => {
resetUiState()
resetTurnState()
turnController.fullReset()
patchUiState({ showReasoning: true })
})
it('persists completed tool rows when message.complete lands immediately after tool.complete', () => {

View File

@@ -17,6 +17,64 @@ describe('createSlashHandler', () => {
expect(getOverlayState().picker).toBe(true)
})
it('opens the skills hub locally for bare /skills', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/skills')).toBe(true)
expect(getOverlayState().skillsHub).toBe(true)
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('routes /skills install <name> to skills.manage without opening overlay', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/skills install foo')).toBe(true)
expect(getOverlayState().skillsHub).toBe(false)
expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
action: 'install',
query: 'foo'
})
})
it('routes /skills inspect <name> to skills.manage', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/skills inspect my-skill')
expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
action: 'inspect',
query: 'my-skill'
})
})
it('routes /skills search <query> to skills.manage', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/skills search vibe')
expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
action: 'search',
query: 'vibe'
})
})
it('routes /skills browse [page] to skills.manage with a numeric page', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/skills browse 3')
expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
action: 'browse',
page: 3
})
})
it('shows usage for an unknown /skills subcommand', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/skills zzz')
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
expect(ctx.transcript.sys).toHaveBeenCalledWith(expect.stringContaining('usage: /skills'))
})
it('cycles details mode and persists it', async () => {
const ctx = buildCtx()

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js'
describe('splitReasoning', () => {
it('extracts <think>…</think> and strips it from text', () => {
const { reasoning, text } = splitReasoning('<think>plotting</think>\n\nhere is the answer')
expect(reasoning).toBe('plotting')
expect(text).toBe('here is the answer')
})
it('handles multiple tag shapes', () => {
const input = '<reasoning>a</reasoning> <THINKING>b</THINKING> <thought>c</thought> body'
const { reasoning, text } = splitReasoning(input)
expect(reasoning).toContain('a')
expect(reasoning).toContain('b')
expect(reasoning).toContain('c')
expect(text).toBe('body')
})
it('treats unclosed trailing <think>… as reasoning', () => {
const { reasoning, text } = splitReasoning('answer start <think>still deciding')
expect(reasoning).toBe('still deciding')
expect(text).toBe('answer start')
})
it('returns empty reasoning and untouched text when no tags present', () => {
const { reasoning, text } = splitReasoning('plain body with no tags')
expect(reasoning).toBe('')
expect(text).toBe('plain body with no tags')
})
it('preserves text when reasoning block is empty', () => {
const { reasoning, text } = splitReasoning('<think></think>only body')
expect(reasoning).toBe('')
expect(text).toBe('only body')
})
it('detects presence of any supported tag', () => {
expect(hasReasoningTag('pre <think>x</think> post')).toBe(true)
expect(hasReasoningTag('pre <reasoning>x</reasoning>')).toBe(true)
expect(hasReasoningTag('<REASONING_SCRATCHPAD>x</REASONING_SCRATCHPAD>')).toBe(true)
expect(hasReasoningTag('no tags at all')).toBe(false)
})
})

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { highlightLine, isHighlightable } from '../lib/syntax.js'
import { DEFAULT_THEME } from '../theme.js'
const t = DEFAULT_THEME
describe('syntax highlighter', () => {
it('recognizes supported langs and aliases', () => {
expect(isHighlightable('ts')).toBe(true)
expect(isHighlightable('js')).toBe(true)
expect(isHighlightable('python')).toBe(true)
expect(isHighlightable('rs')).toBe(true)
expect(isHighlightable('bash')).toBe(true)
expect(isHighlightable('whatever')).toBe(false)
expect(isHighlightable('')).toBe(false)
})
it('paints a whole-line comment dim', () => {
const tokens = highlightLine('// hello', 'ts', t)
expect(tokens).toEqual([[t.color.dim, '// hello']])
})
it('paints keywords, strings, and numbers in a ts line', () => {
const tokens = highlightLine(`const x = 'hi' + 42`, 'ts', t)
const colors = tokens.map(tok => tok[0])
expect(colors).toContain(t.color.bronze) // const
expect(colors).toContain(t.color.amber) // 'hi'
expect(colors).toContain(t.color.cornsilk) // 42
})
it('falls through unchanged for unknown langs', () => {
const tokens = highlightLine(`const x = 1`, 'zzz', t)
expect(tokens).toEqual([['', 'const x = 1']])
})
it('treats `#` as a python comment, not a selector', () => {
const tokens = highlightLine('# comment', 'py', t)
expect(tokens).toEqual([[t.color.dim, '# comment']])
})
})

View File

@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { $uiState, resetUiState } from '../app/uiStore.js'
import { applyDisplay } from '../app/useConfigSync.js'
describe('applyDisplay', () => {
beforeEach(() => {
resetUiState()
})
it('fans every display flag out to $uiState and the bell callback', () => {
const setBell = vi.fn()
applyDisplay(
{
config: {
display: {
bell_on_complete: true,
details_mode: 'expanded',
inline_diffs: false,
show_cost: true,
show_reasoning: true,
streaming: false,
tui_compact: true,
tui_statusbar: false
}
}
},
setBell
)
const s = $uiState.get()
expect(setBell).toHaveBeenCalledWith(true)
expect(s.compact).toBe(true)
expect(s.detailsMode).toBe('expanded')
expect(s.inlineDiffs).toBe(false)
expect(s.showCost).toBe(true)
expect(s.showReasoning).toBe(true)
expect(s.statusBar).toBe(false)
expect(s.streaming).toBe(false)
})
it('applies v1 parity defaults when display fields are missing', () => {
const setBell = vi.fn()
applyDisplay({ config: { display: {} } }, setBell)
const s = $uiState.get()
expect(setBell).toHaveBeenCalledWith(false)
expect(s.inlineDiffs).toBe(true)
expect(s.showCost).toBe(false)
expect(s.showReasoning).toBe(false)
expect(s.statusBar).toBe(true)
expect(s.streaming).toBe(true)
})
it('treats a null config like an empty display block', () => {
const setBell = vi.fn()
applyDisplay(null, setBell)
const s = $uiState.get()
expect(setBell).toHaveBeenCalledWith(false)
expect(s.inlineDiffs).toBe(true)
expect(s.streaming).toBe(true)
})
})

View File

@@ -266,7 +266,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
case 'tool.complete':
turnController.recordToolComplete(ev.payload.tool_id, ev.payload.name, ev.payload.error, ev.payload.summary)
if (ev.payload.inline_diff) {
if (ev.payload.inline_diff && getUiState().inlineDiffs) {
sys(ev.payload.inline_diff)
}

View File

@@ -0,0 +1,14 @@
import { atom } from 'nanostores'
export interface InputSelection {
clear: () => void
end: number
start: number
value: string
}
export const $inputSelection = atom<InputSelection | null>(null)
export const setInputSelection = (next: InputSelection | null) => $inputSelection.set(next)
export const getInputSelection = () => $inputSelection.get()

View File

@@ -57,6 +57,7 @@ export interface OverlayState {
pager: null | PagerState
picker: boolean
secret: null | SecretReq
skillsHub: boolean
sudo: null | SudoReq
}
@@ -78,9 +79,13 @@ export interface UiState {
compact: boolean
detailsMode: DetailsMode
info: null | SessionInfo
inlineDiffs: boolean
showCost: boolean
showReasoning: boolean
sid: null | string
status: string
statusBar: boolean
streaming: boolean
theme: Theme
usage: Usage
}
@@ -335,5 +340,6 @@ export interface AppOverlaysProps {
export interface PasteSnippet {
label: string
path?: string
text: string
}

View File

@@ -9,13 +9,16 @@ const buildOverlayState = (): OverlayState => ({
pager: null,
picker: false,
secret: null,
skillsHub: false,
sudo: null
})
export const $overlayState = atom<OverlayState>(buildOverlayState())
export const $isBlocked = computed($overlayState, ({ approval, clarify, modelPicker, pager, picker, secret, sudo }) =>
Boolean(approval || clarify || modelPicker || pager || picker || secret || sudo)
export const $isBlocked = computed(
$overlayState,
({ approval, clarify, modelPicker, pager, picker, secret, skillsHub, sudo }) =>
Boolean(approval || clarify || modelPicker || pager || picker || secret || skillsHub || sudo)
)
export const getOverlayState = () => $overlayState.get()

View File

@@ -1,7 +1,12 @@
import { dailyFortune, randomFortune } from '../../../content/fortunes.js'
import { HOTKEYS } from '../../../content/hotkeys.js'
import { nextDetailsMode, parseDetailsMode } from '../../../domain/details.js'
import type { ConfigGetValueResponse, ConfigSetResponse, SessionSteerResponse, SessionUndoResponse } from '../../../gatewayTypes.js'
import type {
ConfigGetValueResponse,
ConfigSetResponse,
SessionSteerResponse,
SessionUndoResponse
} from '../../../gatewayTypes.js'
import { writeOsc52Clipboard } from '../../../lib/osc52.js'
import type { DetailsMode, Msg, PanelSection } from '../../../types.js'
import { patchOverlayState } from '../../overlayStore.js'
@@ -259,19 +264,27 @@ export const coreCommands: SlashCommand[] = [
// message isn't lost — identical semantics to the gateway handler.
if (!ctx.ui.busy || !ctx.sid) {
ctx.composer.enqueue(payload)
ctx.transcript.sys(`no active turn — queued for next: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"`)
ctx.transcript.sys(
`no active turn — queued for next: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"`
)
return
}
ctx.gateway.rpc<SessionSteerResponse>('session.steer', { session_id: ctx.sid, text: payload }).then(
ctx.guarded<SessionSteerResponse>(r => {
if (r?.status === 'queued') {
ctx.transcript.sys(`⏩ steer queued — arrives after next tool call: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"`)
} else {
ctx.transcript.sys('steer rejected')
}
})
).catch(ctx.guardedErr)
ctx.gateway
.rpc<SessionSteerResponse>('session.steer', { session_id: ctx.sid, text: payload })
.then(
ctx.guarded<SessionSteerResponse>(r => {
if (r?.status === 'queued') {
ctx.transcript.sys(
`⏩ steer queued — arrives after next tool call: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"`
)
} else {
ctx.transcript.sys('steer rejected')
}
})
)
.catch(ctx.guardedErr)
}
},

View File

@@ -1,7 +1,209 @@
import type { ToolsConfigureResponse } from '../../../gatewayTypes.js'
import type { PanelSection } from '../../../types.js'
import { patchOverlayState } from '../../overlayStore.js'
import type { SlashCommand } from '../types.js'
interface SkillInfo {
category?: string
description?: string
name?: string
path?: string
}
interface SkillsListResponse {
skills?: Record<string, string[]>
}
interface SkillsInspectResponse {
info?: SkillInfo
}
interface SkillsSearchResponse {
results?: { description?: string; name: string }[]
}
interface SkillsInstallResponse {
installed?: boolean
name?: string
}
interface SkillsBrowseItem {
description?: string
name: string
source?: string
trust?: string
}
interface SkillsBrowseResponse {
items?: SkillsBrowseItem[]
page?: number
total?: number
total_pages?: number
}
export const opsCommands: SlashCommand[] = [
{
help: 'browse, inspect, install skills',
name: 'skills',
run: (arg, ctx) => {
const text = arg.trim()
if (!text) {
return patchOverlayState({ skillsHub: true })
}
const [sub, ...rest] = text.split(/\s+/)
const query = rest.join(' ').trim()
const { rpc } = ctx.gateway
const { page, panel, sys } = ctx.transcript
if (sub === 'list') {
rpc<SkillsListResponse>('skills.manage', { action: 'list' })
.then(
ctx.guarded<SkillsListResponse>(r => {
const cats = Object.entries(r.skills ?? {}).sort()
if (!cats.length) {
return sys('no skills available')
}
panel(
'Skills',
cats.map<PanelSection>(([title, items]) => ({ items, title }))
)
})
)
.catch(ctx.guardedErr)
return
}
if (sub === 'inspect') {
if (!query) {
return sys('usage: /skills inspect <name>')
}
rpc<SkillsInspectResponse>('skills.manage', { action: 'inspect', query })
.then(
ctx.guarded<SkillsInspectResponse>(r => {
const info = r.info ?? {}
if (!info.name) {
return sys(`unknown skill: ${query}`)
}
const rows: [string, string][] = [
['Name', String(info.name)],
['Category', String(info.category ?? '')],
['Path', String(info.path ?? '')]
]
const sections: PanelSection[] = [{ rows }]
if (info.description) {
sections.push({ text: String(info.description) })
}
panel('Skill', sections)
})
)
.catch(ctx.guardedErr)
return
}
if (sub === 'search') {
if (!query) {
return sys('usage: /skills search <query>')
}
rpc<SkillsSearchResponse>('skills.manage', { action: 'search', query })
.then(
ctx.guarded<SkillsSearchResponse>(r => {
const results = r.results ?? []
if (!results.length) {
return sys(`no results for: ${query}`)
}
panel(`Search: ${query}`, [{ rows: results.map(s => [s.name, s.description ?? '']) }])
})
)
.catch(ctx.guardedErr)
return
}
if (sub === 'install') {
if (!query) {
return sys('usage: /skills install <name or url>')
}
sys(`installing ${query}`)
rpc<SkillsInstallResponse>('skills.manage', { action: 'install', query })
.then(
ctx.guarded<SkillsInstallResponse>(r =>
sys(r.installed ? `installed ${r.name ?? query}` : 'install failed')
)
)
.catch(ctx.guardedErr)
return
}
if (sub === 'browse') {
const pageNum = query ? parseInt(query, 10) : 1
if (Number.isNaN(pageNum) || pageNum < 1) {
return sys('usage: /skills browse [page] (page must be a positive number)')
}
sys('fetching community skills (scans 6 sources, may take ~15s)…')
rpc<SkillsBrowseResponse>('skills.manage', { action: 'browse', page: pageNum })
.then(
ctx.guarded<SkillsBrowseResponse>(r => {
const items = r.items ?? []
if (!items.length) {
return sys(`no skills on page ${pageNum}${r.total ? ` (total ${r.total})` : ''}`)
}
const rows: [string, string][] = items.map(s => [
s.trust ? `${s.name} · ${s.trust}` : s.name,
String(s.description ?? '').slice(0, 160)
])
const footer: string[] = []
if (r.page && r.total_pages) {
footer.push(`page ${r.page} of ${r.total_pages}`)
}
if (r.total) {
footer.push(`${r.total} skills total`)
}
if (r.page && r.total_pages && r.page < r.total_pages) {
footer.push(`/skills browse ${r.page + 1} for more`)
}
panel(`Browse Skills${pageNum > 1 ? ` — p${pageNum}` : ''}`, [
{ rows },
...(footer.length ? [{ text: footer.join(' · ') }] : [])
])
})
)
.catch(ctx.guardedErr)
return
}
sys('usage: /skills [list | inspect <n> | install <n> | search <q> | browse [page]]')
}
},
{
help: 'enable or disable tools (client-side history reset on change)',
name: 'tools',

View File

@@ -6,9 +6,8 @@ import type { SlashCommand } from '../types.js'
export const setupCommands: SlashCommand[] = [
{
aliases: ['provider'],
help: 'configure LLM provider and model (launches `hermes model`)',
name: 'model',
help: 'configure LLM provider + model (launches `hermes model`)',
name: 'provider',
run: (_arg, ctx) =>
void runExternalSetup({
args: ['model'],

View File

@@ -1,5 +1,6 @@
import { REASONING_PULSE_MS, STREAM_BATCH_MS } from '../config/timing.js'
import type { SessionInterruptResponse, SubagentEventPayload } from '../gatewayTypes.js'
import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js'
import {
buildToolTrailLine,
estimateTokensRough,
@@ -11,7 +12,7 @@ import type { ActiveTool, ActivityItem, Msg, SubagentProgress } from '../types.j
import { resetOverlayState } from './overlayStore.js'
import { patchTurnState, resetTurnState } from './turnStore.js'
import { patchUiState } from './uiStore.js'
import { getUiState, patchUiState } from './uiStore.js'
const INTERRUPT_COOLDOWN_MS = 1500
const ACTIVITY_LIMIT = 8
@@ -121,18 +122,31 @@ class TurnController {
}
flushStreamingSegment() {
const text = this.bufRef.trimStart()
const raw = this.bufRef.trimStart()
if (!text) {
if (!raw) {
return
}
const tools = this.pendingSegmentTools
const split = hasReasoningTag(raw) ? splitReasoning(raw) : { reasoning: '', text: raw }
if (split.reasoning && !this.reasoningText.trim()) {
this.reasoningText = split.reasoning
patchTurnState({ reasoning: this.reasoningText, reasoningTokens: estimateTokensRough(this.reasoningText) })
}
const text = split.text
this.streamTimer = clear(this.streamTimer)
this.segmentMessages = [...this.segmentMessages, { role: 'assistant', text, ...(tools.length && { tools }) }]
if (text) {
const tools = this.pendingSegmentTools
this.segmentMessages = [...this.segmentMessages, { role: 'assistant', text, ...(tools.length && { tools }) }]
this.pendingSegmentTools = []
}
this.bufRef = ''
this.pendingSegmentTools = []
patchTurnState({ streamPendingTools: [], streamSegments: this.segmentMessages, streaming: '' })
}
@@ -187,8 +201,11 @@ class TurnController {
}
recordMessageComplete(payload: { rendered?: string; reasoning?: string; text?: string }) {
const finalText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart()
const savedReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
const rawText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart()
const split = splitReasoning(rawText)
const finalText = split.text
const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n')
const savedReasoningTokens = savedReasoning ? estimateTokensRough(savedReasoning) : 0
const savedToolTokens = this.toolTokenAcc
const tools = this.pendingSegmentTools
@@ -226,10 +243,17 @@ class TurnController {
}
this.bufRef = rendered ?? this.bufRef + text
this.scheduleStreaming()
if (getUiState().streaming) {
this.scheduleStreaming()
}
}
recordReasoningAvailable(text: string) {
if (!getUiState().showReasoning) {
return
}
const incoming = text.trim()
if (!incoming || this.reasoningText.trim()) {
@@ -242,6 +266,10 @@ class TurnController {
}
recordReasoningDelta(text: string) {
if (!getUiState().showReasoning) {
return
}
this.reasoningText += text
this.scheduleReasoning()
this.pulseReasoningStreaming()
@@ -344,7 +372,9 @@ class TurnController {
this.streamTimer = setTimeout(() => {
this.streamTimer = null
patchTurnState({ streaming: this.bufRef.trimStart() })
const raw = this.bufRef.trimStart()
const visible = hasReasoningTag(raw) ? splitReasoning(raw).text : raw
patchTurnState({ streaming: visible })
}, STREAM_BATCH_MS)
}

View File

@@ -11,9 +11,13 @@ const buildUiState = (): UiState => ({
compact: false,
detailsMode: 'collapsed',
info: null,
inlineDiffs: true,
showCost: false,
showReasoning: false,
sid: null,
status: 'summoning hermes…',
statusBar: true,
streaming: true,
theme: DEFAULT_THEME,
usage: ZERO
})

View File

@@ -70,12 +70,25 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose
setPasteSnips(prev => [...prev, { label, text: cleanedText }].slice(-32))
void gw
.request<{ path?: string }>('paste.collapse', { text: cleanedText })
.then(r => {
const path = r?.path
if (!path) {
return
}
setPasteSnips(prev => prev.map(s => (s.label === label ? { ...s, path } : s)))
})
.catch(() => {})
return {
cursor: cursor + insert.length,
value: value.slice(0, cursor) + insert + value.slice(cursor)
}
},
[onClipboardPaste]
[gw, onClipboardPaste]
)
const openEditor = useCallback(() => {

View File

@@ -27,14 +27,18 @@ const quietRpc = async <T extends Record<string, any> = Record<string, any>>(
}
}
const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolean) => void) => {
export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolean) => void) => {
const d = cfg?.config?.display ?? {}
setBell(!!d.bell_on_complete)
patchUiState({
compact: !!d.tui_compact,
detailsMode: resolveDetailsMode(d),
statusBar: d.tui_statusbar !== false
inlineDiffs: d.inline_diffs !== false,
showCost: !!d.show_cost,
showReasoning: !!d.show_reasoning,
statusBar: d.tui_statusbar !== false,
streaming: d.streaming !== false
})
}

View File

@@ -8,6 +8,9 @@ import type {
VoiceRecordResponse
} from '../gatewayTypes.js'
import { writeOsc52Clipboard } from '../lib/osc52.js'
import { getInputSelection } from './inputSelectionStore.js'
import type { InputHandlerContext, InputHandlerResult } from './interfaces.js'
import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js'
import { turnController } from './turnController.js'
@@ -63,6 +66,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
return patchOverlayState({ modelPicker: false })
}
if (overlay.skillsHub) {
return patchOverlayState({ skillsHub: false })
}
if (overlay.picker) {
return patchOverlayState({ picker: false })
}
@@ -243,6 +250,15 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
return copySelection()
}
const inputSel = getInputSelection()
if (inputSel && inputSel.end > inputSel.start) {
writeOsc52Clipboard(inputSel.value.slice(inputSel.start, inputSel.end))
inputSel.clear()
return
}
if (live.busy && live.sid) {
return turnController.interruptTurn({
appendMessage: actions.appendMessage,

View File

@@ -99,6 +99,7 @@ export function StatusRule({
usage,
bgCount,
sessionStartedAt,
showCost,
voiceLabel,
t
}: StatusRuleProps) {
@@ -136,6 +137,9 @@ export function StatusRule({
) : null}
{voiceLabel ? <Text color={t.color.dim}> {voiceLabel}</Text> : null}
{bgCount > 0 ? <Text color={t.color.dim}> {bgCount} bg</Text> : null}
{showCost && typeof usage.cost_usd === 'number' ? (
<Text color={t.color.dim}> ${usage.cost_usd.toFixed(4)}</Text>
) : null}
</Text>
</Box>
@@ -285,6 +289,7 @@ interface StatusRuleProps {
cwdLabel: string
model: string
sessionStartedAt?: number | null
showCost: boolean
status: string
statusColor: string
t: Theme

View File

@@ -190,6 +190,7 @@ const ComposerPane = memo(function ComposerPane({
cwdLabel={status.cwdLabel}
model={ui.info?.model?.split('/').pop() ?? ''}
sessionStartedAt={status.sessionStartedAt}
showCost={ui.showCost}
status={ui.status}
statusColor={status.statusColor}
t={ui.theme}

View File

@@ -11,6 +11,7 @@ import { MaskedPrompt } from './maskedPrompt.js'
import { ModelPicker } from './modelPicker.js'
import { ApprovalPrompt, ClarifyPrompt } from './prompts.js'
import { SessionPicker } from './sessionPicker.js'
import { SkillsHub } from './skillsHub.js'
export function PromptZone({
cols,
@@ -82,7 +83,7 @@ export function FloatingOverlays({
const overlay = useStore($overlayState)
const ui = useStore($uiState)
const hasAny = overlay.modelPicker || overlay.pager || overlay.picker || completions.length
const hasAny = overlay.modelPicker || overlay.pager || overlay.picker || overlay.skillsHub || completions.length
if (!hasAny) {
return null
@@ -115,6 +116,12 @@ export function FloatingOverlays({
</FloatBox>
)}
{overlay.skillsHub && (
<FloatBox color={ui.theme.color.bronze}>
<SkillsHub gw={gw} onClose={() => patchOverlayState({ skillsHub: false })} t={ui.theme} />
</FloatBox>
)}
{overlay.pager && (
<FloatBox color={ui.theme.color.bronze}>
<Box flexDirection="column" paddingX={1} paddingY={1}>

View File

@@ -126,11 +126,36 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
{section('Tools', info.tools, 8, 'more toolsets…')}
{section('Skills', info.skills)}
{info.mcp_servers && info.mcp_servers.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold color={t.color.amber}>
MCP Servers
</Text>
{info.mcp_servers.map(s => (
<Text key={s.name} wrap="truncate">
<Text color={t.color.dim}>{` ${s.name} `}</Text>
<Text color={t.color.dim}>{`[${s.transport}]`}</Text>
<Text color={t.color.dim}>: </Text>
{s.connected ? (
<Text color={t.color.cornsilk}>
{s.tools} tool{s.tools === 1 ? '' : 's'}
</Text>
) : (
<Text color={t.color.error}>failed</Text>
)}
</Text>
))}
</Box>
)}
<Text />
<Text color={t.color.cornsilk}>
{flat(info.tools).length} tools{' · '}
{flat(info.skills).length} skills
{info.mcp_servers?.length ? ` · ${info.mcp_servers.length} MCP` : ''}
{' · '}
<Text color={t.color.dim}>/help for commands</Text>
</Text>

View File

@@ -1,6 +1,7 @@
import { Box, Link, Text } from '@hermes/ink'
import { memo, type ReactNode, useMemo } from 'react'
import { highlightLine, isHighlightable } from '../lib/syntax.js'
import type { Theme } from '../theme.js'
const FENCE_RE = /^\s*(`{3,}|~{3,})(.*)$/
@@ -39,13 +40,17 @@ const trimBareUrl = (value: string) => {
}
}
const renderAutolink = (key: number, t: Theme, raw: string) => (
<Link key={key} url={raw}>
<Text color={t.color.amber} underline>
{raw.replace(/^mailto:/, '')}
</Text>
</Link>
)
const renderAutolink = (key: number, t: Theme, raw: string) => {
const url = raw.startsWith('mailto:') ? raw : raw.includes('@') && !raw.startsWith('http') ? `mailto:${raw}` : raw
return (
<Link key={key} url={url}>
<Text color={t.color.amber} underline>
{raw.replace(/^mailto:/, '')}
</Text>
</Link>
)
}
const indentDepth = (indent: string) => Math.floor(indent.replace(/\t/g, ' ').length / 2)
@@ -286,11 +291,28 @@ function MdImpl({ compact, t, text }: MdProps) {
start('code')
const isDiff = lang === 'diff'
const highlighted = !isDiff && isHighlightable(lang)
nodes.push(
<Box flexDirection="column" key={key} paddingLeft={2}>
{lang && !isDiff && <Text color={t.color.dim}>{'─ ' + lang}</Text>}
{block.map((l, j) => {
if (highlighted) {
return (
<Text key={j}>
{highlightLine(l, lang, t).map(([color, text], k) =>
color ? (
<Text color={color} key={k}>
{text}
</Text>
) : (
<Text key={k}>{text}</Text>
)
)}
</Text>
)
}
const add = isDiff && l.startsWith('+')
const del = isDiff && l.startsWith('-')
const hunk = isDiff && l.startsWith('@@')

View File

@@ -35,7 +35,9 @@ export const MessageLine = memo(function MessageLine({
return (
<Box alignSelf="flex-start" borderColor={t.color.dim} borderStyle="round" marginLeft={3} paddingX={1}>
{hasAnsi(msg.text) ? (
<Text wrap="truncate-end"><Ansi>{msg.text}</Ansi></Text>
<Text wrap="truncate-end">
<Ansi>{msg.text}</Ansi>
</Text>
) : (
<Text color={t.color.dim} wrap="truncate-end">
{preview}

View File

@@ -8,6 +8,7 @@ import { TextInput } from './textInput.js'
const OPTS = ['once', 'session', 'always', 'deny'] as const
const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const
const CMD_PREVIEW_LINES = 10
export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
const [sel, setSel] = useState(0)
@@ -34,13 +35,30 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
}
})
const rawLines = req.command.split('\n')
const shown = rawLines.slice(0, CMD_PREVIEW_LINES)
const overflow = rawLines.length - shown.length
return (
<Box borderColor={t.color.warn} borderStyle="double" flexDirection="column" paddingX={1}>
<Text bold color={t.color.warn}>
approval required · {req.description}
</Text>
<Text color={t.color.cornsilk}> {req.command}</Text>
<Box flexDirection="column" paddingLeft={1}>
{shown.map((line, i) => (
<Text color={t.color.cornsilk} key={i} wrap="truncate-end">
{line || ' '}
</Text>
))}
{overflow > 0 ? (
<Text color={t.color.dim}>
+{overflow} more line{overflow === 1 ? '' : 's'} (full text above)
</Text>
) : null}
</Box>
<Text />
{OPTS.map((o, i) => (

View File

@@ -0,0 +1,296 @@
import { Box, Text, useInput } from '@hermes/ink'
import { useEffect, useState } from 'react'
import type { GatewayClient } from '../gatewayClient.js'
import { rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
const VISIBLE = 12
const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE))
const visibleItems = (items: string[], sel: number) => {
const off = pageOffset(items.length, sel)
return { items: items.slice(off, off + VISIBLE), off }
}
export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
const [skillsByCat, setSkillsByCat] = useState<Record<string, string[]>>({})
const [selectedCat, setSelectedCat] = useState('')
const [catIdx, setCatIdx] = useState(0)
const [skillIdx, setSkillIdx] = useState(0)
const [stage, setStage] = useState<'actions' | 'category' | 'skill'>('category')
const [info, setInfo] = useState<null | SkillInfo>(null)
const [installing, setInstalling] = useState(false)
const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
gw.request<{ skills?: Record<string, string[]> }>('skills.manage', { action: 'list' })
.then(r => {
setSkillsByCat(r?.skills ?? {})
setErr('')
setLoading(false)
})
.catch((e: unknown) => {
setErr(rpcErrorMessage(e))
setLoading(false)
})
}, [gw])
const cats = Object.keys(skillsByCat).sort()
const skills = selectedCat ? (skillsByCat[selectedCat] ?? []) : []
const skillName = skills[skillIdx] ?? ''
const inspect = (name: string) => {
setInfo(null)
setErr('')
gw.request<{ info?: SkillInfo }>('skills.manage', { action: 'inspect', query: name })
.then(r => setInfo(r?.info ?? { name }))
.catch((e: unknown) => setErr(rpcErrorMessage(e)))
}
const install = (name: string) => {
setInstalling(true)
setErr('')
gw.request<{ installed?: boolean; name?: string }>('skills.manage', { action: 'install', query: name })
.then(() => onClose())
.catch((e: unknown) => setErr(rpcErrorMessage(e)))
.finally(() => setInstalling(false))
}
useInput((ch, key) => {
if (installing) {
return
}
if (key.escape) {
if (stage === 'actions') {
setStage('skill')
setInfo(null)
setErr('')
return
}
if (stage === 'skill') {
setStage('category')
setSkillIdx(0)
return
}
onClose()
return
}
if (stage === 'actions') {
if (key.return) {
setStage('skill')
setInfo(null)
setErr('')
return
}
if (ch.toLowerCase() === 'x' && skillName) {
install(skillName)
return
}
if (ch.toLowerCase() === 'i' && skillName) {
inspect(skillName)
}
return
}
const count = stage === 'category' ? cats.length : skills.length
const sel = stage === 'category' ? catIdx : skillIdx
const setSel = stage === 'category' ? setCatIdx : setSkillIdx
if (key.upArrow && sel > 0) {
setSel(v => v - 1)
return
}
if (key.downArrow && sel < count - 1) {
setSel(v => v + 1)
return
}
if (key.return) {
if (stage === 'category') {
const cat = cats[catIdx]
if (!cat) {
return
}
setSelectedCat(cat)
setSkillIdx(0)
setStage('skill')
return
}
const name = skills[skillIdx]
if (name) {
setStage('actions')
inspect(name)
}
return
}
const n = ch === '0' ? 10 : parseInt(ch, 10)
if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, count)) {
const off = pageOffset(count, sel)
const next = off + n - 1
if (stage === 'category') {
const cat = cats[next]
if (cat) {
setSelectedCat(cat)
setCatIdx(next)
setSkillIdx(0)
setStage('skill')
}
return
}
const name = skills[next]
if (name) {
setSkillIdx(next)
setStage('actions')
inspect(name)
}
}
})
if (loading) {
return <Text color={t.color.dim}>loading skills</Text>
}
if (err && stage === 'category') {
return (
<Box flexDirection="column">
<Text color={t.color.label}>error: {err}</Text>
<Text color={t.color.dim}>Esc to cancel</Text>
</Box>
)
}
if (!cats.length) {
return (
<Box flexDirection="column">
<Text color={t.color.dim}>no skills available</Text>
<Text color={t.color.dim}>Esc to cancel</Text>
</Box>
)
}
if (stage === 'category') {
const rows = cats.map(c => `${c} · ${skillsByCat[c]?.length ?? 0} skills`)
const { items, off } = visibleItems(rows, catIdx)
return (
<Box flexDirection="column">
<Text bold color={t.color.amber}>
Skills Hub
</Text>
<Text color={t.color.dim}>select a category</Text>
{off > 0 && <Text color={t.color.dim}> {off} more</Text>}
{items.map((row, i) => {
const idx = off + i
return (
<Text color={catIdx === idx ? t.color.cornsilk : t.color.dim} key={row}>
{catIdx === idx ? '▸ ' : ' '}
{i + 1}. {row}
</Text>
)
})}
{off + VISIBLE < rows.length && <Text color={t.color.dim}> {rows.length - off - VISIBLE} more</Text>}
<Text color={t.color.dim}>/ select · Enter open · 1-9,0 quick · Esc cancel</Text>
</Box>
)
}
if (stage === 'skill') {
const { items, off } = visibleItems(skills, skillIdx)
return (
<Box flexDirection="column">
<Text bold color={t.color.amber}>
{selectedCat}
</Text>
<Text color={t.color.dim}>{skills.length} skill(s)</Text>
{!skills.length ? <Text color={t.color.dim}>no skills in this category</Text> : null}
{off > 0 && <Text color={t.color.dim}> {off} more</Text>}
{items.map((row, i) => {
const idx = off + i
return (
<Text color={skillIdx === idx ? t.color.cornsilk : t.color.dim} key={row}>
{skillIdx === idx ? '▸ ' : ' '}
{i + 1}. {row}
</Text>
)
})}
{off + VISIBLE < skills.length && <Text color={t.color.dim}> {skills.length - off - VISIBLE} more</Text>}
<Text color={t.color.dim}>
{skills.length ? '↑/↓ select · Enter open · 1-9,0 quick · Esc back' : 'Esc back'}
</Text>
</Box>
)
}
return (
<Box flexDirection="column">
<Text bold color={t.color.amber}>
{info?.name ?? skillName}
</Text>
<Text color={t.color.dim}>{info?.category ?? selectedCat}</Text>
{info?.description ? <Text color={t.color.cornsilk}>{info.description}</Text> : null}
{info?.path ? <Text color={t.color.dim}>path: {info.path}</Text> : null}
{!info && !err ? <Text color={t.color.dim}>loading</Text> : null}
{err ? <Text color={t.color.label}>error: {err}</Text> : null}
{installing ? <Text color={t.color.amber}>installing</Text> : null}
<Text color={t.color.dim}>i reinspect · x reinstall · Enter/Esc back</Text>
</Box>
)
}
interface SkillInfo {
category?: string
description?: string
name?: string
path?: string
}
interface SkillsHubProps {
gw: GatewayClient
onClose: () => void
t: Theme
}

View File

@@ -2,6 +2,8 @@ import type { InputEvent, Key } from '@hermes/ink'
import * as Ink from '@hermes/ink'
import { useEffect, useMemo, useRef, useState } from 'react'
import { setInputSelection } from '../app/inputSelectionStore.js'
type InkExt = typeof Ink & {
stringWidth: (s: string) => number
useDeclaredCursor: (a: { line: number; column: number; active: boolean }) => (el: any) => void
@@ -351,6 +353,28 @@ export function TextInput({
}
}, [value])
useEffect(() => {
if (!focus) {
return
}
if (selected) {
setInputSelection({
clear: () => {
selRef.current = null
setSel(null)
},
end: selected.end,
start: selected.start,
value: vRef.current
})
} else {
setInputSelection(null)
}
return () => setInputSelection(null)
}, [focus, selected])
useEffect(
() => () => {
if (pasteTimer.current) {
@@ -464,7 +488,7 @@ export function TextInput({
(inp: string, k: Key, event: InputEvent) => {
const eventRaw = event.keypress.raw
if (eventRaw === '\x1bv' || eventRaw === '\x1bV') {
if (eventRaw === '\x1bv' || eventRaw === '\x1bV' || eventRaw === '\x16') {
return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
}

View File

@@ -54,6 +54,10 @@ export type CommandDispatchResponse =
export interface ConfigDisplayConfig {
bell_on_complete?: boolean
details_mode?: string
inline_diffs?: boolean
show_cost?: boolean
show_reasoning?: boolean
streaming?: boolean
thinking_mode?: string
tui_compact?: boolean
tui_statusbar?: boolean

View File

@@ -0,0 +1,50 @@
const TAGS = ['think', 'reasoning', 'thinking', 'thought', 'REASONING_SCRATCHPAD'] as const
export interface SplitReasoning {
reasoning: string
text: string
}
export function splitReasoning(input: string): SplitReasoning {
let text = input
const reasoning: string[] = []
for (const tag of TAGS) {
const paired = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>\\s*`, 'gi')
text = text.replace(paired, (_m, inner: string) => {
const trimmed = inner.trim()
if (trimmed) {
reasoning.push(trimmed)
}
return ''
})
const unclosed = new RegExp(`<${tag}>([\\s\\S]*)$`, 'i')
text = text.replace(unclosed, (_m, inner: string) => {
const trimmed = inner.trim()
if (trimmed) {
reasoning.push(trimmed)
}
return ''
})
}
return {
reasoning: reasoning.join('\n\n').trim(),
text: text.trim()
}
}
export const hasReasoningTag = (input: string) => {
for (const tag of TAGS) {
if (input.includes(`<${tag}>`)) {
return true
}
}
return false
}

117
ui-tui/src/lib/syntax.ts Normal file
View File

@@ -0,0 +1,117 @@
import type { Theme } from '../theme.js'
export type Token = [string, string]
interface LangSpec {
comment: null | string
keywords: Set<string>
}
const KW = (s: string) => new Set(s.split(/\s+/).filter(Boolean))
const TS = KW(`
abstract as async await break case catch class const continue debugger default delete do else enum export extends
false finally for from function get if implements import in instanceof interface is let new null of package private
protected public readonly return set static super switch this throw true try type typeof undefined var void while
with yield
`)
const PY = KW(`
False None True and as assert async await break class continue def del elif else except finally for from global if
import in is lambda nonlocal not or pass raise return try while with yield
`)
const SH = KW(`
if then else elif fi for in do done while until case esac function return break continue local export readonly
declare typeset
`)
const GO = KW(`
break case chan const continue default defer else fallthrough for func go goto if import interface map package range
return select struct switch type var nil true false
`)
const RUST = KW(`
as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move mut
pub ref return self Self static struct super trait true type unsafe use where while yield
`)
const SQL = KW(`
select from where and or not in is null as by group order limit offset insert into values update set delete create
table drop alter add column primary key foreign references join left right inner outer on
`)
const LANGS: Record<string, LangSpec> = {
go: { comment: '//', keywords: GO },
json: { comment: null, keywords: KW('true false null') },
py: { comment: '#', keywords: PY },
rust: { comment: '//', keywords: RUST },
sh: { comment: '#', keywords: SH },
sql: { comment: '--', keywords: SQL },
ts: { comment: '//', keywords: TS },
yaml: { comment: '#', keywords: KW('true false null yes no on off') }
}
const ALIAS: Record<string, string> = {
bash: 'sh',
javascript: 'ts',
js: 'ts',
jsx: 'ts',
python: 'py',
rs: 'rust',
shell: 'sh',
tsx: 'ts',
typescript: 'ts',
yml: 'yaml',
zsh: 'sh'
}
const resolve = (lang: string): LangSpec | null => LANGS[ALIAS[lang] ?? lang] ?? null
export const isHighlightable = (lang: string): boolean => resolve(lang) !== null
const TOKEN_RE = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`|\b\d+(?:\.\d+)?\b|[A-Za-z_$][\w$]*/g
export function highlightLine(line: string, lang: string, t: Theme): Token[] {
const spec = resolve(lang)
if (!spec) {
return [['', line]]
}
if (spec.comment && line.trimStart().startsWith(spec.comment)) {
return [[t.color.dim, line]]
}
const tokens: Token[] = []
let last = 0
for (const m of line.matchAll(TOKEN_RE)) {
const start = m.index ?? 0
if (start > last) {
tokens.push(['', line.slice(last, start)])
}
const tok = m[0]
const ch = tok[0]!
if (ch === '"' || ch === "'" || ch === '`') {
tokens.push([t.color.amber, tok])
} else if (ch >= '0' && ch <= '9') {
tokens.push([t.color.cornsilk, tok])
} else if (spec.keywords.has(tok)) {
tokens.push([t.color.bronze, tok])
} else {
tokens.push(['', tok])
}
last = start + tok.length
}
if (last < line.length) {
tokens.push(['', line.slice(last)])
}
return tokens
}

View File

@@ -51,8 +51,16 @@ export type Role = 'assistant' | 'system' | 'tool' | 'user'
export type DetailsMode = 'hidden' | 'collapsed' | 'expanded'
export type ThinkingMode = 'collapsed' | 'truncated' | 'full'
export interface McpServerStatus {
connected: boolean
name: string
tools: number
transport: string
}
export interface SessionInfo {
cwd?: string
mcp_servers?: McpServerStatus[]
model: string
release_date?: string
skills: Record<string, string[]>
@@ -68,6 +76,7 @@ export interface Usage {
context_max?: number
context_percent?: number
context_used?: number
cost_usd?: number
input: number
output: number
total: number

View File

@@ -63,7 +63,11 @@ declare module '@hermes/ink' {
export const Box: React.ComponentType<any>
export const AlternateScreen: React.ComponentType<any>
export const Ansi: React.ComponentType<any>
export const Link: React.ComponentType<{ readonly url: string; readonly children?: React.ReactNode; readonly fallback?: React.ReactNode }>
export const Link: React.ComponentType<{
readonly children?: React.ReactNode
readonly fallback?: React.ReactNode
readonly url: string
}>
export const NoSelect: React.ComponentType<any>
export const ScrollBox: React.ComponentType<any>
export const Text: React.ComponentType<any>