mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-27 01:11:40 +00:00
feat(tui): /model and /setup slash commands with in-place CLI handoff
- 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
This commit is contained in:
parent
0dd5055d59
commit
a82097e7a2
8 changed files with 141 additions and 7 deletions
|
|
@ -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 useStderr } from './hooks/use-stderr.js'
|
||||||
export { default as useStdout } from './hooks/use-stdout.js'
|
export { default as useStdout } from './hooks/use-stdout.js'
|
||||||
export { Ansi } from './ink/Ansi.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 Text } from './ink/components/Text.js'
|
||||||
export { default as useApp } from './ink/hooks/use-app.js'
|
export { default as useApp } from './ink/hooks/use-app.js'
|
||||||
export { useDeclaredCursor } from './ink/hooks/use-declared-cursor.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 { default as useInput } from './ink/hooks/use-input.js'
|
||||||
export { useHasSelection, useSelection } from './ink/hooks/use-selection.js'
|
export { useHasSelection, useSelection } from './ink/hooks/use-selection.js'
|
||||||
export { default as useStdin } from './ink/hooks/use-stdin.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 { default as measureElement } from './ink/measure-element.js'
|
||||||
export { createRoot, default as render, renderSync } from './ink/root.js'
|
export { createRoot, default as render, renderSync } from './ink/root.js'
|
||||||
export { stringWidth } from './ink/stringWidth.js'
|
export { stringWidth } from './ink/stringWidth.js'
|
||||||
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
|
||||||
|
import instances from '../instances.js'
|
||||||
|
|
||||||
|
export type RunExternalProcess = () => Promise<void>
|
||||||
|
|
||||||
|
export async function withInkSuspended(run: RunExternalProcess): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
return useCallback((run: RunExternalProcess) => withInkSuspended(run), [])
|
||||||
|
}
|
||||||
54
ui-tui/src/app/setupHandoff.ts
Normal file
54
ui-tui/src/app/setupHandoff.ts
Normal file
|
|
@ -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<SlashHandlerContext, 'gateway' | 'session' | 'transcript'>
|
||||||
|
done: string
|
||||||
|
launcher: (args: string[]) => Promise<LaunchResult>
|
||||||
|
suspend: (run: RunExternalProcess) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<SetupStatusResponse>('setup.status', {})
|
||||||
|
|
||||||
|
if (setup?.provider_configured === false) {
|
||||||
|
transcript.sys('still no provider configured')
|
||||||
|
patchUiState({ status: 'setup required' })
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transcript.sys(done)
|
||||||
|
session.newSession()
|
||||||
|
}
|
||||||
33
ui-tui/src/app/slash/commands/setup.ts
Normal file
33
ui-tui/src/app/slash/commands/setup.ts
Normal file
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { coreCommands } from './commands/core.js'
|
import { coreCommands } from './commands/core.js'
|
||||||
import { opsCommands } from './commands/ops.js'
|
import { opsCommands } from './commands/ops.js'
|
||||||
import { sessionCommands } from './commands/session.js'
|
import { sessionCommands } from './commands/session.js'
|
||||||
|
import { setupCommands } from './commands/setup.js'
|
||||||
import type { SlashCommand } from './types.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<string, SlashCommand>(
|
const byName = new Map<string, SlashCommand>(
|
||||||
SLASH_COMMANDS.flatMap(cmd => [cmd.name, ...(cmd.aliases ?? [])].map(name => [name, cmd] as const))
|
SLASH_COMMANDS.flatMap(cmd => [cmd.name, ...(cmd.aliases ?? [])].map(name => [name, cmd] as const))
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,10 @@ export const buildSetupRequiredSections = (): PanelSection[] => [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rows: [
|
rows: [
|
||||||
['1.', 'Exit with Ctrl+C'],
|
['/model', 'configure provider + model in-place'],
|
||||||
['2.', 'Run `hermes model` to choose a provider + model'],
|
['/setup', 'run full first-time setup wizard in-place'],
|
||||||
['3.', 'Or run `hermes setup` for full first-time setup'],
|
['Ctrl+C', 'exit and run `hermes setup` manually']
|
||||||
['4.', 'Re-open `hermes --tui` when setup is done']
|
|
||||||
],
|
],
|
||||||
title: 'Next Steps'
|
title: 'Actions'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
16
ui-tui/src/lib/externalCli.ts
Normal file
16
ui-tui/src/lib/externalCli.ts
Normal file
|
|
@ -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<LaunchResult> =>
|
||||||
|
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 }))
|
||||||
|
})
|
||||||
3
ui-tui/src/types/hermes-ink.d.ts
vendored
3
ui-tui/src/types/hermes-ink.d.ts
vendored
|
|
@ -72,6 +72,9 @@ declare module '@hermes/ink' {
|
||||||
export function render(node: React.ReactNode, options?: NodeJS.WriteStream | RenderOptions): Instance
|
export function render(node: React.ReactNode, options?: NodeJS.WriteStream | RenderOptions): Instance
|
||||||
|
|
||||||
export function useApp(): { readonly exit: (error?: Error) => void }
|
export function useApp(): { readonly exit: (error?: Error) => void }
|
||||||
|
export type RunExternalProcess = () => Promise<void>
|
||||||
|
export function useExternalProcess(): (run: RunExternalProcess) => Promise<void>
|
||||||
|
export function withInkSuspended(run: RunExternalProcess): Promise<void>
|
||||||
export function useInput(handler: InputHandler, options?: { readonly isActive?: boolean }): void
|
export function useInput(handler: InputHandler, options?: { readonly isActive?: boolean }): void
|
||||||
export function useSelection(): {
|
export function useSelection(): {
|
||||||
readonly copySelection: () => string
|
readonly copySelection: () => string
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue