mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
Merge pull request #12263 from NousResearch/bb/tui-audit-followup
fix(tui): TUI v2 audit follow-up — registry, overlays, paste, reasoning, hyperlinks
This commit is contained in:
@@ -363,6 +363,28 @@ def test_image_attach_appends_local_image(monkeypatch):
|
||||
assert len(server._sessions["sid"]["attached_images"]) == 1
|
||||
|
||||
|
||||
def test_commands_catalog_surfaces_quick_commands(monkeypatch):
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"quick_commands": {
|
||||
"build": {"type": "exec", "command": "npm run build"},
|
||||
"git": {"type": "alias", "target": "/shell git"},
|
||||
"notes": {"type": "exec", "command": "cat NOTES.md", "description": "Open design notes"},
|
||||
}})
|
||||
|
||||
resp = server.handle_request({"id": "1", "method": "commands.catalog", "params": {}})
|
||||
|
||||
pairs = dict(resp["result"]["pairs"])
|
||||
assert "npm run build" in pairs["/build"]
|
||||
assert pairs["/git"].startswith("alias →")
|
||||
assert pairs["/notes"] == "Open design notes"
|
||||
|
||||
user_cat = next(c for c in resp["result"]["categories"] if c["name"] == "User commands")
|
||||
user_pairs = dict(user_cat["pairs"])
|
||||
assert set(user_pairs) == {"/build", "/git", "/notes"}
|
||||
|
||||
assert resp["result"]["canon"]["/build"] == "/build"
|
||||
assert resp["result"]["canon"]["/notes"] == "/notes"
|
||||
|
||||
|
||||
def test_command_dispatch_exec_nonzero_surfaces_error(monkeypatch):
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"quick_commands": {"boom": {"type": "exec", "command": "boom"}}})
|
||||
monkeypatch.setattr(
|
||||
@@ -509,3 +531,18 @@ def test_session_steer_errors_when_agent_has_no_steer_method():
|
||||
assert "error" in resp, resp
|
||||
assert resp["error"]["code"] == 4010
|
||||
|
||||
|
||||
def test_session_info_includes_mcp_servers(monkeypatch):
|
||||
fake_status = [
|
||||
{"name": "github", "transport": "http", "tools": 12, "connected": True},
|
||||
{"name": "filesystem", "transport": "stdio", "tools": 4, "connected": True},
|
||||
{"name": "broken", "transport": "stdio", "tools": 0, "connected": False},
|
||||
]
|
||||
fake_mod = types.ModuleType("tools.mcp_tool")
|
||||
fake_mod.get_mcp_status = lambda: fake_status
|
||||
monkeypatch.setitem(sys.modules, "tools.mcp_tool", fake_mod)
|
||||
|
||||
info = server._session_info(types.SimpleNamespace(tools=[], model=""))
|
||||
|
||||
assert info["mcp_servers"] == fake_status
|
||||
|
||||
|
||||
@@ -588,6 +588,11 @@ def _session_info(agent) -> dict:
|
||||
info["skills"] = get_available_skills()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from tools.mcp_tool import get_mcp_status
|
||||
info["mcp_servers"] = get_mcp_status()
|
||||
except Exception:
|
||||
info["mcp_servers"] = []
|
||||
try:
|
||||
from hermes_cli.banner import get_update_result
|
||||
from hermes_cli.config import recommended_update_command
|
||||
@@ -1994,8 +1999,35 @@ def _(rid, params: dict) -> dict:
|
||||
cat_order.append(cat)
|
||||
cat_map[cat].append([name, desc])
|
||||
|
||||
skill_count = 0
|
||||
warning = ""
|
||||
try:
|
||||
qcmds = _load_cfg().get("quick_commands", {}) or {}
|
||||
if isinstance(qcmds, dict) and qcmds:
|
||||
bucket = "User commands"
|
||||
if bucket not in cat_map:
|
||||
cat_map[bucket] = []
|
||||
cat_order.append(bucket)
|
||||
for qname, qc in sorted(qcmds.items()):
|
||||
if not isinstance(qc, dict):
|
||||
continue
|
||||
key = f"/{qname}"
|
||||
canon[key.lower()] = key
|
||||
qtype = qc.get("type", "")
|
||||
if qtype == "exec":
|
||||
default_desc = f"exec: {qc.get('command', '')}"
|
||||
elif qtype == "alias":
|
||||
default_desc = f"alias → {qc.get('target', '')}"
|
||||
else:
|
||||
default_desc = qtype or "quick command"
|
||||
qdesc = str(qc.get("description") or default_desc)
|
||||
qdesc = qdesc[:120] + ("…" if len(qdesc) > 120 else "")
|
||||
all_pairs.append([key, qdesc])
|
||||
cat_map[bucket].append([key, qdesc])
|
||||
except Exception as e:
|
||||
if not warning:
|
||||
warning = f"quick_commands discovery unavailable: {e}"
|
||||
|
||||
skill_count = 0
|
||||
try:
|
||||
from agent.skill_commands import scan_skill_commands
|
||||
for k, info in sorted(scan_skill_commands().items()):
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js'
|
||||
import { resetOverlayState } from '../app/overlayStore.js'
|
||||
import { turnController } from '../app/turnController.js'
|
||||
import { resetTurnState } from '../app/turnStore.js'
|
||||
import { resetUiState } from '../app/uiStore.js'
|
||||
import { patchUiState, resetUiState } from '../app/uiStore.js'
|
||||
import { estimateTokensRough } from '../lib/text.js'
|
||||
import type { Msg } from '../types.js'
|
||||
|
||||
@@ -47,6 +47,7 @@ describe('createGatewayEventHandler', () => {
|
||||
resetUiState()
|
||||
resetTurnState()
|
||||
turnController.fullReset()
|
||||
patchUiState({ showReasoning: true })
|
||||
})
|
||||
|
||||
it('persists completed tool rows when message.complete lands immediately after tool.complete', () => {
|
||||
|
||||
@@ -17,6 +17,64 @@ describe('createSlashHandler', () => {
|
||||
expect(getOverlayState().picker).toBe(true)
|
||||
})
|
||||
|
||||
it('opens the skills hub locally for bare /skills', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
expect(createSlashHandler(ctx)('/skills')).toBe(true)
|
||||
expect(getOverlayState().skillsHub).toBe(true)
|
||||
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
|
||||
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes /skills install <name> to skills.manage without opening overlay', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
expect(createSlashHandler(ctx)('/skills install foo')).toBe(true)
|
||||
expect(getOverlayState().skillsHub).toBe(false)
|
||||
expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
|
||||
action: 'install',
|
||||
query: 'foo'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes /skills inspect <name> to skills.manage', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
createSlashHandler(ctx)('/skills inspect my-skill')
|
||||
expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
|
||||
action: 'inspect',
|
||||
query: 'my-skill'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes /skills search <query> to skills.manage', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
createSlashHandler(ctx)('/skills search vibe')
|
||||
expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
|
||||
action: 'search',
|
||||
query: 'vibe'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes /skills browse [page] to skills.manage with a numeric page', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
createSlashHandler(ctx)('/skills browse 3')
|
||||
expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
|
||||
action: 'browse',
|
||||
page: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('shows usage for an unknown /skills subcommand', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
createSlashHandler(ctx)('/skills zzz')
|
||||
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
|
||||
expect(ctx.transcript.sys).toHaveBeenCalledWith(expect.stringContaining('usage: /skills'))
|
||||
})
|
||||
|
||||
it('cycles details mode and persists it', async () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
|
||||
50
ui-tui/src/__tests__/reasoning.test.ts
Normal file
50
ui-tui/src/__tests__/reasoning.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js'
|
||||
|
||||
describe('splitReasoning', () => {
|
||||
it('extracts <think>…</think> and strips it from text', () => {
|
||||
const { reasoning, text } = splitReasoning('<think>plotting</think>\n\nhere is the answer')
|
||||
|
||||
expect(reasoning).toBe('plotting')
|
||||
expect(text).toBe('here is the answer')
|
||||
})
|
||||
|
||||
it('handles multiple tag shapes', () => {
|
||||
const input = '<reasoning>a</reasoning> <THINKING>b</THINKING> <thought>c</thought> body'
|
||||
const { reasoning, text } = splitReasoning(input)
|
||||
|
||||
expect(reasoning).toContain('a')
|
||||
expect(reasoning).toContain('b')
|
||||
expect(reasoning).toContain('c')
|
||||
expect(text).toBe('body')
|
||||
})
|
||||
|
||||
it('treats unclosed trailing <think>… as reasoning', () => {
|
||||
const { reasoning, text } = splitReasoning('answer start <think>still deciding')
|
||||
|
||||
expect(reasoning).toBe('still deciding')
|
||||
expect(text).toBe('answer start')
|
||||
})
|
||||
|
||||
it('returns empty reasoning and untouched text when no tags present', () => {
|
||||
const { reasoning, text } = splitReasoning('plain body with no tags')
|
||||
|
||||
expect(reasoning).toBe('')
|
||||
expect(text).toBe('plain body with no tags')
|
||||
})
|
||||
|
||||
it('preserves text when reasoning block is empty', () => {
|
||||
const { reasoning, text } = splitReasoning('<think></think>only body')
|
||||
|
||||
expect(reasoning).toBe('')
|
||||
expect(text).toBe('only body')
|
||||
})
|
||||
|
||||
it('detects presence of any supported tag', () => {
|
||||
expect(hasReasoningTag('pre <think>x</think> post')).toBe(true)
|
||||
expect(hasReasoningTag('pre <reasoning>x</reasoning>')).toBe(true)
|
||||
expect(hasReasoningTag('<REASONING_SCRATCHPAD>x</REASONING_SCRATCHPAD>')).toBe(true)
|
||||
expect(hasReasoningTag('no tags at all')).toBe(false)
|
||||
})
|
||||
})
|
||||
45
ui-tui/src/__tests__/syntax.test.ts
Normal file
45
ui-tui/src/__tests__/syntax.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { highlightLine, isHighlightable } from '../lib/syntax.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
const t = DEFAULT_THEME
|
||||
|
||||
describe('syntax highlighter', () => {
|
||||
it('recognizes supported langs and aliases', () => {
|
||||
expect(isHighlightable('ts')).toBe(true)
|
||||
expect(isHighlightable('js')).toBe(true)
|
||||
expect(isHighlightable('python')).toBe(true)
|
||||
expect(isHighlightable('rs')).toBe(true)
|
||||
expect(isHighlightable('bash')).toBe(true)
|
||||
expect(isHighlightable('whatever')).toBe(false)
|
||||
expect(isHighlightable('')).toBe(false)
|
||||
})
|
||||
|
||||
it('paints a whole-line comment dim', () => {
|
||||
const tokens = highlightLine('// hello', 'ts', t)
|
||||
|
||||
expect(tokens).toEqual([[t.color.dim, '// hello']])
|
||||
})
|
||||
|
||||
it('paints keywords, strings, and numbers in a ts line', () => {
|
||||
const tokens = highlightLine(`const x = 'hi' + 42`, 'ts', t)
|
||||
const colors = tokens.map(tok => tok[0])
|
||||
|
||||
expect(colors).toContain(t.color.bronze) // const
|
||||
expect(colors).toContain(t.color.amber) // 'hi'
|
||||
expect(colors).toContain(t.color.cornsilk) // 42
|
||||
})
|
||||
|
||||
it('falls through unchanged for unknown langs', () => {
|
||||
const tokens = highlightLine(`const x = 1`, 'zzz', t)
|
||||
|
||||
expect(tokens).toEqual([['', 'const x = 1']])
|
||||
})
|
||||
|
||||
it('treats `#` as a python comment, not a selector', () => {
|
||||
const tokens = highlightLine('# comment', 'py', t)
|
||||
|
||||
expect(tokens).toEqual([[t.color.dim, '# comment']])
|
||||
})
|
||||
})
|
||||
67
ui-tui/src/__tests__/useConfigSync.test.ts
Normal file
67
ui-tui/src/__tests__/useConfigSync.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $uiState, resetUiState } from '../app/uiStore.js'
|
||||
import { applyDisplay } from '../app/useConfigSync.js'
|
||||
|
||||
describe('applyDisplay', () => {
|
||||
beforeEach(() => {
|
||||
resetUiState()
|
||||
})
|
||||
|
||||
it('fans every display flag out to $uiState and the bell callback', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay(
|
||||
{
|
||||
config: {
|
||||
display: {
|
||||
bell_on_complete: true,
|
||||
details_mode: 'expanded',
|
||||
inline_diffs: false,
|
||||
show_cost: true,
|
||||
show_reasoning: true,
|
||||
streaming: false,
|
||||
tui_compact: true,
|
||||
tui_statusbar: false
|
||||
}
|
||||
}
|
||||
},
|
||||
setBell
|
||||
)
|
||||
|
||||
const s = $uiState.get()
|
||||
expect(setBell).toHaveBeenCalledWith(true)
|
||||
expect(s.compact).toBe(true)
|
||||
expect(s.detailsMode).toBe('expanded')
|
||||
expect(s.inlineDiffs).toBe(false)
|
||||
expect(s.showCost).toBe(true)
|
||||
expect(s.showReasoning).toBe(true)
|
||||
expect(s.statusBar).toBe(false)
|
||||
expect(s.streaming).toBe(false)
|
||||
})
|
||||
|
||||
it('applies v1 parity defaults when display fields are missing', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay({ config: { display: {} } }, setBell)
|
||||
|
||||
const s = $uiState.get()
|
||||
expect(setBell).toHaveBeenCalledWith(false)
|
||||
expect(s.inlineDiffs).toBe(true)
|
||||
expect(s.showCost).toBe(false)
|
||||
expect(s.showReasoning).toBe(false)
|
||||
expect(s.statusBar).toBe(true)
|
||||
expect(s.streaming).toBe(true)
|
||||
})
|
||||
|
||||
it('treats a null config like an empty display block', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay(null, setBell)
|
||||
|
||||
const s = $uiState.get()
|
||||
expect(setBell).toHaveBeenCalledWith(false)
|
||||
expect(s.inlineDiffs).toBe(true)
|
||||
expect(s.streaming).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -266,7 +266,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
||||
case 'tool.complete':
|
||||
turnController.recordToolComplete(ev.payload.tool_id, ev.payload.name, ev.payload.error, ev.payload.summary)
|
||||
|
||||
if (ev.payload.inline_diff) {
|
||||
if (ev.payload.inline_diff && getUiState().inlineDiffs) {
|
||||
sys(ev.payload.inline_diff)
|
||||
}
|
||||
|
||||
|
||||
14
ui-tui/src/app/inputSelectionStore.ts
Normal file
14
ui-tui/src/app/inputSelectionStore.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
export interface InputSelection {
|
||||
clear: () => void
|
||||
end: number
|
||||
start: number
|
||||
value: string
|
||||
}
|
||||
|
||||
export const $inputSelection = atom<InputSelection | null>(null)
|
||||
|
||||
export const setInputSelection = (next: InputSelection | null) => $inputSelection.set(next)
|
||||
|
||||
export const getInputSelection = () => $inputSelection.get()
|
||||
@@ -57,6 +57,7 @@ export interface OverlayState {
|
||||
pager: null | PagerState
|
||||
picker: boolean
|
||||
secret: null | SecretReq
|
||||
skillsHub: boolean
|
||||
sudo: null | SudoReq
|
||||
}
|
||||
|
||||
@@ -78,9 +79,13 @@ export interface UiState {
|
||||
compact: boolean
|
||||
detailsMode: DetailsMode
|
||||
info: null | SessionInfo
|
||||
inlineDiffs: boolean
|
||||
showCost: boolean
|
||||
showReasoning: boolean
|
||||
sid: null | string
|
||||
status: string
|
||||
statusBar: boolean
|
||||
streaming: boolean
|
||||
theme: Theme
|
||||
usage: Usage
|
||||
}
|
||||
@@ -335,5 +340,6 @@ export interface AppOverlaysProps {
|
||||
|
||||
export interface PasteSnippet {
|
||||
label: string
|
||||
path?: string
|
||||
text: string
|
||||
}
|
||||
|
||||
@@ -9,13 +9,16 @@ const buildOverlayState = (): OverlayState => ({
|
||||
pager: null,
|
||||
picker: false,
|
||||
secret: null,
|
||||
skillsHub: false,
|
||||
sudo: null
|
||||
})
|
||||
|
||||
export const $overlayState = atom<OverlayState>(buildOverlayState())
|
||||
|
||||
export const $isBlocked = computed($overlayState, ({ approval, clarify, modelPicker, pager, picker, secret, sudo }) =>
|
||||
Boolean(approval || clarify || modelPicker || pager || picker || secret || sudo)
|
||||
export const $isBlocked = computed(
|
||||
$overlayState,
|
||||
({ approval, clarify, modelPicker, pager, picker, secret, skillsHub, sudo }) =>
|
||||
Boolean(approval || clarify || modelPicker || pager || picker || secret || skillsHub || sudo)
|
||||
)
|
||||
|
||||
export const getOverlayState = () => $overlayState.get()
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { dailyFortune, randomFortune } from '../../../content/fortunes.js'
|
||||
import { HOTKEYS } from '../../../content/hotkeys.js'
|
||||
import { nextDetailsMode, parseDetailsMode } from '../../../domain/details.js'
|
||||
import type { ConfigGetValueResponse, ConfigSetResponse, SessionSteerResponse, SessionUndoResponse } from '../../../gatewayTypes.js'
|
||||
import type {
|
||||
ConfigGetValueResponse,
|
||||
ConfigSetResponse,
|
||||
SessionSteerResponse,
|
||||
SessionUndoResponse
|
||||
} from '../../../gatewayTypes.js'
|
||||
import { writeOsc52Clipboard } from '../../../lib/osc52.js'
|
||||
import type { DetailsMode, Msg, PanelSection } from '../../../types.js'
|
||||
import { patchOverlayState } from '../../overlayStore.js'
|
||||
@@ -259,19 +264,27 @@ export const coreCommands: SlashCommand[] = [
|
||||
// message isn't lost — identical semantics to the gateway handler.
|
||||
if (!ctx.ui.busy || !ctx.sid) {
|
||||
ctx.composer.enqueue(payload)
|
||||
ctx.transcript.sys(`no active turn — queued for next: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"`)
|
||||
ctx.transcript.sys(
|
||||
`no active turn — queued for next: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"`
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx.gateway.rpc<SessionSteerResponse>('session.steer', { session_id: ctx.sid, text: payload }).then(
|
||||
ctx.guarded<SessionSteerResponse>(r => {
|
||||
if (r?.status === 'queued') {
|
||||
ctx.transcript.sys(`⏩ steer queued — arrives after next tool call: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"`)
|
||||
} else {
|
||||
ctx.transcript.sys('steer rejected')
|
||||
}
|
||||
})
|
||||
).catch(ctx.guardedErr)
|
||||
ctx.gateway
|
||||
.rpc<SessionSteerResponse>('session.steer', { session_id: ctx.sid, text: payload })
|
||||
.then(
|
||||
ctx.guarded<SessionSteerResponse>(r => {
|
||||
if (r?.status === 'queued') {
|
||||
ctx.transcript.sys(
|
||||
`⏩ steer queued — arrives after next tool call: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"`
|
||||
)
|
||||
} else {
|
||||
ctx.transcript.sys('steer rejected')
|
||||
}
|
||||
})
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1,7 +1,209 @@
|
||||
import type { ToolsConfigureResponse } from '../../../gatewayTypes.js'
|
||||
import type { PanelSection } from '../../../types.js'
|
||||
import { patchOverlayState } from '../../overlayStore.js'
|
||||
import type { SlashCommand } from '../types.js'
|
||||
|
||||
interface SkillInfo {
|
||||
category?: string
|
||||
description?: string
|
||||
name?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
interface SkillsListResponse {
|
||||
skills?: Record<string, string[]>
|
||||
}
|
||||
|
||||
interface SkillsInspectResponse {
|
||||
info?: SkillInfo
|
||||
}
|
||||
|
||||
interface SkillsSearchResponse {
|
||||
results?: { description?: string; name: string }[]
|
||||
}
|
||||
|
||||
interface SkillsInstallResponse {
|
||||
installed?: boolean
|
||||
name?: string
|
||||
}
|
||||
|
||||
interface SkillsBrowseItem {
|
||||
description?: string
|
||||
name: string
|
||||
source?: string
|
||||
trust?: string
|
||||
}
|
||||
|
||||
interface SkillsBrowseResponse {
|
||||
items?: SkillsBrowseItem[]
|
||||
page?: number
|
||||
total?: number
|
||||
total_pages?: number
|
||||
}
|
||||
|
||||
export const opsCommands: SlashCommand[] = [
|
||||
{
|
||||
help: 'browse, inspect, install skills',
|
||||
name: 'skills',
|
||||
run: (arg, ctx) => {
|
||||
const text = arg.trim()
|
||||
|
||||
if (!text) {
|
||||
return patchOverlayState({ skillsHub: true })
|
||||
}
|
||||
|
||||
const [sub, ...rest] = text.split(/\s+/)
|
||||
const query = rest.join(' ').trim()
|
||||
const { rpc } = ctx.gateway
|
||||
const { page, panel, sys } = ctx.transcript
|
||||
|
||||
if (sub === 'list') {
|
||||
rpc<SkillsListResponse>('skills.manage', { action: 'list' })
|
||||
.then(
|
||||
ctx.guarded<SkillsListResponse>(r => {
|
||||
const cats = Object.entries(r.skills ?? {}).sort()
|
||||
|
||||
if (!cats.length) {
|
||||
return sys('no skills available')
|
||||
}
|
||||
|
||||
panel(
|
||||
'Skills',
|
||||
cats.map<PanelSection>(([title, items]) => ({ items, title }))
|
||||
)
|
||||
})
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'inspect') {
|
||||
if (!query) {
|
||||
return sys('usage: /skills inspect <name>')
|
||||
}
|
||||
|
||||
rpc<SkillsInspectResponse>('skills.manage', { action: 'inspect', query })
|
||||
.then(
|
||||
ctx.guarded<SkillsInspectResponse>(r => {
|
||||
const info = r.info ?? {}
|
||||
|
||||
if (!info.name) {
|
||||
return sys(`unknown skill: ${query}`)
|
||||
}
|
||||
|
||||
const rows: [string, string][] = [
|
||||
['Name', String(info.name)],
|
||||
['Category', String(info.category ?? '')],
|
||||
['Path', String(info.path ?? '')]
|
||||
]
|
||||
|
||||
const sections: PanelSection[] = [{ rows }]
|
||||
|
||||
if (info.description) {
|
||||
sections.push({ text: String(info.description) })
|
||||
}
|
||||
|
||||
panel('Skill', sections)
|
||||
})
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'search') {
|
||||
if (!query) {
|
||||
return sys('usage: /skills search <query>')
|
||||
}
|
||||
|
||||
rpc<SkillsSearchResponse>('skills.manage', { action: 'search', query })
|
||||
.then(
|
||||
ctx.guarded<SkillsSearchResponse>(r => {
|
||||
const results = r.results ?? []
|
||||
|
||||
if (!results.length) {
|
||||
return sys(`no results for: ${query}`)
|
||||
}
|
||||
|
||||
panel(`Search: ${query}`, [{ rows: results.map(s => [s.name, s.description ?? '']) }])
|
||||
})
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'install') {
|
||||
if (!query) {
|
||||
return sys('usage: /skills install <name or url>')
|
||||
}
|
||||
|
||||
sys(`installing ${query}…`)
|
||||
|
||||
rpc<SkillsInstallResponse>('skills.manage', { action: 'install', query })
|
||||
.then(
|
||||
ctx.guarded<SkillsInstallResponse>(r =>
|
||||
sys(r.installed ? `installed ${r.name ?? query}` : 'install failed')
|
||||
)
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (sub === 'browse') {
|
||||
const pageNum = query ? parseInt(query, 10) : 1
|
||||
|
||||
if (Number.isNaN(pageNum) || pageNum < 1) {
|
||||
return sys('usage: /skills browse [page] (page must be a positive number)')
|
||||
}
|
||||
|
||||
sys('fetching community skills (scans 6 sources, may take ~15s)…')
|
||||
|
||||
rpc<SkillsBrowseResponse>('skills.manage', { action: 'browse', page: pageNum })
|
||||
.then(
|
||||
ctx.guarded<SkillsBrowseResponse>(r => {
|
||||
const items = r.items ?? []
|
||||
|
||||
if (!items.length) {
|
||||
return sys(`no skills on page ${pageNum}${r.total ? ` (total ${r.total})` : ''}`)
|
||||
}
|
||||
|
||||
const rows: [string, string][] = items.map(s => [
|
||||
s.trust ? `${s.name} · ${s.trust}` : s.name,
|
||||
String(s.description ?? '').slice(0, 160)
|
||||
])
|
||||
|
||||
const footer: string[] = []
|
||||
|
||||
if (r.page && r.total_pages) {
|
||||
footer.push(`page ${r.page} of ${r.total_pages}`)
|
||||
}
|
||||
|
||||
if (r.total) {
|
||||
footer.push(`${r.total} skills total`)
|
||||
}
|
||||
|
||||
if (r.page && r.total_pages && r.page < r.total_pages) {
|
||||
footer.push(`/skills browse ${r.page + 1} for more`)
|
||||
}
|
||||
|
||||
panel(`Browse Skills${pageNum > 1 ? ` — p${pageNum}` : ''}`, [
|
||||
{ rows },
|
||||
...(footer.length ? [{ text: footer.join(' · ') }] : [])
|
||||
])
|
||||
})
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sys('usage: /skills [list | inspect <n> | install <n> | search <q> | browse [page]]')
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'enable or disable tools (client-side history reset on change)',
|
||||
name: 'tools',
|
||||
|
||||
@@ -6,9 +6,8 @@ import type { SlashCommand } from '../types.js'
|
||||
|
||||
export const setupCommands: SlashCommand[] = [
|
||||
{
|
||||
aliases: ['provider'],
|
||||
help: 'configure LLM provider and model (launches `hermes model`)',
|
||||
name: 'model',
|
||||
help: 'configure LLM provider + model (launches `hermes model`)',
|
||||
name: 'provider',
|
||||
run: (_arg, ctx) =>
|
||||
void runExternalSetup({
|
||||
args: ['model'],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { REASONING_PULSE_MS, STREAM_BATCH_MS } from '../config/timing.js'
|
||||
import type { SessionInterruptResponse, SubagentEventPayload } from '../gatewayTypes.js'
|
||||
import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js'
|
||||
import {
|
||||
buildToolTrailLine,
|
||||
estimateTokensRough,
|
||||
@@ -11,7 +12,7 @@ import type { ActiveTool, ActivityItem, Msg, SubagentProgress } from '../types.j
|
||||
|
||||
import { resetOverlayState } from './overlayStore.js'
|
||||
import { patchTurnState, resetTurnState } from './turnStore.js'
|
||||
import { patchUiState } from './uiStore.js'
|
||||
import { getUiState, patchUiState } from './uiStore.js'
|
||||
|
||||
const INTERRUPT_COOLDOWN_MS = 1500
|
||||
const ACTIVITY_LIMIT = 8
|
||||
@@ -121,18 +122,31 @@ class TurnController {
|
||||
}
|
||||
|
||||
flushStreamingSegment() {
|
||||
const text = this.bufRef.trimStart()
|
||||
const raw = this.bufRef.trimStart()
|
||||
|
||||
if (!text) {
|
||||
if (!raw) {
|
||||
return
|
||||
}
|
||||
|
||||
const tools = this.pendingSegmentTools
|
||||
const split = hasReasoningTag(raw) ? splitReasoning(raw) : { reasoning: '', text: raw }
|
||||
|
||||
if (split.reasoning && !this.reasoningText.trim()) {
|
||||
this.reasoningText = split.reasoning
|
||||
patchTurnState({ reasoning: this.reasoningText, reasoningTokens: estimateTokensRough(this.reasoningText) })
|
||||
}
|
||||
|
||||
const text = split.text
|
||||
|
||||
this.streamTimer = clear(this.streamTimer)
|
||||
this.segmentMessages = [...this.segmentMessages, { role: 'assistant', text, ...(tools.length && { tools }) }]
|
||||
|
||||
if (text) {
|
||||
const tools = this.pendingSegmentTools
|
||||
|
||||
this.segmentMessages = [...this.segmentMessages, { role: 'assistant', text, ...(tools.length && { tools }) }]
|
||||
this.pendingSegmentTools = []
|
||||
}
|
||||
|
||||
this.bufRef = ''
|
||||
this.pendingSegmentTools = []
|
||||
patchTurnState({ streamPendingTools: [], streamSegments: this.segmentMessages, streaming: '' })
|
||||
}
|
||||
|
||||
@@ -187,8 +201,11 @@ class TurnController {
|
||||
}
|
||||
|
||||
recordMessageComplete(payload: { rendered?: string; reasoning?: string; text?: string }) {
|
||||
const finalText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart()
|
||||
const savedReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
|
||||
const rawText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart()
|
||||
const split = splitReasoning(rawText)
|
||||
const finalText = split.text
|
||||
const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
|
||||
const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n')
|
||||
const savedReasoningTokens = savedReasoning ? estimateTokensRough(savedReasoning) : 0
|
||||
const savedToolTokens = this.toolTokenAcc
|
||||
const tools = this.pendingSegmentTools
|
||||
@@ -226,10 +243,17 @@ class TurnController {
|
||||
}
|
||||
|
||||
this.bufRef = rendered ?? this.bufRef + text
|
||||
this.scheduleStreaming()
|
||||
|
||||
if (getUiState().streaming) {
|
||||
this.scheduleStreaming()
|
||||
}
|
||||
}
|
||||
|
||||
recordReasoningAvailable(text: string) {
|
||||
if (!getUiState().showReasoning) {
|
||||
return
|
||||
}
|
||||
|
||||
const incoming = text.trim()
|
||||
|
||||
if (!incoming || this.reasoningText.trim()) {
|
||||
@@ -242,6 +266,10 @@ class TurnController {
|
||||
}
|
||||
|
||||
recordReasoningDelta(text: string) {
|
||||
if (!getUiState().showReasoning) {
|
||||
return
|
||||
}
|
||||
|
||||
this.reasoningText += text
|
||||
this.scheduleReasoning()
|
||||
this.pulseReasoningStreaming()
|
||||
@@ -344,7 +372,9 @@ class TurnController {
|
||||
|
||||
this.streamTimer = setTimeout(() => {
|
||||
this.streamTimer = null
|
||||
patchTurnState({ streaming: this.bufRef.trimStart() })
|
||||
const raw = this.bufRef.trimStart()
|
||||
const visible = hasReasoningTag(raw) ? splitReasoning(raw).text : raw
|
||||
patchTurnState({ streaming: visible })
|
||||
}, STREAM_BATCH_MS)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,13 @@ const buildUiState = (): UiState => ({
|
||||
compact: false,
|
||||
detailsMode: 'collapsed',
|
||||
info: null,
|
||||
inlineDiffs: true,
|
||||
showCost: false,
|
||||
showReasoning: false,
|
||||
sid: null,
|
||||
status: 'summoning hermes…',
|
||||
statusBar: true,
|
||||
streaming: true,
|
||||
theme: DEFAULT_THEME,
|
||||
usage: ZERO
|
||||
})
|
||||
|
||||
@@ -70,12 +70,25 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose
|
||||
|
||||
setPasteSnips(prev => [...prev, { label, text: cleanedText }].slice(-32))
|
||||
|
||||
void gw
|
||||
.request<{ path?: string }>('paste.collapse', { text: cleanedText })
|
||||
.then(r => {
|
||||
const path = r?.path
|
||||
|
||||
if (!path) {
|
||||
return
|
||||
}
|
||||
|
||||
setPasteSnips(prev => prev.map(s => (s.label === label ? { ...s, path } : s)))
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
return {
|
||||
cursor: cursor + insert.length,
|
||||
value: value.slice(0, cursor) + insert + value.slice(cursor)
|
||||
}
|
||||
},
|
||||
[onClipboardPaste]
|
||||
[gw, onClipboardPaste]
|
||||
)
|
||||
|
||||
const openEditor = useCallback(() => {
|
||||
|
||||
@@ -27,14 +27,18 @@ const quietRpc = async <T extends Record<string, any> = Record<string, any>>(
|
||||
}
|
||||
}
|
||||
|
||||
const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolean) => void) => {
|
||||
export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolean) => void) => {
|
||||
const d = cfg?.config?.display ?? {}
|
||||
|
||||
setBell(!!d.bell_on_complete)
|
||||
patchUiState({
|
||||
compact: !!d.tui_compact,
|
||||
detailsMode: resolveDetailsMode(d),
|
||||
statusBar: d.tui_statusbar !== false
|
||||
inlineDiffs: d.inline_diffs !== false,
|
||||
showCost: !!d.show_cost,
|
||||
showReasoning: !!d.show_reasoning,
|
||||
statusBar: d.tui_statusbar !== false,
|
||||
streaming: d.streaming !== false
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ import type {
|
||||
VoiceRecordResponse
|
||||
} from '../gatewayTypes.js'
|
||||
|
||||
import { writeOsc52Clipboard } from '../lib/osc52.js'
|
||||
|
||||
import { getInputSelection } from './inputSelectionStore.js'
|
||||
import type { InputHandlerContext, InputHandlerResult } from './interfaces.js'
|
||||
import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js'
|
||||
import { turnController } from './turnController.js'
|
||||
@@ -63,6 +66,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
return patchOverlayState({ modelPicker: false })
|
||||
}
|
||||
|
||||
if (overlay.skillsHub) {
|
||||
return patchOverlayState({ skillsHub: false })
|
||||
}
|
||||
|
||||
if (overlay.picker) {
|
||||
return patchOverlayState({ picker: false })
|
||||
}
|
||||
@@ -243,6 +250,15 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
return copySelection()
|
||||
}
|
||||
|
||||
const inputSel = getInputSelection()
|
||||
|
||||
if (inputSel && inputSel.end > inputSel.start) {
|
||||
writeOsc52Clipboard(inputSel.value.slice(inputSel.start, inputSel.end))
|
||||
inputSel.clear()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (live.busy && live.sid) {
|
||||
return turnController.interruptTurn({
|
||||
appendMessage: actions.appendMessage,
|
||||
|
||||
@@ -99,6 +99,7 @@ export function StatusRule({
|
||||
usage,
|
||||
bgCount,
|
||||
sessionStartedAt,
|
||||
showCost,
|
||||
voiceLabel,
|
||||
t
|
||||
}: StatusRuleProps) {
|
||||
@@ -136,6 +137,9 @@ export function StatusRule({
|
||||
) : null}
|
||||
{voiceLabel ? <Text color={t.color.dim}> │ {voiceLabel}</Text> : null}
|
||||
{bgCount > 0 ? <Text color={t.color.dim}> │ {bgCount} bg</Text> : null}
|
||||
{showCost && typeof usage.cost_usd === 'number' ? (
|
||||
<Text color={t.color.dim}> │ ${usage.cost_usd.toFixed(4)}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -285,6 +289,7 @@ interface StatusRuleProps {
|
||||
cwdLabel: string
|
||||
model: string
|
||||
sessionStartedAt?: number | null
|
||||
showCost: boolean
|
||||
status: string
|
||||
statusColor: string
|
||||
t: Theme
|
||||
|
||||
@@ -190,6 +190,7 @@ const ComposerPane = memo(function ComposerPane({
|
||||
cwdLabel={status.cwdLabel}
|
||||
model={ui.info?.model?.split('/').pop() ?? ''}
|
||||
sessionStartedAt={status.sessionStartedAt}
|
||||
showCost={ui.showCost}
|
||||
status={ui.status}
|
||||
statusColor={status.statusColor}
|
||||
t={ui.theme}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { MaskedPrompt } from './maskedPrompt.js'
|
||||
import { ModelPicker } from './modelPicker.js'
|
||||
import { ApprovalPrompt, ClarifyPrompt } from './prompts.js'
|
||||
import { SessionPicker } from './sessionPicker.js'
|
||||
import { SkillsHub } from './skillsHub.js'
|
||||
|
||||
export function PromptZone({
|
||||
cols,
|
||||
@@ -82,7 +83,7 @@ export function FloatingOverlays({
|
||||
const overlay = useStore($overlayState)
|
||||
const ui = useStore($uiState)
|
||||
|
||||
const hasAny = overlay.modelPicker || overlay.pager || overlay.picker || completions.length
|
||||
const hasAny = overlay.modelPicker || overlay.pager || overlay.picker || overlay.skillsHub || completions.length
|
||||
|
||||
if (!hasAny) {
|
||||
return null
|
||||
@@ -115,6 +116,12 @@ export function FloatingOverlays({
|
||||
</FloatBox>
|
||||
)}
|
||||
|
||||
{overlay.skillsHub && (
|
||||
<FloatBox color={ui.theme.color.bronze}>
|
||||
<SkillsHub gw={gw} onClose={() => patchOverlayState({ skillsHub: false })} t={ui.theme} />
|
||||
</FloatBox>
|
||||
)}
|
||||
|
||||
{overlay.pager && (
|
||||
<FloatBox color={ui.theme.color.bronze}>
|
||||
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
||||
|
||||
@@ -126,11 +126,36 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
|
||||
|
||||
{section('Tools', info.tools, 8, 'more toolsets…')}
|
||||
{section('Skills', info.skills)}
|
||||
|
||||
{info.mcp_servers && info.mcp_servers.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color={t.color.amber}>
|
||||
MCP Servers
|
||||
</Text>
|
||||
|
||||
{info.mcp_servers.map(s => (
|
||||
<Text key={s.name} wrap="truncate">
|
||||
<Text color={t.color.dim}>{` ${s.name} `}</Text>
|
||||
<Text color={t.color.dim}>{`[${s.transport}]`}</Text>
|
||||
<Text color={t.color.dim}>: </Text>
|
||||
{s.connected ? (
|
||||
<Text color={t.color.cornsilk}>
|
||||
{s.tools} tool{s.tools === 1 ? '' : 's'}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={t.color.error}>failed</Text>
|
||||
)}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Text />
|
||||
|
||||
<Text color={t.color.cornsilk}>
|
||||
{flat(info.tools).length} tools{' · '}
|
||||
{flat(info.skills).length} skills
|
||||
{info.mcp_servers?.length ? ` · ${info.mcp_servers.length} MCP` : ''}
|
||||
{' · '}
|
||||
<Text color={t.color.dim}>/help for commands</Text>
|
||||
</Text>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Link, Text } from '@hermes/ink'
|
||||
import { memo, type ReactNode, useMemo } from 'react'
|
||||
|
||||
import { highlightLine, isHighlightable } from '../lib/syntax.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
const FENCE_RE = /^\s*(`{3,}|~{3,})(.*)$/
|
||||
@@ -39,13 +40,17 @@ const trimBareUrl = (value: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const renderAutolink = (key: number, t: Theme, raw: string) => (
|
||||
<Link key={key} url={raw}>
|
||||
<Text color={t.color.amber} underline>
|
||||
{raw.replace(/^mailto:/, '')}
|
||||
</Text>
|
||||
</Link>
|
||||
)
|
||||
const renderAutolink = (key: number, t: Theme, raw: string) => {
|
||||
const url = raw.startsWith('mailto:') ? raw : raw.includes('@') && !raw.startsWith('http') ? `mailto:${raw}` : raw
|
||||
|
||||
return (
|
||||
<Link key={key} url={url}>
|
||||
<Text color={t.color.amber} underline>
|
||||
{raw.replace(/^mailto:/, '')}
|
||||
</Text>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const indentDepth = (indent: string) => Math.floor(indent.replace(/\t/g, ' ').length / 2)
|
||||
|
||||
@@ -286,11 +291,28 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
start('code')
|
||||
|
||||
const isDiff = lang === 'diff'
|
||||
const highlighted = !isDiff && isHighlightable(lang)
|
||||
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key} paddingLeft={2}>
|
||||
{lang && !isDiff && <Text color={t.color.dim}>{'─ ' + lang}</Text>}
|
||||
{block.map((l, j) => {
|
||||
if (highlighted) {
|
||||
return (
|
||||
<Text key={j}>
|
||||
{highlightLine(l, lang, t).map(([color, text], k) =>
|
||||
color ? (
|
||||
<Text color={color} key={k}>
|
||||
{text}
|
||||
</Text>
|
||||
) : (
|
||||
<Text key={k}>{text}</Text>
|
||||
)
|
||||
)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
const add = isDiff && l.startsWith('+')
|
||||
const del = isDiff && l.startsWith('-')
|
||||
const hunk = isDiff && l.startsWith('@@')
|
||||
|
||||
@@ -35,7 +35,9 @@ export const MessageLine = memo(function MessageLine({
|
||||
return (
|
||||
<Box alignSelf="flex-start" borderColor={t.color.dim} borderStyle="round" marginLeft={3} paddingX={1}>
|
||||
{hasAnsi(msg.text) ? (
|
||||
<Text wrap="truncate-end"><Ansi>{msg.text}</Ansi></Text>
|
||||
<Text wrap="truncate-end">
|
||||
<Ansi>{msg.text}</Ansi>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
{preview}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TextInput } from './textInput.js'
|
||||
|
||||
const OPTS = ['once', 'session', 'always', 'deny'] as const
|
||||
const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const
|
||||
const CMD_PREVIEW_LINES = 10
|
||||
|
||||
export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
|
||||
const [sel, setSel] = useState(0)
|
||||
@@ -34,13 +35,30 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
|
||||
}
|
||||
})
|
||||
|
||||
const rawLines = req.command.split('\n')
|
||||
const shown = rawLines.slice(0, CMD_PREVIEW_LINES)
|
||||
const overflow = rawLines.length - shown.length
|
||||
|
||||
return (
|
||||
<Box borderColor={t.color.warn} borderStyle="double" flexDirection="column" paddingX={1}>
|
||||
<Text bold color={t.color.warn}>
|
||||
⚠ approval required · {req.description}
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.cornsilk}> {req.command}</Text>
|
||||
<Box flexDirection="column" paddingLeft={1}>
|
||||
{shown.map((line, i) => (
|
||||
<Text color={t.color.cornsilk} key={i} wrap="truncate-end">
|
||||
{line || ' '}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{overflow > 0 ? (
|
||||
<Text color={t.color.dim}>
|
||||
… +{overflow} more line{overflow === 1 ? '' : 's'} (full text above)
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
<Text />
|
||||
|
||||
{OPTS.map((o, i) => (
|
||||
|
||||
296
ui-tui/src/components/skillsHub.tsx
Normal file
296
ui-tui/src/components/skillsHub.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import { Box, Text, useInput } from '@hermes/ink'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { GatewayClient } from '../gatewayClient.js'
|
||||
import { rpcErrorMessage } from '../lib/rpc.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
const VISIBLE = 12
|
||||
|
||||
const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE))
|
||||
|
||||
const visibleItems = (items: string[], sel: number) => {
|
||||
const off = pageOffset(items.length, sel)
|
||||
|
||||
return { items: items.slice(off, off + VISIBLE), off }
|
||||
}
|
||||
|
||||
export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
|
||||
const [skillsByCat, setSkillsByCat] = useState<Record<string, string[]>>({})
|
||||
const [selectedCat, setSelectedCat] = useState('')
|
||||
const [catIdx, setCatIdx] = useState(0)
|
||||
const [skillIdx, setSkillIdx] = useState(0)
|
||||
const [stage, setStage] = useState<'actions' | 'category' | 'skill'>('category')
|
||||
const [info, setInfo] = useState<null | SkillInfo>(null)
|
||||
const [installing, setInstalling] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
gw.request<{ skills?: Record<string, string[]> }>('skills.manage', { action: 'list' })
|
||||
.then(r => {
|
||||
setSkillsByCat(r?.skills ?? {})
|
||||
setErr('')
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
setErr(rpcErrorMessage(e))
|
||||
setLoading(false)
|
||||
})
|
||||
}, [gw])
|
||||
|
||||
const cats = Object.keys(skillsByCat).sort()
|
||||
const skills = selectedCat ? (skillsByCat[selectedCat] ?? []) : []
|
||||
const skillName = skills[skillIdx] ?? ''
|
||||
|
||||
const inspect = (name: string) => {
|
||||
setInfo(null)
|
||||
setErr('')
|
||||
|
||||
gw.request<{ info?: SkillInfo }>('skills.manage', { action: 'inspect', query: name })
|
||||
.then(r => setInfo(r?.info ?? { name }))
|
||||
.catch((e: unknown) => setErr(rpcErrorMessage(e)))
|
||||
}
|
||||
|
||||
const install = (name: string) => {
|
||||
setInstalling(true)
|
||||
setErr('')
|
||||
|
||||
gw.request<{ installed?: boolean; name?: string }>('skills.manage', { action: 'install', query: name })
|
||||
.then(() => onClose())
|
||||
.catch((e: unknown) => setErr(rpcErrorMessage(e)))
|
||||
.finally(() => setInstalling(false))
|
||||
}
|
||||
|
||||
useInput((ch, key) => {
|
||||
if (installing) {
|
||||
return
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
if (stage === 'actions') {
|
||||
setStage('skill')
|
||||
setInfo(null)
|
||||
setErr('')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (stage === 'skill') {
|
||||
setStage('category')
|
||||
setSkillIdx(0)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
onClose()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (stage === 'actions') {
|
||||
if (key.return) {
|
||||
setStage('skill')
|
||||
setInfo(null)
|
||||
setErr('')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (ch.toLowerCase() === 'x' && skillName) {
|
||||
install(skillName)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (ch.toLowerCase() === 'i' && skillName) {
|
||||
inspect(skillName)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const count = stage === 'category' ? cats.length : skills.length
|
||||
const sel = stage === 'category' ? catIdx : skillIdx
|
||||
const setSel = stage === 'category' ? setCatIdx : setSkillIdx
|
||||
|
||||
if (key.upArrow && sel > 0) {
|
||||
setSel(v => v - 1)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (key.downArrow && sel < count - 1) {
|
||||
setSel(v => v + 1)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
if (stage === 'category') {
|
||||
const cat = cats[catIdx]
|
||||
|
||||
if (!cat) {
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedCat(cat)
|
||||
setSkillIdx(0)
|
||||
setStage('skill')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const name = skills[skillIdx]
|
||||
|
||||
if (name) {
|
||||
setStage('actions')
|
||||
inspect(name)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const n = ch === '0' ? 10 : parseInt(ch, 10)
|
||||
|
||||
if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, count)) {
|
||||
const off = pageOffset(count, sel)
|
||||
const next = off + n - 1
|
||||
|
||||
if (stage === 'category') {
|
||||
const cat = cats[next]
|
||||
|
||||
if (cat) {
|
||||
setSelectedCat(cat)
|
||||
setCatIdx(next)
|
||||
setSkillIdx(0)
|
||||
setStage('skill')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const name = skills[next]
|
||||
|
||||
if (name) {
|
||||
setSkillIdx(next)
|
||||
setStage('actions')
|
||||
inspect(name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return <Text color={t.color.dim}>loading skills…</Text>
|
||||
}
|
||||
|
||||
if (err && stage === 'category') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={t.color.label}>error: {err}</Text>
|
||||
<Text color={t.color.dim}>Esc to cancel</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (!cats.length) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={t.color.dim}>no skills available</Text>
|
||||
<Text color={t.color.dim}>Esc to cancel</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (stage === 'category') {
|
||||
const rows = cats.map(c => `${c} · ${skillsByCat[c]?.length ?? 0} skills`)
|
||||
const { items, off } = visibleItems(rows, catIdx)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.amber}>
|
||||
Skills Hub
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim}>select a category</Text>
|
||||
{off > 0 && <Text color={t.color.dim}> ↑ {off} more</Text>}
|
||||
|
||||
{items.map((row, i) => {
|
||||
const idx = off + i
|
||||
|
||||
return (
|
||||
<Text color={catIdx === idx ? t.color.cornsilk : t.color.dim} key={row}>
|
||||
{catIdx === idx ? '▸ ' : ' '}
|
||||
{i + 1}. {row}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
|
||||
{off + VISIBLE < rows.length && <Text color={t.color.dim}> ↓ {rows.length - off - VISIBLE} more</Text>}
|
||||
<Text color={t.color.dim}>↑/↓ select · Enter open · 1-9,0 quick · Esc cancel</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (stage === 'skill') {
|
||||
const { items, off } = visibleItems(skills, skillIdx)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.amber}>
|
||||
{selectedCat}
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim}>{skills.length} skill(s)</Text>
|
||||
{!skills.length ? <Text color={t.color.dim}>no skills in this category</Text> : null}
|
||||
{off > 0 && <Text color={t.color.dim}> ↑ {off} more</Text>}
|
||||
|
||||
{items.map((row, i) => {
|
||||
const idx = off + i
|
||||
|
||||
return (
|
||||
<Text color={skillIdx === idx ? t.color.cornsilk : t.color.dim} key={row}>
|
||||
{skillIdx === idx ? '▸ ' : ' '}
|
||||
{i + 1}. {row}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
|
||||
{off + VISIBLE < skills.length && <Text color={t.color.dim}> ↓ {skills.length - off - VISIBLE} more</Text>}
|
||||
<Text color={t.color.dim}>
|
||||
{skills.length ? '↑/↓ select · Enter open · 1-9,0 quick · Esc back' : 'Esc back'}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.amber}>
|
||||
{info?.name ?? skillName}
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.dim}>{info?.category ?? selectedCat}</Text>
|
||||
{info?.description ? <Text color={t.color.cornsilk}>{info.description}</Text> : null}
|
||||
{info?.path ? <Text color={t.color.dim}>path: {info.path}</Text> : null}
|
||||
{!info && !err ? <Text color={t.color.dim}>loading…</Text> : null}
|
||||
{err ? <Text color={t.color.label}>error: {err}</Text> : null}
|
||||
{installing ? <Text color={t.color.amber}>installing…</Text> : null}
|
||||
|
||||
<Text color={t.color.dim}>i reinspect · x reinstall · Enter/Esc back</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface SkillInfo {
|
||||
category?: string
|
||||
description?: string
|
||||
name?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
interface SkillsHubProps {
|
||||
gw: GatewayClient
|
||||
onClose: () => void
|
||||
t: Theme
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import type { InputEvent, Key } from '@hermes/ink'
|
||||
import * as Ink from '@hermes/ink'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { setInputSelection } from '../app/inputSelectionStore.js'
|
||||
|
||||
type InkExt = typeof Ink & {
|
||||
stringWidth: (s: string) => number
|
||||
useDeclaredCursor: (a: { line: number; column: number; active: boolean }) => (el: any) => void
|
||||
@@ -351,6 +353,28 @@ export function TextInput({
|
||||
}
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
if (!focus) {
|
||||
return
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
setInputSelection({
|
||||
clear: () => {
|
||||
selRef.current = null
|
||||
setSel(null)
|
||||
},
|
||||
end: selected.end,
|
||||
start: selected.start,
|
||||
value: vRef.current
|
||||
})
|
||||
} else {
|
||||
setInputSelection(null)
|
||||
}
|
||||
|
||||
return () => setInputSelection(null)
|
||||
}, [focus, selected])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (pasteTimer.current) {
|
||||
@@ -464,7 +488,7 @@ export function TextInput({
|
||||
(inp: string, k: Key, event: InputEvent) => {
|
||||
const eventRaw = event.keypress.raw
|
||||
|
||||
if (eventRaw === '\x1bv' || eventRaw === '\x1bV') {
|
||||
if (eventRaw === '\x1bv' || eventRaw === '\x1bV' || eventRaw === '\x16') {
|
||||
return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,10 @@ export type CommandDispatchResponse =
|
||||
export interface ConfigDisplayConfig {
|
||||
bell_on_complete?: boolean
|
||||
details_mode?: string
|
||||
inline_diffs?: boolean
|
||||
show_cost?: boolean
|
||||
show_reasoning?: boolean
|
||||
streaming?: boolean
|
||||
thinking_mode?: string
|
||||
tui_compact?: boolean
|
||||
tui_statusbar?: boolean
|
||||
|
||||
50
ui-tui/src/lib/reasoning.ts
Normal file
50
ui-tui/src/lib/reasoning.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
const TAGS = ['think', 'reasoning', 'thinking', 'thought', 'REASONING_SCRATCHPAD'] as const
|
||||
|
||||
export interface SplitReasoning {
|
||||
reasoning: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export function splitReasoning(input: string): SplitReasoning {
|
||||
let text = input
|
||||
const reasoning: string[] = []
|
||||
|
||||
for (const tag of TAGS) {
|
||||
const paired = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>\\s*`, 'gi')
|
||||
text = text.replace(paired, (_m, inner: string) => {
|
||||
const trimmed = inner.trim()
|
||||
|
||||
if (trimmed) {
|
||||
reasoning.push(trimmed)
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
const unclosed = new RegExp(`<${tag}>([\\s\\S]*)$`, 'i')
|
||||
text = text.replace(unclosed, (_m, inner: string) => {
|
||||
const trimmed = inner.trim()
|
||||
|
||||
if (trimmed) {
|
||||
reasoning.push(trimmed)
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
reasoning: reasoning.join('\n\n').trim(),
|
||||
text: text.trim()
|
||||
}
|
||||
}
|
||||
|
||||
export const hasReasoningTag = (input: string) => {
|
||||
for (const tag of TAGS) {
|
||||
if (input.includes(`<${tag}>`)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
117
ui-tui/src/lib/syntax.ts
Normal file
117
ui-tui/src/lib/syntax.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
export type Token = [string, string]
|
||||
|
||||
interface LangSpec {
|
||||
comment: null | string
|
||||
keywords: Set<string>
|
||||
}
|
||||
|
||||
const KW = (s: string) => new Set(s.split(/\s+/).filter(Boolean))
|
||||
|
||||
const TS = KW(`
|
||||
abstract as async await break case catch class const continue debugger default delete do else enum export extends
|
||||
false finally for from function get if implements import in instanceof interface is let new null of package private
|
||||
protected public readonly return set static super switch this throw true try type typeof undefined var void while
|
||||
with yield
|
||||
`)
|
||||
|
||||
const PY = KW(`
|
||||
False None True and as assert async await break class continue def del elif else except finally for from global if
|
||||
import in is lambda nonlocal not or pass raise return try while with yield
|
||||
`)
|
||||
|
||||
const SH = KW(`
|
||||
if then else elif fi for in do done while until case esac function return break continue local export readonly
|
||||
declare typeset
|
||||
`)
|
||||
|
||||
const GO = KW(`
|
||||
break case chan const continue default defer else fallthrough for func go goto if import interface map package range
|
||||
return select struct switch type var nil true false
|
||||
`)
|
||||
|
||||
const RUST = KW(`
|
||||
as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move mut
|
||||
pub ref return self Self static struct super trait true type unsafe use where while yield
|
||||
`)
|
||||
|
||||
const SQL = KW(`
|
||||
select from where and or not in is null as by group order limit offset insert into values update set delete create
|
||||
table drop alter add column primary key foreign references join left right inner outer on
|
||||
`)
|
||||
|
||||
const LANGS: Record<string, LangSpec> = {
|
||||
go: { comment: '//', keywords: GO },
|
||||
json: { comment: null, keywords: KW('true false null') },
|
||||
py: { comment: '#', keywords: PY },
|
||||
rust: { comment: '//', keywords: RUST },
|
||||
sh: { comment: '#', keywords: SH },
|
||||
sql: { comment: '--', keywords: SQL },
|
||||
ts: { comment: '//', keywords: TS },
|
||||
yaml: { comment: '#', keywords: KW('true false null yes no on off') }
|
||||
}
|
||||
|
||||
const ALIAS: Record<string, string> = {
|
||||
bash: 'sh',
|
||||
javascript: 'ts',
|
||||
js: 'ts',
|
||||
jsx: 'ts',
|
||||
python: 'py',
|
||||
rs: 'rust',
|
||||
shell: 'sh',
|
||||
tsx: 'ts',
|
||||
typescript: 'ts',
|
||||
yml: 'yaml',
|
||||
zsh: 'sh'
|
||||
}
|
||||
|
||||
const resolve = (lang: string): LangSpec | null => LANGS[ALIAS[lang] ?? lang] ?? null
|
||||
|
||||
export const isHighlightable = (lang: string): boolean => resolve(lang) !== null
|
||||
|
||||
const TOKEN_RE = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`|\b\d+(?:\.\d+)?\b|[A-Za-z_$][\w$]*/g
|
||||
|
||||
export function highlightLine(line: string, lang: string, t: Theme): Token[] {
|
||||
const spec = resolve(lang)
|
||||
|
||||
if (!spec) {
|
||||
return [['', line]]
|
||||
}
|
||||
|
||||
if (spec.comment && line.trimStart().startsWith(spec.comment)) {
|
||||
return [[t.color.dim, line]]
|
||||
}
|
||||
|
||||
const tokens: Token[] = []
|
||||
let last = 0
|
||||
|
||||
for (const m of line.matchAll(TOKEN_RE)) {
|
||||
const start = m.index ?? 0
|
||||
|
||||
if (start > last) {
|
||||
tokens.push(['', line.slice(last, start)])
|
||||
}
|
||||
|
||||
const tok = m[0]
|
||||
const ch = tok[0]!
|
||||
|
||||
if (ch === '"' || ch === "'" || ch === '`') {
|
||||
tokens.push([t.color.amber, tok])
|
||||
} else if (ch >= '0' && ch <= '9') {
|
||||
tokens.push([t.color.cornsilk, tok])
|
||||
} else if (spec.keywords.has(tok)) {
|
||||
tokens.push([t.color.bronze, tok])
|
||||
} else {
|
||||
tokens.push(['', tok])
|
||||
}
|
||||
|
||||
last = start + tok.length
|
||||
}
|
||||
|
||||
if (last < line.length) {
|
||||
tokens.push(['', line.slice(last)])
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
@@ -51,8 +51,16 @@ export type Role = 'assistant' | 'system' | 'tool' | 'user'
|
||||
export type DetailsMode = 'hidden' | 'collapsed' | 'expanded'
|
||||
export type ThinkingMode = 'collapsed' | 'truncated' | 'full'
|
||||
|
||||
export interface McpServerStatus {
|
||||
connected: boolean
|
||||
name: string
|
||||
tools: number
|
||||
transport: string
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
cwd?: string
|
||||
mcp_servers?: McpServerStatus[]
|
||||
model: string
|
||||
release_date?: string
|
||||
skills: Record<string, string[]>
|
||||
@@ -68,6 +76,7 @@ export interface Usage {
|
||||
context_max?: number
|
||||
context_percent?: number
|
||||
context_used?: number
|
||||
cost_usd?: number
|
||||
input: number
|
||||
output: number
|
||||
total: number
|
||||
|
||||
6
ui-tui/src/types/hermes-ink.d.ts
vendored
6
ui-tui/src/types/hermes-ink.d.ts
vendored
@@ -63,7 +63,11 @@ declare module '@hermes/ink' {
|
||||
export const Box: React.ComponentType<any>
|
||||
export const AlternateScreen: React.ComponentType<any>
|
||||
export const Ansi: React.ComponentType<any>
|
||||
export const Link: React.ComponentType<{ readonly url: string; readonly children?: React.ReactNode; readonly fallback?: React.ReactNode }>
|
||||
export const Link: React.ComponentType<{
|
||||
readonly children?: React.ReactNode
|
||||
readonly fallback?: React.ReactNode
|
||||
readonly url: string
|
||||
}>
|
||||
export const NoSelect: React.ComponentType<any>
|
||||
export const ScrollBox: React.ComponentType<any>
|
||||
export const Text: React.ComponentType<any>
|
||||
|
||||
Reference in New Issue
Block a user