File size: 6,235 Bytes
48ea638 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 | import { parseCommandDispatch, parseSlashCommand } from '@hermes/shared/slash'
import type { SlashExecResponse } from '../gatewayTypes.js'
import { rpcErrorMessage } from '../lib/rpc.js'
import { launchWidget } from '../sdk/host.js'
import { getWidgetApp } from '../sdk/registry.js'
import type { SlashHandlerContext } from './interfaces.js'
import { scoreSlashMenuItem } from './slash/fuzzyScore.js'
import { findSlashCommand } from './slash/registry.js'
import type { SlashRunCtx } from './slash/types.js'
import { getUiState } from './uiStore.js'
export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean {
const { gw } = ctx.gateway
const { catalog } = ctx.local
const { page, send, sys } = ctx.transcript
const handler = (cmd: string): boolean => {
const flight = ++ctx.slashFlightRef.current
const ui = getUiState()
const sid = ui.sid
const parsed = parseSlashCommand(cmd)
const argTail = parsed.arg ? ` ${parsed.arg}` : ''
const stale = () => flight !== ctx.slashFlightRef.current || getUiState().sid !== sid
const guarded =
<T>(fn: (r: T) => void) =>
(r: null | T): void => {
if (!stale() && r) {
fn(r)
}
}
const guardedErr = (e: unknown) => {
if (!stale()) {
sys(`error: ${rpcErrorMessage(e)}`)
}
}
const runCtx: SlashRunCtx = { ...ctx, flight, guarded, guardedErr, sid, stale, ui }
const found = findSlashCommand(parsed.name)
if (found) {
found.run(parsed.arg, runCtx, cmd)
return true
}
// Registry-first fallback: widget apps registered AFTER the static
// command table was built (user widgets from $HERMES_HOME/tui-widgets,
// /widgets-reload) dispatch straight off the live registry.
if (getWidgetApp(parsed.name)) {
const err = launchWidget(parsed.name, parsed.arg)
if (err) {
sys(err)
}
return true
}
if (catalog?.canon) {
const needle = `/${parsed.name}`.toLowerCase()
const exact = Object.entries(catalog.canon).find(([alias]) => alias.toLowerCase() === needle)?.[1]
if (exact) {
if (exact.toLowerCase() !== needle) {
return handler(`${exact}${argTail}`)
}
} else {
// Tiered name scoring (ported from grok-cli's slash menu): prefix
// matches rank above substring matches, so `/hea` still resolves to
// /heartbeat while `/beat` now finds it too instead of dead-ending.
// Only the best tier survives — a substring hit never widens an
// unambiguous prefix hit into an "ambiguous command" complaint.
// Description tiers (score >= 3) are a completion-menu concern and
// never auto-execute a command here.
const scored = Object.entries(catalog.canon)
.map(([alias, canon]) => ({ canon, score: scoreSlashMenuItem({ id: alias.slice(1) }, needle.slice(1)) }))
.filter(entry => entry.score < 3)
const best = Math.min(...scored.map(entry => entry.score))
const matches = [...new Set(scored.filter(entry => entry.score === best).map(entry => entry.canon))]
if (matches.length === 1 && matches[0]!.toLowerCase() !== needle) {
return handler(`${matches[0]}${argTail}`)
}
if (matches.length > 1) {
sys(`ambiguous command: ${matches.slice(0, 6).join(', ')}${matches.length > 6 ? ', …' : ''}`)
return true
}
}
}
const handleDispatch = (raw: unknown): void => {
const d = parseCommandDispatch(raw)
if (!d) {
return sys('error: invalid response: command.dispatch')
}
if (d.type === 'exec' || d.type === 'plugin') {
return sys(d.output || '(no output)')
}
if (d.type === 'alias') {
return void handler(`/${d.target}${argTail}`)
}
// A skill/bundle dispatch's `message` is the expanded skill body —
// model-facing scaffolding. `display` is the invocation the gateway
// projected; the transcript shows that instead. An ordinary send has no
// projection and goes through unchanged. No client-side fallback here:
// the TUI spawns its gateway from this same checkout, so the two can't
// version-skew (unlike the desktop, which can meet an older backend).
const sendDispatch = (display: string | undefined, message: string) => {
const shown = display?.trim()
return shown ? send(message, true, shown) : send(message)
}
if (d.type === 'skill') {
return d.message?.trim()
? sendDispatch(d.display, d.message)
: sys(`/${parsed.name}: skill payload missing message`)
}
if (d.type === 'send') {
if (d.notice?.trim()) {
sys(d.notice)
}
return d.message?.trim() ? sendDispatch(d.display, d.message) : sys(`/${parsed.name}: empty message`)
}
if (d.type === 'prefill') {
// /undo returns prefill: drop the backed-up message text into
// the composer so the user can edit and resubmit, instead of
// submitting it immediately like 'send'.
if (d.notice?.trim()) {
sys(d.notice)
}
if (d.message) {
ctx.composer.setInput(d.message)
}
}
}
gw.request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: sid })
.then(r => {
if (stale()) {
return
}
if (parseCommandDispatch(r)) {
return handleDispatch(r)
}
const body = r?.output || `/${parsed.name}: no output`
const text = r?.warning ? `warning: ${r.warning}\n${body}` : body
const long = text.length > 180 || text.split('\n').filter(Boolean).length > 2
long ? page(text, parsed.name[0]!.toUpperCase() + parsed.name.slice(1)) : sys(text)
})
.catch(() => {
gw.request('command.dispatch', { arg: parsed.arg, name: parsed.name, session_id: sid })
.then((raw: unknown) => {
if (stale()) {
return
}
handleDispatch(raw)
})
.catch(guardedErr)
})
return true
}
return handler
}
|