Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/tui-audit-followup

# Conflicts:
#	ui-tui/src/components/markdown.tsx
#	ui-tui/src/types/hermes-ink.d.ts
This commit is contained in:
Brooklyn Nicholson
2026-04-18 14:52:54 -05:00
71 changed files with 3243 additions and 42 deletions

View File

@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import { asCommandDispatch } from '../lib/rpc.js'
describe('asCommandDispatch', () => {
it('parses exec, alias, and skill', () => {
it('parses exec, alias, skill, and send', () => {
expect(asCommandDispatch({ type: 'exec', output: 'hi' })).toEqual({ type: 'exec', output: 'hi' })
expect(asCommandDispatch({ type: 'alias', target: 'help' })).toEqual({ type: 'alias', target: 'help' })
expect(asCommandDispatch({ type: 'skill', name: 'x', message: 'do' })).toEqual({
@@ -11,11 +11,17 @@ describe('asCommandDispatch', () => {
name: 'x',
message: 'do'
})
expect(asCommandDispatch({ type: 'send', message: 'hello world' })).toEqual({
type: 'send',
message: 'hello world'
})
})
it('rejects malformed payloads', () => {
expect(asCommandDispatch(null)).toBeNull()
expect(asCommandDispatch({ type: 'alias' })).toBeNull()
expect(asCommandDispatch({ type: 'skill', name: 1 })).toBeNull()
expect(asCommandDispatch({ type: 'send' })).toBeNull()
expect(asCommandDispatch({ type: 'send', message: 42 })).toBeNull()
})
})

View File

@@ -179,6 +179,67 @@ describe('createSlashHandler', () => {
expect(createSlashHandler(ctx)('/h')).toBe(true)
expect(ctx.transcript.panel).toHaveBeenCalledWith(expect.any(String), expect.any(Array))
})
it('falls through to command.dispatch for skill commands and sends the message', async () => {
const skillMessage = 'Use this skill to do X.\n\n## Steps\n1. First step'
const ctx = buildCtx({
gateway: {
gw: {
getLogTail: vi.fn(() => ''),
request: vi.fn((method: string) => {
if (method === 'slash.exec') {
return Promise.reject(new Error('skill command: use command.dispatch'))
}
if (method === 'command.dispatch') {
return Promise.resolve({ type: 'skill', message: skillMessage, name: 'hermes-agent-dev' })
}
return Promise.resolve({})
})
},
rpc: vi.fn(() => Promise.resolve({}))
}
})
const h = createSlashHandler(ctx)
expect(h('/hermes-agent-dev')).toBe(true)
await vi.waitFor(() => {
expect(ctx.transcript.sys).toHaveBeenCalledWith('⚡ loading skill: hermes-agent-dev')
})
expect(ctx.transcript.send).toHaveBeenCalledWith(skillMessage)
})
it('handles send-type dispatch for /plan command', async () => {
const planMessage = 'Plan skill content loaded'
const ctx = buildCtx({
gateway: {
gw: {
getLogTail: vi.fn(() => ''),
request: vi.fn((method: string) => {
if (method === 'slash.exec') {
return Promise.reject(new Error('pending-input command'))
}
if (method === 'command.dispatch') {
return Promise.resolve({ type: 'send', message: planMessage })
}
return Promise.resolve({})
})
},
rpc: vi.fn(() => Promise.resolve({}))
}
})
const h = createSlashHandler(ctx)
expect(h('/plan create a REST API')).toBe(true)
await vi.waitFor(() => {
expect(ctx.transcript.send).toHaveBeenCalledWith(planMessage)
})
})
})
const buildCtx = (overrides: Partial<Ctx> = {}): Ctx => ({

View File

@@ -105,6 +105,10 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: skill payload missing message`)
}
if (d.type === 'send') {
return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: empty message`)
}
})
.catch(guardedErr)
})

View File

@@ -1,4 +1,4 @@
import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout } from '@hermes/ink'
import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -284,6 +284,13 @@ export function useMainApp(gw: GatewayClient) {
useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid: ui.sid })
// ── Terminal tab title ─────────────────────────────────────────────
// Show model name + status so users can identify the Hermes tab.
const shortModel = ui.info?.model?.replace(/^.*\//, '') ?? ''
const titleStatus = ui.busy ? '⏳' : '✓'
const terminalTitle = shortModel ? `${titleStatus} ${shortModel} — Hermes` : 'Hermes'
useTerminalTitle(terminalTitle)
useEffect(() => {
if (!ui.sid || !stdout) {
return

View File

@@ -28,12 +28,19 @@ export const MessageLine = memo(function MessageLine({
}
if (msg.role === 'tool') {
const maxChars = Math.max(24, cols - 14)
const stripped = hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text
const preview = compactPreview(stripped, maxChars) || '(empty tool result)'
return (
<Box alignSelf="flex-start" borderColor={t.color.dim} borderStyle="round" marginLeft={3} paddingX={1}>
<Text color={t.color.dim} wrap="truncate-end">
{compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) ||
'(empty tool result)'}
</Text>
{hasAnsi(msg.text) ? (
<Text wrap="truncate-end"><Ansi>{msg.text}</Ansi></Text>
) : (
<Text color={t.color.dim} wrap="truncate-end">
{preview}
</Text>
)}
</Box>
)
}

View File

@@ -47,6 +47,7 @@ export type CommandDispatchResponse =
| { output?: string; type: 'exec' | 'plugin' }
| { target: string; type: 'alias' }
| { message?: string; name: string; type: 'skill' }
| { message: string; type: 'send' }
// ── Config ───────────────────────────────────────────────────────────

View File

@@ -26,6 +26,10 @@ export const asCommandDispatch = (value: unknown): CommandDispatchResponse | nul
return { type: 'skill', name: o.name, message: typeof o.message === 'string' ? o.message : undefined }
}
if (t === 'send' && typeof o.message === 'string') {
return { type: 'send', message: o.message }
}
return null
}

View File

@@ -97,6 +97,7 @@ declare module '@hermes/ink' {
export function useHasSelection(): boolean
export function useStdout(): { readonly stdout?: NodeJS.WriteStream }
export function useTerminalFocus(): boolean
export function useTerminalTitle(title: string | null): void
export function useDeclaredCursor(args: {
readonly line: number
readonly column: number