diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 3df9f2818c..fd06d1c684 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -1,3 +1,5 @@ +import { existsSync, readFileSync, unlinkSync } from 'node:fs' + import { beforeEach, describe, expect, it, vi } from 'vitest' import { createSlashHandler } from '../app/createSlashHandler.js' @@ -287,6 +289,58 @@ describe('createSlashHandler', () => { expect(ctx.transcript.page).not.toHaveBeenCalled() expect(ctx.transcript.sys).toHaveBeenCalledWith('no conversation yet') }) + + it('/save writes the current TUI transcript without using the slash worker', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 3, 25, 15, 4, 5)) + const filename = 'hermes_conversation_20260425_150405.json' + + try { + if (existsSync(filename)) { + unlinkSync(filename) + } + + const ctx = buildCtx({ + local: { + ...buildLocal(), + getHistoryItems: vi.fn(() => [ + { role: 'system', text: 'intro' }, + { role: 'user', text: 'hello' }, + { role: 'assistant', text: 'hi there', tools: ['read_file'] }, + { role: 'tool', text: 'tool output' } + ]) + } + }) + + createSlashHandler(ctx)('/save') + + expect(ctx.gateway.gw.request).not.toHaveBeenCalled() + expect(ctx.transcript.sys).toHaveBeenCalledWith(`conversation saved to: ${filename}`) + + const saved = JSON.parse(readFileSync(filename, 'utf8')) + + expect(saved.messages).toEqual([ + { role: 'user', text: 'hello' }, + { role: 'assistant', text: 'hi there', tools: ['read_file'] }, + { role: 'tool', text: 'tool output' } + ]) + } finally { + if (existsSync(filename)) { + unlinkSync(filename) + } + + vi.useRealTimers() + } + }) + + it('/save reports empty state without touching the slash worker', () => { + const ctx = buildCtx() + + createSlashHandler(ctx)('/save') + + expect(ctx.gateway.gw.request).not.toHaveBeenCalled() + expect(ctx.transcript.sys).toHaveBeenCalledWith('no conversation yet') + }) }) const buildCtx = (overrides: Partial = {}): Ctx => ({ diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 7ab64be99e..81865959c0 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -1,3 +1,5 @@ +import { writeFileSync } from 'node:fs' + import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js' import { dailyFortune, randomFortune } from '../../../content/fortunes.js' import { HOTKEYS } from '../../../content/hotkeys.js' @@ -46,6 +48,24 @@ const DETAILS_USAGE = const DETAILS_SECTION_USAGE = 'usage: /details
[hidden|collapsed|expanded|reset]' +const pad2 = (n: number): string => String(n).padStart(2, '0') + +const saveTimestamp = (d = new Date()): string => + `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}_${pad2(d.getHours())}${pad2( + d.getMinutes() + )}${pad2(d.getSeconds())}` + +const serializableTranscript = (items: Msg[]) => + items + .filter(m => m.role === 'user' || m.role === 'assistant' || m.role === 'tool') + .filter(m => m.text.trim() || m.thinking?.trim() || m.tools?.length) + .map(m => ({ + role: m.role, + text: m.text, + ...(m.thinking ? { thinking: m.thinking } : {}), + ...(m.tools?.length ? { tools: m.tools } : {}) + })) + export const coreCommands: SlashCommand[] = [ { help: 'list commands + hotkeys', @@ -351,6 +371,40 @@ export const coreCommands: SlashCommand[] = [ } }, + { + help: 'save the current transcript to JSON', + name: 'save', + run: (_arg, ctx) => { + const messages = serializableTranscript(ctx.local.getHistoryItems()) + + if (!messages.length) { + return ctx.transcript.sys('no conversation yet') + } + + const filename = `hermes_conversation_${saveTimestamp()}.json` + + try { + writeFileSync( + filename, + `${JSON.stringify( + { + model: ctx.ui.info?.model ?? null, + saved_at: new Date().toISOString(), + session_id: ctx.sid, + messages + }, + null, + 2 + )}\n`, + 'utf8' + ) + ctx.transcript.sys(`conversation saved to: ${filename}`) + } catch (error) { + ctx.transcript.sys(`failed to save: ${String(error)}`) + } + } + }, + { aliases: ['sb'], help: 'status bar position (on|off|top|bottom)',