feat(cli): add /update slash command to CLI and TUI (#23854)

* feat: add /update slash command to CLI and TUI

* test(cli): add Python tests for /update slash command

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): address Copilot review for /update slash command

Route classic CLI /update through prompt_toolkit modal confirmation and
defer relaunch to the main-thread cleanup path after app.exit(). Tighten
Y/n semantics, add Python wrapper and catalog coverage tests, and assert
/update stays visible in the TUI command catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): address review feedback on /update command

- Replace raw input() with _prompt_text_input_modal in _handle_update_command
  to avoid EOF/hang/keystroke-leak races with prompt_toolkit's stdin ownership
- Fix confirmation logic: only proceed on recognized affirmative aliases
  (y/yes/1/ok); cancel on everything else including empty string, typos,
  and unrecognized input — matches all other [Y/n] prompts in the codebase
- Route relaunch through main-thread shutdown path: set _pending_relaunch
  and return False from process_command so process_loop triggers app.exit();
  run() then calls relaunch() after prompt_toolkit has restored terminal modes
  and after cleanup — safe on both POSIX (execvp) and Windows (subprocess+exit)
- Fix misleading docstring in test_update_command.py: the Vitest only covers
  the TypeScript slash handler that emits code 42, not the Python wrapper
  branch that acts on it
- Rewrite tests to use SimpleNamespace pattern (like test_destructive_slash_confirm)
  so _prompt_text_input_modal can be stubbed directly
- Add Python test for _launch_tui exit-code-42 → relaunch branch in main.py

Agent-Logs-Url: https://github.com/NousResearch/hermes-agent/sessions/f6da68cf-e7b1-4b7a-aed6-3d4b0f523bdb

Co-authored-by: austinpickett <260188+austinpickett@users.noreply.github.com>

* fix(cli): polish test fixtures for /update command

- Remove unused _prompt_text_input from SimpleNamespace stub
- Use pytest.fail sentinel in managed-install guard test to catch unexpected modal invocations

Agent-Logs-Url: https://github.com/NousResearch/hermes-agent/sessions/f6da68cf-e7b1-4b7a-aed6-3d4b0f523bdb

Co-authored-by: austinpickett <260188+austinpickett@users.noreply.github.com>

* chore: re-trigger CI after Copilot review fixes

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: austinpickett <260188+austinpickett@users.noreply.github.com>
This commit is contained in:
Austin Pickett 2026-05-18 20:10:46 -04:00 committed by GitHub
parent 378bca1d2f
commit 2ef501e1f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 289 additions and 3 deletions

View file

@ -34,6 +34,21 @@ describe('createSlashHandler', () => {
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('handles /update locally and exits with code 42 via dieWithCode', () => {
vi.useFakeTimers()
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/update')).toBe(true)
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
expect(ctx.transcript.sys).toHaveBeenCalledWith('exiting TUI to run update...')
// Advance past the 100ms setTimeout
vi.advanceTimersByTime(150)
expect(ctx.session.dieWithCode).toHaveBeenCalledWith(42)
vi.useRealTimers()
})
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' }))
@ -730,6 +745,7 @@ const buildComposer = () => ({
const buildGateway = () => ({
gw: {
getLogTail: vi.fn(() => ''),
kill: vi.fn(),
request: vi.fn(() => Promise.resolve({}))
},
rpc: vi.fn(() => Promise.resolve({}))
@ -746,6 +762,7 @@ const buildLocal = () => ({
const buildSession = () => ({
closeSession: vi.fn(() => Promise.resolve(null)),
die: vi.fn(),
dieWithCode: vi.fn(),
guardBusySessionSwitch: vi.fn(() => false),
newSession: vi.fn(),
resetVisibleHistory: vi.fn(),

View file

@ -277,6 +277,7 @@ export interface SlashHandlerContext {
session: {
closeSession: (targetSid?: null | string) => Promise<unknown>
die: () => void
dieWithCode: (code: number) => void
guardBusySessionSwitch: (what?: string) => boolean
newSession: (msg?: string, title?: string) => void
resetVisibleHistory: (info?: null | SessionInfo) => void

View file

@ -92,6 +92,17 @@ export const coreCommands: SlashCommand[] = [
run: (_arg, ctx) => ctx.session.die()
},
{
help: 'update Hermes Agent to the latest version (exits TUI)',
name: 'update',
run: (_arg, ctx) => {
ctx.transcript.sys('exiting TUI to run update...')
// Exit code 42 signals the Python wrapper to exec `hermes update`.
// Use dieWithCode for proper cleanup (gateway kill + Ink unmount).
setTimeout(() => ctx.session.dieWithCode(42), 100)
}
},
{
aliases: ['scroll'],
help: 'toggle mouse/wheel tracking [on|off|toggle]',

View file

@ -377,6 +377,12 @@ export function useMainApp(gw: GatewayClient) {
process.exit(0)
}, [exit, gw])
const dieWithCode = useCallback((code: number) => {
gw.kill()
exit()
process.exit(code)
}, [exit, gw])
const session = useSessionLifecycle({
colsRef,
composerActions,
@ -643,6 +649,7 @@ export function useMainApp(gw: GatewayClient) {
session: {
closeSession: session.closeSession,
die,
dieWithCode,
guardBusySessionSwitch: session.guardBusySessionSwitch,
newSession: session.newSession,
resetVisibleHistory: session.resetVisibleHistory,