Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/claude-sdk-cli/src/AppLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
17 changes: 17 additions & 0 deletions apps/claude-sdk-cli/src/cliConfig.ts
Original file line number Diff line number Diff line change
@@ -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,
},
};
9 changes: 9 additions & 0 deletions apps/claude-sdk-cli/src/entry/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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();
Expand Down
91 changes: 91 additions & 0 deletions apps/claude-sdk-cli/src/replayHistory.ts
Original file line number Diff line number Diff line change
@@ -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<HistoryReplayConfig, 'showThinking'>): 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;
}
196 changes: 196 additions & 0 deletions apps/claude-sdk-cli/test/replayHistory.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading