From a82097e7a211e8a3a15e492be47db604ebc3ad4e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 17 Apr 2026 10:58:18 -0500 Subject: [PATCH] feat(tui): /model and /setup slash commands with in-place CLI handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hermes-ink: export `withInkSuspended()` + `useExternalProcess()` that pause/resume Ink around an arbitrary external process (built on the existing enterAlternateScreen/exitAlternateScreen plumbing) - tui: `launchHermesCommand(args)` spawns the `hermes` binary with inherited stdio, with `HERMES_BIN` override for non-standard launches - tui: `/model` and `/setup` slash commands invoke the CLI wizards in-place, then re-preflight `setup.status` and auto-start a session on success — no more exit-and-relaunch to finish first-run setup - setup panel now advertises those slashes instead of only pointing users back at the shell --- .../packages/hermes-ink/src/entry-exports.ts | 3 +- .../src/ink/hooks/use-external-process.ts | 27 ++++++++++ ui-tui/src/app/setupHandoff.ts | 54 +++++++++++++++++++ ui-tui/src/app/slash/commands/setup.ts | 33 ++++++++++++ ui-tui/src/app/slash/registry.ts | 3 +- ui-tui/src/content/setup.ts | 9 ++-- ui-tui/src/lib/externalCli.ts | 16 ++++++ ui-tui/src/types/hermes-ink.d.ts | 3 ++ 8 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 ui-tui/packages/hermes-ink/src/ink/hooks/use-external-process.ts create mode 100644 ui-tui/src/app/setupHandoff.ts create mode 100644 ui-tui/src/app/slash/commands/setup.ts create mode 100644 ui-tui/src/lib/externalCli.ts diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts index d9fd98dee..58c4e6976 100644 --- a/ui-tui/packages/hermes-ink/src/entry-exports.ts +++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts @@ -1,3 +1,4 @@ +export { default as TextInput, UncontrolledTextInput } from 'ink-text-input' export { default as useStderr } from './hooks/use-stderr.js' export { default as useStdout } from './hooks/use-stdout.js' export { Ansi } from './ink/Ansi.js' @@ -12,6 +13,7 @@ export { default as Spacer } from './ink/components/Spacer.js' export { default as Text } from './ink/components/Text.js' export { default as useApp } from './ink/hooks/use-app.js' export { useDeclaredCursor } from './ink/hooks/use-declared-cursor.js' +export { useExternalProcess, withInkSuspended, type RunExternalProcess } from './ink/hooks/use-external-process.js' export { default as useInput } from './ink/hooks/use-input.js' export { useHasSelection, useSelection } from './ink/hooks/use-selection.js' export { default as useStdin } from './ink/hooks/use-stdin.js' @@ -22,4 +24,3 @@ export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js' export { default as measureElement } from './ink/measure-element.js' export { createRoot, default as render, renderSync } from './ink/root.js' export { stringWidth } from './ink/stringWidth.js' -export { default as TextInput, UncontrolledTextInput } from 'ink-text-input' diff --git a/ui-tui/packages/hermes-ink/src/ink/hooks/use-external-process.ts b/ui-tui/packages/hermes-ink/src/ink/hooks/use-external-process.ts new file mode 100644 index 000000000..c895edeb2 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/hooks/use-external-process.ts @@ -0,0 +1,27 @@ +import { useCallback } from 'react' + +import instances from '../instances.js' + +export type RunExternalProcess = () => Promise + +export async function withInkSuspended(run: RunExternalProcess): Promise { + const ink = instances.get(process.stdout) + + if (!ink) { + await run() + + return + } + + ink.enterAlternateScreen() + + try { + await run() + } finally { + ink.exitAlternateScreen() + } +} + +export function useExternalProcess(): (run: RunExternalProcess) => Promise { + return useCallback((run: RunExternalProcess) => withInkSuspended(run), []) +} diff --git a/ui-tui/src/app/setupHandoff.ts b/ui-tui/src/app/setupHandoff.ts new file mode 100644 index 000000000..21338c95e --- /dev/null +++ b/ui-tui/src/app/setupHandoff.ts @@ -0,0 +1,54 @@ +import type { RunExternalProcess } from '@hermes/ink' + +import type { SetupStatusResponse } from '../gatewayTypes.js' +import type { LaunchResult } from '../lib/externalCli.js' + +import type { SlashHandlerContext } from './interfaces.js' +import { patchUiState } from './uiStore.js' + +export interface RunExternalSetupOptions { + args: string[] + ctx: Pick + done: string + launcher: (args: string[]) => Promise + suspend: (run: RunExternalProcess) => Promise +} + +export async function runExternalSetup({ args, ctx, done, launcher, suspend }: RunExternalSetupOptions) { + const { gateway, session, transcript } = ctx + + transcript.sys(`launching \`hermes ${args.join(' ')}\`…`) + patchUiState({ status: 'setup running…' }) + + let result: LaunchResult = { code: null } + + await suspend(async () => { + result = await launcher(args) + }) + + if (result.error) { + transcript.sys(`error launching hermes: ${result.error}`) + patchUiState({ status: 'setup required' }) + + return + } + + if (result.code !== 0) { + transcript.sys(`hermes ${args[0]} exited with code ${result.code}`) + patchUiState({ status: 'setup required' }) + + return + } + + const setup = await gateway.rpc('setup.status', {}) + + if (setup?.provider_configured === false) { + transcript.sys('still no provider configured') + patchUiState({ status: 'setup required' }) + + return + } + + transcript.sys(done) + session.newSession() +} diff --git a/ui-tui/src/app/slash/commands/setup.ts b/ui-tui/src/app/slash/commands/setup.ts new file mode 100644 index 000000000..c6d5cc863 --- /dev/null +++ b/ui-tui/src/app/slash/commands/setup.ts @@ -0,0 +1,33 @@ +import { withInkSuspended } from '@hermes/ink' + +import { launchHermesCommand } from '../../../lib/externalCli.js' +import { runExternalSetup } from '../../setupHandoff.js' +import type { SlashCommand } from '../types.js' + +export const setupCommands: SlashCommand[] = [ + { + aliases: ['provider'], + help: 'configure LLM provider and model (launches `hermes model`)', + name: 'model', + run: (_arg, ctx) => + void runExternalSetup({ + args: ['model'], + ctx, + done: 'provider updated — starting session…', + launcher: launchHermesCommand, + suspend: withInkSuspended + }) + }, + { + help: 'run full setup wizard (launches `hermes setup`)', + name: 'setup', + run: (arg, ctx) => + void runExternalSetup({ + args: ['setup', ...arg.split(/\s+/).filter(Boolean)], + ctx, + done: 'setup complete — starting session…', + launcher: launchHermesCommand, + suspend: withInkSuspended + }) + } +] diff --git a/ui-tui/src/app/slash/registry.ts b/ui-tui/src/app/slash/registry.ts index 6a59d0638..ae7d7d50b 100644 --- a/ui-tui/src/app/slash/registry.ts +++ b/ui-tui/src/app/slash/registry.ts @@ -1,9 +1,10 @@ import { coreCommands } from './commands/core.js' import { opsCommands } from './commands/ops.js' import { sessionCommands } from './commands/session.js' +import { setupCommands } from './commands/setup.js' import type { SlashCommand } from './types.js' -export const SLASH_COMMANDS: SlashCommand[] = [...coreCommands, ...sessionCommands, ...opsCommands] +export const SLASH_COMMANDS: SlashCommand[] = [...coreCommands, ...sessionCommands, ...opsCommands, ...setupCommands] const byName = new Map( SLASH_COMMANDS.flatMap(cmd => [cmd.name, ...(cmd.aliases ?? [])].map(name => [name, cmd] as const)) diff --git a/ui-tui/src/content/setup.ts b/ui-tui/src/content/setup.ts index 1170b638d..49dd9aa24 100644 --- a/ui-tui/src/content/setup.ts +++ b/ui-tui/src/content/setup.ts @@ -8,11 +8,10 @@ export const buildSetupRequiredSections = (): PanelSection[] => [ }, { rows: [ - ['1.', 'Exit with Ctrl+C'], - ['2.', 'Run `hermes model` to choose a provider + model'], - ['3.', 'Or run `hermes setup` for full first-time setup'], - ['4.', 'Re-open `hermes --tui` when setup is done'] + ['/model', 'configure provider + model in-place'], + ['/setup', 'run full first-time setup wizard in-place'], + ['Ctrl+C', 'exit and run `hermes setup` manually'] ], - title: 'Next Steps' + title: 'Actions' } ] diff --git a/ui-tui/src/lib/externalCli.ts b/ui-tui/src/lib/externalCli.ts new file mode 100644 index 000000000..7ff88f2b8 --- /dev/null +++ b/ui-tui/src/lib/externalCli.ts @@ -0,0 +1,16 @@ +import { spawn } from 'node:child_process' + +export interface LaunchResult { + code: null | number + error?: string +} + +const resolveHermesBin = () => process.env.HERMES_BIN?.trim() || 'hermes' + +export const launchHermesCommand = (args: string[]): Promise => + new Promise(resolve => { + const child = spawn(resolveHermesBin(), args, { stdio: 'inherit' }) + + child.on('error', err => resolve({ code: null, error: err.message })) + child.on('exit', code => resolve({ code })) + }) diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index d144656b3..9b2deec35 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -72,6 +72,9 @@ declare module '@hermes/ink' { export function render(node: React.ReactNode, options?: NodeJS.WriteStream | RenderOptions): Instance export function useApp(): { readonly exit: (error?: Error) => void } + export type RunExternalProcess = () => Promise + export function useExternalProcess(): (run: RunExternalProcess) => Promise + export function withInkSuspended(run: RunExternalProcess): Promise export function useInput(handler: InputHandler, options?: { readonly isActive?: boolean }): void export function useSelection(): { readonly copySelection: () => string