2026-04-15 23:29:00 -05:00
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
|
|
|
|
|
|
import { createSlashHandler } from '../app/createSlashHandler.js'
|
|
|
|
|
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
|
|
|
|
|
import { getUiState, resetUiState } from '../app/uiStore.js'
|
|
|
|
|
|
|
|
|
|
describe('createSlashHandler', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
resetOverlayState()
|
|
|
|
|
resetUiState()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('opens the resume picker locally', () => {
|
|
|
|
|
const ctx = buildCtx()
|
|
|
|
|
|
|
|
|
|
expect(createSlashHandler(ctx)('/resume')).toBe(true)
|
|
|
|
|
expect(getOverlayState().picker).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-18 09:29:39 -05:00
|
|
|
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()
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-18 09:46:36 -05:00
|
|
|
it('routes /skills install <name> to skills.manage without opening overlay', () => {
|
2026-04-18 09:29:39 -05:00
|
|
|
const ctx = buildCtx()
|
|
|
|
|
|
|
|
|
|
expect(createSlashHandler(ctx)('/skills install foo')).toBe(true)
|
|
|
|
|
expect(getOverlayState().skillsHub).toBe(false)
|
2026-04-18 09:46:36 -05:00
|
|
|
expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
|
|
|
|
|
action: 'install',
|
|
|
|
|
query: 'foo'
|
2026-04-18 09:29:39 -05:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-18 09:46:36 -05:00
|
|
|
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'))
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-15 23:29:00 -05:00
|
|
|
it('cycles details mode and persists it', async () => {
|
|
|
|
|
const ctx = buildCtx()
|
|
|
|
|
|
|
|
|
|
expect(getUiState().detailsMode).toBe('collapsed')
|
|
|
|
|
expect(createSlashHandler(ctx)('/details toggle')).toBe(true)
|
|
|
|
|
expect(getUiState().detailsMode).toBe('expanded')
|
|
|
|
|
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
|
|
|
|
|
key: 'details_mode',
|
|
|
|
|
value: 'expanded'
|
|
|
|
|
})
|
|
|
|
|
expect(ctx.transcript.sys).toHaveBeenCalledWith('details: expanded')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows tool enable usage when names are missing', () => {
|
|
|
|
|
const ctx = buildCtx()
|
|
|
|
|
|
|
|
|
|
expect(createSlashHandler(ctx)('/tools enable')).toBe(true)
|
|
|
|
|
expect(ctx.transcript.sys).toHaveBeenNthCalledWith(1, 'usage: /tools enable <name> [name ...]')
|
|
|
|
|
expect(ctx.transcript.sys).toHaveBeenNthCalledWith(2, 'built-in toolset: /tools enable web')
|
|
|
|
|
expect(ctx.transcript.sys).toHaveBeenNthCalledWith(3, 'MCP tool: /tools enable github:create_issue')
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-16 01:04:35 -05:00
|
|
|
it('drops stale slash.exec output after a newer slash', async () => {
|
|
|
|
|
let resolveLate: (v: { output?: string }) => void
|
|
|
|
|
let slashExecCalls = 0
|
|
|
|
|
|
|
|
|
|
const ctx = buildCtx({
|
|
|
|
|
gateway: {
|
|
|
|
|
gw: {
|
|
|
|
|
getLogTail: vi.fn(() => ''),
|
|
|
|
|
request: vi.fn((method: string) => {
|
|
|
|
|
if (method === 'slash.exec') {
|
|
|
|
|
slashExecCalls += 1
|
|
|
|
|
|
|
|
|
|
if (slashExecCalls === 1) {
|
|
|
|
|
return new Promise<{ output?: string }>(res => {
|
|
|
|
|
resolveLate = res
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Promise.resolve({ output: 'fresh' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Promise.resolve({})
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
rpc: vi.fn(() => Promise.resolve({}))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const h = createSlashHandler(ctx)
|
|
|
|
|
expect(h('/slow')).toBe(true)
|
|
|
|
|
expect(h('/fast')).toBe(true)
|
|
|
|
|
resolveLate!({ output: 'too late' })
|
|
|
|
|
await vi.waitFor(() => {
|
|
|
|
|
expect(ctx.transcript.sys).toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(ctx.transcript.sys).not.toHaveBeenCalledWith('too late')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('dispatches command.dispatch with typed alias', async () => {
|
|
|
|
|
const ctx = buildCtx({
|
|
|
|
|
gateway: {
|
|
|
|
|
gw: {
|
|
|
|
|
getLogTail: vi.fn(() => ''),
|
|
|
|
|
request: vi.fn((method: string) => {
|
|
|
|
|
if (method === 'slash.exec') {
|
|
|
|
|
return Promise.reject(new Error('no'))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (method === 'command.dispatch') {
|
|
|
|
|
return Promise.resolve({ type: 'alias', target: 'help' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Promise.resolve({})
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
rpc: vi.fn(() => Promise.resolve({}))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const h = createSlashHandler(ctx)
|
|
|
|
|
expect(h('/zzz')).toBe(true)
|
|
|
|
|
await vi.waitFor(() => {
|
feat(tui): put the kawaii face+verb ticker in the status bar, not the thinking panel
The status bar was showing stale lifecycle text ("running…") while the
face+verb stream flickered through the thinking panel as Python pushed
thinking.delta events. That's backwards — the face ticker is the
primary "I'm alive" signal, it belongs in the status bar; the thinking
panel is for substantive reasoning and tool activity.
Status bar now reads `ui.busy`: when true, renders a local `<FaceTicker>`
cycling FACES × VERBS on a 2.5s interval, unaffected by server events.
When false, the bar shows the actual status string (ready, starting
agent…, interrupted, etc.).
Side effect: `scheduleThinkingStatus` still patches `ui.status` with
Python's face text, but while busy the bar ignores that string and uses
the ticker instead. No server-side changes needed — Python keeps
emitting thinking.delta as a liveness heartbeat, the TUI just doesn't
let it fight the status bar.
2026-04-16 20:14:25 -05:00
|
|
|
expect(ctx.transcript.panel).toHaveBeenCalledWith(expect.any(String), expect.any(Array))
|
2026-04-16 01:04:35 -05:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-15 23:29:00 -05:00
|
|
|
it('resolves unique local aliases through the catalog', () => {
|
|
|
|
|
const ctx = buildCtx({
|
|
|
|
|
local: {
|
|
|
|
|
catalog: {
|
|
|
|
|
canon: {
|
|
|
|
|
'/h': '/help',
|
|
|
|
|
'/help': '/help'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(createSlashHandler(ctx)('/h')).toBe(true)
|
feat(tui): put the kawaii face+verb ticker in the status bar, not the thinking panel
The status bar was showing stale lifecycle text ("running…") while the
face+verb stream flickered through the thinking panel as Python pushed
thinking.delta events. That's backwards — the face ticker is the
primary "I'm alive" signal, it belongs in the status bar; the thinking
panel is for substantive reasoning and tool activity.
Status bar now reads `ui.busy`: when true, renders a local `<FaceTicker>`
cycling FACES × VERBS on a 2.5s interval, unaffected by server events.
When false, the bar shows the actual status string (ready, starting
agent…, interrupted, etc.).
Side effect: `scheduleThinkingStatus` still patches `ui.status` with
Python's face text, but while busy the bar ignores that string and uses
the ticker instead. No server-side changes needed — Python keeps
emitting thinking.delta as a liveness heartbeat, the TUI just doesn't
let it fight the status bar.
2026-04-16 20:14:25 -05:00
|
|
|
expect(ctx.transcript.panel).toHaveBeenCalledWith(expect.any(String), expect.any(Array))
|
2026-04-15 23:29:00 -05:00
|
|
|
})
|
2026-04-18 17:36:06 +05:30
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
})
|
fix(tui): slash.exec _pending_input commands, tool ANSI, terminal title
Additional TUI fixes discovered in the same audit:
1. /plan slash command was silently lost — process_command() queues the
plan skill invocation onto _pending_input which nobody reads in the
slash worker subprocess. Now intercepted in slash.exec and routed
through command.dispatch with a new 'send' dispatch type.
Same interception added for /retry, /queue, /steer as safety nets
(these already have correct TUI-local handlers in core.ts, but the
server-side guard prevents regressions if the local handler is
bypassed).
2. Tool results were stripping ANSI escape codes — the messageLine
component used stripAnsi() + plain <Text> for tool role messages,
losing all color/styling from terminal, search_files, etc. Now
uses <Ansi> component (already imported) when ANSI is detected.
3. Terminal tab title now shows model + busy status via useTerminalTitle
hook from @hermes/ink (was never used). Users can identify Hermes
tabs and see at a glance whether the agent is busy or ready.
4. Added 'send' variant to CommandDispatchResponse type + asCommandDispatch
parser + createSlashHandler handler for commands that need to inject
a message into the conversation (plan, queue fallback, steer fallback).
2026-04-18 17:52:19 +05:30
|
|
|
|
2026-04-21 18:33:27 -05:00
|
|
|
it('/history pages the current TUI transcript (user + assistant)', () => {
|
|
|
|
|
const ctx = buildCtx({
|
|
|
|
|
local: {
|
|
|
|
|
...buildLocal(),
|
|
|
|
|
getHistoryItems: vi.fn(() => [
|
|
|
|
|
{ role: 'user', text: 'hello' },
|
|
|
|
|
{ role: 'system', text: 'ignore me' },
|
|
|
|
|
{ role: 'assistant', text: 'hi there' },
|
|
|
|
|
{ role: 'user', text: 'test' }
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
createSlashHandler(ctx)('/history')
|
|
|
|
|
expect(ctx.transcript.page).toHaveBeenCalledTimes(1)
|
|
|
|
|
|
|
|
|
|
const [body, title] = ctx.transcript.page.mock.calls[0]!
|
|
|
|
|
|
|
|
|
|
expect(title).toBe('History')
|
|
|
|
|
expect(body).toContain('[You #1]')
|
|
|
|
|
expect(body).toContain('hello')
|
|
|
|
|
expect(body).toContain('[Hermes #2]')
|
|
|
|
|
expect(body).toContain('hi there')
|
|
|
|
|
expect(body).toContain('[You #3]')
|
|
|
|
|
expect(body).not.toContain('ignore me')
|
|
|
|
|
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('/history reports empty state without paging', () => {
|
|
|
|
|
const ctx = buildCtx()
|
|
|
|
|
|
|
|
|
|
createSlashHandler(ctx)('/history')
|
|
|
|
|
expect(ctx.transcript.page).not.toHaveBeenCalled()
|
|
|
|
|
expect(ctx.transcript.sys).toHaveBeenCalledWith('no conversation yet')
|
|
|
|
|
})
|
|
|
|
|
|
fix(tui): slash.exec _pending_input commands, tool ANSI, terminal title
Additional TUI fixes discovered in the same audit:
1. /plan slash command was silently lost — process_command() queues the
plan skill invocation onto _pending_input which nobody reads in the
slash worker subprocess. Now intercepted in slash.exec and routed
through command.dispatch with a new 'send' dispatch type.
Same interception added for /retry, /queue, /steer as safety nets
(these already have correct TUI-local handlers in core.ts, but the
server-side guard prevents regressions if the local handler is
bypassed).
2. Tool results were stripping ANSI escape codes — the messageLine
component used stripAnsi() + plain <Text> for tool role messages,
losing all color/styling from terminal, search_files, etc. Now
uses <Ansi> component (already imported) when ANSI is detected.
3. Terminal tab title now shows model + busy status via useTerminalTitle
hook from @hermes/ink (was never used). Users can identify Hermes
tabs and see at a glance whether the agent is busy or ready.
4. Added 'send' variant to CommandDispatchResponse type + asCommandDispatch
parser + createSlashHandler handler for commands that need to inject
a message into the conversation (plan, queue fallback, steer fallback).
2026-04-18 17:52:19 +05:30
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-04-15 23:29:00 -05:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const buildCtx = (overrides: Partial<Ctx> = {}): Ctx => ({
|
|
|
|
|
...overrides,
|
2026-04-16 01:04:35 -05:00
|
|
|
slashFlightRef: overrides.slashFlightRef ?? { current: 0 },
|
2026-04-15 23:29:00 -05:00
|
|
|
composer: { ...buildComposer(), ...overrides.composer },
|
|
|
|
|
gateway: { ...buildGateway(), ...overrides.gateway },
|
|
|
|
|
local: { ...buildLocal(), ...overrides.local },
|
|
|
|
|
session: { ...buildSession(), ...overrides.session },
|
|
|
|
|
transcript: { ...buildTranscript(), ...overrides.transcript },
|
|
|
|
|
voice: { ...buildVoice(), ...overrides.voice }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const buildComposer = () => ({
|
|
|
|
|
enqueue: vi.fn(),
|
|
|
|
|
hasSelection: false,
|
|
|
|
|
paste: vi.fn(),
|
|
|
|
|
queueRef: { current: [] as string[] },
|
|
|
|
|
selection: { copySelection: vi.fn(() => '') },
|
|
|
|
|
setInput: vi.fn()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const buildGateway = () => ({
|
|
|
|
|
gw: {
|
|
|
|
|
getLogTail: vi.fn(() => ''),
|
|
|
|
|
request: vi.fn(() => Promise.resolve({}))
|
|
|
|
|
},
|
|
|
|
|
rpc: vi.fn(() => Promise.resolve({}))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const buildLocal = () => ({
|
|
|
|
|
catalog: null,
|
|
|
|
|
getHistoryItems: vi.fn(() => []),
|
|
|
|
|
getLastUserMsg: vi.fn(() => ''),
|
|
|
|
|
maybeWarn: vi.fn()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const buildSession = () => ({
|
|
|
|
|
closeSession: vi.fn(() => Promise.resolve(null)),
|
|
|
|
|
die: vi.fn(),
|
|
|
|
|
guardBusySessionSwitch: vi.fn(() => false),
|
|
|
|
|
newSession: vi.fn(),
|
|
|
|
|
resetVisibleHistory: vi.fn(),
|
|
|
|
|
resumeById: vi.fn(),
|
|
|
|
|
setSessionStartedAt: vi.fn()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const buildTranscript = () => ({
|
|
|
|
|
page: vi.fn(),
|
|
|
|
|
panel: vi.fn(),
|
|
|
|
|
send: vi.fn(),
|
|
|
|
|
setHistoryItems: vi.fn(),
|
|
|
|
|
sys: vi.fn(),
|
|
|
|
|
trimLastExchange: vi.fn(items => items)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const buildVoice = () => ({
|
|
|
|
|
setVoiceEnabled: vi.fn()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
interface Ctx {
|
2026-04-16 01:04:35 -05:00
|
|
|
slashFlightRef: { current: number }
|
2026-04-15 23:29:00 -05:00
|
|
|
composer: ReturnType<typeof buildComposer>
|
|
|
|
|
gateway: ReturnType<typeof buildGateway>
|
|
|
|
|
local: ReturnType<typeof buildLocal>
|
|
|
|
|
session: ReturnType<typeof buildSession>
|
|
|
|
|
transcript: ReturnType<typeof buildTranscript>
|
|
|
|
|
voice: ReturnType<typeof buildVoice>
|
|
|
|
|
}
|