From db99fa220a588741e7065c50802c6f98d4e513aa Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Tue, 7 Apr 2026 00:28:14 +1000 Subject: [PATCH] Extract CommandModeState and renderCommandMode from AppLayout (step 5d) CommandModeState wraps the three related pieces of UI state that AppLayout was holding directly: commandMode flag, previewMode flag, and AttachmentStore. They belong together because previewMode depends on having a selected attachment, and exitCommandMode needs to reset both flags at once. The async I/O in #handleCommandKey (clipboard reads, file stat) stays in AppLayout since those are side effects, not state. CommandModeState exposes the individual mutations (addText, addFile, removeSelected, selectLeft, selectRight, togglePreview) and handleCommandKey calls them after the I/O resolves. renderCommandMode takes two row-limit parameters (maxTextLines, maxRows) rather than one because the original code used two different fractions of screen height: totalRows/3 for text content lines and totalRows/2 for the final output cap. A single parameter would require approximation that breaks at some terminal heights. The preview guard (commandMode && previewMode) moves inside buildPreviewRows rather than living in the caller. The function is now self-contained and callers don't need to know the precondition. Deadcode removed: #buildCommandRow and #buildPreviewRows (98 lines). Dead imports removed: AttachmentStore, basename, DIM, INVERSE_OFF, INVERSE_ON, RESET, wrapLine, StatusLineBuilder, CONTENT_INDENT. 25 new tests in CommandModeState.spec.ts, 18 in renderCommandMode.spec.ts. Total: 319 tests across 13 files (up from 276). --- .claude/sessions/2026-04-07.md | 39 +++ CLAUDE.md | 6 +- apps/claude-sdk-cli/src/AppLayout.ts | 153 ++---------- apps/claude-sdk-cli/src/CommandModeState.ts | 86 +++++++ apps/claude-sdk-cli/src/renderCommandMode.ts | 133 ++++++++++ .../test/CommandModeState.spec.ts | 233 ++++++++++++++++++ .../test/renderCommandMode.spec.ts | 202 +++++++++++++++ 7 files changed, 717 insertions(+), 135 deletions(-) create mode 100644 apps/claude-sdk-cli/src/CommandModeState.ts create mode 100644 apps/claude-sdk-cli/src/renderCommandMode.ts create mode 100644 apps/claude-sdk-cli/test/CommandModeState.spec.ts create mode 100644 apps/claude-sdk-cli/test/renderCommandMode.spec.ts 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); + }); +});