From 90b74b6a6df25e5c9da083a57625f12a51faa67c Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Mon, 6 Apr 2026 19:04:17 +1000 Subject: [PATCH] Replay conversation history into TUI on startup (step 1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On startup, stored messages are walked and rendered as sealed blocks before the first prompt, so prior turns are visible when a session resumes. cliConfig.ts introduces a hardcoded CliConfig with two flags: historyReplay.enabled — whether to replay at all (default: true) historyReplay.showThinking — whether thinking blocks appear (default: false) replayHistory() is a pure function (no AppLayout import, no I/O) that maps BetaMessageParam[] to ReplayBlock[]: user text → prompt block user tool_result → tools block “↩ N result(s)”, appended to prior tools block when tool_use immediately preceded it user compaction → compaction block with summary assistant text → response block assistant tool_use → tools block “→ name”, merged with consecutive uses assistant thinking → thinking block (only when showThinking: true) Content array is walked in order, so text before tools produces a response block above the tools block — matching the live session display (Option A). Tool result blocks carry only tool_use_id, not the tool name. Rather than cross-referencing message pairs, the count is shown (“↩ 2 results”). The raw data is intact in the JSONL; the display is a best-effort derivation. AppLayout.addHistoryBlocks() pushes all blocks and renders once, avoiding a repaint per block during replay. 21 tests covering all block types, ordering, merge behaviour, and the showThinking flag. --- apps/claude-sdk-cli/src/AppLayout.ts | 8 + apps/claude-sdk-cli/src/cliConfig.ts | 17 ++ apps/claude-sdk-cli/src/entry/main.ts | 9 + apps/claude-sdk-cli/src/replayHistory.ts | 91 ++++++++ .../claude-sdk-cli/test/replayHistory.spec.ts | 196 ++++++++++++++++++ 5 files changed, 321 insertions(+) create mode 100644 apps/claude-sdk-cli/src/cliConfig.ts create mode 100644 apps/claude-sdk-cli/src/replayHistory.ts create mode 100644 apps/claude-sdk-cli/test/replayHistory.spec.ts diff --git a/apps/claude-sdk-cli/src/AppLayout.ts b/apps/claude-sdk-cli/src/AppLayout.ts index 4b507ef..9b1ecd0 100644 --- a/apps/claude-sdk-cli/src/AppLayout.ts +++ b/apps/claude-sdk-cli/src/AppLayout.ts @@ -175,6 +175,14 @@ export class AppLayout implements Disposable { this.render(); } + /** Push pre-built sealed blocks (e.g. from history replay) and render once. */ + public addHistoryBlocks(blocks: { type: BlockType; content: string }[]): void { + for (const block of blocks) { + this.#sealedBlocks.push(block); + } + this.render(); + } + public exit(): void { this.#cleanupResize(); this.#screen.exitAltBuffer(); diff --git a/apps/claude-sdk-cli/src/cliConfig.ts b/apps/claude-sdk-cli/src/cliConfig.ts new file mode 100644 index 0000000..0ab061e --- /dev/null +++ b/apps/claude-sdk-cli/src/cliConfig.ts @@ -0,0 +1,17 @@ +export type HistoryReplayConfig = { + /** Whether to replay history messages into the display on startup. */ + enabled: boolean; + /** Whether to show thinking blocks when replaying history. */ + showThinking: boolean; +}; + +export type CliConfig = { + historyReplay: HistoryReplayConfig; +}; + +export const config: CliConfig = { + historyReplay: { + enabled: true, + showThinking: false, + }, +}; diff --git a/apps/claude-sdk-cli/src/entry/main.ts b/apps/claude-sdk-cli/src/entry/main.ts index d8935b7..98a5e61 100644 --- a/apps/claude-sdk-cli/src/entry/main.ts +++ b/apps/claude-sdk-cli/src/entry/main.ts @@ -2,9 +2,11 @@ import { parseArgs } from 'node:util'; import { AnthropicAuth, createAnthropicAgent } from '@shellicar/claude-sdk'; import { RefStore } from '@shellicar/claude-sdk-tools/RefStore'; import { AppLayout } from '../AppLayout.js'; +import { config } from '../cliConfig.js'; import { printUsage, printVersion, printVersionInfo, startupBannerText } from '../help.js'; import { logger } from '../logger.js'; import { ReadLine } from '../ReadLine.js'; +import { replayHistory } from '../replayHistory.js'; import { runAgent } from '../runAgent.js'; const { values } = parseArgs({ @@ -64,6 +66,13 @@ const main = async () => { layout.showStartupBanner(startupBannerText()); const agent = createAnthropicAgent({ authToken, logger, historyFile: HISTORY_FILE }); + + if (config.historyReplay.enabled) { + const history = agent.getHistory(); + if (history.length > 0) { + layout.addHistoryBlocks(replayHistory(history, config.historyReplay)); + } + } const store = new RefStore(); while (true) { const prompt = await layout.waitForInput(); diff --git a/apps/claude-sdk-cli/src/replayHistory.ts b/apps/claude-sdk-cli/src/replayHistory.ts new file mode 100644 index 0000000..1416971 --- /dev/null +++ b/apps/claude-sdk-cli/src/replayHistory.ts @@ -0,0 +1,91 @@ +import type { Anthropic } from '@anthropic-ai/sdk'; +import type { HistoryReplayConfig } from './cliConfig.js'; + +// Subset of AppLayout's BlockType — meta is never produced during replay. +export type ReplayBlockType = 'prompt' | 'thinking' | 'response' | 'tools' | 'compaction'; + +export type ReplayBlock = { + type: ReplayBlockType; + content: string; +}; + +/** + * Convert a stored message history into a flat list of display blocks. + * + * Pure function — no I/O, no AppLayout import. The caller pushes the result + * into AppLayout.addHistoryBlocks(). + * + * Mapping: + * user text blocks → prompt block + * user tool_result blocks → tools block "↩ N results" (appended if tools block already open) + * user compaction block → compaction block with summary text + * asst text blocks → response block + * asst thinking blocks → thinking block (only if opts.showThinking) + * asst tool_use blocks → tools block "→ name" (merged into running tools block) + * + * Content array is walked in order so text before tool calls appears in a + * response block above the tools block, matching the live session display. + */ +export function replayHistory(messages: Anthropic.Beta.Messages.BetaMessageParam[], opts: Pick): ReplayBlock[] { + const blocks: ReplayBlock[] = []; + + const appendToTools = (line: string): void => { + const last = blocks[blocks.length - 1]; + if (last?.type === 'tools') { + last.content += `\n${line}`; + } else { + blocks.push({ type: 'tools', content: line }); + } + }; + + for (const message of messages) { + const content = Array.isArray(message.content) ? message.content : [{ type: 'text' as const, text: message.content as string }]; + + if (message.role === 'user') { + // Compaction takes priority — a compaction message has only a compaction block. + const compaction = content.find((b) => b.type === 'compaction') as { type: 'compaction'; summary?: string } | undefined; + if (compaction) { + blocks.push({ type: 'compaction', content: compaction.summary ?? '' }); + continue; + } + + // Tool results — count only, name not available without cross-referencing tool_use ids. + const resultCount = content.filter((b) => b.type === 'tool_result').length; + if (resultCount > 0) { + appendToTools(`↩ ${resultCount} result${resultCount === 1 ? '' : 's'}`); + continue; + } + + // Regular user text. + const text = content + .filter((b) => b.type === 'text') + .map((b) => (b as { type: 'text'; text: string }).text) + .join('\n'); + if (text.trim()) { + blocks.push({ type: 'prompt', content: text }); + } + } else if (message.role === 'assistant') { + // Walk content in order — text before tools matches live session display. + for (const block of content) { + if (block.type === 'text') { + const text = (block as { type: 'text'; text: string }).text; + if (text.trim()) { + blocks.push({ type: 'response', content: text }); + } + } else if (block.type === 'thinking') { + if (opts.showThinking) { + const thinking = (block as { type: 'thinking'; thinking: string }).thinking; + if (thinking.trim()) { + blocks.push({ type: 'thinking', content: thinking }); + } + } + } else if (block.type === 'tool_use') { + const name = (block as { type: 'tool_use'; name: string }).name; + appendToTools(`→ ${name}`); + } + } + } + } + + return blocks; +} diff --git a/apps/claude-sdk-cli/test/replayHistory.spec.ts b/apps/claude-sdk-cli/test/replayHistory.spec.ts new file mode 100644 index 0000000..7df61e5 --- /dev/null +++ b/apps/claude-sdk-cli/test/replayHistory.spec.ts @@ -0,0 +1,196 @@ +import type { Anthropic } from '@anthropic-ai/sdk'; +import { describe, expect, it } from 'vitest'; +import { replayHistory } from '../src/replayHistory.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +type Msg = Anthropic.Beta.Messages.BetaMessageParam; + +const user = (text: string): Msg => ({ role: 'user', content: [{ type: 'text', text }] }); + +const assistant = (text: string): Msg => ({ role: 'assistant', content: [{ type: 'text', text }] }); + +const toolUse = (name: string): Anthropic.Beta.Messages.BetaContentBlockParam => ({ type: 'tool_use', id: `tu_${name}`, name, input: {} }) as unknown as Anthropic.Beta.Messages.BetaContentBlockParam; + +const toolResult = (id: string): Anthropic.Beta.Messages.BetaContentBlockParam => ({ type: 'tool_result', tool_use_id: id, content: 'ok' }) as unknown as Anthropic.Beta.Messages.BetaContentBlockParam; + +const thinking = (text: string): Anthropic.Beta.Messages.BetaContentBlockParam => ({ type: 'thinking', thinking: text, signature: 'sig' }) as unknown as Anthropic.Beta.Messages.BetaContentBlockParam; + +const compaction = (summary: string): Msg => ({ role: 'user', content: [{ type: 'compaction', summary, llm_identifier: 'claude-3-5-sonnet-20241022' }] }) as unknown as Msg; + +const noThinking = { showThinking: false }; +const withThinking = { showThinking: true }; + +// --------------------------------------------------------------------------- +// Empty input +// --------------------------------------------------------------------------- + +describe('replayHistory — empty input', () => { + it('returns empty array for no messages', () => { + const expected = 0; + const actual = replayHistory([], noThinking).length; + expect(actual).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// User messages +// --------------------------------------------------------------------------- + +describe('replayHistory — user messages', () => { + it('user text produces a prompt block', () => { + const expected = 'prompt'; + const actual = replayHistory([user('hello')], noThinking)[0]?.type; + expect(actual).toBe(expected); + }); + + it('user text content is preserved', () => { + const expected = 'hello'; + const actual = replayHistory([user('hello')], noThinking)[0]?.content; + expect(actual).toBe(expected); + }); + + it('compaction produces a compaction block', () => { + const expected = 'compaction'; + const actual = replayHistory([compaction('summary text')], noThinking)[0]?.type; + expect(actual).toBe(expected); + }); + + it('compaction block carries the summary text', () => { + const expected = 'summary text'; + const actual = replayHistory([compaction('summary text')], noThinking)[0]?.content; + expect(actual).toBe(expected); + }); + + it('tool results produce a tools block', () => { + const msg: Msg = { role: 'user', content: [toolResult('tu_1'), toolResult('tu_2')] }; + const expected = 'tools'; + const actual = replayHistory([msg], noThinking)[0]?.type; + expect(actual).toBe(expected); + }); + + it('tool result count is shown in content', () => { + const msg: Msg = { role: 'user', content: [toolResult('tu_1'), toolResult('tu_2')] }; + const expected = '↩ 2 results'; + const actual = replayHistory([msg], noThinking)[0]?.content; + expect(actual).toBe(expected); + }); + + it('single tool result uses singular form', () => { + const msg: Msg = { role: 'user', content: [toolResult('tu_1')] }; + const expected = '↩ 1 result'; + const actual = replayHistory([msg], noThinking)[0]?.content; + expect(actual).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// Assistant messages +// --------------------------------------------------------------------------- + +describe('replayHistory — assistant messages', () => { + it('assistant text produces a response block', () => { + const expected = 'response'; + const actual = replayHistory([assistant('hi')], noThinking)[0]?.type; + expect(actual).toBe(expected); + }); + + it('assistant text content is preserved', () => { + const expected = 'hi'; + const actual = replayHistory([assistant('hi')], noThinking)[0]?.content; + expect(actual).toBe(expected); + }); + + it('tool_use produces a tools block', () => { + const msg: Msg = { role: 'assistant', content: [toolUse('ReadFile')] }; + const expected = 'tools'; + const actual = replayHistory([msg], noThinking)[0]?.type; + expect(actual).toBe(expected); + }); + + it('tool_use content shows arrow and name', () => { + const msg: Msg = { role: 'assistant', content: [toolUse('ReadFile')] }; + const expected = '→ ReadFile'; + const actual = replayHistory([msg], noThinking)[0]?.content; + expect(actual).toBe(expected); + }); + + it('multiple tool_use blocks merge into one tools block', () => { + const msg: Msg = { role: 'assistant', content: [toolUse('ReadFile'), toolUse('Grep')] }; + const expected = 1; + const actual = replayHistory([msg], noThinking).length; + expect(actual).toBe(expected); + }); + + it('multiple tool_use names appear on separate lines', () => { + const msg: Msg = { role: 'assistant', content: [toolUse('ReadFile'), toolUse('Grep')] }; + const expected = '→ ReadFile\n→ Grep'; + const actual = replayHistory([msg], noThinking)[0]?.content; + expect(actual).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// Thinking blocks +// --------------------------------------------------------------------------- + +describe('replayHistory — thinking blocks', () => { + it('thinking is skipped when showThinking is false', () => { + const msg: Msg = { role: 'assistant', content: [thinking('internal thoughts')] }; + const expected = 0; + const actual = replayHistory([msg], noThinking).length; + expect(actual).toBe(expected); + }); + + it('thinking produces a thinking block when showThinking is true', () => { + const msg: Msg = { role: 'assistant', content: [thinking('internal thoughts')] }; + const expected = 'thinking'; + const actual = replayHistory([msg], withThinking)[0]?.type; + expect(actual).toBe(expected); + }); + + it('thinking content is preserved when shown', () => { + const msg: Msg = { role: 'assistant', content: [thinking('internal thoughts')] }; + const expected = 'internal thoughts'; + const actual = replayHistory([msg], withThinking)[0]?.content; + expect(actual).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// Ordering and merging +// --------------------------------------------------------------------------- + +describe('replayHistory — ordering and merging', () => { + it('text before tool_use produces response block then tools block', () => { + const msg: Msg = { role: 'assistant', content: [{ type: 'text', text: 'Looking...' }, toolUse('ReadFile')] }; + const expected = ['response', 'tools']; + const actual = replayHistory([msg], noThinking).map((b) => b.type); + expect(actual).toEqual(expected); + }); + + it('tool_result appends to preceding tools block from tool_use', () => { + const asstMsg: Msg = { role: 'assistant', content: [toolUse('ReadFile')] }; + const userMsg: Msg = { role: 'user', content: [toolResult('tu_ReadFile')] }; + const expected = 1; + const actual = replayHistory([asstMsg, userMsg], noThinking).length; + expect(actual).toBe(expected); + }); + + it('tool_result content appended after tool_use in same block', () => { + const asstMsg: Msg = { role: 'assistant', content: [toolUse('ReadFile')] }; + const userMsg: Msg = { role: 'user', content: [toolResult('tu_ReadFile')] }; + const expected = '→ ReadFile\n↩ 1 result'; + const actual = replayHistory([asstMsg, userMsg], noThinking)[0]?.content; + expect(actual).toBe(expected); + }); + + it('full turn sequence produces correct block order', () => { + const messages: Msg[] = [user('what files are here?'), { role: 'assistant', content: [{ type: 'text', text: 'Let me check.' }, toolUse('Find')] }, { role: 'user', content: [toolResult('tu_Find')] }, assistant('Here are the files.')]; + const expected = ['prompt', 'response', 'tools', 'response']; + const actual = replayHistory(messages, noThinking).map((b) => b.type); + expect(actual).toEqual(expected); + }); +});