forked from Zakaria/hermes-agent
637 lines
18 KiB
TypeScript
637 lines
18 KiB
TypeScript
import { forceRedraw, type MouseTrackingMode } from '@hermes/ink'
|
|
|
|
import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js'
|
|
import { dailyFortune, randomFortune } from '../../../content/fortunes.js'
|
|
import { HOTKEYS } from '../../../content/hotkeys.js'
|
|
import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from '../../../domain/details.js'
|
|
import type {
|
|
ConfigGetValueResponse,
|
|
ConfigSetResponse,
|
|
SessionSaveResponse,
|
|
SessionStatusResponse,
|
|
SessionSteerResponse,
|
|
SessionTitleResponse,
|
|
SessionUndoResponse
|
|
} from '../../../gatewayTypes.js'
|
|
import { writeClipboardText } from '../../../lib/clipboard.js'
|
|
import { writeOsc52Clipboard } from '../../../lib/osc52.js'
|
|
import { configureDetectedTerminalKeybindings, configureTerminalKeybindings } from '../../../lib/terminalSetup.js'
|
|
import type { Msg, PanelSection } from '../../../types.js'
|
|
import type { StatusBarMode } from '../../interfaces.js'
|
|
import { patchOverlayState } from '../../overlayStore.js'
|
|
import { patchUiState } from '../../uiStore.js'
|
|
import type { SlashCommand } from '../types.js'
|
|
|
|
const flagFromArg = (arg: string, current: boolean): boolean | null => {
|
|
if (!arg) {
|
|
return !current
|
|
}
|
|
|
|
const mode = arg.trim().toLowerCase()
|
|
|
|
if (mode === 'on') {
|
|
return true
|
|
}
|
|
|
|
if (mode === 'off') {
|
|
return false
|
|
}
|
|
|
|
if (mode === 'toggle') {
|
|
return !current
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
// `/mouse` toggles between full tracking and off when called bare so the
|
|
// old binary muscle-memory still works. Explicit presets (wheel / buttons /
|
|
// all) target the tmux-friendly hover-free subsets.
|
|
const MOUSE_MODE_ALIASES: Record<string, MouseTrackingMode> = {
|
|
all: 'all',
|
|
any: 'all',
|
|
button: 'buttons',
|
|
buttons: 'buttons',
|
|
click: 'buttons',
|
|
full: 'all',
|
|
off: 'off',
|
|
on: 'all',
|
|
scroll: 'wheel',
|
|
wheel: 'wheel'
|
|
}
|
|
|
|
const mouseModeFromArg = (arg: string, current: MouseTrackingMode): MouseTrackingMode | null => {
|
|
if (!arg || arg.trim().toLowerCase() === 'toggle') {
|
|
return current === 'off' ? 'all' : 'off'
|
|
}
|
|
|
|
return MOUSE_MODE_ALIASES[arg.trim().toLowerCase()] ?? null
|
|
}
|
|
|
|
const RESET_WORDS = new Set(['reset', 'clear', 'default'])
|
|
const CYCLE_WORDS = new Set(['cycle', 'toggle'])
|
|
|
|
const DETAILS_USAGE =
|
|
'usage: /details [hidden|collapsed|expanded|cycle] or /details <section> [hidden|collapsed|expanded|reset]'
|
|
|
|
const DETAILS_SECTION_USAGE = 'usage: /details <section> [hidden|collapsed|expanded|reset]'
|
|
|
|
export const coreCommands: SlashCommand[] = [
|
|
{
|
|
help: 'list commands + hotkeys',
|
|
name: 'help',
|
|
run: (_arg, ctx) => {
|
|
const sections: PanelSection[] = (ctx.local.catalog?.categories ?? []).map(cat => ({
|
|
rows: cat.pairs,
|
|
title: cat.name
|
|
}))
|
|
|
|
if (ctx.local.catalog?.skillCount) {
|
|
sections.push({ text: `${ctx.local.catalog.skillCount} skill commands available — /skills to browse` })
|
|
}
|
|
|
|
sections.push(
|
|
{
|
|
rows: [
|
|
['/details [hidden|collapsed|expanded|cycle]', 'set global agent detail visibility mode'],
|
|
[
|
|
'/details <section> [hidden|collapsed|expanded|reset]',
|
|
'override one section (thinking/tools/subagents/activity)'
|
|
],
|
|
['/fortune [random|daily]', 'show a random or daily local fortune']
|
|
],
|
|
title: 'TUI'
|
|
},
|
|
{ rows: HOTKEYS, title: 'Hotkeys' }
|
|
)
|
|
|
|
ctx.transcript.panel(ctx.ui.theme.brand.helpHeader, sections)
|
|
}
|
|
},
|
|
|
|
{
|
|
aliases: ['exit'],
|
|
help: 'exit hermes',
|
|
name: 'quit',
|
|
run: (_arg, ctx) => ctx.session.die()
|
|
},
|
|
|
|
{
|
|
help: 'update Hermes Agent to the latest version (exits TUI)',
|
|
name: 'update',
|
|
run: (_arg, ctx) => {
|
|
ctx.transcript.sys('exiting TUI to run update...')
|
|
// Exit code 42 signals the Python wrapper to exec `hermes update`.
|
|
// Use dieWithCode for proper cleanup (gateway kill + Ink unmount).
|
|
setTimeout(() => ctx.session.dieWithCode(42), 100)
|
|
}
|
|
},
|
|
|
|
{
|
|
aliases: ['scroll'],
|
|
help: 'set mouse tracking preset [on|off|toggle|wheel|buttons|all]',
|
|
name: 'mouse',
|
|
run: (arg, ctx) => {
|
|
const current = ctx.ui.mouseTracking
|
|
const next = mouseModeFromArg(arg, current)
|
|
|
|
if (next === null) {
|
|
return ctx.transcript.sys('usage: /mouse [on|off|toggle|wheel|buttons|all]')
|
|
}
|
|
|
|
patchUiState({ mouseTracking: next })
|
|
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'mouse', value: next }).catch(() => {})
|
|
|
|
queueMicrotask(() => ctx.transcript.sys(`mouse tracking ${next}`))
|
|
}
|
|
},
|
|
|
|
{
|
|
aliases: ['new'],
|
|
help: 'start a new session',
|
|
name: 'clear',
|
|
run: (arg, ctx, cmd) => {
|
|
if (ctx.session.guardBusySessionSwitch('switch sessions')) {
|
|
return
|
|
}
|
|
|
|
const isNew = cmd.startsWith('/new')
|
|
const requestedTitle = isNew ? arg.trim() : ''
|
|
|
|
const commit = () => {
|
|
patchUiState({ status: 'forging session…' })
|
|
ctx.session.newSession(isNew ? 'new session started' : undefined, requestedTitle || undefined)
|
|
}
|
|
|
|
if (NO_CONFIRM_DESTRUCTIVE) {
|
|
return commit()
|
|
}
|
|
|
|
patchOverlayState({
|
|
confirm: {
|
|
cancelLabel: 'No, keep going',
|
|
confirmLabel: isNew ? 'Yes, start a new session' : 'Yes, clear the session',
|
|
danger: true,
|
|
detail: 'This ends the current conversation and clears the transcript.',
|
|
onConfirm: commit,
|
|
title: isNew ? 'Start a new session?' : 'Clear the current session?'
|
|
}
|
|
})
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'force a full UI repaint',
|
|
name: 'redraw',
|
|
run: (_arg, ctx) => {
|
|
forceRedraw(process.stdout)
|
|
ctx.transcript.sys('ui redrawn')
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'show live session info',
|
|
name: 'status',
|
|
run: (_arg, ctx) => {
|
|
if (!ctx.sid) {
|
|
return ctx.transcript.sys('no active session')
|
|
}
|
|
|
|
ctx.gateway
|
|
.rpc<SessionStatusResponse>('session.status', { session_id: ctx.sid })
|
|
.then(ctx.guarded<SessionStatusResponse>(r => ctx.transcript.page(r.output || '(no status)', 'Status')))
|
|
.catch(ctx.guardedErr)
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'set or show current session title',
|
|
name: 'title',
|
|
run: (arg, ctx) => {
|
|
if (!ctx.sid) {
|
|
return ctx.transcript.sys('no active session')
|
|
}
|
|
|
|
const title = arg.trim()
|
|
|
|
if (!arg) {
|
|
ctx.gateway
|
|
.rpc<SessionTitleResponse>('session.title', { session_id: ctx.sid })
|
|
.then(
|
|
ctx.guarded<SessionTitleResponse>(r => {
|
|
const current = (r?.title ?? '').trim()
|
|
ctx.transcript.sys(current ? `title: ${current}` : 'no title set')
|
|
})
|
|
)
|
|
.catch(ctx.guardedErr)
|
|
|
|
return
|
|
}
|
|
|
|
if (!title) {
|
|
return ctx.transcript.sys('usage: /title <your session title>')
|
|
}
|
|
|
|
ctx.gateway
|
|
.rpc<SessionTitleResponse>('session.title', { session_id: ctx.sid, title })
|
|
.then(
|
|
ctx.guarded<SessionTitleResponse>(r => {
|
|
const next = (r?.title ?? title).trim()
|
|
const suffix = r?.pending ? ' (queued while session initializes)' : ''
|
|
ctx.transcript.sys(`session title set: ${next}${suffix}`)
|
|
})
|
|
)
|
|
.catch(ctx.guardedErr)
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'toggle compact transcript',
|
|
name: 'compact',
|
|
run: (arg, ctx) => {
|
|
const next = flagFromArg(arg, ctx.ui.compact)
|
|
|
|
if (next === null) {
|
|
return ctx.transcript.sys('usage: /compact [on|off|toggle]')
|
|
}
|
|
|
|
patchUiState({ compact: next })
|
|
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'compact', value: next ? 'on' : 'off' }).catch(() => {})
|
|
|
|
queueMicrotask(() => ctx.transcript.sys(`compact ${next ? 'on' : 'off'}`))
|
|
}
|
|
},
|
|
|
|
{
|
|
aliases: ['detail'],
|
|
help: 'control agent detail visibility (global or per-section)',
|
|
name: 'details',
|
|
run: (arg, ctx) => {
|
|
const { gateway, transcript, ui } = ctx
|
|
|
|
if (!arg) {
|
|
gateway
|
|
.rpc<ConfigGetValueResponse>('config.get', { key: 'details_mode' })
|
|
.then(r => {
|
|
if (ctx.stale()) {
|
|
return
|
|
}
|
|
|
|
const mode = parseDetailsMode(r?.value) ?? ui.detailsMode
|
|
patchUiState({ detailsMode: mode, detailsModeCommandOverride: false })
|
|
|
|
const overrides = SECTION_NAMES.filter(s => ui.sections[s])
|
|
.map(s => `${s}=${ui.sections[s]}`)
|
|
.join(' ')
|
|
|
|
transcript.sys(`details: ${mode}${overrides ? ` (${overrides})` : ''}`)
|
|
})
|
|
.catch(() => !ctx.stale() && transcript.sys(`details: ${ui.detailsMode}`))
|
|
|
|
return
|
|
}
|
|
|
|
const [first, second] = arg.trim().toLowerCase().split(/\s+/)
|
|
|
|
if (second && isSectionName(first)) {
|
|
const reset = RESET_WORDS.has(second)
|
|
const mode = reset ? null : parseDetailsMode(second)
|
|
|
|
if (!reset && !mode) {
|
|
return transcript.sys(DETAILS_SECTION_USAGE)
|
|
}
|
|
|
|
const { [first]: _drop, ...rest } = ui.sections
|
|
|
|
patchUiState({ sections: mode ? { ...rest, [first]: mode } : rest })
|
|
gateway
|
|
.rpc<ConfigSetResponse>('config.set', { key: `details_mode.${first}`, value: mode ?? '' })
|
|
.catch(() => {})
|
|
transcript.sys(`details ${first}: ${mode ?? 'reset'}`)
|
|
|
|
return
|
|
}
|
|
|
|
const next = CYCLE_WORDS.has(first ?? '') ? nextDetailsMode(ui.detailsMode) : parseDetailsMode(first)
|
|
|
|
if (!next) {
|
|
return transcript.sys(DETAILS_USAGE)
|
|
}
|
|
|
|
const sections = Object.fromEntries(SECTION_NAMES.map(section => [section, next]))
|
|
|
|
patchUiState({ detailsMode: next, detailsModeCommandOverride: true, sections })
|
|
gateway.rpc<ConfigSetResponse>('config.set', { key: 'details_mode', value: next }).catch(() => {})
|
|
transcript.sys(`details: ${next}`)
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'local fortune',
|
|
name: 'fortune',
|
|
run: (arg, ctx) => {
|
|
const key = arg.trim().toLowerCase()
|
|
|
|
if (!arg || key === 'random') {
|
|
return ctx.transcript.sys(randomFortune())
|
|
}
|
|
|
|
if (['daily', 'stable', 'today'].includes(key)) {
|
|
return ctx.transcript.sys(dailyFortune(ctx.sid))
|
|
}
|
|
|
|
ctx.transcript.sys('usage: /fortune [random|daily]')
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'copy selection or assistant message',
|
|
name: 'copy',
|
|
run: async (arg, ctx) => {
|
|
const { sys } = ctx.transcript
|
|
|
|
if (!arg && ctx.composer.hasSelection) {
|
|
const text = await ctx.composer.selection.copySelection()
|
|
|
|
if (text) {
|
|
return sys(`copied ${text.length} characters`)
|
|
} else {
|
|
return sys(
|
|
'clipboard copy failed — try HERMES_TUI_FORCE_OSC52=1 to force the escape sequence'
|
|
)
|
|
}
|
|
}
|
|
|
|
if (arg && Number.isNaN(parseInt(arg, 10))) {
|
|
return sys('usage: /copy [number]')
|
|
}
|
|
|
|
const all = ctx.local.getHistoryItems().filter(m => m.role === 'assistant')
|
|
const target = all[arg ? Math.min(parseInt(arg, 10), all.length) - 1 : all.length - 1]
|
|
|
|
if (!target) {
|
|
return sys('nothing to copy — start a conversation first')
|
|
}
|
|
|
|
void writeClipboardText(target.text)
|
|
.then(nativeOk => {
|
|
if (ctx.stale()) {
|
|
return
|
|
}
|
|
|
|
if (nativeOk) {
|
|
sys('copied to clipboard')
|
|
} else {
|
|
writeOsc52Clipboard(target.text)
|
|
sys('sent OSC52 copy sequence (terminal support required)')
|
|
}
|
|
})
|
|
.catch(error => {
|
|
if (!ctx.stale()) {
|
|
sys(`copy failed: ${String(error)}`)
|
|
}
|
|
})
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'attach clipboard image',
|
|
name: 'paste',
|
|
run: (arg, ctx) => (arg ? ctx.transcript.sys('usage: /paste') : ctx.composer.paste())
|
|
},
|
|
|
|
{
|
|
help: 'configure IDE terminal keybindings for multiline + undo/redo',
|
|
name: 'terminal-setup',
|
|
run: (arg, ctx) => {
|
|
const target = arg.trim().toLowerCase()
|
|
|
|
if (target && !['auto', 'cursor', 'vscode', 'windsurf'].includes(target)) {
|
|
return ctx.transcript.sys('usage: /terminal-setup [auto|vscode|cursor|windsurf]')
|
|
}
|
|
|
|
const runner =
|
|
!target || target === 'auto'
|
|
? configureDetectedTerminalKeybindings()
|
|
: configureTerminalKeybindings(target as 'cursor' | 'vscode' | 'windsurf')
|
|
|
|
void runner
|
|
.then(result => {
|
|
if (ctx.stale()) {
|
|
return
|
|
}
|
|
|
|
ctx.transcript.sys(result.message)
|
|
|
|
if (result.success && result.requiresRestart) {
|
|
ctx.transcript.sys('restart the IDE terminal for the new keybindings to take effect')
|
|
}
|
|
})
|
|
.catch(error => {
|
|
if (!ctx.stale()) {
|
|
ctx.transcript.sys(`terminal setup failed: ${String(error)}`)
|
|
}
|
|
})
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'view gateway logs',
|
|
name: 'logs',
|
|
run: (arg, ctx) => {
|
|
const text = ctx.gateway.gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20)))
|
|
|
|
text ? ctx.transcript.page(text, 'Logs') : ctx.transcript.sys('no gateway logs')
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'view current transcript (user + assistant messages)',
|
|
name: 'history',
|
|
run: (arg, ctx) => {
|
|
// The CLI-side `/history` runs in a detached slash-worker subprocess
|
|
// that never sees the TUI's turns — it only surfaces whatever was
|
|
// persisted before this process started. Render the TUI's own
|
|
// transcript so `/history` actually reflects what the user just did.
|
|
const items = ctx.local.getHistoryItems().filter(m => m.role === 'user' || m.role === 'assistant')
|
|
|
|
if (!items.length) {
|
|
return ctx.transcript.sys('no conversation yet')
|
|
}
|
|
|
|
const preview = Math.max(80, parseInt(arg, 10) || 400)
|
|
|
|
const lines = items.map((m, i) => {
|
|
const tag = m.role === 'user' ? `You #${i + 1}` : `Hermes #${i + 1}`
|
|
const body = m.text.trim() || (m.tools?.length ? `(${m.tools.length} tool calls)` : '(empty)')
|
|
const clipped = body.length > preview ? `${body.slice(0, preview).trimEnd()}…` : body
|
|
|
|
return `[${tag}]\n${clipped}`
|
|
})
|
|
|
|
ctx.transcript.page(lines.join('\n\n'), 'History')
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'save the current transcript to JSON',
|
|
name: 'save',
|
|
run: (_arg, ctx) => {
|
|
const hasConversation = ctx.local
|
|
.getHistoryItems()
|
|
.some(m => m.role === 'user' || m.role === 'assistant' || m.role === 'tool')
|
|
|
|
if (!hasConversation) {
|
|
return ctx.transcript.sys('no conversation yet')
|
|
}
|
|
|
|
if (!ctx.sid) {
|
|
return ctx.transcript.sys('no active session — nothing to save')
|
|
}
|
|
|
|
ctx.gateway
|
|
.rpc<SessionSaveResponse>('session.save', { session_id: ctx.sid })
|
|
.then(
|
|
ctx.guarded<SessionSaveResponse>(r => {
|
|
const file = r?.file
|
|
|
|
if (file) {
|
|
ctx.transcript.sys(`conversation saved to: ${file}`)
|
|
} else {
|
|
ctx.transcript.sys('failed to save')
|
|
}
|
|
})
|
|
)
|
|
.catch(ctx.guardedErr)
|
|
}
|
|
},
|
|
|
|
{
|
|
aliases: ['sb'],
|
|
help: 'status bar position (on|off|top|bottom)',
|
|
name: 'statusbar',
|
|
run: (arg, ctx) => {
|
|
const mode = arg.trim().toLowerCase()
|
|
const toggle: StatusBarMode = ctx.ui.statusBar === 'off' ? 'top' : 'off'
|
|
|
|
const next: null | StatusBarMode =
|
|
!mode || mode === 'toggle'
|
|
? toggle
|
|
: mode === 'on' || mode === 'top'
|
|
? 'top'
|
|
: mode === 'off' || mode === 'bottom'
|
|
? mode
|
|
: null
|
|
|
|
if (!next) {
|
|
return ctx.transcript.sys('usage: /statusbar [on|off|top|bottom|toggle]')
|
|
}
|
|
|
|
patchUiState({ statusBar: next })
|
|
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'statusbar', value: next }).catch(() => {})
|
|
|
|
queueMicrotask(() => ctx.transcript.sys(`status bar ${next}`))
|
|
}
|
|
},
|
|
|
|
{
|
|
aliases: ['q'],
|
|
help: 'inspect or enqueue a message',
|
|
name: 'queue',
|
|
run: (arg, ctx) => {
|
|
if (!arg) {
|
|
return ctx.transcript.sys(`${ctx.composer.queueRef.current.length} queued message(s)`)
|
|
}
|
|
|
|
ctx.composer.enqueue(arg)
|
|
ctx.transcript.sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`)
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'inject a message after the next tool call (no interrupt)',
|
|
name: 'steer',
|
|
run: (arg, ctx) => {
|
|
const payload = arg?.trim() ?? ''
|
|
|
|
if (!payload) {
|
|
return ctx.transcript.sys('usage: /steer <prompt>')
|
|
}
|
|
|
|
// If the agent isn't running, fall back to the queue so the user's
|
|
// 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 ? '…' : ''}"`
|
|
)
|
|
|
|
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)
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'undo last exchange',
|
|
name: 'undo',
|
|
run: (_arg, ctx) => {
|
|
if (!ctx.sid) {
|
|
return ctx.transcript.sys('nothing to undo')
|
|
}
|
|
|
|
ctx.gateway.rpc<SessionUndoResponse>('session.undo', { session_id: ctx.sid }).then(
|
|
ctx.guarded<SessionUndoResponse>(r => {
|
|
if ((r.removed ?? 0) > 0) {
|
|
ctx.transcript.setHistoryItems((prev: Msg[]) => ctx.transcript.trimLastExchange(prev))
|
|
ctx.transcript.sys(`undid ${r.removed} messages`)
|
|
} else {
|
|
ctx.transcript.sys('nothing to undo')
|
|
}
|
|
})
|
|
)
|
|
}
|
|
},
|
|
|
|
{
|
|
help: 'retry last user message',
|
|
name: 'retry',
|
|
run: (_arg, ctx) => {
|
|
const last = ctx.local.getLastUserMsg()
|
|
|
|
if (!last) {
|
|
return ctx.transcript.sys('nothing to retry')
|
|
}
|
|
|
|
if (!ctx.sid) {
|
|
return ctx.transcript.send(last)
|
|
}
|
|
|
|
ctx.gateway.rpc<SessionUndoResponse>('session.undo', { session_id: ctx.sid }).then(
|
|
ctx.guarded<SessionUndoResponse>(r => {
|
|
if ((r.removed ?? 0) <= 0) {
|
|
return ctx.transcript.sys('nothing to retry')
|
|
}
|
|
|
|
ctx.transcript.setHistoryItems((prev: Msg[]) => ctx.transcript.trimLastExchange(prev))
|
|
ctx.transcript.send(last)
|
|
})
|
|
)
|
|
}
|
|
}
|
|
]
|