Files
hermes-agent/ui-tui/src/app/slash/commands/core.ts
T
ethernet c42edd8055 fix(tui): clipboard copy on linux/wayland
`probeLinuxCopy` and `copyNative` in `osc.ts` await `execFileNoThrow`
for wl-copy / xclip / xsel. Those tools double-fork a daemon that
holds the system selection live, and the daemon inherits stdio pipes
from `spawn(stdio: 'pipe')`. Node's 'close' event only fires when
stdio is fully closed → the daemon keeps the pipes open → 'close'
never fires → the await leaks past the timeout (kill(SIGTERM) on an
already-exited child is a no-op, daemon survives).

Result: `linuxCopy` cache stays `undefined` permanently, the actual
copy never runs, ctrl-c silently does nothing on wayland/x11.
Reproduced in isolation, confirmed across wl-copy and a
daemonization-shaped fixture.

Fix: add `resolveOnExit` option to `execFileNoThrow`. When set, the
promise settles on the immediate child's 'exit' event instead of
waiting for stdio drainage. Wired into both the probe and the actual
copy spawns for every clipboard tool (pbcopy, wl-copy, xclip, xsel,
clip).

Tests: 5 new vitest cases covering daemon-style child handling,
non-zero exit propagation, timeout behavior, and double-resolve
guard. The forever-hang case is committed as `it.skip` with
documentation so a reviewer can verify the bug by hand.
2026-05-20 19:47:30 -04:00

624 lines
18 KiB
TypeScript

import { forceRedraw } 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 { SECTION_NAMES, isSectionName, nextDetailsMode, parseDetailsMode } 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
}
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', 'q'],
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: 'toggle mouse/wheel tracking [on|off|toggle]',
name: 'mouse',
run: (arg, ctx) => {
const current = ctx.ui.mouseTracking
const next = flagFromArg(arg, current)
if (next === null) {
return ctx.transcript.sys('usage: /mouse [on|off|toggle]')
}
patchUiState({ mouseTracking: next })
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'mouse', value: next ? 'on' : 'off' }).catch(() => {})
queueMicrotask(() => ctx.transcript.sys(`mouse tracking ${next ? 'on' : 'off'}`))
}
},
{
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: 'resume a prior session',
name: 'resume',
run: (arg, ctx) => {
if (ctx.session.guardBusySessionSwitch('switch sessions')) {
return
}
arg ? ctx.session.resumeById(arg) : patchOverlayState({ picker: true })
}
},
{
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}`))
}
},
{
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)
})
)
}
}
]