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": []}) 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") @method("cron.manage")
def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict:
action, jid = params.get("action", "list"), params.get("name", "") action, jid = params.get("action", "list"), params.get("name", "")

View file

@ -88,6 +88,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@ -317,29 +318,6 @@
"node": ">=6.9.0" "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": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@ -1464,6 +1442,7 @@
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.19.0" "undici-types": "~7.19.0"
} }
@ -1474,6 +1453,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@ -1484,6 +1464,7 @@
"integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.12.2", "@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/scope-manager": "8.58.1",
@ -1513,6 +1494,7 @@
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/scope-manager": "8.58.1",
"@typescript-eslint/types": "8.58.1", "@typescript-eslint/types": "8.58.1",
@ -1830,6 +1812,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -2165,6 +2148,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.10.12", "baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782", "caniuse-lite": "^1.0.30001782",
@ -2850,6 +2834,7 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@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", "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz",
"integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"chalk": "^5.3.0", "chalk": "^5.3.0",
"type-fest": "^4.18.2" "type-fest": "^4.18.2"
@ -5085,6 +5071,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -5184,6 +5171,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -5956,6 +5944,7 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "~0.27.0", "esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5" "get-tsconfig": "^4.7.5"
@ -6082,6 +6071,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -6191,6 +6181,7 @@
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",
@ -6599,6 +6590,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "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 { Box, Text, useApp, useInput, useStdout } from '@hermes/ink'
import { useCallback, useEffect, useRef, useState } from 'react' 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 { MaskedPrompt } from './components/maskedPrompt.js'
import { MessageLine } from './components/messageLine.js' import { MessageLine } from './components/messageLine.js'
import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js'
@ -36,6 +36,7 @@ import type {
ApprovalReq, ApprovalReq,
ClarifyReq, ClarifyReq,
Msg, Msg,
PanelSection,
PasteMode, PasteMode,
PendingPaste, PendingPaste,
SecretReq, SecretReq,
@ -343,7 +344,7 @@ export function App({ gw }: { gw: GatewayClient }) {
const [turnTrail, setTurnTrail] = useState<string[]>([]) const [turnTrail, setTurnTrail] = useState<string[]>([])
const [bgTasks, setBgTasks] = useState<Set<string>>(new Set()) const [bgTasks, setBgTasks] = useState<Set<string>>(new Set())
const [catalog, setCatalog] = useState<SlashCatalog | null>(null) 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 [voiceEnabled, setVoiceEnabled] = useState(false)
const [voiceRecording, setVoiceRecording] = useState(false) const [voiceRecording, setVoiceRecording] = useState(false)
const [voiceProcessing, setVoiceProcessing] = 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 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') 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) => { const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) => {
setActivity(prev => { setActivity(prev => {
const base = replaceLabel ? prev.filter(a => !sameToolTrailGroup(replaceLabel, a.text)) : prev const base = replaceLabel ? prev.filter(a => !sameToolTrailGroup(replaceLabel, a.text)) : prev
@ -1504,37 +1512,18 @@ export function App({ gw }: { gw: GatewayClient }) {
switch (name) { switch (name) {
case 'help': { case 'help': {
const cats = catalog?.categories ?? [] const sections: PanelSection[] = (catalog?.categories ?? []).map(({ name: catName, pairs }) => ({
const skills = catalog?.skillCount ?? 0 title: catName,
const lines: string[] = [] rows: pairs
}))
for (const { name: catName, pairs } of cats) { if (catalog?.skillCount) {
if (lines.length) { sections.push({ text: `${catalog.skillCount} skill commands available — /skills to browse` })
lines.push('')
} }
lines.push(` ${catName}:`) sections.push({ title: 'Hotkeys', rows: HOTKEYS })
for (const [c, d] of pairs) { panel('Commands', sections)
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'))
return true return true
} }
@ -1598,16 +1587,16 @@ export function App({ gw }: { gw: GatewayClient }) {
} }
if (arg === 'list') { if (arg === 'list') {
sys( if (!pastes.length) {
pastes.length sys('no text pastes')
? pastes } else {
.map( panel('Paste Shelf', [{
p => rows: pastes.map(p => [
`#${p.id} ${p.mode} · ${p.lineCount}L · ${p.kind} · ${compactPreview(p.text, 60) || '(empty)'}` `#${p.id} ${p.mode}`,
) `${p.lineCount}L · ${p.kind} · ${compactPreview(p.text, 60) || '(empty)'}`
.join('\n') ] as [string, string])
: 'no text pastes' }])
) }
return true return true
} }
@ -1660,10 +1649,12 @@ export function App({ gw }: { gw: GatewayClient }) {
return true return true
case 'logs': case 'logs': {
sys(gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20))) || 'no gateway 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 return true
}
case 'statusbar': case 'statusbar':
@ -1769,7 +1760,9 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'model': case 'model':
if (!arg) { 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 { } else {
rpc('config.set', { session_id: sid, key: 'model', value: arg.replace('--global', '').trim() }).then( rpc('config.set', { session_id: sid, key: 'model', value: arg.replace('--global', '').trim() }).then(
(r: any) => { (r: any) => {
@ -1798,7 +1791,7 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'provider': case 'provider':
gw.request('slash.exec', { command: 'provider', session_id: sid }) 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')) .catch(() => sys('provider command failed'))
return true return true
@ -1840,7 +1833,7 @@ export function App({ gw }: { gw: GatewayClient }) {
) )
} else { } else {
gw.request('slash.exec', { command: 'personality', session_id: sid }) 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')) .catch(() => sys('personality command failed'))
} }
@ -1900,30 +1893,30 @@ export function App({ gw }: { gw: GatewayClient }) {
} }
const f = (v: number) => (v ?? 0).toLocaleString() 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 = const cost =
r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null
sys( const rows: [string, string][] = [
[ ['Model', r.model ?? ''],
hr, ['Input tokens', f(r.input)],
ln('Model:', r.model ?? ''), ['Cache read tokens', f(r.cache_read)],
ln('Input tokens:', f(r.input)), ['Cache write tokens', f(r.cache_write)],
ln('Cache read tokens:', f(r.cache_read)), ['Output tokens', f(r.output)],
ln('Cache write tokens:', f(r.cache_write)), ['Total tokens', f(r.total)],
ln('Output tokens:', f(r.output)), ['API calls', f(r.calls)]
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}`
] ]
.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 return true
@ -1939,7 +1932,16 @@ export function App({ gw }: { gw: GatewayClient }) {
return true return true
case 'profile': 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 return true
@ -1957,7 +1959,13 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'insights': case 'insights':
rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) => 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 return true
@ -1970,7 +1978,12 @@ export function App({ gw }: { gw: GatewayClient }) {
return sys('no checkpoints') 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 { } else {
const hash = sub === 'restore' || sub === 'diff' ? rArgs[0] : sub const hash = sub === 'restore' || sub === 'diff' ? rArgs[0] : sub
@ -2003,7 +2016,9 @@ export function App({ gw }: { gw: GatewayClient }) {
return sys('no plugins') 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 return true
@ -2018,43 +2033,31 @@ export function App({ gw }: { gw: GatewayClient }) {
return sys('no skills installed') return sys('no skills installed')
} }
const lines: string[] = [] panel('Installed Skills', Object.entries(sk).map(([cat, names]) => ({
title: cat,
for (const [cat, names] of Object.entries(sk)) { items: names as string[]
lines.push(` ${cat}: ${(names as string[]).join(', ')}`) })))
}
sys(lines.join('\n'))
}) })
return true return true
} }
if (sub === 'browse') { if (sub === 'browse') {
const page = parseInt(sArgs[0] ?? '1', 10) || 1 const pg = parseInt(sArgs[0] ?? '1', 10) || 1
rpc('skills.manage', { action: 'browse', page }).then((r: any) => { rpc('skills.manage', { action: 'browse', page: pg }).then((r: any) => {
if (!r.items?.length) { if (!r.items?.length) return sys('no skills found in the hub')
return sys('no skills found in the hub')
}
const lines = [ const sections: PanelSection[] = [{
` Skills Hub (page ${r.page}/${r.total_pages}, ${r.total} total)`, rows: r.items.map((s: any) => [
'', s.name ?? '',
...r.items.map( (s.description ?? '').slice(0, 60) + (s.description?.length > 60 ? '…' : '')
(s: any) => ] as [string, string])
` ${(s.name ?? '').padEnd(28)} ${(s.description ?? '').slice(0, 60)}${s.description?.length > 60 ? '…' : ''}` }]
)
]
if (r.page < r.total_pages) { if (r.page < r.total_pages) sections.push({ text: `/skills browse ${r.page + 1} → next page` })
lines.push('', ` /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) { panel(`Skills Hub (page ${r.page}/${r.total_pages}, ${r.total} total)`, sections)
lines.push(` /skills browse ${r.page - 1} → prev page`)
}
sys(lines.join('\n'))
}) })
return true return true
@ -2067,6 +2070,94 @@ export function App({ gw }: { gw: GatewayClient }) {
return true 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: default:
gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) gw.request('slash.exec', { command: cmd.slice(1), session_id: sid })
.then((r: any) => sys(r?.output || `/${name}: no output`)) .then((r: any) => sys(r?.output || `/${name}: no output`))
@ -2090,22 +2181,7 @@ export function App({ gw }: { gw: GatewayClient }) {
return true return true
} }
}, },
[ [catalog, compact, gw, lastUserMsg, messages, newSession, page, panel, pastes, pushActivity, rpc, send, sid, statusBar, sys]
catalog,
compact,
gw,
lastUserMsg,
messages,
newSession,
page,
pastes,
pushActivity,
rpc,
send,
sid,
statusBar,
sys
]
) )
slashRef.current = slash slashRef.current = slash
@ -2199,6 +2275,8 @@ export function App({ gw }: { gw: GatewayClient }) {
<Banner t={theme} /> <Banner t={theme} />
<SessionPanel info={m.info} sid={sid} t={theme} /> <SessionPanel info={m.info} sid={sid} t={theme} />
</Box> </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} /> <MessageLine cols={cols} compact={compact} msg={m} t={theme} />
)} )}
@ -2321,17 +2399,27 @@ export function App({ gw }: { gw: GatewayClient }) {
)} )}
{pager && ( {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) => ( {pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => (
<Text key={i}>{line}</Text> <Text key={i}>{line}</Text>
))} ))}
<Box marginTop={1}>
<Text color={theme.color.dim}> <Text color={theme.color.dim}>
{pager.offset + pagerPageSize < pager.lines.length {pager.offset + pagerPageSize < pager.lines.length
? `── Enter/Space for more · q to close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${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) ──`} : `end · q to close (${pager.lines.length} lines)`}
</Text> </Text>
</Box> </Box>
</Box>
)} )}
{!isBlocked && ( {!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 { artWidth, caduceus, CADUCEUS_WIDTH, logo, LOGO_WIDTH } from '../banner.js'
import { flat } from '../lib/text.js' import { flat } from '../lib/text.js'
import type { Theme } from '../theme.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][] }) { export function ArtLines({ lines }: { lines: [string, string][] }) {
return ( return (
@ -142,3 +142,41 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string
</Box> </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 { export interface Msg {
role: Role role: Role
text: string text: string
kind?: 'intro' | 'slash' kind?: 'intro' | 'panel' | 'slash'
info?: SessionInfo info?: SessionInfo
panelData?: PanelData
thinking?: string thinking?: string
tools?: string[] tools?: string[]
} }
@ -63,6 +64,18 @@ export interface SecretReq {
requestId: string 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 PasteKind = 'code' | 'log' | 'text'
export type PasteMode = 'attach' | 'excerpt' | 'inline' export type PasteMode = 'attach' | 'excerpt' | 'inline'