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

@ -1,11 +1,14 @@
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 { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from '../../../domain/details.js'
import { SECTION_NAMES, isSectionName, nextDetailsMode, parseDetailsMode } from '../../../domain/details.js'
import type {
ConfigGetValueResponse,
ConfigSetResponse,
SessionSaveResponse,
SessionStatusResponse,
SessionSteerResponse,
SessionTitleResponse,
SessionUndoResponse
@ -112,16 +115,17 @@ export const coreCommands: SlashCommand[] = [
aliases: ['new'],
help: 'start a new session',
name: 'clear',
run: (_arg, ctx, cmd) => {
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)
ctx.session.newSession(isNew ? 'new session started' : undefined, requestedTitle || undefined)
}
if (NO_CONFIRM_DESTRUCTIVE) {
@ -141,6 +145,30 @@ export const coreCommands: SlashCommand[] = [
}
},
{
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',

View file

@ -1,5 +1,6 @@
import type {
BrowserManageResponse,
CommandsCatalogResponse,
DelegationPauseResponse,
ProcessStopResponse,
ReloadEnvResponse,
@ -56,6 +57,10 @@ interface SkillsBrowseResponse {
total_pages?: number
}
interface SkillsReloadResponse {
output?: string
}
export const opsCommands: SlashCommand[] = [
{
help: 'stop background processes',
@ -435,10 +440,44 @@ export const opsCommands: SlashCommand[] = [
}
},
{
aliases: ['reload_skills'],
help: 're-scan installed skills in the live TUI gateway',
name: 'reload-skills',
run: (_arg, ctx) => {
ctx.gateway
.rpc<SkillsReloadResponse>('skills.reload', {})
.then(
ctx.guarded<SkillsReloadResponse>(r => {
ctx.transcript.page(r.output || 'skills reloaded', 'Reload Skills')
ctx.gateway
.rpc<CommandsCatalogResponse>('commands.catalog', {})
.then(
ctx.guarded<CommandsCatalogResponse>(catalog => {
if (!catalog?.pairs) {
return
}
ctx.local.setCatalog({
canon: (catalog.canon ?? {}) as Record<string, string>,
categories: catalog.categories ?? [],
pairs: catalog.pairs as [string, string][],
skillCount: (catalog.skill_count ?? 0) as number,
sub: (catalog.sub ?? {}) as Record<string, string[]>
})
})
)
.catch(() => {})
})
)
.catch(ctx.guardedErr)
}
},
{
help: 'browse, inspect, install skills',
name: 'skills',
run: (arg, ctx) => {
run: (arg, ctx, cmd) => {
const text = arg.trim()
if (!text) {
@ -449,6 +488,22 @@ export const opsCommands: SlashCommand[] = [
const query = rest.join(' ').trim()
const { rpc } = ctx.gateway
const { panel, sys } = ctx.transcript
const runViaSlashWorker = () => {
ctx.gateway.gw
.request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: ctx.sid })
.then(r => {
if (ctx.stale()) {
return
}
const body = r?.output || '/skills: no output'
const formatted = r?.warning ? `warning: ${r.warning}\n${body}` : body
const long = formatted.length > 180 || formatted.split('\n').filter(Boolean).length > 2
long ? ctx.transcript.page(formatted, 'Skills') : ctx.transcript.sys(formatted)
})
.catch(ctx.guardedErr)
}
if (sub === 'list') {
rpc<SkillsListResponse>('skills.manage', { action: 'list' })
@ -593,7 +648,7 @@ export const opsCommands: SlashCommand[] = [
return
}
sys('usage: /skills [list | inspect <n> | install <n> | search <q> | browse [page]]')
runViaSlashWorker()
}
},