Merge branch 'feat/ink-refactor' of github.com:NousResearch/hermes-agent into feat/ink-refactor

This commit is contained in:
Brooklyn Nicholson 2026-04-11 14:05:17 -05:00
commit acbf1794f2
5 changed files with 380 additions and 146 deletions

View file

@ -1824,6 +1824,109 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"plugins": []})
@method("config.show")
def _(rid, params: dict) -> dict:
try:
cfg = _load_cfg()
model = _resolve_model()
api_key = os.environ.get("HERMES_API_KEY", "") or cfg.get("api_key", "")
masked = f"****{api_key[-4:]}" if len(api_key) > 4 else "(not set)"
base_url = os.environ.get("HERMES_BASE_URL", "") or cfg.get("base_url", "")
sections = [{
"title": "Model",
"rows": [
["Model", model],
["Base URL", base_url or "(default)"],
["API Key", masked],
]
}, {
"title": "Agent",
"rows": [
["Max Turns", str(cfg.get("max_turns", 25))],
["Toolsets", ", ".join(cfg.get("enabled_toolsets", [])) or "all"],
["Verbose", str(cfg.get("verbose", False))],
]
}, {
"title": "Environment",
"rows": [
["Working Dir", os.getcwd()],
["Config File", str(_hermes_home / "config.yaml")],
]
}]
return _ok(rid, {"sections": sections})
except Exception as e:
return _err(rid, 5030, str(e))
@method("tools.list")
def _(rid, params: dict) -> dict:
try:
from toolsets import get_all_toolsets, get_toolset_info
session = _sessions.get(params.get("session_id", ""))
enabled = set()
if session:
enabled = set(getattr(session["agent"], "enabled_toolsets", []) or [])
items = []
for name in sorted(get_all_toolsets().keys()):
info = get_toolset_info(name)
if not info:
continue
items.append({
"name": name,
"description": info["description"],
"tool_count": info["tool_count"],
"enabled": name in enabled if enabled else True,
"tools": info["resolved_tools"],
})
return _ok(rid, {"toolsets": items})
except Exception as e:
return _err(rid, 5031, str(e))
@method("toolsets.list")
def _(rid, params: dict) -> dict:
try:
from toolsets import get_all_toolsets, get_toolset_info
session = _sessions.get(params.get("session_id", ""))
enabled = set()
if session:
enabled = set(getattr(session["agent"], "enabled_toolsets", []) or [])
items = []
for name in sorted(get_all_toolsets().keys()):
info = get_toolset_info(name)
if not info:
continue
items.append({
"name": name,
"description": info["description"],
"tool_count": info["tool_count"],
"enabled": name in enabled if enabled else True,
})
return _ok(rid, {"toolsets": items})
except Exception as e:
return _err(rid, 5032, str(e))
@method("agents.list")
def _(rid, params: dict) -> dict:
try:
from tools.process_registry import ProcessRegistry
procs = ProcessRegistry().list_sessions()
return _ok(rid, {
"processes": [{
"session_id": p["session_id"],
"command": p["command"][:80],
"status": p["status"],
"uptime": p["uptime_seconds"],
} for p in procs]
})
except Exception as e:
return _err(rid, 5033, str(e))
@method("cron.manage")
def _(rid, params: dict) -> dict:
action, jid = params.get("action", "list"), params.get("name", "")

View file

@ -88,6 +88,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@ -317,29 +318,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@ -1464,6 +1442,7 @@
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.19.0"
}
@ -1474,6 +1453,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -1484,6 +1464,7 @@
"integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.58.1",
@ -1513,6 +1494,7 @@
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.58.1",
"@typescript-eslint/types": "8.58.1",
@ -1830,6 +1812,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -2165,6 +2148,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@ -2850,6 +2834,7 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -3745,6 +3730,7 @@
"resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz",
"integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==",
"license": "MIT",
"peer": true,
"dependencies": {
"chalk": "^5.3.0",
"type-fest": "^4.18.2"
@ -5085,6 +5071,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -5184,6 +5171,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -5956,6 +5944,7 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@ -6082,6 +6071,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -6191,6 +6181,7 @@
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@ -6599,6 +6590,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View file

@ -6,7 +6,7 @@ import { join } from 'node:path'
import { Box, Text, useApp, useInput, useStdout } from '@hermes/ink'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Banner, SessionPanel } from './components/branding.js'
import { Banner, Panel, SessionPanel } from './components/branding.js'
import { MaskedPrompt } from './components/maskedPrompt.js'
import { MessageLine } from './components/messageLine.js'
import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js'
@ -36,6 +36,7 @@ import type {
ApprovalReq,
ClarifyReq,
Msg,
PanelSection,
PasteMode,
PendingPaste,
SecretReq,
@ -343,7 +344,7 @@ export function App({ gw }: { gw: GatewayClient }) {
const [turnTrail, setTurnTrail] = useState<string[]>([])
const [bgTasks, setBgTasks] = useState<Set<string>>(new Set())
const [catalog, setCatalog] = useState<SlashCatalog | null>(null)
const [pager, setPager] = useState<{ lines: string[]; offset: number } | null>(null)
const [pager, setPager] = useState<{ lines: string[]; offset: number; title?: string } | null>(null)
const [voiceEnabled, setVoiceEnabled] = useState(false)
const [voiceRecording, setVoiceRecording] = useState(false)
const [voiceProcessing, setVoiceProcessing] = useState(false)
@ -426,11 +427,18 @@ export function App({ gw }: { gw: GatewayClient }) {
const sys = useCallback((text: string) => appendMessage({ role: 'system' as const, text }), [appendMessage])
const page = useCallback((text: string) => {
const page = useCallback((text: string, title?: string) => {
const lines = text.split('\n')
setPager({ lines, offset: 0 })
setPager({ lines, offset: 0, title })
}, [])
const panel = useCallback(
(title: string, sections: PanelSection[]) => {
appendMessage({ role: 'system', text: '', kind: 'panel', panelData: { title, sections } })
},
[appendMessage]
)
const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) => {
setActivity(prev => {
const base = replaceLabel ? prev.filter(a => !sameToolTrailGroup(replaceLabel, a.text)) : prev
@ -1504,37 +1512,18 @@ export function App({ gw }: { gw: GatewayClient }) {
switch (name) {
case 'help': {
const cats = catalog?.categories ?? []
const skills = catalog?.skillCount ?? 0
const lines: string[] = []
const sections: PanelSection[] = (catalog?.categories ?? []).map(({ name: catName, pairs }) => ({
title: catName,
rows: pairs
}))
for (const { name: catName, pairs } of cats) {
if (lines.length) {
lines.push('')
if (catalog?.skillCount) {
sections.push({ text: `${catalog.skillCount} skill commands available — /skills to browse` })
}
lines.push(` ${catName}:`)
sections.push({ title: 'Hotkeys', rows: HOTKEYS })
for (const [c, d] of pairs) {
lines.push(` ${c.padEnd(18)} ${d}`)
}
}
if (!lines.length) {
lines.push(' (no commands loaded)')
}
if (skills > 0) {
lines.push('', ` ${skills} skill commands available — /skills to browse`)
}
lines.push('', ' Hotkeys:')
for (const [k, d] of HOTKEYS) {
lines.push(` ${k.padEnd(14)} ${d}`)
}
sys(lines.join('\n'))
panel('Commands', sections)
return true
}
@ -1598,16 +1587,16 @@ export function App({ gw }: { gw: GatewayClient }) {
}
if (arg === 'list') {
sys(
pastes.length
? pastes
.map(
p =>
`#${p.id} ${p.mode} · ${p.lineCount}L · ${p.kind} · ${compactPreview(p.text, 60) || '(empty)'}`
)
.join('\n')
: 'no text pastes'
)
if (!pastes.length) {
sys('no text pastes')
} else {
panel('Paste Shelf', [{
rows: pastes.map(p => [
`#${p.id} ${p.mode}`,
`${p.lineCount}L · ${p.kind} · ${compactPreview(p.text, 60) || '(empty)'}`
] as [string, string])
}])
}
return true
}
@ -1660,10 +1649,12 @@ export function App({ gw }: { gw: GatewayClient }) {
return true
case 'logs':
sys(gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20))) || 'no gateway logs')
case 'logs': {
const logText = gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20)))
logText ? page(logText, 'Logs') : sys('no gateway logs')
return true
}
case 'statusbar':
@ -1769,7 +1760,9 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'model':
if (!arg) {
rpc('config.get', { key: 'provider' }).then((r: any) => sys(`${r.model} (${r.provider})`))
rpc('config.get', { key: 'provider' }).then((r: any) =>
panel('Model', [{ rows: [['Model', r.model], ['Provider', r.provider]] }])
)
} else {
rpc('config.set', { session_id: sid, key: 'model', value: arg.replace('--global', '').trim() }).then(
(r: any) => {
@ -1798,7 +1791,7 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'provider':
gw.request('slash.exec', { command: 'provider', session_id: sid })
.then((r: any) => page(r?.output || '(no output)'))
.then((r: any) => page(r?.output || '(no output)', 'Provider'))
.catch(() => sys('provider command failed'))
return true
@ -1840,7 +1833,7 @@ export function App({ gw }: { gw: GatewayClient }) {
)
} else {
gw.request('slash.exec', { command: 'personality', session_id: sid })
.then((r: any) => sys(r?.output || '(no output)'))
.then((r: any) => panel('Personality', [{ text: r?.output || '(no output)' }]))
.catch(() => sys('personality command failed'))
}
@ -1900,30 +1893,30 @@ export function App({ gw }: { gw: GatewayClient }) {
}
const f = (v: number) => (v ?? 0).toLocaleString()
const ln = (k: string, v: string) => ` ${k.padEnd(26)}${v.padStart(10)}`
const hr = ` ${'─'.repeat(36)}`
const cost =
r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null
sys(
[
hr,
ln('Model:', r.model ?? ''),
ln('Input tokens:', f(r.input)),
ln('Cache read tokens:', f(r.cache_read)),
ln('Cache write tokens:', f(r.cache_write)),
ln('Output tokens:', f(r.output)),
ln('Total tokens:', f(r.total)),
ln('API calls:', f(r.calls)),
cost && ln('Cost:', cost),
hr,
r.context_max && ` Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)`,
r.compressions && ` Compressions: ${r.compressions}`
const rows: [string, string][] = [
['Model', r.model ?? ''],
['Input tokens', f(r.input)],
['Cache read tokens', f(r.cache_read)],
['Cache write tokens', f(r.cache_write)],
['Output tokens', f(r.output)],
['Total tokens', f(r.total)],
['API calls', f(r.calls)]
]
.filter(Boolean)
.join('\n')
)
if (cost) rows.push(['Cost', cost])
const sections: PanelSection[] = [{ rows }]
if (r.context_max) {
sections.push({ text: `Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)` })
}
if (r.compressions) sections.push({ text: `Compressions: ${r.compressions}` })
panel('Usage', sections)
})
return true
@ -1939,7 +1932,16 @@ export function App({ gw }: { gw: GatewayClient }) {
return true
case 'profile':
rpc('config.get', { key: 'profile' }).then((r: any) => sys(r.display || r.home))
rpc('config.get', { key: 'profile' }).then((r: any) => {
const text = r.display || r.home
const lines = text.split('\n').filter(Boolean)
if (lines.length <= 2) {
panel('Profile', [{ text }])
} else {
page(text, 'Profile')
}
})
return true
@ -1957,7 +1959,13 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'insights':
rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) =>
sys(`${r.days}d: ${r.sessions} sessions, ${r.messages} messages`)
panel('Insights', [{
rows: [
['Period', `${r.days} days`],
['Sessions', `${r.sessions}`],
['Messages', `${r.messages}`]
]
}])
)
return true
@ -1970,7 +1978,12 @@ export function App({ gw }: { gw: GatewayClient }) {
return sys('no checkpoints')
}
sys(r.checkpoints.map((c: any, i: number) => ` ${i + 1} ${c.hash?.slice(0, 8)} ${c.message}`).join('\n'))
panel('Checkpoints', [{
rows: r.checkpoints.map((c: any, i: number) => [
`${i + 1} ${c.hash?.slice(0, 8)}`,
c.message
] as [string, string])
}])
})
} else {
const hash = sub === 'restore' || sub === 'diff' ? rArgs[0] : sub
@ -2003,7 +2016,9 @@ export function App({ gw }: { gw: GatewayClient }) {
return sys('no plugins')
}
sys(r.plugins.map((p: any) => ` ${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`).join('\n'))
panel('Plugins', [{
items: r.plugins.map((p: any) => `${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`)
}])
})
return true
@ -2018,43 +2033,31 @@ export function App({ gw }: { gw: GatewayClient }) {
return sys('no skills installed')
}
const lines: string[] = []
for (const [cat, names] of Object.entries(sk)) {
lines.push(` ${cat}: ${(names as string[]).join(', ')}`)
}
sys(lines.join('\n'))
panel('Installed Skills', Object.entries(sk).map(([cat, names]) => ({
title: cat,
items: names as string[]
})))
})
return true
}
if (sub === 'browse') {
const page = parseInt(sArgs[0] ?? '1', 10) || 1
rpc('skills.manage', { action: 'browse', page }).then((r: any) => {
if (!r.items?.length) {
return sys('no skills found in the hub')
}
const pg = parseInt(sArgs[0] ?? '1', 10) || 1
rpc('skills.manage', { action: 'browse', page: pg }).then((r: any) => {
if (!r.items?.length) return sys('no skills found in the hub')
const lines = [
` Skills Hub (page ${r.page}/${r.total_pages}, ${r.total} total)`,
'',
...r.items.map(
(s: any) =>
` ${(s.name ?? '').padEnd(28)} ${(s.description ?? '').slice(0, 60)}${s.description?.length > 60 ? '…' : ''}`
)
]
const sections: PanelSection[] = [{
rows: r.items.map((s: any) => [
s.name ?? '',
(s.description ?? '').slice(0, 60) + (s.description?.length > 60 ? '…' : '')
] as [string, string])
}]
if (r.page < r.total_pages) {
lines.push('', ` /skills browse ${r.page + 1} → next page`)
}
if (r.page < r.total_pages) sections.push({ text: `/skills browse ${r.page + 1} → next page` })
if (r.page > 1) sections.push({ text: `/skills browse ${r.page - 1} → prev page` })
if (r.page > 1) {
lines.push(` /skills browse ${r.page - 1} → prev page`)
}
sys(lines.join('\n'))
panel(`Skills Hub (page ${r.page}/${r.total_pages}, ${r.total} total)`, sections)
})
return true
@ -2067,6 +2070,94 @@ export function App({ gw }: { gw: GatewayClient }) {
return true
}
case 'agents':
case 'tasks':
rpc('agents.list', {}).then((r: any) => {
const procs = r.processes ?? []
const running = procs.filter((p: any) => p.status === 'running')
const finished = procs.filter((p: any) => p.status !== 'running')
const sections: PanelSection[] = []
if (running.length) {
sections.push({
title: `Running (${running.length})`,
rows: running.map((p: any) => [p.session_id.slice(0, 8), p.command])
})
}
if (finished.length) {
sections.push({
title: `Finished (${finished.length})`,
rows: finished.map((p: any) => [p.session_id.slice(0, 8), p.command])
})
}
if (!sections.length) sections.push({ text: 'No active processes' })
panel('Agents', sections)
}).catch(() => sys('agents command failed'))
return true
case 'cron':
if (!arg || arg === 'list') {
rpc('cron.manage', { action: 'list' }).then((r: any) => {
const jobs = r.jobs ?? []
if (!jobs.length) return sys('no scheduled jobs')
panel('Cron', [{
rows: jobs.map((j: any) => [
j.name || j.job_id?.slice(0, 12),
`${j.schedule} · ${j.state ?? 'active'}`
] as [string, string])
}])
}).catch(() => sys('cron command failed'))
} else {
gw.request('slash.exec', { command: cmd.slice(1), session_id: sid })
.then((r: any) => sys(r?.output || '(no output)'))
.catch(() => sys('cron command failed'))
}
return true
case 'config':
rpc('config.show', {}).then((r: any) => {
panel('Config', (r.sections ?? []).map((s: any) => ({
title: s.title,
rows: s.rows
})))
}).catch(() => sys('config command failed'))
return true
case 'tools':
rpc('tools.list', { session_id: sid }).then((r: any) => {
if (!r.toolsets?.length) return sys('no tools')
panel('Tools', r.toolsets.map((ts: any) => ({
title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`,
items: ts.tools
})))
}).catch(() => sys('tools command failed'))
return true
case 'toolsets':
rpc('toolsets.list', { session_id: sid }).then((r: any) => {
if (!r.toolsets?.length) return sys('no toolsets')
panel('Toolsets', [{
rows: r.toolsets.map((ts: any) => [
`${ts.enabled ? '(*)' : ' '} ${ts.name}`,
`[${ts.tool_count}] ${ts.description}`
] as [string, string])
}])
}).catch(() => sys('toolsets command failed'))
return true
default:
gw.request('slash.exec', { command: cmd.slice(1), session_id: sid })
.then((r: any) => sys(r?.output || `/${name}: no output`))
@ -2090,22 +2181,7 @@ export function App({ gw }: { gw: GatewayClient }) {
return true
}
},
[
catalog,
compact,
gw,
lastUserMsg,
messages,
newSession,
page,
pastes,
pushActivity,
rpc,
send,
sid,
statusBar,
sys
]
[catalog, compact, gw, lastUserMsg, messages, newSession, page, panel, pastes, pushActivity, rpc, send, sid, statusBar, sys]
)
slashRef.current = slash
@ -2199,6 +2275,8 @@ export function App({ gw }: { gw: GatewayClient }) {
<Banner t={theme} />
<SessionPanel info={m.info} sid={sid} t={theme} />
</Box>
) : m.kind === 'panel' && m.panelData ? (
<Panel sections={m.panelData.sections} t={theme} title={m.panelData.title} />
) : (
<MessageLine cols={cols} compact={compact} msg={m} t={theme} />
)}
@ -2321,17 +2399,27 @@ export function App({ gw }: { gw: GatewayClient }) {
)}
{pager && (
<Box flexDirection="column">
<Box borderColor={theme.color.bronze} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}>
{pager.title && (
<Box justifyContent="center" marginBottom={1}>
<Text bold color={theme.color.gold}>
{pager.title}
</Text>
</Box>
)}
{pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => (
<Text key={i}>{line}</Text>
))}
<Box marginTop={1}>
<Text color={theme.color.dim}>
{pager.offset + pagerPageSize < pager.lines.length
? `── Enter/Space for more · q to close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length}) ──`
: `── end · q to close (${pager.lines.length} lines) ──`}
? `Enter/Space for more · q to close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length})`
: `end · q to close (${pager.lines.length} lines)`}
</Text>
</Box>
</Box>
)}
{!isBlocked && (

View file

@ -3,7 +3,7 @@ import { Box, Text, useStdout } from '@hermes/ink'
import { artWidth, caduceus, CADUCEUS_WIDTH, logo, LOGO_WIDTH } from '../banner.js'
import { flat } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { SessionInfo } from '../types.js'
import type { PanelSection, SessionInfo } from '../types.js'
export function ArtLines({ lines }: { lines: [string, string][] }) {
return (
@ -142,3 +142,41 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string
</Box>
)
}
export function Panel({ sections, t, title }: { sections: PanelSection[]; t: Theme; title: string }) {
return (
<Box borderColor={t.color.bronze} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}>
<Box justifyContent="center" marginBottom={1}>
<Text bold color={t.color.gold}>
{title}
</Text>
</Box>
{sections.map((sec, si) => (
<Box flexDirection="column" key={si} marginTop={si > 0 ? 1 : 0}>
{sec.title && (
<Text bold color={t.color.amber}>
{sec.title}
</Text>
)}
{sec.rows?.map(([k, v], ri) => (
<Text key={ri} wrap="truncate">
<Text color={t.color.dim}>{k.padEnd(20)}</Text>
<Text color={t.color.cornsilk}>{v}</Text>
</Text>
))}
{sec.items?.map((item, ii) => (
<Text color={t.color.cornsilk} key={ii} wrap="truncate">
{item}
</Text>
))}
{sec.text && <Text color={t.color.dim}>{sec.text}</Text>}
</Box>
))}
</Box>
)
}

View file

@ -25,8 +25,9 @@ export interface ClarifyReq {
export interface Msg {
role: Role
text: string
kind?: 'intro' | 'slash'
kind?: 'intro' | 'panel' | 'slash'
info?: SessionInfo
panelData?: PanelData
thinking?: string
tools?: string[]
}
@ -63,6 +64,18 @@ export interface SecretReq {
requestId: string
}
export interface PanelData {
sections: PanelSection[]
title: string
}
export interface PanelSection {
items?: string[]
rows?: [string, string][]
text?: string
title?: string
}
export type PasteKind = 'code' | 'log' | 'text'
export type PasteMode = 'attach' | 'excerpt' | 'inline'