mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
chore: uptick
This commit is contained in:
parent
f4bf57ff7a
commit
1218994992
10 changed files with 1513 additions and 280 deletions
|
|
@ -496,6 +496,81 @@ def do_inspect(identifier: str, console: Optional[Console] = None) -> None:
|
||||||
c.print()
|
c.print()
|
||||||
|
|
||||||
|
|
||||||
|
def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> list[dict]:
|
||||||
|
"""Paginated hub browse for programmatic callers (e.g. TUI gateway)."""
|
||||||
|
from tools.skills_hub import GitHubAuth, create_source_router
|
||||||
|
|
||||||
|
page_size = max(1, min(page_size, 100))
|
||||||
|
_TRUST_RANK = {"builtin": 3, "trusted": 2, "community": 1}
|
||||||
|
_PER_SOURCE_LIMIT = {"official": 100, "skills-sh": 100, "well-known": 25, "github": 100, "clawhub": 50,
|
||||||
|
"claude-marketplace": 50, "lobehub": 50}
|
||||||
|
auth = GitHubAuth()
|
||||||
|
sources = create_source_router(auth)
|
||||||
|
all_results: list = []
|
||||||
|
for src in sources:
|
||||||
|
sid = src.source_id()
|
||||||
|
if source != "all" and sid != source and sid != "official":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
limit = _PER_SOURCE_LIMIT.get(sid, 50)
|
||||||
|
all_results.extend(src.search("", limit=limit))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not all_results:
|
||||||
|
return []
|
||||||
|
seen: dict = {}
|
||||||
|
for r in all_results:
|
||||||
|
rank = _TRUST_RANK.get(r.trust_level, 0)
|
||||||
|
if r.name not in seen or rank > _TRUST_RANK.get(seen[r.name].trust_level, 0):
|
||||||
|
seen[r.name] = r
|
||||||
|
deduped = list(seen.values())
|
||||||
|
deduped.sort(key=lambda r: (-_TRUST_RANK.get(r.trust_level, 0), r.source != "official", r.name.lower()))
|
||||||
|
total = len(deduped)
|
||||||
|
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||||
|
page = max(1, min(page, total_pages))
|
||||||
|
start = (page - 1) * page_size
|
||||||
|
page_items = deduped[start : min(start + page_size, total)]
|
||||||
|
return [{"name": r.name, "description": r.description} for r in page_items]
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_skill(identifier: str) -> Optional[dict]:
|
||||||
|
"""Skill metadata (+ SKILL.md preview) for programmatic callers."""
|
||||||
|
from tools.skills_hub import GitHubAuth, create_source_router
|
||||||
|
|
||||||
|
class _Q:
|
||||||
|
def print(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
c = _Q()
|
||||||
|
auth = GitHubAuth()
|
||||||
|
sources = create_source_router(auth)
|
||||||
|
ident = identifier
|
||||||
|
if "/" not in ident:
|
||||||
|
ident = _resolve_short_name(ident, sources, c)
|
||||||
|
if not ident:
|
||||||
|
return None
|
||||||
|
meta, bundle, _ = _resolve_source_meta_and_bundle(ident, sources)
|
||||||
|
if not meta:
|
||||||
|
return None
|
||||||
|
out: dict = {
|
||||||
|
"name": meta.name,
|
||||||
|
"description": meta.description,
|
||||||
|
"source": meta.source,
|
||||||
|
"identifier": meta.identifier,
|
||||||
|
"tags": list(meta.tags) if meta.tags else [],
|
||||||
|
}
|
||||||
|
if bundle and "SKILL.md" in bundle.files:
|
||||||
|
content = bundle.files["SKILL.md"]
|
||||||
|
if isinstance(content, bytes):
|
||||||
|
content = content.decode("utf-8", errors="replace")
|
||||||
|
lines = content.split("\n")
|
||||||
|
preview = "\n".join(lines[:50])
|
||||||
|
if len(lines) > 50:
|
||||||
|
preview += f"\n\n... ({len(lines) - 50} more lines)"
|
||||||
|
out["skill_md_preview"] = preview
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def do_list(source_filter: str = "all", console: Optional[Console] = None) -> None:
|
def do_list(source_filter: str = "all", console: Optional[Console] = None) -> None:
|
||||||
"""List installed skills, distinguishing hub, builtin, and local skills."""
|
"""List installed skills, distinguishing hub, builtin, and local skills."""
|
||||||
from tools.skills_hub import HubLockFile, ensure_hub_dirs
|
from tools.skills_hub import HubLockFile, ensure_hub_dirs
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -8,14 +8,16 @@ import { CommandPalette } from './components/commandPalette.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'
|
||||||
import { QueuedMessages } from './components/queuedMessages.js'
|
import { QueuedMessages } from './components/queuedMessages.js'
|
||||||
|
import { MaskedPrompt } from './components/maskedPrompt.js'
|
||||||
|
import { SessionPicker } from './components/sessionPicker.js'
|
||||||
import { Thinking } from './components/thinking.js'
|
import { Thinking } from './components/thinking.js'
|
||||||
import { COMMANDS, HOTKEYS, INTERPOLATION_RE, MAX_CTX, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js'
|
import { COMMANDS, HOTKEYS, INTERPOLATION_RE, MAX_CTX, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js'
|
||||||
import type { GatewayClient } from './gatewayClient.js'
|
import { type GatewayClient, type GatewayEvent } from './gatewayClient.js'
|
||||||
import { type GatewayEvent } from './gatewayClient.js'
|
import * as inputHistory from './lib/history.js'
|
||||||
import { upsert } from './lib/messages.js'
|
import { upsert } from './lib/messages.js'
|
||||||
import { estimateRows, flat, fmtK, hasInterpolation, pick, userDisplay } from './lib/text.js'
|
import { estimateRows, flat, fmtK, hasInterpolation, pick, userDisplay } from './lib/text.js'
|
||||||
import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js'
|
import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js'
|
||||||
import type { ActiveTool, ApprovalReq, ClarifyReq, Msg, SessionInfo, Usage } from './types.js'
|
import type { ActiveTool, ApprovalReq, ClarifyReq, Msg, SecretReq, SessionInfo, SudoReq, Usage } from './types.js'
|
||||||
|
|
||||||
const PLACEHOLDER = pick(PLACEHOLDERS)
|
const PLACEHOLDER = pick(PLACEHOLDERS)
|
||||||
|
|
||||||
|
|
@ -39,7 +41,12 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
const [usage, setUsage] = useState<Usage>(ZERO)
|
const [usage, setUsage] = useState<Usage>(ZERO)
|
||||||
const [clarify, setClarify] = useState<ClarifyReq | null>(null)
|
const [clarify, setClarify] = useState<ClarifyReq | null>(null)
|
||||||
const [approval, setApproval] = useState<ApprovalReq | null>(null)
|
const [approval, setApproval] = useState<ApprovalReq | null>(null)
|
||||||
|
const [sudo, setSudo] = useState<SudoReq | null>(null)
|
||||||
|
const [secret, setSecret] = useState<SecretReq | null>(null)
|
||||||
|
const [picker, setPicker] = useState(false)
|
||||||
const [reasoning, setReasoning] = useState('')
|
const [reasoning, setReasoning] = useState('')
|
||||||
|
const [thinkingText, setThinkingText] = useState('')
|
||||||
|
const [statusBar, setStatusBar] = useState(true)
|
||||||
const [lastUserMsg, setLastUserMsg] = useState('')
|
const [lastUserMsg, setLastUserMsg] = useState('')
|
||||||
const [queueEditIdx, setQueueEditIdx] = useState<number | null>(null)
|
const [queueEditIdx, setQueueEditIdx] = useState<number | null>(null)
|
||||||
const [historyIdx, setHistoryIdx] = useState<number | null>(null)
|
const [historyIdx, setHistoryIdx] = useState<number | null>(null)
|
||||||
|
|
@ -49,13 +56,13 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
const buf = useRef('')
|
const buf = useRef('')
|
||||||
const stickyRef = useRef(true)
|
const stickyRef = useRef(true)
|
||||||
const queueRef = useRef<string[]>([])
|
const queueRef = useRef<string[]>([])
|
||||||
const historyRef = useRef<string[]>([])
|
const historyRef = useRef<string[]>(inputHistory.load())
|
||||||
const historyDraftRef = useRef('')
|
const historyDraftRef = useRef('')
|
||||||
const queueEditRef = useRef<number | null>(null)
|
const queueEditRef = useRef<number | null>(null)
|
||||||
const lastEmptyAt = useRef(0)
|
const lastEmptyAt = useRef(0)
|
||||||
|
|
||||||
const empty = !messages.length
|
const empty = !messages.length
|
||||||
const blocked = !!(clarify || approval)
|
const blocked = !!(clarify || approval || sudo || secret || picker)
|
||||||
|
|
||||||
const syncQueue = () => setQueuedDisplay([...queueRef.current])
|
const syncQueue = () => setQueuedDisplay([...queueRef.current])
|
||||||
|
|
||||||
|
|
@ -84,9 +91,9 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
|
|
||||||
const pushHistory = (text: string) => {
|
const pushHistory = (text: string) => {
|
||||||
const trimmed = text.trim()
|
const trimmed = text.trim()
|
||||||
|
|
||||||
if (trimmed && historyRef.current.at(-1) !== trimmed) {
|
if (trimmed && historyRef.current.at(-1) !== trimmed) {
|
||||||
historyRef.current.push(trimmed)
|
historyRef.current.push(trimmed)
|
||||||
|
inputHistory.append(trimmed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,13 +135,31 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
|
|
||||||
const sys = useCallback((text: string) => setMessages(prev => [...prev, { role: 'system' as const, text }]), [])
|
const sys = useCallback((text: string) => setMessages(prev => [...prev, { role: 'system' as const, text }]), [])
|
||||||
|
|
||||||
|
const rpc = (method: string, params: Record<string, unknown> = {}) =>
|
||||||
|
gw.request(method, params).catch((e: Error) => {
|
||||||
|
sys(`error: ${e.message}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const newSession = (msg?: string) =>
|
||||||
|
rpc('session.create').then((r: any) => {
|
||||||
|
if (!r) return
|
||||||
|
setSid(r.session_id)
|
||||||
|
setMessages([])
|
||||||
|
setUsage(ZERO)
|
||||||
|
setStatus('ready')
|
||||||
|
if (msg) sys(msg)
|
||||||
|
})
|
||||||
|
|
||||||
const idle = () => {
|
const idle = () => {
|
||||||
setThinking(false)
|
setThinking(false)
|
||||||
setTools([])
|
setTools([])
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
setClarify(null)
|
setClarify(null)
|
||||||
setApproval(null)
|
setApproval(null)
|
||||||
|
setSudo(null)
|
||||||
|
setSecret(null)
|
||||||
setReasoning('')
|
setReasoning('')
|
||||||
|
setThinkingText('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const die = () => {
|
const die = () => {
|
||||||
|
|
@ -229,10 +254,22 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
|
|
||||||
useInput((ch, key) => {
|
useInput((ch, key) => {
|
||||||
if (blocked) {
|
if (blocked) {
|
||||||
if (key.ctrl && ch === 'c' && approval) {
|
if (key.ctrl && ch === 'c') {
|
||||||
gw.request('approval.respond', { choice: 'deny', session_id: sid }).catch(() => {})
|
if (approval) {
|
||||||
setApproval(null)
|
gw.request('approval.respond', { choice: 'deny', session_id: sid }).catch(() => {})
|
||||||
sys('denied')
|
setApproval(null)
|
||||||
|
sys('denied')
|
||||||
|
} else if (sudo) {
|
||||||
|
gw.request('sudo.respond', { request_id: sudo.requestId, password: '' }).catch(() => {})
|
||||||
|
setSudo(null)
|
||||||
|
sys('sudo cancelled')
|
||||||
|
} else if (secret) {
|
||||||
|
gw.request('secret.respond', { request_id: secret.requestId, value: '' }).catch(() => {})
|
||||||
|
setSecret(null)
|
||||||
|
sys('secret entry cancelled')
|
||||||
|
} else if (picker) {
|
||||||
|
setPicker(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
@ -336,12 +373,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus('forging session…')
|
setStatus('forging session…')
|
||||||
gw.request('session.create')
|
newSession()
|
||||||
.then((r: any) => {
|
|
||||||
setSid(r.session_id)
|
|
||||||
setStatus('ready')
|
|
||||||
})
|
|
||||||
.catch((e: Error) => setStatus(`error: ${e.message}`))
|
|
||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
@ -351,12 +383,14 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'thinking.delta':
|
case 'thinking.delta':
|
||||||
|
if (p?.text) setThinkingText(prev => prev + p.text)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'message.start':
|
case 'message.start':
|
||||||
setThinking(true)
|
setThinking(true)
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setReasoning('')
|
setReasoning('')
|
||||||
|
setThinkingText('')
|
||||||
setStatus('thinking…')
|
setStatus('thinking…')
|
||||||
|
|
||||||
break
|
break
|
||||||
|
|
@ -414,7 +448,24 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
case 'approval.request':
|
case 'approval.request':
|
||||||
setApproval({ command: p.command, description: p.description })
|
setApproval({ command: p.command, description: p.description })
|
||||||
setStatus('approval needed')
|
setStatus('approval needed')
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'sudo.request':
|
||||||
|
setSudo({ requestId: p.request_id })
|
||||||
|
setStatus('sudo password needed')
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'secret.request':
|
||||||
|
setSecret({ requestId: p.request_id, prompt: p.prompt, envVar: p.env_var })
|
||||||
|
setStatus('secret input needed')
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'background.complete':
|
||||||
|
sys(`[bg ${p.task_id}] ${p.text}`)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'btw.complete':
|
||||||
|
sys(`[btw] ${p.text}`)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'message.delta':
|
case 'message.delta':
|
||||||
|
|
@ -474,7 +525,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[gw, sys]
|
[gw, sys, newSession]
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -509,8 +560,8 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
return true
|
return true
|
||||||
|
|
||||||
case 'clear':
|
case 'clear':
|
||||||
setMessages([])
|
setStatus('forging session…')
|
||||||
|
newSession()
|
||||||
return true
|
return true
|
||||||
|
|
||||||
case 'quit': // falls through
|
case 'quit': // falls through
|
||||||
|
|
@ -522,16 +573,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
|
|
||||||
case 'new':
|
case 'new':
|
||||||
setStatus('forging session…')
|
setStatus('forging session…')
|
||||||
gw.request('session.create')
|
newSession('new session started')
|
||||||
.then((r: any) => {
|
|
||||||
setSid(r.session_id)
|
|
||||||
setMessages([])
|
|
||||||
setUsage(ZERO)
|
|
||||||
setStatus('ready')
|
|
||||||
sys('new session started')
|
|
||||||
})
|
|
||||||
.catch((e: Error) => setStatus(`error: ${e.message}`))
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
||||||
case 'undo':
|
case 'undo':
|
||||||
|
|
@ -539,7 +581,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
gw.request('session.undo', { session_id: sid })
|
rpc('session.undo', { session_id: sid })
|
||||||
.then((r: any) => {
|
.then((r: any) => {
|
||||||
if (r.removed > 0) {
|
if (r.removed > 0) {
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
|
|
@ -560,28 +602,23 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
sys('nothing to undo')
|
sys('nothing to undo')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e: Error) => sys(`error: ${e.message}`))
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
||||||
case 'retry':
|
case 'retry':
|
||||||
if (!lastUserMsg) {
|
if (!lastUserMsg) {
|
||||||
sys('nothing to retry')
|
sys('nothing to retry')
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if (sid) {
|
||||||
|
gw.request('session.undo', { session_id: sid }).catch(() => {})
|
||||||
|
}
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
const q = [...prev]
|
const q = [...prev]
|
||||||
|
while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') q.pop()
|
||||||
while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') {
|
|
||||||
q.pop()
|
|
||||||
}
|
|
||||||
|
|
||||||
return q
|
return q
|
||||||
})
|
})
|
||||||
send(lastUserMsg)
|
send(lastUserMsg)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
||||||
case 'compact':
|
case 'compact':
|
||||||
|
|
@ -595,7 +632,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
gw.request('session.compress', { session_id: sid })
|
rpc('session.compress', { session_id: sid })
|
||||||
.then((r: any) => {
|
.then((r: any) => {
|
||||||
sys('context compressed')
|
sys('context compressed')
|
||||||
|
|
||||||
|
|
@ -603,7 +640,6 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
setUsage(r.usage)
|
setUsage(r.usage)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e: Error) => sys(`error: ${e.message}`))
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
|
@ -656,19 +692,321 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
||||||
case 'skills':
|
case 'resume':
|
||||||
if (!info?.skills || !Object.keys(info.skills).length) {
|
setPicker(true)
|
||||||
sys('no skills loaded')
|
return true
|
||||||
|
|
||||||
|
case 'history':
|
||||||
|
if (!sid) { setPicker(true); return true }
|
||||||
|
rpc('session.history', { session_id: sid })
|
||||||
|
.then((r: any) => sys(`session ${sid}: ${r.count} messages in context`))
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'title':
|
||||||
|
if (!sid) return true
|
||||||
|
if (!arg) {
|
||||||
|
rpc('session.title', { session_id: sid })
|
||||||
|
.then((r: any) => sys(`title: ${r.title || '(none)'} session: ${r.session_key}`))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
rpc('session.title', { session_id: sid, title: arg })
|
||||||
|
.then(() => sys(`title → ${arg}`))
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'tools':
|
||||||
|
if (!info?.tools || !Object.keys(info.tools).length) {
|
||||||
|
sys('no tools loaded')
|
||||||
|
return true
|
||||||
|
}
|
||||||
sys(
|
sys(
|
||||||
Object.entries(info.skills)
|
Object.entries(info.tools)
|
||||||
.map(([k, vs]) => `${k}: ${vs.join(', ')}`)
|
.map(([k, vs]) => `${k} (${vs.length}): ${vs.join(', ')}`)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
)
|
)
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'skills':
|
||||||
|
if (!arg || arg === 'list') {
|
||||||
|
if (!info?.skills || !Object.keys(info.skills).length) {
|
||||||
|
sys('no skills loaded')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
sys(Object.entries(info.skills).map(([k, vs]) => `${k}: ${vs.join(', ')}`).join('\n'))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (arg.startsWith('search ')) {
|
||||||
|
rpc('skills.manage', { action: 'search', query: arg.slice(7).trim() })
|
||||||
|
.then((r: any) => {
|
||||||
|
if (!r.results?.length) { sys('no results'); return }
|
||||||
|
sys(r.results.map((s: any) => ` ${s.name}: ${s.description}`).join('\n'))
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (arg.startsWith('install ')) {
|
||||||
|
rpc('skills.manage', { action: 'install', query: arg.slice(8).trim() })
|
||||||
|
.then((r: any) => sys(r.installed ? `installed ${r.name}` : 'install failed'))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (arg === 'browse' || arg.startsWith('browse ')) {
|
||||||
|
rpc('skills.manage', { action: 'browse', query: arg.slice(6).trim() })
|
||||||
|
.then((r: any) => {
|
||||||
|
if (!r.results?.length) { sys('no skills available'); return }
|
||||||
|
sys(r.results.map((s: any) => ` ${s.name}: ${s.description}`).join('\n'))
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (arg.startsWith('inspect ')) {
|
||||||
|
rpc('skills.manage', { action: 'inspect', query: arg.slice(8).trim() })
|
||||||
|
.then((r: any) => sys(JSON.stringify(r.info, null, 2)))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
sys('usage: /skills [list|search <q>|install <name>|browse|inspect <name>]')
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'verbose':
|
||||||
|
rpc('config.set', { key: 'verbose', value: arg || 'cycle' })
|
||||||
|
.then((r: any) => sys(`verbose → ${r.value}`))
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'yolo':
|
||||||
|
rpc('config.set', { key: 'yolo', value: '' })
|
||||||
|
.then((r: any) => sys(`yolo → ${r.value === '1' ? 'on' : 'off'}`))
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'reasoning':
|
||||||
|
if (!arg) {
|
||||||
|
sys('usage: /reasoning <none|low|medium|high|xhigh|show|hide>')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
rpc('config.set', { key: 'reasoning', value: arg })
|
||||||
|
.then((r: any) => sys(`reasoning → ${r.value}`))
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'stop':
|
||||||
|
rpc('process.stop')
|
||||||
|
.then((r: any) => sys(`killed ${r.killed} process(es)`))
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'profile':
|
||||||
|
gw.request('config.get', { key: 'profile' })
|
||||||
|
.then((r: any) => sys(`profile: ${r.display}`))
|
||||||
|
.catch(() => sys(`profile: ${process.env.HERMES_HOME ?? '~/.hermes'}`))
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'save':
|
||||||
|
if (!sid) return true
|
||||||
|
rpc('session.save', { session_id: sid })
|
||||||
|
.then((r: any) => sys(`saved to ${r.file}`))
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'provider':
|
||||||
|
rpc('config.get', { key: 'provider' })
|
||||||
|
.then((r: any) => {
|
||||||
|
const lines = [`model: ${r.model} provider: ${r.provider}`]
|
||||||
|
if (r.providers?.length) lines.push(`available: ${r.providers.join(', ')}`)
|
||||||
|
sys(lines.join('\n'))
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'prompt':
|
||||||
|
if (!arg) {
|
||||||
|
rpc('config.get', { key: 'prompt' })
|
||||||
|
.then((r: any) => sys(`custom prompt: ${r.prompt || '(none set)'}`))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
rpc('config.set', { key: 'prompt', value: arg })
|
||||||
|
.then((r: any) => sys(r.value ? `prompt set (${r.value.length} chars)` : 'prompt cleared'))
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'personality':
|
||||||
|
if (!arg) {
|
||||||
|
sys('usage: /personality <name> (concise, creative, analytical, friendly, none)')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
rpc('config.set', { key: 'personality', value: arg })
|
||||||
|
.then((r: any) => sys(`personality → ${r.value || 'default'}`))
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'plan':
|
||||||
|
send(arg ? `/plan ${arg}` : 'Create a detailed plan for the current task.')
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'background':
|
||||||
|
case 'bg':
|
||||||
|
if (!arg) {
|
||||||
|
sys('usage: /background <prompt>')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
rpc('prompt.background', { session_id: sid, text: arg })
|
||||||
|
.then((r: any) => sys(`background task ${r.task_id} started`))
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'btw':
|
||||||
|
if (!arg) {
|
||||||
|
sys('usage: /btw <question>')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
rpc('prompt.btw', { session_id: sid, text: arg })
|
||||||
|
.then(() => sys('btw running…'))
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'queue':
|
||||||
|
if (!arg) {
|
||||||
|
sys(`${queueRef.current.length} queued message(s)`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
enqueue(arg)
|
||||||
|
sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`)
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'rollback':
|
||||||
|
if (!sid) return true
|
||||||
|
if (!arg) {
|
||||||
|
rpc('rollback.list', { session_id: sid })
|
||||||
|
.then((r: any) => {
|
||||||
|
if (!r.enabled) { sys('checkpoints not enabled — use hermes --checkpoints'); return }
|
||||||
|
if (!r.checkpoints?.length) { sys('no checkpoints'); return }
|
||||||
|
sys(r.checkpoints.map((c: any, i: number) =>
|
||||||
|
` ${i + 1}. ${c.message || c.hash.slice(0, 8)} (${c.timestamp})`
|
||||||
|
).join('\n'))
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (arg.startsWith('diff ')) {
|
||||||
|
const ref = arg.slice(5).trim()
|
||||||
|
rpc('rollback.list', { session_id: sid }).then((r: any) => {
|
||||||
|
const hash = /^\d+$/.test(ref) ? r.checkpoints?.[parseInt(ref) - 1]?.hash : ref
|
||||||
|
if (!hash) { sys(`checkpoint ${ref} not found`); return }
|
||||||
|
rpc('rollback.diff', { session_id: sid, hash })
|
||||||
|
.then((d: any) => sys(d.stat || d.diff || 'no changes'))
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const parts = arg.trim().split(/\s+/)
|
||||||
|
const ref = parts[0]!
|
||||||
|
const file = parts[1]
|
||||||
|
rpc('rollback.list', { session_id: sid }).then((r: any) => {
|
||||||
|
const hash = /^\d+$/.test(ref) ? r.checkpoints?.[parseInt(ref) - 1]?.hash : ref
|
||||||
|
if (!hash) { sys(`checkpoint ${ref} not found`); return }
|
||||||
|
rpc('rollback.restore', { session_id: sid, hash, ...(file ? { file } : {}) })
|
||||||
|
.then((d: any) => sys(d.success ? `restored${file ? ` ${file}` : ''}` : `failed: ${d.error || 'unknown'}`))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'insights':
|
||||||
|
rpc('insights.get', { days: arg ? parseInt(arg) : 30 })
|
||||||
|
.then((r: any) => sys(`last ${r.days}d: ${r.sessions} sessions, ${r.messages} messages`))
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'toolsets':
|
||||||
|
if (!info?.tools) {
|
||||||
|
sys('no toolsets loaded')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
sys(Object.entries(info.tools).map(([k, vs]) => `${k}: ${vs.length} tools`).join('\n'))
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'paste':
|
||||||
|
sys('clipboard paste: use your terminal\'s paste shortcut (images not yet supported in TUI)')
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'reload-mcp':
|
||||||
|
case 'reload_mcp':
|
||||||
|
rpc('reload.mcp', { session_id: sid })
|
||||||
|
.then(() => sys('MCP servers reloaded'))
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'browser':
|
||||||
|
if (!arg || arg === 'status') {
|
||||||
|
rpc('browser.manage', { action: 'status' })
|
||||||
|
.then((r: any) => sys(r.connected ? `browser: connected (${r.url})` : 'browser: not connected'))
|
||||||
|
} else if (arg === 'connect' || arg.startsWith('connect ')) {
|
||||||
|
const url = arg.split(/\s+/)[1]
|
||||||
|
rpc('browser.manage', { action: 'connect', ...(url ? { url } : {}) })
|
||||||
|
.then((r: any) => sys(`browser connected: ${r.url}`))
|
||||||
|
} else if (arg === 'disconnect') {
|
||||||
|
rpc('browser.manage', { action: 'disconnect' })
|
||||||
|
.then(() => sys('browser disconnected'))
|
||||||
|
} else {
|
||||||
|
sys('usage: /browser [connect|disconnect|status]')
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'platforms':
|
||||||
|
case 'gateway':
|
||||||
|
sys('gateway status is not available in TUI mode')
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'statusbar':
|
||||||
|
case 'sb':
|
||||||
|
setStatusBar(v => !v)
|
||||||
|
sys(`status bar ${statusBar ? 'off' : 'on'}`)
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'voice':
|
||||||
|
if (!arg || arg === 'status') {
|
||||||
|
rpc('voice.toggle', { action: 'status' })
|
||||||
|
.then((r: any) => sys(`voice: ${r.enabled ? 'on' : 'off'}`))
|
||||||
|
} else if (arg === 'on' || arg === 'off') {
|
||||||
|
rpc('voice.toggle', { action: arg })
|
||||||
|
.then((r: any) => sys(`voice → ${r.enabled ? 'on' : 'off'}`))
|
||||||
|
} else if (arg === 'record') {
|
||||||
|
rpc('voice.record', { action: 'start' })
|
||||||
|
.then(() => sys('recording… (use /voice stop to transcribe)'))
|
||||||
|
} else if (arg === 'stop') {
|
||||||
|
rpc('voice.record', { action: 'stop' })
|
||||||
|
.then((r: any) => {
|
||||||
|
if (r.text) { send(r.text) } else { sys('no speech detected') }
|
||||||
|
})
|
||||||
|
} else if (arg === 'tts') {
|
||||||
|
const last = messages.filter(m => m.role === 'assistant').at(-1)
|
||||||
|
if (last) {
|
||||||
|
rpc('voice.tts', { text: last.text })
|
||||||
|
.then(() => sys('speaking…'))
|
||||||
|
} else {
|
||||||
|
sys('no response to speak')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sys('usage: /voice [on|off|status|record|stop|tts]')
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'plugins':
|
||||||
|
rpc('plugins.list')
|
||||||
|
.then((r: any) => {
|
||||||
|
if (!r.plugins?.length) { sys('no plugins installed'); return }
|
||||||
|
sys(r.plugins.map((p: any) => ` ${p.name} v${p.version} ${p.enabled ? '✓' : '✗'}`).join('\n'))
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'cron':
|
||||||
|
if (!arg || arg === 'list') {
|
||||||
|
rpc('cron.manage', { action: 'list' })
|
||||||
|
.then((r: any) => {
|
||||||
|
const jobs = r.jobs || r.schedules || []
|
||||||
|
if (!jobs.length) { sys('no cron jobs'); return }
|
||||||
|
sys(jobs.map((j: any) => ` ${j.name}: ${j.schedule} ${j.paused ? '(paused)' : ''}`).join('\n'))
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const parts = arg.split(/\s+/)
|
||||||
|
const sub = parts[0]!
|
||||||
|
if (sub === 'add' || sub === 'create') {
|
||||||
|
const name = parts[1] || ''
|
||||||
|
const schedule = parts[2] || ''
|
||||||
|
const prompt = parts.slice(3).join(' ')
|
||||||
|
rpc('cron.manage', { action: 'add', name, schedule, prompt })
|
||||||
|
.then((r: any) => sys(r.message || r.status || 'created'))
|
||||||
|
} else {
|
||||||
|
rpc('cron.manage', { action: sub, name: parts[1] || '' })
|
||||||
|
.then((r: any) => sys(r.message || r.status || JSON.stringify(r)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'update':
|
||||||
|
sys('update not available in TUI mode — run: pip install -U hermes-agent')
|
||||||
return true
|
return true
|
||||||
|
|
||||||
case 'model':
|
case 'model':
|
||||||
|
|
@ -678,9 +1016,8 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
gw.request('config.set', { key: 'model', value: arg })
|
rpc('config.set', { key: 'model', value: arg })
|
||||||
.then(() => sys(`model → ${arg}`))
|
.then(() => sys(`model → ${arg}`))
|
||||||
.catch((e: Error) => sys(`error: ${e.message}`))
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
|
@ -691,18 +1028,42 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
gw.request('config.set', { key: 'skin', value: arg })
|
rpc('config.set', { key: 'skin', value: arg })
|
||||||
.then(() => sys(`skin → ${arg} (restart to apply)`))
|
.then(() => sys(`skin → ${arg} (restart to apply)`))
|
||||||
.catch((e: Error) => sys(`error: ${e.message}`))
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return false
|
gw.request('command.dispatch', { name: name ?? '', arg, session_id: sid })
|
||||||
|
.then((r: any) => {
|
||||||
|
if (r.type === 'exec') {
|
||||||
|
sys(r.output || '(no output)')
|
||||||
|
} else if (r.type === 'alias') {
|
||||||
|
slash(`/${r.target}${arg ? ' ' + arg : ''}`)
|
||||||
|
} else if (r.type === 'plugin') {
|
||||||
|
sys(r.output || '(no output)')
|
||||||
|
} else if (r.type === 'skill') {
|
||||||
|
sys(`⚡ loading skill: ${r.name}`)
|
||||||
|
send(r.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
gw.request('command.resolve', { name: name ?? '' })
|
||||||
|
.then((r: any) => {
|
||||||
|
if (r.canonical && r.canonical !== name) {
|
||||||
|
sys(`/${name} → /${r.canonical}`)
|
||||||
|
slash(`/${r.canonical}${arg ? ' ' + arg : ''}`)
|
||||||
|
} else {
|
||||||
|
sys(`unknown command: /${name}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => sys(`unknown command: /${name}`))
|
||||||
|
})
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[compact, gw, info, lastUserMsg, messages, sid, status, sys, usage]
|
[compact, gw, info, lastUserMsg, messages, newSession, rpc, send, sid, status, sys, usage, statusBar]
|
||||||
)
|
)
|
||||||
|
|
||||||
const submit = useCallback(
|
const submit = useCallback(
|
||||||
|
|
@ -878,7 +1239,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{thinking && <Thinking reasoning={reasoning} t={theme} tools={tools} />}
|
{thinking && <Thinking reasoning={reasoning} t={theme} thinking={thinkingText} tools={tools} />}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{clarify && (
|
{clarify && (
|
||||||
|
|
@ -907,6 +1268,57 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{sudo && (
|
||||||
|
<MaskedPrompt
|
||||||
|
icon="🔐"
|
||||||
|
label="sudo password required"
|
||||||
|
onSubmit={password => {
|
||||||
|
gw.request('sudo.respond', { request_id: sudo.requestId, password }).catch(() => {})
|
||||||
|
setSudo(null)
|
||||||
|
setStatus('running…')
|
||||||
|
}}
|
||||||
|
t={theme}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{secret && (
|
||||||
|
<MaskedPrompt
|
||||||
|
icon="🔑"
|
||||||
|
label={secret.prompt}
|
||||||
|
onSubmit={value => {
|
||||||
|
gw.request('secret.respond', { request_id: secret.requestId, value }).catch(() => {})
|
||||||
|
setSecret(null)
|
||||||
|
setStatus('running…')
|
||||||
|
}}
|
||||||
|
sub={`for ${secret.envVar}`}
|
||||||
|
t={theme}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{picker && (
|
||||||
|
<SessionPicker
|
||||||
|
gw={gw}
|
||||||
|
onCancel={() => setPicker(false)}
|
||||||
|
onSelect={id => {
|
||||||
|
setPicker(false)
|
||||||
|
setStatus('resuming…')
|
||||||
|
gw.request('session.resume', { session_id: id })
|
||||||
|
.then((r: any) => {
|
||||||
|
setSid(r.session_id)
|
||||||
|
setMessages([])
|
||||||
|
setUsage(ZERO)
|
||||||
|
sys(`resumed session (${r.message_count} messages)`)
|
||||||
|
setStatus('ready')
|
||||||
|
})
|
||||||
|
.catch((e: Error) => {
|
||||||
|
sys(`error: ${e.message}`)
|
||||||
|
setStatus('ready')
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
t={theme}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{!blocked && input.startsWith('/') && <CommandPalette filter={input} t={theme} />}
|
{!blocked && input.startsWith('/') && <CommandPalette filter={input} t={theme} />}
|
||||||
|
|
||||||
<QueuedMessages cols={cols} queued={queuedDisplay} queueEditIdx={queueEditIdx} t={theme} />
|
<QueuedMessages cols={cols} queued={queuedDisplay} queueEditIdx={queueEditIdx} t={theme} />
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,52 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (line.match(/^>\s?/)) {
|
||||||
|
const quoteLines: string[] = []
|
||||||
|
while (i < lines.length && lines[i]!.match(/^>\s?/)) {
|
||||||
|
quoteLines.push(lines[i]!.replace(/^>\s?/, ''))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
nodes.push(
|
||||||
|
<Box flexDirection="column" key={key}>
|
||||||
|
{quoteLines.map((ql, qi) => (
|
||||||
|
<Text color={t.color.dim} key={qi}>
|
||||||
|
{' │ '}<MdInline t={t} text={ql} />
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.includes('|') && line.trim().startsWith('|')) {
|
||||||
|
const tableRows: string[][] = []
|
||||||
|
while (i < lines.length && lines[i]!.trim().startsWith('|')) {
|
||||||
|
const row = lines[i]!.trim()
|
||||||
|
if (!/^[|\s:-]+$/.test(row)) {
|
||||||
|
tableRows.push(
|
||||||
|
row.split('|').filter(Boolean).map(c => c.trim())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if (tableRows.length) {
|
||||||
|
const widths = tableRows[0]!.map((_, ci) =>
|
||||||
|
Math.max(...tableRows.map(r => (r[ci] ?? '').length))
|
||||||
|
)
|
||||||
|
nodes.push(
|
||||||
|
<Box flexDirection="column" key={key} paddingLeft={2}>
|
||||||
|
{tableRows.map((row, ri) => (
|
||||||
|
<Text color={ri === 0 ? t.color.amber : t.color.cornsilk} key={ri}>
|
||||||
|
{row.map((cell, ci) => cell.padEnd(widths[ci] ?? 0)).join(' ')}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
nodes.push(<MdInline key={key} t={t} text={line} />)
|
nodes.push(<MdInline key={key} t={t} text={line} />)
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
ui-tui/src/components/maskedPrompt.tsx
Normal file
29
ui-tui/src/components/maskedPrompt.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { Box, Text } from 'ink'
|
||||||
|
import TextInput from 'ink-text-input'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import type { Theme } from '../theme.js'
|
||||||
|
|
||||||
|
export function MaskedPrompt({
|
||||||
|
icon, label, onSubmit, sub, t
|
||||||
|
}: {
|
||||||
|
icon: string
|
||||||
|
label: string
|
||||||
|
onSubmit: (v: string) => void
|
||||||
|
sub?: string
|
||||||
|
t: Theme
|
||||||
|
}) {
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text bold color={t.color.warn}>{icon} {label}</Text>
|
||||||
|
{sub && <Text color={t.color.dim}> {sub}</Text>}
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text color={t.color.label}>{'> '}</Text>
|
||||||
|
<TextInput mask="*" onChange={setValue} onSubmit={onSubmit} value={value} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
ui-tui/src/components/sessionPicker.tsx
Normal file
94
ui-tui/src/components/sessionPicker.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { Box, Text, useInput } from 'ink'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import type { GatewayClient } from '../gatewayClient.js'
|
||||||
|
import type { Theme } from '../theme.js'
|
||||||
|
|
||||||
|
interface SessionItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
preview: string
|
||||||
|
started_at: number
|
||||||
|
message_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function age(ts: number): string {
|
||||||
|
const d = (Date.now() / 1000 - ts) / 86400
|
||||||
|
if (d < 1) return 'today'
|
||||||
|
if (d < 2) return 'yesterday'
|
||||||
|
return `${Math.floor(d)}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
const VISIBLE = 15
|
||||||
|
|
||||||
|
export function SessionPicker({
|
||||||
|
gw,
|
||||||
|
onCancel,
|
||||||
|
onSelect,
|
||||||
|
t
|
||||||
|
}: {
|
||||||
|
gw: GatewayClient
|
||||||
|
onCancel: () => void
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
t: Theme
|
||||||
|
}) {
|
||||||
|
const [items, setItems] = useState<SessionItem[]>([])
|
||||||
|
const [sel, setSel] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
gw.request('session.list', { limit: 20 })
|
||||||
|
.then((r: any) => {
|
||||||
|
setItems(r.sessions ?? [])
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
.catch(() => setLoading(false))
|
||||||
|
}, [gw])
|
||||||
|
|
||||||
|
useInput((ch, key) => {
|
||||||
|
if (key.escape) return onCancel()
|
||||||
|
if (key.upArrow && sel > 0) setSel(s => s - 1)
|
||||||
|
if (key.downArrow && sel < items.length - 1) setSel(s => s + 1)
|
||||||
|
if (key.return && items[sel]) onSelect(items[sel]!.id)
|
||||||
|
|
||||||
|
const n = parseInt(ch)
|
||||||
|
if (n >= 1 && n <= Math.min(9, items.length)) onSelect(items[n - 1]!.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (loading) return <Text color={t.color.dim}>loading sessions…</Text>
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={t.color.dim}>no previous sessions</Text>
|
||||||
|
<Text color={t.color.dim}>Esc to cancel</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const off = Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), items.length - VISIBLE))
|
||||||
|
const visible = items.slice(off, off + VISIBLE)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text bold color={t.color.amber}>Resume Session</Text>
|
||||||
|
{off > 0 && <Text color={t.color.dim}> ↑ {off} more</Text>}
|
||||||
|
{visible.map((s, vi) => {
|
||||||
|
const i = off + vi
|
||||||
|
return (
|
||||||
|
<Text key={s.id}>
|
||||||
|
<Text color={sel === i ? t.color.label : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
|
||||||
|
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>
|
||||||
|
{i + 1}. {s.title || s.preview || s.id.slice(0, 8)}
|
||||||
|
</Text>
|
||||||
|
<Text color={t.color.dim}>
|
||||||
|
{' '}({s.message_count} msgs, {age(s.started_at)})
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{off + VISIBLE < items.length && <Text color={t.color.dim}> ↓ {items.length - off - VISIBLE} more</Text>}
|
||||||
|
<Text color={t.color.dim}>↑/↓ select · Enter resume · 1-9 quick · Esc cancel</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ import { pick } from '../lib/text.js'
|
||||||
import type { Theme } from '../theme.js'
|
import type { Theme } from '../theme.js'
|
||||||
import type { ActiveTool } from '../types.js'
|
import type { ActiveTool } from '../types.js'
|
||||||
|
|
||||||
export function Thinking({ reasoning, t, tools }: { reasoning: string; t: Theme; tools: ActiveTool[] }) {
|
export function Thinking({ reasoning, t, thinking, tools }: { reasoning: string; t: Theme; thinking?: string; tools: ActiveTool[] }) {
|
||||||
const [frame, setFrame] = useState(0)
|
const [frame, setFrame] = useState(0)
|
||||||
const [verb] = useState(() => pick(VERBS))
|
const [verb] = useState(() => pick(VERBS))
|
||||||
const [face] = useState(() => pick(FACES))
|
const [face] = useState(() => pick(FACES))
|
||||||
|
|
@ -30,10 +30,10 @@ export function Thinking({ reasoning, t, tools }: { reasoning: string; t: Theme;
|
||||||
{SPINNER[frame]} {face} {verb}…
|
{SPINNER[frame]} {face} {verb}…
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{reasoning && (
|
{(reasoning || thinking) && (
|
||||||
<Text color={t.color.dim} dimColor wrap="truncate-end">
|
<Text color={t.color.dim} dimColor wrap="truncate-end">
|
||||||
{' 💭 '}
|
{' 💭 '}
|
||||||
{reasoning.slice(-120).replace(/\n/g, ' ')}
|
{(reasoning || thinking || '').slice(-120).replace(/\n/g, ' ')}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,45 @@ import type { Role, Usage } from './types.js'
|
||||||
|
|
||||||
export const COMMANDS: [string, string][] = [
|
export const COMMANDS: [string, string][] = [
|
||||||
['/help', 'commands & hotkeys'],
|
['/help', 'commands & hotkeys'],
|
||||||
['/model', 'switch model'],
|
|
||||||
['/skin', 'change theme'],
|
|
||||||
['/clear', 'reset chat'],
|
|
||||||
['/new', 'new session'],
|
['/new', 'new session'],
|
||||||
|
['/resume', 'resume a previous session'],
|
||||||
|
['/title', 'set session title'],
|
||||||
|
['/history', 'show session list'],
|
||||||
|
['/clear', 'reset session + chat'],
|
||||||
['/undo', 'drop last exchange'],
|
['/undo', 'drop last exchange'],
|
||||||
['/retry', 'resend last message'],
|
['/retry', 'resend last message'],
|
||||||
|
['/save', 'save conversation to file'],
|
||||||
['/compact', 'toggle compact [focus]'],
|
['/compact', 'toggle compact [focus]'],
|
||||||
['/cost', 'token usage stats'],
|
|
||||||
['/copy', 'copy last response'],
|
|
||||||
['/context', 'context window info'],
|
|
||||||
['/compress', 'compress context'],
|
['/compress', 'compress context'],
|
||||||
|
['/model', 'switch model'],
|
||||||
|
['/skin', 'change theme'],
|
||||||
|
['/provider', 'show model/provider info'],
|
||||||
|
['/prompt', 'set custom system prompt'],
|
||||||
|
['/personality', 'set personality preset'],
|
||||||
|
['/verbose', 'cycle tool verbosity'],
|
||||||
|
['/yolo', 'toggle auto-approve mode'],
|
||||||
|
['/reasoning', 'set reasoning level'],
|
||||||
|
['/tools', 'list active tools'],
|
||||||
|
['/toolsets', 'list toolsets'],
|
||||||
['/skills', 'list skills'],
|
['/skills', 'list skills'],
|
||||||
|
['/stop', 'kill background processes'],
|
||||||
|
['/background', 'run prompt in background'],
|
||||||
|
['/btw', 'side question (no tools)'],
|
||||||
|
['/plan', 'invoke plan skill'],
|
||||||
|
['/queue', 'queue prompt for next turn'],
|
||||||
|
['/profile', 'show active profile'],
|
||||||
|
['/cost', 'token usage stats'],
|
||||||
|
['/context', 'context window info'],
|
||||||
|
['/insights', 'usage analytics'],
|
||||||
|
['/copy', 'copy last response'],
|
||||||
|
['/paste', 'clipboard info'],
|
||||||
['/config', 'show config'],
|
['/config', 'show config'],
|
||||||
['/status', 'session info'],
|
['/status', 'session info'],
|
||||||
|
['/statusbar', 'toggle status bar'],
|
||||||
|
['/voice', 'voice mode toggle'],
|
||||||
|
['/reload-mcp', 'reload MCP servers'],
|
||||||
|
['/rollback', 'checkpoint info'],
|
||||||
|
['/browser', 'browser tools info'],
|
||||||
['/quit', 'exit hermes']
|
['/quit', 'exit hermes']
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -47,7 +72,9 @@ export const HOTKEYS: [string, string][] = [
|
||||||
['Esc', 'clear input'],
|
['Esc', 'clear input'],
|
||||||
['\\+Enter', 'multi-line continuation'],
|
['\\+Enter', 'multi-line continuation'],
|
||||||
['!cmd', 'run shell command'],
|
['!cmd', 'run shell command'],
|
||||||
['{!cmd}', 'interpolate shell output inline']
|
['{!cmd}', 'interpolate shell output inline'],
|
||||||
|
['/voice record', 'start PTT recording'],
|
||||||
|
['/voice stop', 'stop + transcribe']
|
||||||
]
|
]
|
||||||
|
|
||||||
export const INTERPOLATION_RE = /\{!(.+?)\}/g
|
export const INTERPOLATION_RE = /\{!(.+?)\}/g
|
||||||
|
|
|
||||||
52
ui-tui/src/lib/history.ts
Normal file
52
ui-tui/src/lib/history.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { existsSync, mkdirSync, readFileSync, appendFileSync } from 'node:fs'
|
||||||
|
import { homedir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
const MAX = 1000
|
||||||
|
const dir = join(process.env.HERMES_HOME ?? join(homedir(), '.hermes'))
|
||||||
|
const file = join(dir, 'tui_history')
|
||||||
|
|
||||||
|
let cache: string[] | null = null
|
||||||
|
|
||||||
|
function encode(s: string): string {
|
||||||
|
return s.replace(/\\/g, '\\\\').replace(/\n/g, '\\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function decode(s: string): string {
|
||||||
|
return s.replace(/\\n/g, '\n').replace(/\\\\/g, '\\')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function load(): string[] {
|
||||||
|
if (cache) return cache
|
||||||
|
try {
|
||||||
|
if (existsSync(file)) {
|
||||||
|
cache = readFileSync(file, 'utf8')
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(decode)
|
||||||
|
.slice(-MAX)
|
||||||
|
} else {
|
||||||
|
cache = []
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
cache = []
|
||||||
|
}
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
export function append(line: string): void {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
const items = load()
|
||||||
|
if (items.at(-1) === trimmed) return
|
||||||
|
items.push(trimmed)
|
||||||
|
if (items.length > MAX) items.splice(0, items.length - MAX)
|
||||||
|
try {
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||||
|
appendFileSync(file, encode(trimmed) + '\n')
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function all(): string[] {
|
||||||
|
return load()
|
||||||
|
}
|
||||||
|
|
@ -33,3 +33,13 @@ export interface Usage {
|
||||||
output: number
|
output: number
|
||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SudoReq {
|
||||||
|
requestId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecretReq {
|
||||||
|
envVar: string
|
||||||
|
prompt: string
|
||||||
|
requestId: string
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue