fix(tui): close slash parity gaps with CLI (#20339)

* fix(tui): close slash parity gaps with CLI

Route unsupported /skills subcommands through slash.exec, support /new <name>
titles, and handle /redraw natively so TUI behavior matches classic CLI. Also
filter gateway-only commands out of the TUI catalog while keeping /status
discoverable.

* fix(tui): run remaining CLI parity paths natively

Forward chat launch flags into the TUI runtime and handle live-session status
and skill reloads in the gateway process so TUI state no longer depends on the
slash worker's stale CLI instance.

* fix(tui): block stale snapshot restores

Prevent snapshot restore from running through the isolated slash worker because
it mutates disk state without refreshing the live TUI agent.

* chore: uptick

* fix(tui): guard async session title updates

Handle failures from the fire-and-forget session.title RPC so title-setting errors do not surface as unhandled promise rejections while preserving session-scoped messaging.
This commit is contained in:
brooklyn! 2026-05-05 13:42:39 -07:00 committed by GitHub
parent acca3ec3af
commit 794f48766c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1266 additions and 284 deletions

View file

@ -18,6 +18,27 @@ describe('createSlashHandler', () => {
expect(getOverlayState().picker).toBe(true)
})
it('handles /redraw locally without slash worker fallback', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/redraw')).toBe(true)
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
expect(ctx.transcript.sys).toHaveBeenCalledWith('ui redrawn')
})
it('routes /status to live session.status instead of slash worker', async () => {
patchUiState({ sid: 'sid-abc' })
const rpc = vi.fn(() => Promise.resolve({ output: 'Hermes TUI Status' }))
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
expect(createSlashHandler(ctx)('/status')).toBe(true)
expect(rpc).toHaveBeenCalledWith('session.status', { session_id: 'sid-abc' })
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(ctx.transcript.page).toHaveBeenCalledWith('Hermes TUI Status', 'Status')
})
})
it('keeps typed /model switches session-scoped by default', async () => {
patchUiState({ sid: 'sid-abc' })
@ -157,12 +178,49 @@ describe('createSlashHandler', () => {
})
})
it('shows usage for an unknown /skills subcommand', () => {
it('delegates non-native /skills subcommands to slash.exec', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/skills zzz')
createSlashHandler(ctx)('/skills check')
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
expect(ctx.transcript.sys).toHaveBeenCalledWith(expect.stringContaining('usage: /skills'))
expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', {
command: 'skills check',
session_id: null
})
})
it('passes /new <title> through to the session lifecycle', () => {
const ctx = buildCtx()
createSlashHandler(ctx)('/new sprint planning')
getOverlayState().confirm?.onConfirm()
expect(ctx.session.newSession).toHaveBeenCalledWith('new session started', 'sprint planning')
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
})
it('reloads skills in the live gateway and refreshes the catalog', async () => {
const rpc = vi.fn((method: string) => {
if (method === 'skills.reload') {
return Promise.resolve({ output: '42 skill(s) available' })
}
if (method === 'commands.catalog') {
return Promise.resolve({ canon: { '/new-skill': '/new-skill' }, pairs: [['/new-skill', 'demo']] })
}
return Promise.resolve({})
})
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
createSlashHandler(ctx)('/reload-skills')
expect(rpc).toHaveBeenCalledWith('skills.reload', {})
await vi.waitFor(() => {
expect(ctx.transcript.page).toHaveBeenCalledWith('42 skill(s) available', 'Reload Skills')
expect(ctx.local.setCatalog).toHaveBeenCalledWith(
expect.objectContaining({ canon: { '/new-skill': '/new-skill' }, pairs: [['/new-skill', 'demo']] })
)
})
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
// Regressions from Copilot review on #19835: /voice output + frontend
@ -192,9 +250,7 @@ describe('createSlashHandler', () => {
expect(ctx.transcript.sys).toHaveBeenCalledWith('Voice mode enabled')
expect(ctx.transcript.sys).toHaveBeenCalledWith(' Alt+R to start/stop recording')
})
expect(ctx.voice.setVoiceRecordKey).toHaveBeenCalledWith(
expect.objectContaining({ ch: 'r', mod: 'alt' })
)
expect(ctx.voice.setVoiceRecordKey).toHaveBeenCalledWith(expect.objectContaining({ ch: 'r', mod: 'alt' }))
})
it('/voice falls back to Ctrl+B when the gateway response omits record_key', async () => {
@ -447,17 +503,17 @@ describe('createSlashHandler', () => {
local: {
catalog: {
canon: {
'/status': '/status',
'/statusbar': '/statusbar'
'/profile': '/profile',
'/plugins': '/plugins'
}
}
}
})
expect(createSlashHandler(ctx)('/status')).toBe(true)
expect(createSlashHandler(ctx)('/profile')).toBe(true)
await vi.waitFor(() => {
expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', {
command: 'status',
command: 'profile',
session_id: null
})
})
@ -675,7 +731,8 @@ const buildLocal = () => ({
catalog: null,
getHistoryItems: vi.fn(() => []),
getLastUserMsg: vi.fn(() => ''),
maybeWarn: vi.fn()
maybeWarn: vi.fn(),
setCatalog: vi.fn()
})
const buildSession = () => ({