From 8a23862a4d2c2ad59bdb90b24e02276641bf6b27 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Mon, 6 Apr 2026 23:22:36 +1000 Subject: [PATCH 1/2] Extract ConversationState and renderConversation from AppLayout (step 5b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves block state and rendering out of AppLayout into two focused files: - ConversationState.ts: sealed blocks, active block, flush boundary. Pure state machine — no I/O, no rendering. transitionBlock returns a result struct so the caller can log without needing to re-read private state. appendToLastSealed returns 'active', a sealed index, or 'miss' so the caller can log the exact target without duplicating the search logic. - renderConversation.ts: all block rendering helpers (renderBlockContent, buildDivider, BLOCK_EMOJI/PLAIN constants) plus three exports: renderConversation(state, cols) — sealed + active blocks for the alt-buffer viewport renderBlocksToString(blocks, startIndex, cols) — slice-to-string for the flush-to-scroll path; uses full array for continuation checks buildDivider(label, cols) — used by AppLayout for the prompt divider and separator (buildDivider(null, cols)) AppLayout after: holds #conversationState, delegates all block mutations to it, calls renderConversation in render(), calls renderBlocksToString in #flushToScroll(). The separator DIM+FILL+RESET is now buildDivider(null, cols) — same output, no separate FILL constant needed in AppLayout. Tests: 41 new (27 ConversationState, 14 renderConversation). 231 total. --- apps/claude-sdk-cli/src/AppLayout.ts | 223 +++----------- apps/claude-sdk-cli/src/ConversationState.ts | 109 +++++++ apps/claude-sdk-cli/src/renderConversation.ts | 171 +++++++++++ .../test/ConversationState.spec.ts | 272 ++++++++++++++++++ .../test/renderConversation.spec.ts | 149 ++++++++++ 5 files changed, 736 insertions(+), 188 deletions(-) create mode 100644 apps/claude-sdk-cli/src/ConversationState.ts create mode 100644 apps/claude-sdk-cli/src/renderConversation.ts create mode 100644 apps/claude-sdk-cli/test/ConversationState.spec.ts create mode 100644 apps/claude-sdk-cli/test/renderConversation.spec.ts diff --git a/apps/claude-sdk-cli/src/AppLayout.ts b/apps/claude-sdk-cli/src/AppLayout.ts index 64e730e..55d9b73 100644 --- a/apps/claude-sdk-cli/src/AppLayout.ts +++ b/apps/claude-sdk-cli/src/AppLayout.ts @@ -8,11 +8,13 @@ import type { Screen } from '@shellicar/claude-core/screen'; import { StdoutScreen } from '@shellicar/claude-core/screen'; import { StatusLineBuilder } from '@shellicar/claude-core/status-line'; import type { SdkMessageUsage } from '@shellicar/claude-sdk'; -import { highlight } from 'cli-highlight'; import { AttachmentStore } from './AttachmentStore.js'; +import type { Block, BlockType } from './ConversationState.js'; +import { ConversationState } from './ConversationState.js'; import { readClipboardPath, readClipboardText } from './clipboard.js'; import { EditorState } from './EditorState.js'; import { logger } from './logger.js'; +import { buildDivider, renderBlocksToString, renderConversation } from './renderConversation.js'; import { renderEditor } from './renderEditor.js'; import { renderStatus } from './renderStatus.js'; import { StatusState } from './StatusState.js'; @@ -25,88 +27,10 @@ export type PendingTool = { type Mode = 'editor' | 'streaming'; -type BlockType = 'prompt' | 'thinking' | 'response' | 'tools' | 'compaction' | 'meta'; - -type Block = { - type: BlockType; - content: string; -}; - -const FILL = '\u2500'; - -const BLOCK_PLAIN: Record = { - prompt: 'prompt', - thinking: 'thinking', - response: 'response', - tools: 'tools', - compaction: 'compaction', - meta: 'query', -}; - -const BLOCK_EMOJI: Record = { - prompt: '💬 ', - thinking: '💭 ', - response: '📝 ', - tools: '🔧 ', - compaction: '🗜 ', - meta: 'ℹ️ ', -}; - +// Indentation used for tool expansion and attachment preview rows. +// renderConversation.ts uses the same value for block content lines. const CONTENT_INDENT = ' '; -const CODE_FENCE_RE = /```(\w*)\n([\s\S]*?)```/g; - -function renderBlockContent(content: string, cols: number): string[] { - const result: string[] = []; - let lastIndex = 0; - - const addText = (text: string) => { - const lines = text.split('\n'); - const trimmed = lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines; - for (const line of trimmed) { - result.push(...wrapLine(CONTENT_INDENT + line, cols)); - } - }; - - for (const match of content.matchAll(CODE_FENCE_RE)) { - if (match.index > lastIndex) { - addText(content.slice(lastIndex, match.index)); - } - const lang = match[1] || 'plaintext'; - const code = (match[2] ?? '').trimEnd(); - result.push(`${CONTENT_INDENT}\`\`\`${lang}`); - try { - const highlighted = highlight(code, { language: lang, ignoreIllegals: true }); - for (const line of highlighted.split('\n')) { - result.push(CONTENT_INDENT + line); - } - } catch { - for (const line of code.split('\n')) { - result.push(CONTENT_INDENT + line); - } - } - result.push(`${CONTENT_INDENT}\`\`\``); - lastIndex = match.index + match[0].length; - } - - if (lastIndex < content.length) { - addText(content.slice(lastIndex)); - } else if (lastIndex === 0) { - addText(content); - } - - return result; -} - -function buildDivider(displayLabel: string | null, cols: number): string { - if (!displayLabel) { - return DIM + FILL.repeat(cols) + RESET; - } - const prefix = `${FILL}${FILL} ${displayLabel} `; - const remaining = Math.max(0, cols - prefix.length); - return DIM + prefix + FILL.repeat(remaining) + RESET; -} - /** Returns true if the string looks like a deliberate filesystem path (for missing-file chips). */ function isLikelyPath(s: string): boolean { if (!s || s.length > 1024) { @@ -123,9 +47,7 @@ export class AppLayout implements Disposable { readonly #cleanupResize: () => void; #mode: Mode = 'editor'; - #sealedBlocks: Block[] = []; - #flushedCount = 0; - #activeBlock: Block | null = null; + #conversationState = new ConversationState(); #editorState = new EditorState(); #renderPending = false; @@ -159,15 +81,13 @@ export class AppLayout implements Disposable { /** Push a sealed meta block at startup so version info appears before the first prompt. */ public showStartupBanner(text: string): void { - this.#sealedBlocks.push({ type: 'meta', content: text }); + this.#conversationState.addBlocks([{ type: 'meta', content: text }]); 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); - } + public addHistoryBlocks(blocks: Block[]): void { + this.#conversationState.addBlocks(blocks); this.render(); } @@ -178,8 +98,7 @@ export class AppLayout implements Disposable { /** Transition to streaming mode. Seals the prompt as a block; active block is created on first content. */ public startStreaming(prompt: string): void { - this.#sealedBlocks.push({ type: 'prompt', content: prompt }); - this.#activeBlock = null; + this.#conversationState.addBlocks([{ type: 'prompt', content: prompt }]); this.#mode = 'streaming'; this.#flushToScroll(); this.render(); @@ -189,34 +108,26 @@ export class AppLayout implements Disposable { * Consecutive same-type blocks are merged visually by the renderer (no header or gap between them), * so there is nothing special to do here — every call produces its own block. */ public transitionBlock(type: BlockType): void { - if (this.#activeBlock?.type === type) { - logger.debug('transitionBlock_noop', { type, totalSealed: this.#sealedBlocks.length }); + const result = this.#conversationState.transitionBlock(type); + if (result.noop) { + logger.debug('transitionBlock_noop', { type, totalSealed: this.#conversationState.sealedBlocks.length }); return; } - const from = this.#activeBlock?.type ?? null; - const sealed = !!this.#activeBlock?.content.trim(); - if (this.#activeBlock?.content.trim()) { - this.#sealedBlocks.push(this.#activeBlock); - } - logger.debug('transitionBlock', { from, to: type, sealed, totalSealed: this.#sealedBlocks.length }); - this.#activeBlock = { type, content: '' }; + logger.debug('transitionBlock', { from: result.from, to: type, sealed: result.sealed, totalSealed: this.#conversationState.sealedBlocks.length }); this.render(); } /** Append a chunk of text to the active block. */ public appendStreaming(text: string): void { - if (this.#activeBlock) { - this.#activeBlock.content += sanitiseLoneSurrogates(text); + if (this.#conversationState.activeBlock) { + this.#conversationState.appendToActive(sanitiseLoneSurrogates(text)); this.render(); } } /** Seal the completed response block and return to editor mode. */ public completeStreaming(): void { - if (this.#activeBlock?.content.trim()) { - this.#sealedBlocks.push(this.#activeBlock); - } - this.#activeBlock = null; + this.#conversationState.completeActive(); this.#pendingTools = []; this.#mode = 'editor'; this.#commandMode = false; @@ -255,26 +166,20 @@ export class AppLayout implements Disposable { * the next message_usage arrives). Has no effect if no matching block exists. */ public appendToLastSealed(type: BlockType, text: string): void { - const activeType = this.#activeBlock?.type ?? null; - logger.debug('appendToLastSealed', { type, activeType, totalSealed: this.#sealedBlocks.length }); + const activeType = this.#conversationState.activeBlock?.type ?? null; + logger.debug('appendToLastSealed', { type, activeType, totalSealed: this.#conversationState.sealedBlocks.length }); // When tool batches run back-to-back (no thinking/text between them), transitionBlock // is a no-op so the tools block stays *active* when message_usage fires. Check active first. - if (this.#activeBlock?.type === type) { + const result = this.#conversationState.appendToLastSealed(type, text); + if (result === 'active') { logger.debug('appendToLastSealed_found', { target: 'active' }); - this.#activeBlock.content += text; this.render(); - return; - } - for (let i = this.#sealedBlocks.length - 1; i >= 0; i--) { - if (this.#sealedBlocks[i]?.type === type) { - logger.debug('appendToLastSealed_found', { index: i, totalSealed: this.#sealedBlocks.length }); - // biome-ignore lint/style/noNonNullAssertion: checked above - this.#sealedBlocks[i]!.content += text; - this.render(); - return; - } + } else if (result === 'miss') { + logger.warn('appendToLastSealed_miss', { type, activeType }); + } else { + logger.debug('appendToLastSealed_found', { index: result, totalSealed: this.#conversationState.sealedBlocks.length }); + this.render(); } - logger.warn('appendToLastSealed_miss', { type, activeType }); } public updateUsage(msg: SdkMessageUsage): void { @@ -433,32 +338,14 @@ export class AppLayout implements Disposable { } #flushToScroll(): void { - if (this.#flushedCount >= this.#sealedBlocks.length) { + const sealedBlocks = this.#conversationState.sealedBlocks; + const flushedCount = this.#conversationState.flushedCount; + if (flushedCount >= sealedBlocks.length) { return; } const cols = this.#screen.columns; - let out = ''; - for (let i = this.#flushedCount; i < this.#sealedBlocks.length; i++) { - const block = this.#sealedBlocks[i]; - if (!block) { - continue; - } - // Consecutive blocks of the same type are shown without a header or gap between them. - const isContinuation = this.#sealedBlocks[i - 1]?.type === block.type; - const hasNextContinuation = this.#sealedBlocks[i + 1]?.type === block.type; - if (!isContinuation) { - const emoji = BLOCK_EMOJI[block.type] ?? ''; - const plain = BLOCK_PLAIN[block.type] ?? block.type; - out += `${buildDivider(`${emoji}${plain}`, cols)}\n\n`; - } - for (const line of renderBlockContent(block.content, cols)) { - out += `${line}\n`; - } - if (!hasNextContinuation) { - out += '\n'; - } - } - this.#flushedCount = this.#sealedBlocks.length; + const out = renderBlocksToString(sealedBlocks, flushedCount, cols); + this.#conversationState.advanceFlushedCount(sealedBlocks.length); this.#screen.exitAltBuffer(); this.#screen.write(out); this.#screen.enterAltBuffer(); @@ -474,50 +361,10 @@ export class AppLayout implements Disposable { const statusBarHeight = 4 + expandedRows.length; const contentRows = Math.max(2, totalRows - statusBarHeight); - // Build all content rows from sealed blocks, active block, and editor - const allContent: string[] = []; - - for (let i = 0; i < this.#sealedBlocks.length; i++) { - const block = this.#sealedBlocks[i]; - if (!block) { - continue; - } - // Consecutive blocks of the same type flow as one: skip header and gap for continuations, - // and suppress the trailing blank when the next block will continue the sequence. - const isContinuation = this.#sealedBlocks[i - 1]?.type === block.type; - const nextBlock = this.#sealedBlocks[i + 1] ?? (i === this.#sealedBlocks.length - 1 ? this.#activeBlock : undefined); - const hasNextContinuation = nextBlock?.type === block.type; - if (!isContinuation) { - const emoji = BLOCK_EMOJI[block.type] ?? ''; - const plain = BLOCK_PLAIN[block.type] ?? block.type; - allContent.push(buildDivider(`${emoji}${plain}`, cols)); - allContent.push(''); - } - allContent.push(...renderBlockContent(block.content, cols)); - if (!hasNextContinuation) { - allContent.push(''); - } - } - - if (this.#activeBlock) { - const lastSealed = this.#sealedBlocks[this.#sealedBlocks.length - 1]; - const isContinuation = lastSealed?.type === this.#activeBlock.type; - if (!isContinuation) { - const activeEmoji = BLOCK_EMOJI[this.#activeBlock.type] ?? ''; - const activePlain = BLOCK_PLAIN[this.#activeBlock.type] ?? this.#activeBlock.type; - allContent.push(buildDivider(`${activeEmoji}${activePlain}`, cols)); - allContent.push(''); - } - const activeEmoji = BLOCK_EMOJI[this.#activeBlock.type] ?? ''; - const activeLines = this.#activeBlock.content.split('\n'); - for (let i = 0; i < activeLines.length; i++) { - const pfx = i === 0 ? activeEmoji : CONTENT_INDENT; - allContent.push(...wrapLine(pfx + (activeLines[i] ?? ''), cols)); - } - } - + // Build content rows: conversation blocks + editor (when in editor mode) + const allContent = renderConversation(this.#conversationState, cols); if (this.#mode === 'editor') { - allContent.push(buildDivider(BLOCK_PLAIN.prompt ?? 'prompt', cols)); + allContent.push(buildDivider('prompt', cols)); allContent.push(''); allContent.push(...renderEditor(this.#editorState, cols)); } @@ -526,7 +373,7 @@ export class AppLayout implements Disposable { const overflow = allContent.length - contentRows; const visibleRows = overflow > 0 ? allContent.slice(overflow) : [...new Array(contentRows - allContent.length).fill(''), ...allContent]; - const separator = DIM + FILL.repeat(cols) + RESET; + const separator = buildDivider(null, cols); const statusLine = this.#buildStatusLine(cols); const approvalRow = this.#buildApprovalRow(cols); const allRows = [...visibleRows, separator, statusLine, approvalRow, commandRow, ...expandedRows]; diff --git a/apps/claude-sdk-cli/src/ConversationState.ts b/apps/claude-sdk-cli/src/ConversationState.ts new file mode 100644 index 0000000..3fecbee --- /dev/null +++ b/apps/claude-sdk-cli/src/ConversationState.ts @@ -0,0 +1,109 @@ +export type BlockType = 'prompt' | 'thinking' | 'response' | 'tools' | 'compaction' | 'meta'; + +export type Block = { + type: BlockType; + content: string; +}; + +export type TransitionResult = { + noop: boolean; + from: BlockType | null; + sealed: boolean; +}; + +/** + * Pure state for the conversation display: sealed blocks, the active streaming block, + * and the flush boundary (how many sealed blocks have been permanently written to scroll). + * + * No rendering, no I/O. Methods take the state to a new state and return enough + * information for the caller to log or react to the transition. + */ +export class ConversationState { + #sealedBlocks: Block[] = []; + #flushedCount = 0; + #activeBlock: Block | null = null; + + public get sealedBlocks(): ReadonlyArray { + return this.#sealedBlocks; + } + + public get flushedCount(): number { + return this.#flushedCount; + } + + public get activeBlock(): Block | null { + return this.#activeBlock; + } + + /** Push one or more pre-built blocks (e.g. from history replay or startup banner). */ + public addBlocks(blocks: ReadonlyArray): void { + for (const block of blocks) { + this.#sealedBlocks.push(block); + } + } + + /** + * Seal the current active block (if non-empty) and open a new one of the given type. + * + * Returns metadata so the caller can log appropriately: + * - `noop: true` — same type was already active, nothing changed + * - `noop: false` — transition happened; `from` is the previous type (null if none), + * `sealed` is true if the previous block had content and was sealed + */ + public transitionBlock(type: BlockType): TransitionResult { + if (this.#activeBlock?.type === type) { + return { noop: true, from: type, sealed: false }; + } + const from = this.#activeBlock?.type ?? null; + const sealed = !!this.#activeBlock?.content.trim(); + if (this.#activeBlock?.content.trim()) { + this.#sealedBlocks.push(this.#activeBlock); + } + this.#activeBlock = { type, content: '' }; + return { noop: false, from, sealed }; + } + + /** Append text to the active block. No-op if there is no active block. */ + public appendToActive(text: string): void { + if (this.#activeBlock) { + this.#activeBlock.content += text; + } + } + + /** Seal the active block if it has content, then clear it. */ + public completeActive(): void { + if (this.#activeBlock?.content.trim()) { + this.#sealedBlocks.push(this.#activeBlock); + } + this.#activeBlock = null; + } + + /** + * Append text to the most recent block of the given type, checking the active block + * first then searching sealed blocks in reverse. Used for retroactive annotations. + * + * Returns: + * - `'active'` — text was appended to the active block + * - a number — text was appended to the sealed block at that index + * - `'miss'` — no matching block found, text was not appended + */ + public appendToLastSealed(type: BlockType, text: string): 'active' | number | 'miss' { + if (this.#activeBlock?.type === type) { + this.#activeBlock.content += text; + return 'active'; + } + for (let i = this.#sealedBlocks.length - 1; i >= 0; i--) { + if (this.#sealedBlocks[i]?.type === type) { + // biome-ignore lint/style/noNonNullAssertion: checked above + this.#sealedBlocks[i]!.content += text; + return i; + } + } + return 'miss'; + } + + /** Advance the flush boundary after blocks have been permanently written to scroll. */ + public advanceFlushedCount(to: number): void { + this.#flushedCount = to; + } +} diff --git a/apps/claude-sdk-cli/src/renderConversation.ts b/apps/claude-sdk-cli/src/renderConversation.ts new file mode 100644 index 0000000..b51b311 --- /dev/null +++ b/apps/claude-sdk-cli/src/renderConversation.ts @@ -0,0 +1,171 @@ +import { DIM, RESET } from '@shellicar/claude-core/ansi'; +import { wrapLine } from '@shellicar/claude-core/reflow'; +import { highlight } from 'cli-highlight'; +import type { Block, ConversationState } from './ConversationState.js'; + +const FILL = '\u2500'; + +const BLOCK_PLAIN: Record = { + prompt: 'prompt', + thinking: 'thinking', + response: 'response', + tools: 'tools', + compaction: 'compaction', + meta: 'query', +}; + +const BLOCK_EMOJI: Record = { + prompt: '💬 ', + thinking: '💭 ', + response: '📝 ', + tools: '🔧 ', + compaction: '🗜 ', + meta: 'ℹ️ ', +}; + +const CONTENT_INDENT = ' '; + +const CODE_FENCE_RE = /```(\w*)\n([\s\S]*?)```/g; + +function renderBlockContent(content: string, cols: number): string[] { + const result: string[] = []; + let lastIndex = 0; + + const addText = (text: string) => { + const lines = text.split('\n'); + const trimmed = lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines; + for (const line of trimmed) { + result.push(...wrapLine(CONTENT_INDENT + line, cols)); + } + }; + + for (const match of content.matchAll(CODE_FENCE_RE)) { + if (match.index > lastIndex) { + addText(content.slice(lastIndex, match.index)); + } + const lang = match[1] || 'plaintext'; + const code = (match[2] ?? '').trimEnd(); + result.push(`${CONTENT_INDENT}\`\`\`${lang}`); + try { + const highlighted = highlight(code, { language: lang, ignoreIllegals: true }); + for (const line of highlighted.split('\n')) { + result.push(CONTENT_INDENT + line); + } + } catch { + for (const line of code.split('\n')) { + result.push(CONTENT_INDENT + line); + } + } + result.push(`${CONTENT_INDENT}\`\`\``); + lastIndex = match.index + match[0].length; + } + + if (lastIndex < content.length) { + addText(content.slice(lastIndex)); + } else if (lastIndex === 0) { + addText(content); + } + + return result; +} + +/** + * Build a divider line with an optional centred label. + * + * - `null` → plain DIM fill (used as the separator between content area and status bar) + * - non-null → "── label ────────" (used as block headers and the prompt divider) + */ +export function buildDivider(displayLabel: string | null, cols: number): string { + if (!displayLabel) { + return DIM + FILL.repeat(cols) + RESET; + } + const prefix = `${FILL}${FILL} ${displayLabel} `; + const remaining = Math.max(0, cols - prefix.length); + return DIM + prefix + FILL.repeat(remaining) + RESET; +} + +/** + * Render conversation blocks into an array of display lines for the alt-buffer viewport. + * + * Returns sealed blocks + active streaming block. The caller (AppLayout) appends the + * editor divider and editor lines when in editor mode, then slices to contentRows. + */ +export function renderConversation(state: ConversationState, cols: number): string[] { + const allContent: string[] = []; + const sealedBlocks = state.sealedBlocks; + + for (let i = 0; i < sealedBlocks.length; i++) { + const block = sealedBlocks[i]; + if (!block) { + continue; + } + // Consecutive blocks of the same type flow as one: skip header and gap for + // continuations, suppress the trailing blank when the next block continues. + const isContinuation = sealedBlocks[i - 1]?.type === block.type; + const nextBlock = sealedBlocks[i + 1] ?? (i === sealedBlocks.length - 1 ? state.activeBlock : undefined); + const hasNextContinuation = nextBlock?.type === block.type; + + if (!isContinuation) { + const emoji = BLOCK_EMOJI[block.type] ?? ''; + const plain = BLOCK_PLAIN[block.type] ?? block.type; + allContent.push(buildDivider(`${emoji}${plain}`, cols)); + allContent.push(''); + } + allContent.push(...renderBlockContent(block.content, cols)); + if (!hasNextContinuation) { + allContent.push(''); + } + } + + if (state.activeBlock) { + const lastSealed = sealedBlocks[sealedBlocks.length - 1]; + const isContinuation = lastSealed?.type === state.activeBlock.type; + if (!isContinuation) { + const activeEmoji = BLOCK_EMOJI[state.activeBlock.type] ?? ''; + const activePlain = BLOCK_PLAIN[state.activeBlock.type] ?? state.activeBlock.type; + allContent.push(buildDivider(`${activeEmoji}${activePlain}`, cols)); + allContent.push(''); + } + // Active block: emoji prefix on the first content line, indent on subsequent lines. + // This gives the streaming-in-progress visual effect. + const activeEmoji = BLOCK_EMOJI[state.activeBlock.type] ?? ''; + const activeLines = state.activeBlock.content.split('\n'); + for (let i = 0; i < activeLines.length; i++) { + const pfx = i === 0 ? activeEmoji : CONTENT_INDENT; + allContent.push(...wrapLine(pfx + (activeLines[i] ?? ''), cols)); + } + } + + return allContent; +} + +/** + * Render a slice of sealed blocks — from `startIndex` to the end of the array — into a + * single string suitable for flushing to the terminal scroll buffer. + * + * Continuation checks reference the full array so headers are correctly suppressed for + * consecutive same-type blocks even when the preceding block was already flushed. + */ +export function renderBlocksToString(allBlocks: ReadonlyArray, startIndex: number, cols: number): string { + let out = ''; + for (let i = startIndex; i < allBlocks.length; i++) { + const block = allBlocks[i]; + if (!block) { + continue; + } + const isContinuation = allBlocks[i - 1]?.type === block.type; + const hasNextContinuation = allBlocks[i + 1]?.type === block.type; + if (!isContinuation) { + const emoji = BLOCK_EMOJI[block.type] ?? ''; + const plain = BLOCK_PLAIN[block.type] ?? block.type; + out += `${buildDivider(`${emoji}${plain}`, cols)}\n\n`; + } + for (const line of renderBlockContent(block.content, cols)) { + out += `${line}\n`; + } + if (!hasNextContinuation) { + out += '\n'; + } + } + return out; +} diff --git a/apps/claude-sdk-cli/test/ConversationState.spec.ts b/apps/claude-sdk-cli/test/ConversationState.spec.ts new file mode 100644 index 0000000..06b9365 --- /dev/null +++ b/apps/claude-sdk-cli/test/ConversationState.spec.ts @@ -0,0 +1,272 @@ +import { describe, expect, it } from 'vitest'; +import { ConversationState } from '../src/ConversationState.js'; + +describe('ConversationState — initial state', () => { + it('sealedBlocks starts empty', () => { + const state = new ConversationState(); + const expected = 0; + const actual = state.sealedBlocks.length; + expect(actual).toBe(expected); + }); + + it('flushedCount starts at zero', () => { + const state = new ConversationState(); + const expected = 0; + const actual = state.flushedCount; + expect(actual).toBe(expected); + }); + + it('activeBlock starts null', () => { + const state = new ConversationState(); + const expected = null; + const actual = state.activeBlock; + expect(actual).toBe(expected); + }); +}); + +describe('ConversationState — addBlocks', () => { + it('pushes blocks into sealedBlocks', () => { + const state = new ConversationState(); + state.addBlocks([ + { type: 'meta', content: 'hello' }, + { type: 'prompt', content: 'world' }, + ]); + const expected = 2; + const actual = state.sealedBlocks.length; + expect(actual).toBe(expected); + }); + + it('preserves block content', () => { + const state = new ConversationState(); + state.addBlocks([{ type: 'response', content: 'test content' }]); + const expected = 'test content'; + const actual = state.sealedBlocks[0]?.content; + expect(actual).toBe(expected); + }); +}); + +describe('ConversationState — transitionBlock', () => { + it('creates an active block with the given type', () => { + const state = new ConversationState(); + state.transitionBlock('response'); + const expected = 'response'; + const actual = state.activeBlock?.type; + expect(actual).toBe(expected); + }); + + it('active block starts with empty content', () => { + const state = new ConversationState(); + state.transitionBlock('thinking'); + const expected = ''; + const actual = state.activeBlock?.content; + expect(actual).toBe(expected); + }); + + it('returns noop: true when same type already active', () => { + const state = new ConversationState(); + state.transitionBlock('tools'); + const result = state.transitionBlock('tools'); + const expected = true; + const actual = result.noop; + expect(actual).toBe(expected); + }); + + it('returns noop: false when transitioning to a different type', () => { + const state = new ConversationState(); + state.transitionBlock('thinking'); + const result = state.transitionBlock('response'); + const expected = false; + const actual = result.noop; + expect(actual).toBe(expected); + }); + + it('seals non-empty active block on transition', () => { + const state = new ConversationState(); + state.transitionBlock('thinking'); + state.appendToActive('some content'); + state.transitionBlock('response'); + const expected = 1; + const actual = state.sealedBlocks.length; + expect(actual).toBe(expected); + }); + + it('returns sealed: true when active block had content', () => { + const state = new ConversationState(); + state.transitionBlock('thinking'); + state.appendToActive('content'); + const result = state.transitionBlock('response'); + const expected = true; + const actual = result.sealed; + expect(actual).toBe(expected); + }); + + it('discards empty active block without sealing', () => { + const state = new ConversationState(); + state.transitionBlock('thinking'); + // no appendToActive call — content is empty + state.transitionBlock('response'); + const expected = 0; + const actual = state.sealedBlocks.length; + expect(actual).toBe(expected); + }); + + it('returns sealed: false when active block was empty', () => { + const state = new ConversationState(); + state.transitionBlock('thinking'); + const result = state.transitionBlock('response'); + const expected = false; + const actual = result.sealed; + expect(actual).toBe(expected); + }); + + it('returns from: null when no previous active block', () => { + const state = new ConversationState(); + const result = state.transitionBlock('response'); + const expected = null; + const actual = result.from; + expect(actual).toBe(expected); + }); + + it('returns from: the previous type when transitioning', () => { + const state = new ConversationState(); + state.transitionBlock('thinking'); + state.appendToActive('content'); + const result = state.transitionBlock('response'); + const expected = 'thinking'; + const actual = result.from; + expect(actual).toBe(expected); + }); +}); + +describe('ConversationState — appendToActive', () => { + it('appends text to the active block content', () => { + const state = new ConversationState(); + state.transitionBlock('response'); + state.appendToActive('hello'); + state.appendToActive(' world'); + const expected = 'hello world'; + const actual = state.activeBlock?.content; + expect(actual).toBe(expected); + }); + + it('is a no-op when there is no active block', () => { + const state = new ConversationState(); + // No transitionBlock call — activeBlock is null + state.appendToActive('ignored'); + const expected = null; + const actual = state.activeBlock; + expect(actual).toBe(expected); + }); +}); + +describe('ConversationState — completeActive', () => { + it('seals the active block when it has content', () => { + const state = new ConversationState(); + state.transitionBlock('response'); + state.appendToActive('content'); + state.completeActive(); + const expected = 1; + const actual = state.sealedBlocks.length; + expect(actual).toBe(expected); + }); + + it('discards the active block when it is empty', () => { + const state = new ConversationState(); + state.transitionBlock('response'); + // no content appended + state.completeActive(); + const expected = 0; + const actual = state.sealedBlocks.length; + expect(actual).toBe(expected); + }); + + it('clears activeBlock after completing', () => { + const state = new ConversationState(); + state.transitionBlock('response'); + state.appendToActive('content'); + state.completeActive(); + const expected = null; + const actual = state.activeBlock; + expect(actual).toBe(expected); + }); +}); + +describe('ConversationState — appendToLastSealed', () => { + it('returns "active" and appends when type matches active block', () => { + const state = new ConversationState(); + state.transitionBlock('tools'); + state.appendToActive('initial'); + const result = state.appendToLastSealed('tools', ' appended'); + const expected = 'active'; + const actual = result; + expect(actual).toBe(expected); + }); + + it('content is updated on the active block', () => { + const state = new ConversationState(); + state.transitionBlock('tools'); + state.appendToActive('initial'); + state.appendToLastSealed('tools', ' appended'); + const expected = 'initial appended'; + const actual = state.activeBlock?.content; + expect(actual).toBe(expected); + }); + + it('returns the sealed block index when found in sealed blocks', () => { + const state = new ConversationState(); + state.transitionBlock('tools'); + state.appendToActive('tool content'); + state.transitionBlock('response'); // seals tools block at index 0 + const result = state.appendToLastSealed('tools', ' annotation'); + const expected = 0; + const actual = result; + expect(actual).toBe(expected); + }); + + it('content is updated on the sealed block', () => { + const state = new ConversationState(); + state.transitionBlock('tools'); + state.appendToActive('tool content'); + state.transitionBlock('response'); + state.appendToLastSealed('tools', ' annotation'); + const expected = 'tool content annotation'; + const actual = state.sealedBlocks[0]?.content; + expect(actual).toBe(expected); + }); + + it('returns "miss" when no matching block exists', () => { + const state = new ConversationState(); + const result = state.appendToLastSealed('tools', 'annotation'); + const expected = 'miss'; + const actual = result; + expect(actual).toBe(expected); + }); + + it('finds the most recent sealed block when multiple exist', () => { + const state = new ConversationState(); + state.addBlocks([ + { type: 'tools', content: 'first' }, + { type: 'response', content: 'middle' }, + { type: 'tools', content: 'second' }, + ]); + state.appendToLastSealed('tools', ' extra'); + // Most recent tools block is index 2 + const expected = 'second extra'; + const actual = state.sealedBlocks[2]?.content; + expect(actual).toBe(expected); + }); +}); + +describe('ConversationState — advanceFlushedCount', () => { + it('updates flushedCount to the given value', () => { + const state = new ConversationState(); + state.addBlocks([ + { type: 'prompt', content: 'a' }, + { type: 'response', content: 'b' }, + ]); + state.advanceFlushedCount(2); + const expected = 2; + const actual = state.flushedCount; + expect(actual).toBe(expected); + }); +}); diff --git a/apps/claude-sdk-cli/test/renderConversation.spec.ts b/apps/claude-sdk-cli/test/renderConversation.spec.ts new file mode 100644 index 0000000..3777a8f --- /dev/null +++ b/apps/claude-sdk-cli/test/renderConversation.spec.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; +import { ConversationState } from '../src/ConversationState.js'; +import { buildDivider, renderConversation } from '../src/renderConversation.js'; + +// Strip ANSI escape codes so assertions can match plain text +function stripAnsi(s: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI for test assertions + return s.replace(/\x1b\[[^m]*m/g, ''); +} + +describe('renderConversation — empty state', () => { + it('returns an empty array when no blocks exist', () => { + const state = new ConversationState(); + const expected = 0; + const actual = renderConversation(state, 80).length; + expect(actual).toBe(expected); + }); +}); + +describe('renderConversation — single sealed block', () => { + it('includes a divider line for the block', () => { + const state = new ConversationState(); + state.addBlocks([{ type: 'response', content: 'hello' }]); + const lines = renderConversation(state, 80).map(stripAnsi); + const actual = lines.some((l) => l.includes('response')); + expect(actual).toBe(true); + }); + + it('includes a blank line after the divider', () => { + const state = new ConversationState(); + state.addBlocks([{ type: 'response', content: 'hello' }]); + const lines = renderConversation(state, 80).map(stripAnsi); + const dividerIdx = lines.findIndex((l) => l.includes('response')); + const actual = lines[dividerIdx + 1]; + expect(actual).toBe(''); + }); + + it('includes the block content', () => { + const state = new ConversationState(); + state.addBlocks([{ type: 'response', content: 'hello world' }]); + const lines = renderConversation(state, 80).map(stripAnsi); + const actual = lines.some((l) => l.includes('hello world')); + expect(actual).toBe(true); + }); + + it('includes a trailing blank line after the content', () => { + const state = new ConversationState(); + state.addBlocks([{ type: 'response', content: 'hello' }]); + const lines = renderConversation(state, 80); + const actual = lines[lines.length - 1]; + expect(actual).toBe(''); + }); +}); + +describe('renderConversation — continuation suppression', () => { + it('suppresses the divider between two consecutive same-type blocks', () => { + const state = new ConversationState(); + state.addBlocks([ + { type: 'tools', content: 'tool A' }, + { type: 'tools', content: 'tool B' }, + ]); + const lines = renderConversation(state, 80).map(stripAnsi); + const dividerCount = lines.filter((l) => l.includes('tools')).length; + // Only the first block gets a divider; the second is a continuation + const expected = 1; + const actual = dividerCount; + expect(actual).toBe(expected); + }); + + it('suppresses the trailing blank between two consecutive same-type blocks', () => { + const state = new ConversationState(); + state.addBlocks([ + { type: 'tools', content: 'tool A' }, + { type: 'tools', content: 'tool B' }, + ]); + const lines = renderConversation(state, 80).map(stripAnsi); + // Find the line with "tool A"; the line after should NOT be blank (continuation) + const aIdx = lines.findIndex((l) => l.includes('tool A')); + const actual = lines[aIdx + 1]; + // Should be content of second block, not a blank gap + expect(actual).not.toBe(''); + }); +}); + +describe('renderConversation — active block', () => { + it('includes a divider for the active block when it differs from the last sealed block', () => { + const state = new ConversationState(); + state.addBlocks([{ type: 'prompt', content: 'user prompt' }]); + state.transitionBlock('response'); + state.appendToActive('streaming...'); + const lines = renderConversation(state, 80).map(stripAnsi); + const actual = lines.some((l) => l.includes('response')); + expect(actual).toBe(true); + }); + + it('suppresses the active block divider when it continues the last sealed block type', () => { + const state = new ConversationState(); + state.addBlocks([{ type: 'tools', content: 'tool A' }]); + state.transitionBlock('tools'); + state.appendToActive('tool B'); + const lines = renderConversation(state, 80).map(stripAnsi); + const dividerCount = lines.filter((l) => l.includes('tools')).length; + // Only the sealed block gets a divider; active continuation does not + const expected = 1; + const actual = dividerCount; + expect(actual).toBe(expected); + }); + + it('includes the active block content', () => { + const state = new ConversationState(); + state.transitionBlock('response'); + state.appendToActive('live content'); + const lines = renderConversation(state, 80).map(stripAnsi); + const actual = lines.some((l) => l.includes('live content')); + expect(actual).toBe(true); + }); + + it('does not add a trailing blank after the active block', () => { + const state = new ConversationState(); + state.transitionBlock('response'); + state.appendToActive('streaming'); + const lines = renderConversation(state, 80); + // Last line is the content, not a blank + const actual = lines[lines.length - 1]; + expect(actual).not.toBe(''); + }); +}); + +describe('buildDivider', () => { + it('returns a plain DIM fill when label is null', () => { + const result = stripAnsi(buildDivider(null, 10)); + const expected = '\u2500'.repeat(10); + const actual = result; + expect(actual).toBe(expected); + }); + + it('includes the label in the divider', () => { + const result = stripAnsi(buildDivider('response', 40)); + const actual = result.includes('response'); + expect(actual).toBe(true); + }); + + it('fills remaining space with the fill character', () => { + const result = stripAnsi(buildDivider('hi', 20)); + // Should contain fill characters beyond the prefix + const actual = result.includes('\u2500\u2500 hi '); + expect(actual).toBe(true); + }); +}); From 05d76c067cc595870d54fb0a241c0724b58e0f66 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Mon, 6 Apr 2026 23:24:33 +1000 Subject: [PATCH 2/2] Update session log and CLAUDE.md for step 5b --- .claude/CLAUDE.md | 13 ++++---- .claude/sessions/2026-04-07.md | 58 ++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 .claude/sessions/2026-04-07.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index b8aa7cd..da4ff60 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -66,7 +66,7 @@ Every session has three phases: start, work, end. ## Current State -Branch: `feature/status-state` — PR #194 open (step 5a), auto-merge set. +Branch: `feature/conversation-state` — PR #196 open (step 5b), auto-merge set. Active development is in **`apps/claude-sdk-cli/`** — a TUI terminal app built on `@shellicar/claude-sdk`. @@ -82,12 +82,13 @@ Follows a State / Renderer / ScreenCoordinator (MVVM) pattern. Each substep ship - **3c** `renderEditor(state, cols): string[]` pure renderer extracted — PR #191 - **4a** `AgentMessageHandler` stateless cases extracted from `runAgent.ts` — PR #192 - **4b** `AgentMessageHandler` stateful cases moved in (`message_usage`, `tool_approval_request`, `tool_error`) — PR #193 -- **5a** `StatusState` + `renderStatus(state, cols): string` extracted — PR #194 (pending merge) +- **5a** `StatusState` + `renderStatus(state, cols): string` extracted — PR #194 +- **5b** `ConversationState` + `renderConversation` extracted — PR #196 (pending merge) -**Next: step 5b** — extract `ConversationState` + `renderConversation` from `AppLayout` -- Move sealed blocks, active block, flush count, `transitionBlock`, `appendStreaming`, `completeStreaming`, `appendToLastSealed` to `ConversationState` -- Move render logic to `renderConversation(state, cols, availableRows): string[]` -- Largest extraction so far — flush-to-scroll and block rendering are the complex parts +**Next: step 5c** — extract `ToolApprovalState` + `renderToolApproval` from `AppLayout` +- Move `#pendingTools`, `#selectedTool`, `#toolExpanded`, `#pendingApprovals` to `ToolApprovalState` +- Move `#buildApprovalRow`, `#buildExpandedRows` logic to `renderToolApproval(state, cols): string[]` +- The async approval promise queue must move together with the state diff --git a/.claude/sessions/2026-04-07.md b/.claude/sessions/2026-04-07.md new file mode 100644 index 0000000..71ebcb2 --- /dev/null +++ b/.claude/sessions/2026-04-07.md @@ -0,0 +1,58 @@ +# Session 2026-04-07 + +## What was done + +### Step 5b — Extract `ConversationState` + `renderConversation` from `AppLayout` (PR #196) + +Started from main with PRs #194 (5a StatusState) and #195 (perf test fix) already merged. + +**New `ConversationState.ts`** — pure state, no I/O: +- Holds `#sealedBlocks: Block[]`, `#flushedCount: number`, `#activeBlock: Block | null` +- `addBlocks(blocks)` — for startup banner and history replay +- `transitionBlock(type)` — seals non-empty active if type differs, returns `{noop, from, sealed}` so caller can log without re-reading private state +- `appendToActive(text)` — no-op when no active block +- `completeActive()` — seals non-empty active, clears it +- `appendToLastSealed(type, text)` — checks active first, then sealed in reverse; returns `'active'`, a sealed index, or `'miss'` so caller can log the exact target +- `advanceFlushedCount(to)` — called by AppLayout after flushing blocks to scroll +- `Block` and `BlockType` types exported from here (removed from AppLayout) + +**New `renderConversation.ts`** — all block rendering: +- All constants and helpers move here: `FILL`, `BLOCK_PLAIN`, `BLOCK_EMOJI`, `CONTENT_INDENT`, `CODE_FENCE_RE`, `renderBlockContent` +- `buildDivider(label, cols)` exported — AppLayout uses it for the prompt divider and the content/status separator (`buildDivider(null, cols)` replaces the old `DIM + FILL.repeat(cols) + RESET` inline) +- `renderConversation(state, cols)` — sealed blocks + active block for the alt-buffer viewport +- `renderBlocksToString(allBlocks, startIndex, cols)` — for the flush-to-scroll path; takes the full array so continuation checks work correctly for already-flushed blocks + +**`AppLayout` after (684 → 619 lines, -65):** +- `#sealedBlocks`, `#flushedCount`, `#activeBlock` replaced by `#conversationState = new ConversationState()` +- `BlockType`/`Block` type definitions removed (now imported from ConversationState.ts) +- `highlight` import removed (moved to renderConversation.ts) +- `FILL` constant removed; `BLOCK_PLAIN`, `BLOCK_EMOJI`, `CONTENT_INDENT` definitions removed +- `renderBlockContent`, `buildDivider` functions removed +- All block mutation methods delegate to `#conversationState` +- `render()` replaces 50-line block loop with `renderConversation(this.#conversationState, cols)` +- `#flushToScroll()` replaces 25-line loop with `renderBlocksToString(sealedBlocks, flushedCount, cols)` +- The `CONTENT_INDENT` constant is kept locally in AppLayout (still used by `#buildExpandedRows` and `#buildPreviewRows` until steps 5c/5d extract those) + +**Tests: 41 new (231 total, up from 190):** +- `ConversationState.spec.ts` (27): initial state, addBlocks, transitionBlock sequences (noop/seal/discard/from tracking), appendToActive, completeActive, appendToLastSealed (active/sealed-index/miss/most-recent-selection), advanceFlushedCount +- `renderConversation.spec.ts` (14): empty state, single block structure (divider/blank/content/trailing-blank), continuation suppression (no header between same-type blocks, no gap), active block rendering (new divider when different type, suppressed when continuation), buildDivider (null/label/fill) + +## Decisions + +**`transitionBlock` return value:** Returns `{noop, from, sealed}` rather than void. The caller (AppLayout) needs this info for debug logging. Alternative was logging before calling, but that would require AppLayout to pre-read private state from ConversationState. Returning a result struct keeps the state machine opaque. + +**`appendToLastSealed` return value:** Returns `'active'`, a number (sealed index), or `'miss'`. Restores the original log fidelity (`{ index: i }`) without leaking the search loop into AppLayout. + +**`buildDivider(null, cols)` for the separator:** The function was already handling null → plain fill. Using it for the content/status separator removes the last direct reference to `FILL` from AppLayout and makes both divider types go through one path. + +**`renderBlocksToString` takes full array:** Not a slice. The continuation check for block `i` references `allBlocks[i-1]`, which may be a block already flushed to scroll. Passing the full array ensures headers are correctly suppressed when consecutive same-type blocks span a flush boundary. + +**`CONTENT_INDENT` kept locally in AppLayout:** Steps 5c and 5d will extract tool approval and command mode renderers, which are the remaining users of `CONTENT_INDENT` in AppLayout. Duplicating the 3-char constant temporarily is less disruptive than exporting it from `renderConversation.ts` (which would create an odd dependency from the coordinator on a conversation-specific renderer's internals). + +## What's next + +**Step 5c** — extract `ToolApprovalState` + `renderToolApproval`: +- Move `#pendingTools`, `#selectedTool`, `#toolExpanded`, `#pendingApprovals` to `ToolApprovalState` +- Move `#buildApprovalRow` + `#buildExpandedRows` logic to `renderToolApproval(state, cols): string[]` +- Risk: medium-high. The async approval flow (promise queue resolved by keyboard handler) must move together — splitting it would leave a broken intermediate state. +- Tests: async approval flow, cancel flow, keyboard navigation (left/right cycle, space expand).