diff --git a/.claude/sessions/2026-04-07.md b/.claude/sessions/2026-04-07.md index 4b0f76c..0de07ec 100644 --- a/.claude/sessions/2026-04-07.md +++ b/.claude/sessions/2026-04-07.md @@ -98,3 +98,42 @@ Continuation of the same session after context compaction. **`requestApproval` split:** Promise creation moves into `ToolApprovalState.requestApproval()`. `this.render()` stays in `AppLayout.requestApproval()` since rendering is AppLayout's responsibility, not state's. **`#cancelFn` stays in AppLayout:** It's an agent lifecycle concern (set by `runAgent`, triggered by Escape), not tool approval state. Not extracted here. + + +--- + +## Step 5d — Extract `CommandModeState` + `renderCommandMode` from `AppLayout` (PR #198) + +**New `CommandModeState.ts`** — pure state, no I/O: +- Holds `#commandMode`, `#previewMode`, `#attachments: AttachmentStore` +- `toggleCommandMode()` / `exitCommandMode()` (both flags to false) / `reset()` (exit + clear attachments) +- `togglePreview()` — no-op when `selectedIndex < 0`, otherwise flips `previewMode` +- Delegates to AttachmentStore: `addText`, `addFile`, `removeSelected`, `selectLeft`, `selectRight`, `takeAttachments` +- Re-exports `Attachment`, `FileAttachment`, `TextAttachment` from AttachmentStore + +**New `renderCommandMode.ts`** — pure renderer: +- Returns `CommandModeRender = { commandRow: string; previewRows: string[] }` +- Signature: `renderCommandMode(state, cols, maxTextLines, maxRows)` — two row limits because the original code used `totalRows/3` for text line cap and `totalRows/2` for final output cap; passing both avoids approximation +- `commandRow`: empty unless attachments present or command mode active; shows chips + key hints +- `previewRows`: empty unless `commandMode && previewMode`; gated internally rather than requiring caller to conditionally call +- ANSI string concatenation from original replaced with template literals (biome lint) + +**`AppLayout.ts` changes:** +- `#commandMode`, `#previewMode`, `#attachments` → `#commandModeState = new CommandModeState()` +- `completeStreaming`: three lines → `this.#commandModeState.reset()` +- `handleKey` 'p' case: `if (selectedIndex >= 0) previewMode = !previewMode` → `togglePreview()` +- Dead methods `#buildCommandRow` and `#buildPreviewRows` removed (98 lines deleted) +- Removed imports: `AttachmentStore`, `basename`, `DIM`, `INVERSE_OFF`, `INVERSE_ON`, `RESET`, `wrapLine`, `StatusLineBuilder`, `CONTENT_INDENT` +- `render()`: `renderCommandMode(this.#commandModeState, cols, maxTextLines, maxRows)` now provides both `commandRow` and `previewRows` + +**Tests:** 25 new in `CommandModeState.spec.ts`, 18 in `renderCommandMode.spec.ts`. Total: 319 (up from 276). + +## Decisions + +**`CommandModeState` wraps `AttachmentStore`:** The three concerns (commandMode flag, previewMode flag, attachment list) only make sense together — `previewMode` depends on having a selected attachment, and `exitCommandMode` resets both flags. Keeping them in one object reflects that they're a single UI mode. + +**Two parameters for `renderCommandMode` instead of one:** Original code uses `totalRows/3` for text line cap and `totalRows/2` for final row cap. Passing both as `maxTextLines` and `maxRows` is exact; deriving `maxTextLines` from `maxRows` requires an approximation that breaks for some terminal heights. + +**Preview gate moved inside renderer:** In AppLayout, `buildPreviewRows` was only called when `previewMode && commandMode`. Moving that guard inside `renderCommandMode` makes the function self-contained — callers don't need to know the precondition. + +**`isLikelyPath` and async clipboard/stat code stay in AppLayout:** These are I/O operations, not state. They belong with `#handleCommandKey` which stays in AppLayout as part of the event dispatch layer. Step 5e will decide whether this warrants further extraction. diff --git a/CLAUDE.md b/CLAUDE.md index 61ce302..3e9deb6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,10 +52,10 @@ Refactoring `AppLayout.ts` into focused, testable units (milestone 1.0 prerequis | 5a StatusState + renderStatus | ✅ Done | #194 | | 5b ConversationState + renderConversation | ✅ Done | #196 | | 5c ToolApprovalState + renderToolApproval | ✅ Done | #197 | -| 5d CommandModeState + renderCommandMode | ⏳ Next | — | -| 5e ScreenCoordinator cleanup | — | — | +| 5d CommandModeState + renderCommandMode | ✅ Done | #198 | +| 5e ScreenCoordinator cleanup | ⏳ Next | — | -Test count: 276 across 11 spec files. +Test count: 319 across 13 spec files. ## Recent Decisions diff --git a/apps/claude-sdk-cli/src/AppLayout.ts b/apps/claude-sdk-cli/src/AppLayout.ts index 82f725a..104d796 100644 --- a/apps/claude-sdk-cli/src/AppLayout.ts +++ b/apps/claude-sdk-cli/src/AppLayout.ts @@ -1,19 +1,18 @@ import { stat } from 'node:fs/promises'; -import { basename, resolve } from 'node:path'; -import { clearDown, clearLine, cursorAt, DIM, hideCursor, INVERSE_OFF, INVERSE_ON, RESET, syncEnd, syncStart } from '@shellicar/claude-core/ansi'; +import { resolve } from 'node:path'; +import { clearDown, clearLine, cursorAt, hideCursor, syncEnd, syncStart } from '@shellicar/claude-core/ansi'; import type { KeyAction } from '@shellicar/claude-core/input'; -import { wrapLine } from '@shellicar/claude-core/reflow'; import { sanitiseLoneSurrogates } from '@shellicar/claude-core/sanitise'; 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 { AttachmentStore } from './AttachmentStore.js'; +import { CommandModeState } from './CommandModeState.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 { renderCommandMode } from './renderCommandMode.js'; import { buildDivider, renderBlocksToString, renderConversation } from './renderConversation.js'; import { renderEditor } from './renderEditor.js'; import { renderStatus } from './renderStatus.js'; @@ -26,10 +25,6 @@ export type { PendingTool } from './ToolApprovalState.js'; type Mode = 'editor' | 'streaming'; -// Indentation used for tool expansion and attachment preview rows. -// renderConversation.ts uses the same value for block content lines. -const CONTENT_INDENT = ' '; - /** 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) { @@ -52,9 +47,7 @@ export class AppLayout implements Disposable { #toolApprovalState = new ToolApprovalState(); - #commandMode = false; - #previewMode = false; - #attachments = new AttachmentStore(); + #commandModeState = new CommandModeState(); #editorResolve: ((value: string) => void) | null = null; #cancelFn: (() => void) | null = null; @@ -126,9 +119,7 @@ export class AppLayout implements Disposable { this.#conversationState.completeActive(); this.#toolApprovalState.clearTools(); this.#mode = 'editor'; - this.#commandMode = false; - this.#previewMode = false; - this.#attachments.clear(); + this.#commandModeState.reset(); this.#editorState.reset(); this.#flushToScroll(); this.render(); @@ -218,16 +209,15 @@ export class AppLayout implements Disposable { if (key.type === 'ctrl+/') { if (this.#mode === 'editor') { - this.#commandMode = !this.#commandMode; + this.#commandModeState.toggleCommandMode(); this.render(); } return; } if (key.type === 'escape') { - if (this.#commandMode) { - this.#commandMode = false; - this.#previewMode = false; + if (this.#commandModeState.commandMode) { + this.#commandModeState.exitCommandMode(); this.render(); return; } @@ -269,7 +259,7 @@ export class AppLayout implements Disposable { } // Command mode: consume all keys, dispatch actions immediately - if (this.#commandMode) { + if (this.#commandModeState.commandMode) { this.#handleCommandKey(key); return; } @@ -283,13 +273,13 @@ export class AppLayout implements Disposable { return; } const text = this.#editorState.text.trim(); - if (!text && !this.#attachments.hasAttachments) { + if (!text && !this.#commandModeState.hasAttachments) { return; } if (!this.#editorResolve) { return; } - const attachments = this.#attachments.takeAttachments(); + const attachments = this.#commandModeState.takeAttachments(); const parts: string[] = [text]; if (attachments) { for (let n = 0; n < attachments.length; n++) { @@ -342,9 +332,8 @@ export class AppLayout implements Disposable { const totalRows = this.#screen.rows; const { approvalRow, expandedRows: toolRows } = renderToolApproval(this.#toolApprovalState, cols, Math.floor(totalRows / 2)); - const previewRows = this.#previewMode && this.#commandMode ? this.#buildPreviewRows(cols) : []; + const { commandRow, previewRows } = renderCommandMode(this.#commandModeState, cols, Math.max(1, Math.floor(totalRows / 3)), Math.floor(totalRows / 2)); const expandedRows = [...toolRows, ...previewRows]; - const commandRow = this.#buildCommandRow(cols); // Fixed status bar: separator (1) + status line (1) + approval row (1) + command row (always 1) + optional expanded rows const statusBarHeight = 4 + expandedRows.length; const contentRows = Math.max(2, totalRows - statusBarHeight); @@ -389,7 +378,7 @@ export class AppLayout implements Disposable { readClipboardText() .then((text) => { if (text) { - this.#attachments.addText(text); + this.#commandModeState.addText(text); } this.render(); }) @@ -409,15 +398,15 @@ export class AppLayout implements Disposable { const info = await stat(resolved); // File exists — attach it directly, no further heuristic needed. if (info.isDirectory()) { - this.#attachments.addFile(resolved, 'dir'); + this.#commandModeState.addFile(resolved, 'dir'); } else { - this.#attachments.addFile(resolved, 'file', info.size); + this.#commandModeState.addFile(resolved, 'file', info.size); } } catch { // File not found — only create a missing chip if the text // looks like a deliberate path (explicit prefix). if (isLikelyPath(filePath)) { - this.#attachments.addFile(resolved, 'missing'); + this.#commandModeState.addFile(resolved, 'missing'); } } } @@ -429,129 +418,29 @@ export class AppLayout implements Disposable { return; } case 'd': - this.#attachments.removeSelected(); + this.#commandModeState.removeSelected(); this.render(); return; case 'p': - if (this.#attachments.selectedIndex >= 0) { - this.#previewMode = !this.#previewMode; - } + this.#commandModeState.togglePreview(); this.render(); return; } } if (key.type === 'left') { - this.#attachments.selectLeft(); + this.#commandModeState.selectLeft(); this.render(); return; } if (key.type === 'right') { - this.#attachments.selectRight(); + this.#commandModeState.selectRight(); this.render(); return; } // All other keys silently consumed } - #buildCommandRow(_cols: number): string { - const hasAttachments = this.#attachments.hasAttachments; - if (!this.#commandMode && !hasAttachments) { - return ''; - } - const b = new StatusLineBuilder(); - b.text(' '); - const atts = this.#attachments.attachments; - for (let i = 0; i < atts.length; i++) { - const att = atts[i]; - if (!att) { - continue; - } - let chip: string; - if (att.kind === 'text') { - if (att.truncated) { - const fullStr = att.fullSizeBytes >= 1024 ? `${(att.fullSizeBytes / 1024).toFixed(1)}KB` : `${att.fullSizeBytes}B`; - chip = `[txt ${fullStr}!]`; - } else { - const sizeStr = att.sizeBytes >= 1024 ? `${(att.sizeBytes / 1024).toFixed(1)}KB` : `${att.sizeBytes}B`; - chip = `[txt ${sizeStr}]`; - } - } else { - const name = basename(att.path); - if (att.fileType === 'missing') { - chip = `[${name} ?]`; - } else if (att.fileType === 'dir') { - chip = `[${name}/]`; - } else { - const sz = att.sizeBytes ?? 0; - const sizeStr = sz >= 1024 ? `${(sz / 1024).toFixed(1)}KB` : `${sz}B`; - chip = `[${name} ${sizeStr}]`; - } - } - if (this.#commandMode && i === this.#attachments.selectedIndex) { - b.ansi(INVERSE_ON); - b.text(chip); - b.ansi(INVERSE_OFF); - } else { - b.ansi(DIM); - b.text(chip); - b.ansi(RESET); - } - b.text(' '); - } - if (this.#commandMode) { - b.ansi(DIM); - b.text('cmd'); - b.ansi(RESET); - if (hasAttachments) { - b.text(' \u2190 \u2192 select d del p prev \u00b7 t paste \u00b7 f file \u00b7 ESC cancel'); - } else { - b.text(' t paste · f file · ESC cancel'); - } - } - return b.output; - } - #buildStatusLine(cols: number): string { return renderStatus(this.#statusState, cols); } - - #buildPreviewRows(cols: number): string[] { - const idx = this.#attachments.selectedIndex; - if (idx < 0) { - return []; - } - const att = this.#attachments.attachments[idx]; - if (!att) { - return []; - } - - const rows: string[] = []; - if (att.kind === 'text') { - if (att.truncated) { - const showSize = att.sizeBytes >= 1024 ? `${(att.sizeBytes / 1024).toFixed(1)}KB` : `${att.sizeBytes}B`; - const fullSize = att.fullSizeBytes >= 1024 ? `${(att.fullSizeBytes / 1024).toFixed(1)}KB` : `${att.fullSizeBytes}B`; - rows.push(DIM + ` showing ${showSize} of ${fullSize} (truncated)` + RESET); - } - const lines = att.text.split('\n'); - const maxPreviewLines = Math.max(1, Math.floor(this.#screen.rows / 3)); - for (const line of lines.slice(0, maxPreviewLines)) { - rows.push(...wrapLine(CONTENT_INDENT + line, cols)); - } - if (lines.length > maxPreviewLines) { - rows.push(DIM + ` \u2026 ${lines.length - maxPreviewLines} more lines` + RESET); - } - } else { - rows.push(` path: ${att.path}`); - if (att.fileType === 'file') { - const sz = att.sizeBytes ?? 0; - const sizeStr = sz >= 1024 ? `${(sz / 1024).toFixed(1)}KB` : `${sz}B`; - rows.push(` type: file size: ${sizeStr}`); - } else if (att.fileType === 'dir') { - rows.push(' type: dir'); - } else { - rows.push(' // not found'); - } - } - return rows.slice(0, Math.floor(this.#screen.rows / 2)); - } } diff --git a/apps/claude-sdk-cli/src/CommandModeState.ts b/apps/claude-sdk-cli/src/CommandModeState.ts new file mode 100644 index 0000000..5df7586 --- /dev/null +++ b/apps/claude-sdk-cli/src/CommandModeState.ts @@ -0,0 +1,86 @@ +import type { Attachment } from './AttachmentStore.js'; +import { AttachmentStore } from './AttachmentStore.js'; + +export type { Attachment, FileAttachment, TextAttachment } from './AttachmentStore.js'; + +/** + * Pure state for the command mode UI: the active/inactive flag, attachment preview + * toggle, and the underlying attachment store. + * + * No async I/O, no rendering. The clipboard reads and file-stat calls that happen + * when the user presses t or f stay in AppLayout — they are I/O, not state. + */ +export class CommandModeState { + #commandMode = false; + #previewMode = false; + #attachments = new AttachmentStore(); + + public get commandMode(): boolean { + return this.#commandMode; + } + + public get previewMode(): boolean { + return this.#previewMode; + } + + public get hasAttachments(): boolean { + return this.#attachments.hasAttachments; + } + + public get attachments(): readonly Attachment[] { + return this.#attachments.attachments; + } + + public get selectedIndex(): number { + return this.#attachments.selectedIndex; + } + + /** Enter or exit command mode. Only meaningful in editor mode. */ + public toggleCommandMode(): void { + this.#commandMode = !this.#commandMode; + } + + /** Exit command mode and collapse any preview. */ + public exitCommandMode(): void { + this.#commandMode = false; + this.#previewMode = false; + } + + /** Reset all command mode state — used when streaming completes. */ + public reset(): void { + this.exitCommandMode(); + this.#attachments.clear(); + } + + /** Toggle attachment preview for the selected item. No-op if nothing is selected. */ + public togglePreview(): void { + if (this.#attachments.selectedIndex >= 0) { + this.#previewMode = !this.#previewMode; + } + } + + public addText(text: string): 'added' | 'duplicate' { + return this.#attachments.addText(text); + } + + public addFile(path: string, fileType: 'file' | 'dir' | 'missing', sizeBytes?: number): 'added' | 'duplicate' { + return this.#attachments.addFile(path, fileType, sizeBytes); + } + + public removeSelected(): void { + this.#attachments.removeSelected(); + } + + public selectLeft(): void { + this.#attachments.selectLeft(); + } + + public selectRight(): void { + this.#attachments.selectRight(); + } + + /** Returns all attachments and clears the store. Returns null if empty. */ + public takeAttachments(): ReturnType { + return this.#attachments.takeAttachments(); + } +} diff --git a/apps/claude-sdk-cli/src/renderCommandMode.ts b/apps/claude-sdk-cli/src/renderCommandMode.ts new file mode 100644 index 0000000..fea5a27 --- /dev/null +++ b/apps/claude-sdk-cli/src/renderCommandMode.ts @@ -0,0 +1,133 @@ +import { basename } from 'node:path'; +import { DIM, INVERSE_OFF, INVERSE_ON, RESET } from '@shellicar/claude-core/ansi'; +import { wrapLine } from '@shellicar/claude-core/reflow'; +import { StatusLineBuilder } from '@shellicar/claude-core/status-line'; +import type { CommandModeState } from './CommandModeState.js'; + +// Same indent used by renderConversation for block content lines. +const CONTENT_INDENT = ' '; + +export type CommandModeRender = { + commandRow: string; + previewRows: string[]; +}; + +/** + * Render the command mode UI from pure state. + * + * Returns two separate pieces because they occupy different fixed positions in + * the layout: commandRow sits between the approval row and content area; + * previewRows are appended below commandRow and their count affects the + * content area height calculation. + * + * maxTextLines caps how many lines of a text attachment are shown (caller passes + * Math.max(1, Math.floor(totalRows / 3))). maxRows is the absolute cap on + * previewRows length (caller passes Math.floor(totalRows / 2)). + */ +export function renderCommandMode(state: CommandModeState, cols: number, maxTextLines: number, maxRows: number): CommandModeRender { + return { + commandRow: buildCommandRow(state), + previewRows: buildPreviewRows(state, cols, maxTextLines, maxRows), + }; +} + +function buildCommandRow(state: CommandModeState): string { + const hasAttachments = state.hasAttachments; + if (!state.commandMode && !hasAttachments) { + return ''; + } + const b = new StatusLineBuilder(); + b.text(' '); + const atts = state.attachments; + for (let i = 0; i < atts.length; i++) { + const att = atts[i]; + if (!att) { + continue; + } + let chip: string; + if (att.kind === 'text') { + if (att.truncated) { + const fullStr = att.fullSizeBytes >= 1024 ? `${(att.fullSizeBytes / 1024).toFixed(1)}KB` : `${att.fullSizeBytes}B`; + chip = `[txt ${fullStr}!]`; + } else { + const sizeStr = att.sizeBytes >= 1024 ? `${(att.sizeBytes / 1024).toFixed(1)}KB` : `${att.sizeBytes}B`; + chip = `[txt ${sizeStr}]`; + } + } else { + const name = basename(att.path); + if (att.fileType === 'missing') { + chip = `[${name} ?]`; + } else if (att.fileType === 'dir') { + chip = `[${name}/]`; + } else { + const sz = att.sizeBytes ?? 0; + const sizeStr = sz >= 1024 ? `${(sz / 1024).toFixed(1)}KB` : `${sz}B`; + chip = `[${name} ${sizeStr}]`; + } + } + if (state.commandMode && i === state.selectedIndex) { + b.ansi(INVERSE_ON); + b.text(chip); + b.ansi(INVERSE_OFF); + } else { + b.ansi(DIM); + b.text(chip); + b.ansi(RESET); + } + b.text(' '); + } + if (state.commandMode) { + b.ansi(DIM); + b.text('cmd'); + b.ansi(RESET); + if (hasAttachments) { + b.text(' \u2190 \u2192 select d del p prev \u00b7 t paste \u00b7 f file \u00b7 ESC cancel'); + } else { + b.text(' t paste \u00b7 f file \u00b7 ESC cancel'); + } + } + return b.output; +} + +function buildPreviewRows(state: CommandModeState, cols: number, maxTextLines: number, maxRows: number): string[] { + // Preview is only visible when command mode is active and preview is toggled on. + if (!state.commandMode || !state.previewMode) { + return []; + } + const idx = state.selectedIndex; + if (idx < 0) { + return []; + } + const att = state.attachments[idx]; + if (!att) { + return []; + } + + const rows: string[] = []; + if (att.kind === 'text') { + if (att.truncated) { + const showSize = att.sizeBytes >= 1024 ? `${(att.sizeBytes / 1024).toFixed(1)}KB` : `${att.sizeBytes}B`; + const fullSize = att.fullSizeBytes >= 1024 ? `${(att.fullSizeBytes / 1024).toFixed(1)}KB` : `${att.fullSizeBytes}B`; + rows.push(`${DIM} showing ${showSize} of ${fullSize} (truncated)${RESET}`); + } + const lines = att.text.split('\n'); + for (const line of lines.slice(0, maxTextLines)) { + rows.push(...wrapLine(CONTENT_INDENT + line, cols)); + } + if (lines.length > maxTextLines) { + rows.push(`${DIM} \u2026 ${lines.length - maxTextLines} more lines${RESET}`); + } + } else { + rows.push(` path: ${att.path}`); + if (att.fileType === 'file') { + const sz = att.sizeBytes ?? 0; + const sizeStr = sz >= 1024 ? `${(sz / 1024).toFixed(1)}KB` : `${sz}B`; + rows.push(` type: file size: ${sizeStr}`); + } else if (att.fileType === 'dir') { + rows.push(' type: dir'); + } else { + rows.push(' // not found'); + } + } + return rows.slice(0, maxRows); +} diff --git a/apps/claude-sdk-cli/test/CommandModeState.spec.ts b/apps/claude-sdk-cli/test/CommandModeState.spec.ts new file mode 100644 index 0000000..81ef755 --- /dev/null +++ b/apps/claude-sdk-cli/test/CommandModeState.spec.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from 'vitest'; +import { CommandModeState } from '../src/CommandModeState.js'; + +describe('CommandModeState — initial state', () => { + it('commandMode starts false', () => { + const state = new CommandModeState(); + const expected = false; + const actual = state.commandMode; + expect(actual).toBe(expected); + }); + + it('previewMode starts false', () => { + const state = new CommandModeState(); + const expected = false; + const actual = state.previewMode; + expect(actual).toBe(expected); + }); + + it('hasAttachments starts false', () => { + const state = new CommandModeState(); + const expected = false; + const actual = state.hasAttachments; + expect(actual).toBe(expected); + }); + + it('attachments starts empty', () => { + const state = new CommandModeState(); + const expected = 0; + const actual = state.attachments.length; + expect(actual).toBe(expected); + }); + + it('selectedIndex starts at -1', () => { + const state = new CommandModeState(); + const expected = -1; + const actual = state.selectedIndex; + expect(actual).toBe(expected); + }); +}); + +describe('CommandModeState — toggleCommandMode', () => { + it('flips commandMode from false to true', () => { + const state = new CommandModeState(); + state.toggleCommandMode(); + const expected = true; + const actual = state.commandMode; + expect(actual).toBe(expected); + }); + + it('flips commandMode from true to false', () => { + const state = new CommandModeState(); + state.toggleCommandMode(); + state.toggleCommandMode(); + const expected = false; + const actual = state.commandMode; + expect(actual).toBe(expected); + }); +}); + +describe('CommandModeState — exitCommandMode', () => { + it('sets commandMode to false', () => { + const state = new CommandModeState(); + state.toggleCommandMode(); + state.exitCommandMode(); + const expected = false; + const actual = state.commandMode; + expect(actual).toBe(expected); + }); + + it('sets previewMode to false', () => { + const state = new CommandModeState(); + state.addText('hello'); + state.toggleCommandMode(); + state.togglePreview(); + state.exitCommandMode(); + const expected = false; + const actual = state.previewMode; + expect(actual).toBe(expected); + }); +}); + +describe('CommandModeState — reset', () => { + it('sets commandMode to false', () => { + const state = new CommandModeState(); + state.toggleCommandMode(); + state.reset(); + const expected = false; + const actual = state.commandMode; + expect(actual).toBe(expected); + }); + + it('sets previewMode to false', () => { + const state = new CommandModeState(); + state.addText('hello'); + state.togglePreview(); + state.reset(); + const expected = false; + const actual = state.previewMode; + expect(actual).toBe(expected); + }); + + it('clears attachments', () => { + const state = new CommandModeState(); + state.addText('hello'); + state.reset(); + const expected = 0; + const actual = state.attachments.length; + expect(actual).toBe(expected); + }); +}); + +describe('CommandModeState — togglePreview', () => { + it('is a no-op when nothing is selected', () => { + const state = new CommandModeState(); + state.togglePreview(); + const expected = false; + const actual = state.previewMode; + expect(actual).toBe(expected); + }); + + it('flips previewMode when an attachment is selected', () => { + const state = new CommandModeState(); + state.addText('hello'); + state.togglePreview(); + const expected = true; + const actual = state.previewMode; + expect(actual).toBe(expected); + }); + + it('flips previewMode back when called twice', () => { + const state = new CommandModeState(); + state.addText('hello'); + state.togglePreview(); + state.togglePreview(); + const expected = false; + const actual = state.previewMode; + expect(actual).toBe(expected); + }); +}); + +describe('CommandModeState — addText', () => { + it('returns "added" for new text', () => { + const state = new CommandModeState(); + const expected = 'added'; + const actual = state.addText('hello'); + expect(actual).toBe(expected); + }); + + it('returns "duplicate" for the same text', () => { + const state = new CommandModeState(); + state.addText('hello'); + const expected = 'duplicate'; + const actual = state.addText('hello'); + expect(actual).toBe(expected); + }); + + it('hasAttachments is true after addText', () => { + const state = new CommandModeState(); + state.addText('hello'); + const expected = true; + const actual = state.hasAttachments; + expect(actual).toBe(expected); + }); +}); + +describe('CommandModeState — addFile', () => { + it('returns "added" for a new path', () => { + const state = new CommandModeState(); + const expected = 'added'; + const actual = state.addFile('/tmp/foo', 'file', 100); + expect(actual).toBe(expected); + }); + + it('returns "duplicate" for the same path', () => { + const state = new CommandModeState(); + state.addFile('/tmp/foo', 'file', 100); + const expected = 'duplicate'; + const actual = state.addFile('/tmp/foo', 'file', 100); + expect(actual).toBe(expected); + }); +}); + +describe('CommandModeState — removeSelected', () => { + it('removes the selected attachment', () => { + const state = new CommandModeState(); + state.addText('hello'); + state.removeSelected(); + const expected = 0; + const actual = state.attachments.length; + expect(actual).toBe(expected); + }); +}); + +describe('CommandModeState — selectLeft / selectRight', () => { + it('selectRight moves to the next attachment', () => { + const state = new CommandModeState(); + state.addText('a'); + state.addText('b'); + // selectedIndex after two adds is 1 (last added) + state.selectLeft(); + const expected = 0; + const actual = state.selectedIndex; + expect(actual).toBe(expected); + }); + + it('selectRight does not exceed last index', () => { + const state = new CommandModeState(); + state.addText('only'); + state.selectRight(); + const expected = 0; + const actual = state.selectedIndex; + expect(actual).toBe(expected); + }); +}); + +describe('CommandModeState — takeAttachments', () => { + it('returns null when no attachments', () => { + const state = new CommandModeState(); + const expected = null; + const actual = state.takeAttachments(); + expect(actual).toBe(expected); + }); + + it('returns attachments and clears the store', () => { + const state = new CommandModeState(); + state.addText('hello'); + const taken = state.takeAttachments(); + const expected = 0; + const actual = state.attachments.length; + expect(actual).toBe(expected); + expect(taken).not.toBeNull(); + }); +}); diff --git a/apps/claude-sdk-cli/test/renderCommandMode.spec.ts b/apps/claude-sdk-cli/test/renderCommandMode.spec.ts new file mode 100644 index 0000000..e51710a --- /dev/null +++ b/apps/claude-sdk-cli/test/renderCommandMode.spec.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from 'vitest'; +import { CommandModeState } from '../src/CommandModeState.js'; +import { renderCommandMode } from '../src/renderCommandMode.js'; + +const COLS = 120; +const MAX_TEXT_LINES = 8; +const MAX_ROWS = 12; + +function emptyState(): CommandModeState { + return new CommandModeState(); +} + +function stateWithText(text = 'hello world'): CommandModeState { + const state = new CommandModeState(); + state.addText(text); + return state; +} + +function stateInCommandMode(): CommandModeState { + const state = new CommandModeState(); + state.toggleCommandMode(); + return state; +} + +function stateInCommandModeWithText(text = 'hello world'): CommandModeState { + const state = new CommandModeState(); + state.addText(text); + state.toggleCommandMode(); + return state; +} + +// --------------------------------------------------------------------------- +// No command mode, no attachments +// --------------------------------------------------------------------------- + +describe('renderCommandMode — empty state', () => { + it('commandRow is empty when no command mode and no attachments', () => { + const expected = ''; + const actual = renderCommandMode(emptyState(), COLS, MAX_TEXT_LINES, MAX_ROWS).commandRow; + expect(actual).toBe(expected); + }); + + it('previewRows is empty when no command mode and no attachments', () => { + const expected = 0; + const actual = renderCommandMode(emptyState(), COLS, MAX_TEXT_LINES, MAX_ROWS).previewRows.length; + expect(actual).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// Attachments visible without command mode +// --------------------------------------------------------------------------- + +describe('renderCommandMode — attachment chips without command mode', () => { + it('commandRow is non-empty when attachments exist even without command mode', () => { + const expected = true; + const actual = renderCommandMode(stateWithText(), COLS, MAX_TEXT_LINES, MAX_ROWS).commandRow.length > 0; + expect(actual).toBe(expected); + }); + + it('commandRow does not include cmd hint when not in command mode', () => { + const expected = false; + const actual = renderCommandMode(stateWithText(), COLS, MAX_TEXT_LINES, MAX_ROWS).commandRow.includes('cmd'); + expect(actual).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// Command mode active +// --------------------------------------------------------------------------- + +describe('renderCommandMode — command mode active', () => { + it('commandRow includes "cmd" when in command mode', () => { + const expected = true; + const actual = renderCommandMode(stateInCommandMode(), COLS, MAX_TEXT_LINES, MAX_ROWS).commandRow.includes('cmd'); + expect(actual).toBe(expected); + }); + + it('commandRow includes paste hint when in command mode with no attachments', () => { + const expected = true; + const actual = renderCommandMode(stateInCommandMode(), COLS, MAX_TEXT_LINES, MAX_ROWS).commandRow.includes('paste'); + expect(actual).toBe(expected); + }); + + it('commandRow includes select hint when in command mode with attachments', () => { + const expected = true; + const actual = renderCommandMode(stateInCommandModeWithText(), COLS, MAX_TEXT_LINES, MAX_ROWS).commandRow.includes('select'); + expect(actual).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// Attachment chip content +// --------------------------------------------------------------------------- + +describe('renderCommandMode — text attachment chip', () => { + it('commandRow includes [txt ...] chip for text attachments', () => { + const expected = true; + const actual = renderCommandMode(stateWithText(), COLS, MAX_TEXT_LINES, MAX_ROWS).commandRow.includes('[txt '); + expect(actual).toBe(expected); + }); +}); + +describe('renderCommandMode — file attachment chip', () => { + it('commandRow includes filename in chip for file attachments', () => { + const state = new CommandModeState(); + state.addFile('/tmp/myfile.txt', 'file', 512); + const expected = true; + const actual = renderCommandMode(state, COLS, MAX_TEXT_LINES, MAX_ROWS).commandRow.includes('myfile.txt'); + expect(actual).toBe(expected); + }); + + it('commandRow shows trailing slash for directory attachments', () => { + const state = new CommandModeState(); + state.addFile('/tmp/mydir', 'dir'); + const expected = true; + const actual = renderCommandMode(state, COLS, MAX_TEXT_LINES, MAX_ROWS).commandRow.includes('mydir/'); + expect(actual).toBe(expected); + }); + + it('commandRow shows ? for missing file attachments', () => { + const state = new CommandModeState(); + state.addFile('/tmp/missing.txt', 'missing'); + const expected = true; + const actual = renderCommandMode(state, COLS, MAX_TEXT_LINES, MAX_ROWS).commandRow.includes('?'); + expect(actual).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// Preview rows +// --------------------------------------------------------------------------- + +describe('renderCommandMode — previewRows', () => { + it('previewRows is empty when command mode is off', () => { + const state = stateWithText('line one\nline two'); + state.togglePreview(); // no selection, no-op initially — need to add text first... actually addText selects it + // Re-create: addText selects the item, but commandMode is off + const expected = 0; + const actual = renderCommandMode(state, COLS, MAX_TEXT_LINES, MAX_ROWS).previewRows.length; + expect(actual).toBe(expected); + }); + + it('previewRows is empty when previewMode is off even in command mode', () => { + const state = stateInCommandModeWithText('line one\nline two'); + // previewMode is still false + const expected = 0; + const actual = renderCommandMode(state, COLS, MAX_TEXT_LINES, MAX_ROWS).previewRows.length; + expect(actual).toBe(expected); + }); + + it('previewRows is non-empty when both commandMode and previewMode are on', () => { + const state = stateInCommandModeWithText('line one\nline two'); + state.togglePreview(); + const expected = true; + const actual = renderCommandMode(state, COLS, MAX_TEXT_LINES, MAX_ROWS).previewRows.length > 0; + expect(actual).toBe(expected); + }); + + it('previewRows contains text attachment content', () => { + const state = stateInCommandModeWithText('unique-sentinel-value'); + state.togglePreview(); + const rows = renderCommandMode(state, COLS, MAX_TEXT_LINES, MAX_ROWS).previewRows; + const expected = true; + const actual = rows.some((r) => r.includes('unique-sentinel-value')); + expect(actual).toBe(expected); + }); + + it('previewRows shows path for file attachments', () => { + const state = new CommandModeState(); + state.addFile('/tmp/special-path', 'file', 100); + state.toggleCommandMode(); + state.togglePreview(); + const rows = renderCommandMode(state, COLS, MAX_TEXT_LINES, MAX_ROWS).previewRows; + const expected = true; + const actual = rows.some((r) => r.includes('special-path')); + expect(actual).toBe(expected); + }); + + it('previewRows is capped at maxRows', () => { + const manyLines = Array.from({ length: 100 }, (_, i) => `line ${i}`).join('\n'); + const state = stateInCommandModeWithText(manyLines); + state.togglePreview(); + const cap = 3; + const rows = renderCommandMode(state, COLS, MAX_TEXT_LINES, cap).previewRows; + const expected = true; + const actual = rows.length <= cap; + expect(actual).toBe(expected); + }); + + it('previewRows text content is limited by maxTextLines', () => { + const manyLines = Array.from({ length: 20 }, (_, i) => `line ${i}`).join('\n'); + const state = stateInCommandModeWithText(manyLines); + state.togglePreview(); + const maxText = 4; + const rows = renderCommandMode(state, COLS, maxText, MAX_ROWS).previewRows; + const expected = true; + // Should see the "more lines" ellipsis + const actual = rows.some((r) => r.includes('more lines')); + expect(actual).toBe(expected); + }); +});