From c83e67eedb3e5bdb026f4d71a7015e9a0d291408 Mon Sep 17 00:00:00 2001 From: cte Date: Wed, 7 Jan 2026 23:47:16 -0800 Subject: [PATCH] Some cleanup --- .../cli/src/__tests__/EscapeKeyCancel.test.ts | 187 ------ .../src/__tests__/MultilineTextInput.test.ts | 568 ------------------ apps/cli/src/__tests__/ScrollArea.test.ts | 485 --------------- ....ts => extension-host.integration.test.ts} | 0 .../cli/src/__tests__/useInputHistory.test.ts | 163 ----- apps/cli/src/index.ts | 2 +- apps/cli/src/ui/App.tsx | 6 +- apps/cli/src/{ => ui}/__tests__/store.test.ts | 2 +- .../cli/src/ui/components/ChatHistoryItem.tsx | 2 +- apps/cli/src/ui/components/Header.tsx | 2 +- apps/cli/src/ui/components/MetricsDisplay.tsx | 2 +- .../src/ui/components/MultilineTextInput.tsx | 2 +- apps/cli/src/ui/components/ProgressBar.tsx | 2 +- apps/cli/src/ui/components/ScrollArea.tsx | 2 +- .../cli/src/ui/components/ScrollIndicator.tsx | 2 +- apps/cli/src/ui/components/ToastDisplay.tsx | 2 +- .../src/ui/components/TodoChangeDisplay.tsx | 2 +- apps/cli/src/ui/components/TodoDisplay.tsx | 2 +- .../triggers/SlashCommandTrigger.tsx | 2 +- .../src/ui/components/tools/BrowserTool.tsx | 2 +- .../src/ui/components/tools/CommandTool.tsx | 2 +- .../ui/components/tools/CompletionTool.tsx | 2 +- .../src/ui/components/tools/FileReadTool.tsx | 2 +- .../src/ui/components/tools/FileWriteTool.tsx | 2 +- .../src/ui/components/tools/GenericTool.tsx | 2 +- apps/cli/src/ui/components/tools/ModeTool.tsx | 2 +- .../src/ui/components/tools/SearchTool.tsx | 2 +- .../{ => ui/hooks}/__tests__/useToast.test.ts | 2 +- apps/cli/src/ui/index.ts | 2 +- apps/cli/src/ui/{utils => }/theme.ts | 0 .../__tests__/extensionHostUtils.test.ts} | 8 +- .../__tests__/globalCommands.test.ts | 0 .../__tests__/globalInputSequences.test.ts | 9 +- .../__tests__/historyStorage.test.ts | 8 +- .../{utils.ts => utils/extensionHostUtils.ts} | 0 apps/cli/src/{ => utils}/globalCommands.ts | 0 .../{ui => }/utils/globalInputSequences.ts | 0 37 files changed, 30 insertions(+), 1450 deletions(-) delete mode 100644 apps/cli/src/__tests__/EscapeKeyCancel.test.ts delete mode 100644 apps/cli/src/__tests__/MultilineTextInput.test.ts delete mode 100644 apps/cli/src/__tests__/ScrollArea.test.ts rename apps/cli/src/__tests__/{integration.test.ts => extension-host.integration.test.ts} (100%) delete mode 100644 apps/cli/src/__tests__/useInputHistory.test.ts rename apps/cli/src/{ => ui}/__tests__/store.test.ts (99%) rename apps/cli/src/{ => ui/hooks}/__tests__/useToast.test.ts (98%) rename apps/cli/src/ui/{utils => }/theme.ts (100%) rename apps/cli/src/{__tests__/utils.test.ts => utils/__tests__/extensionHostUtils.test.ts} (97%) rename apps/cli/src/{ => utils}/__tests__/globalCommands.test.ts (100%) rename apps/cli/src/{ => utils}/__tests__/globalInputSequences.test.ts (95%) rename apps/cli/src/{ => utils}/__tests__/historyStorage.test.ts (98%) rename apps/cli/src/{utils.ts => utils/extensionHostUtils.ts} (100%) rename apps/cli/src/{ => utils}/globalCommands.ts (100%) rename apps/cli/src/{ui => }/utils/globalInputSequences.ts (100%) diff --git a/apps/cli/src/__tests__/EscapeKeyCancel.test.ts b/apps/cli/src/__tests__/EscapeKeyCancel.test.ts deleted file mode 100644 index c4e1cac568..0000000000 --- a/apps/cli/src/__tests__/EscapeKeyCancel.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * Tests for Escape key cancel/pause functionality - * - * When the CLI is in a loading state (streaming LLM API calls), - * pressing Escape should send a "cancelTask" message to the extension, - * similar to the Cancel button in the webview-ui. - */ - -describe("Escape key cancel behavior", () => { - describe("escape key detection logic", () => { - /** - * Simulates the escape key handling logic from App.tsx - * - * @param key - The key object from ink's useInput - * @param isLoading - Whether the app is currently loading (streaming) - * @param hasHostRef - Whether the extension host reference is available - * @param isPickerOpen - Whether an autocomplete picker is currently open - * @returns An object describing what action should be taken - */ - const handleEscapeKey = ( - key: { escape: boolean }, - isLoading: boolean, - hasHostRef: boolean, - isPickerOpen: boolean, - ): { shouldCancel: boolean; reason?: string } => { - if (!key.escape) { - return { shouldCancel: false, reason: "Not escape key" } - } - - if (!isLoading) { - return { shouldCancel: false, reason: "Not in loading state" } - } - - if (!hasHostRef) { - return { shouldCancel: false, reason: "No host reference" } - } - - if (isPickerOpen) { - // Let picker handle escape first - return { shouldCancel: false, reason: "Picker is open" } - } - - return { shouldCancel: true } - } - - it("should cancel task when escape is pressed during loading", () => { - const result = handleEscapeKey( - { escape: true }, - true, // isLoading - true, // hasHostRef - false, // isPickerOpen - ) - expect(result.shouldCancel).toBe(true) - }) - - it("should not cancel when not loading", () => { - const result = handleEscapeKey( - { escape: true }, - false, // isLoading - not loading - true, // hasHostRef - false, // isPickerOpen - ) - expect(result.shouldCancel).toBe(false) - expect(result.reason).toBe("Not in loading state") - }) - - it("should not cancel when host reference is not available", () => { - const result = handleEscapeKey( - { escape: true }, - true, // isLoading - false, // hasHostRef - no host reference - false, // isPickerOpen - ) - expect(result.shouldCancel).toBe(false) - expect(result.reason).toBe("No host reference") - }) - - it("should not cancel when picker is open", () => { - const result = handleEscapeKey( - { escape: true }, - true, // isLoading - true, // hasHostRef - true, // isPickerOpen - picker is open - ) - expect(result.shouldCancel).toBe(false) - expect(result.reason).toBe("Picker is open") - }) - - it("should not do anything for non-escape keys", () => { - const result = handleEscapeKey( - { escape: false }, // Not escape key - true, // isLoading - true, // hasHostRef - false, // isPickerOpen - ) - expect(result.shouldCancel).toBe(false) - expect(result.reason).toBe("Not escape key") - }) - }) - - describe("cancel message format", () => { - it("should create the correct message format for cancelTask", () => { - // The message sent to extension should match webview-ui format - const cancelMessage = { type: "cancelTask" } - - expect(cancelMessage).toEqual({ type: "cancelTask" }) - expect(cancelMessage.type).toBe("cancelTask") - }) - - it("should match the webview-ui cancel message format", () => { - // From webview-ui/src/components/chat/ChatView.tsx line 750: - // vscode.postMessage({ type: "cancelTask" }) - const webviewCancelMessage = { type: "cancelTask" } - const cliCancelMessage = { type: "cancelTask" } - - expect(cliCancelMessage).toEqual(webviewCancelMessage) - }) - }) - - describe("loading state scenarios", () => { - /** - * The isLoading state in the CLI store represents: - * - Active API request in progress - * - Task is streaming responses - * - Agent is "thinking" or processing - */ - - it("should identify loading state during agent response", () => { - const view = "AgentResponse" - const isLoading = true - - // During agent response, cancel should be available - expect(view).toBe("AgentResponse") - expect(isLoading).toBe(true) - }) - - it("should identify loading state during tool use", () => { - const view = "ToolUse" - const isLoading = true - - // During tool use, cancel should be available - expect(view).toBe("ToolUse") - expect(isLoading).toBe(true) - }) - - it("should not identify loading state during user input", () => { - const view = "UserInput" - const isLoading = false - - // During user input, no need for cancel - expect(view).toBe("UserInput") - expect(isLoading).toBe(false) - }) - }) - - describe("cancel behavior expectations", () => { - it("should pause the task (not terminate)", () => { - // The cancelTask message pauses the task, allowing the user to: - // 1. Review the current state - // 2. Provide additional input - // 3. Resume the task by typing something - const cancelBehavior = { - action: "pause", - terminates: false, - allowsResume: true, - resumeMethod: "user provides input", - } - - expect(cancelBehavior.action).toBe("pause") - expect(cancelBehavior.terminates).toBe(false) - expect(cancelBehavior.allowsResume).toBe(true) - }) - - it("should allow resuming by typing after cancel", () => { - // After cancel, the user can resume by typing a message - const postCancelState = { - isLoading: false, // Loading stops - canTypeMessage: true, // User can type - messageResumesTask: true, // Typing resumes the task - } - - expect(postCancelState.isLoading).toBe(false) - expect(postCancelState.canTypeMessage).toBe(true) - expect(postCancelState.messageResumesTask).toBe(true) - }) - }) -}) diff --git a/apps/cli/src/__tests__/MultilineTextInput.test.ts b/apps/cli/src/__tests__/MultilineTextInput.test.ts deleted file mode 100644 index f738100679..0000000000 --- a/apps/cli/src/__tests__/MultilineTextInput.test.ts +++ /dev/null @@ -1,568 +0,0 @@ -/** - * Tests for MultilineTextInput component - */ - -describe("MultilineTextInput", () => { - describe("cursor position calculations", () => { - // Test the getCursorPosition logic - const getCursorPosition = (value: string, cursorIndex: number): { line: number; col: number } => { - const lines = value.split("\n") - let pos = 0 - for (let i = 0; i < lines.length; i++) { - const line = lines[i]! - const lineEnd = pos + line.length - if (cursorIndex <= lineEnd) { - return { line: i, col: cursorIndex - pos } - } - pos = lineEnd + 1 // +1 for newline - } - // Cursor at very end - return { line: lines.length - 1, col: (lines[lines.length - 1] || "").length } - } - - // Test the getIndexFromPosition logic - const getIndexFromPosition = (value: string, line: number, col: number): number => { - const lines = value.split("\n") - let index = 0 - for (let i = 0; i < line && i < lines.length; i++) { - index += lines[i]!.length + 1 // +1 for newline - } - const targetLine = lines[line] || "" - index += Math.min(col, targetLine.length) - return index - } - - it("should calculate cursor position for single line", () => { - const value = "hello" - expect(getCursorPosition(value, 0)).toEqual({ line: 0, col: 0 }) - expect(getCursorPosition(value, 2)).toEqual({ line: 0, col: 2 }) - expect(getCursorPosition(value, 5)).toEqual({ line: 0, col: 5 }) - }) - - it("should calculate cursor position for multiple lines", () => { - const value = "hello\nworld" - // "hello" is 5 chars, newline at index 5 - // "world" starts at index 6 - expect(getCursorPosition(value, 0)).toEqual({ line: 0, col: 0 }) - expect(getCursorPosition(value, 5)).toEqual({ line: 0, col: 5 }) // End of first line - expect(getCursorPosition(value, 6)).toEqual({ line: 1, col: 0 }) // Start of second line - expect(getCursorPosition(value, 8)).toEqual({ line: 1, col: 2 }) // Middle of second line - expect(getCursorPosition(value, 11)).toEqual({ line: 1, col: 5 }) // End of second line - }) - - it("should calculate cursor position for three lines", () => { - const value = "foo\nbar\nbaz" - // "foo" = 3 chars, newline at 3 - // "bar" starts at 4, ends at 6, newline at 7 - // "baz" starts at 8 - expect(getCursorPosition(value, 0)).toEqual({ line: 0, col: 0 }) - expect(getCursorPosition(value, 3)).toEqual({ line: 0, col: 3 }) - expect(getCursorPosition(value, 4)).toEqual({ line: 1, col: 0 }) - expect(getCursorPosition(value, 7)).toEqual({ line: 1, col: 3 }) - expect(getCursorPosition(value, 8)).toEqual({ line: 2, col: 0 }) - expect(getCursorPosition(value, 11)).toEqual({ line: 2, col: 3 }) - }) - - it("should calculate index from position for single line", () => { - const value = "hello" - expect(getIndexFromPosition(value, 0, 0)).toBe(0) - expect(getIndexFromPosition(value, 0, 2)).toBe(2) - expect(getIndexFromPosition(value, 0, 5)).toBe(5) - }) - - it("should calculate index from position for multiple lines", () => { - const value = "hello\nworld" - expect(getIndexFromPosition(value, 0, 0)).toBe(0) - expect(getIndexFromPosition(value, 0, 5)).toBe(5) - expect(getIndexFromPosition(value, 1, 0)).toBe(6) - expect(getIndexFromPosition(value, 1, 2)).toBe(8) - expect(getIndexFromPosition(value, 1, 5)).toBe(11) - }) - - it("should clamp column to line length", () => { - const value = "hi\nworld" - // First line "hi" is only 2 chars, requesting col 5 should clamp to 2 - expect(getIndexFromPosition(value, 0, 5)).toBe(2) - }) - }) - - describe("line splitting", () => { - it("should split empty string into single empty line", () => { - const value = "" - const lines = value.split("\n") - expect(lines).toEqual([""]) - }) - - it("should split single line correctly", () => { - const value = "hello world" - const lines = value.split("\n") - expect(lines).toEqual(["hello world"]) - }) - - it("should split multiple lines correctly", () => { - const value = "foo\nbar\nbaz" - const lines = value.split("\n") - expect(lines).toEqual(["foo", "bar", "baz"]) - }) - - it("should handle trailing newline", () => { - const value = "foo\nbar\n" - const lines = value.split("\n") - expect(lines).toEqual(["foo", "bar", ""]) - }) - - it("should handle empty lines in middle", () => { - const value = "foo\n\nbaz" - const lines = value.split("\n") - expect(lines).toEqual(["foo", "", "baz"]) - }) - }) - - describe("line normalization", () => { - const normalizeLineEndings = (text: string): string => { - if (text == null) return "" - return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") - } - - it("should normalize CRLF to LF", () => { - expect(normalizeLineEndings("hello\r\nworld")).toBe("hello\nworld") - }) - - it("should normalize CR to LF", () => { - expect(normalizeLineEndings("hello\rworld")).toBe("hello\nworld") - }) - - it("should leave LF unchanged", () => { - expect(normalizeLineEndings("hello\nworld")).toBe("hello\nworld") - }) - - it("should handle null/undefined", () => { - expect(normalizeLineEndings(null as unknown as string)).toBe("") - expect(normalizeLineEndings(undefined as unknown as string)).toBe("") - }) - - it("should handle mixed line endings", () => { - expect(normalizeLineEndings("a\r\nb\rc\nd")).toBe("a\nb\nc\nd") - }) - }) - - describe("key binding behavior", () => { - it("should detect Ctrl+Enter for newline insertion", () => { - const isNewlineKey = (key: { return: boolean; ctrl: boolean }) => key.return && key.ctrl - expect(isNewlineKey({ return: true, ctrl: true })).toBe(true) - expect(isNewlineKey({ return: true, ctrl: false })).toBe(false) - expect(isNewlineKey({ return: false, ctrl: true })).toBe(false) - }) - - it("should detect Enter for submit", () => { - const isSubmitKey = (key: { return: boolean; ctrl: boolean }) => key.return && !key.ctrl - expect(isSubmitKey({ return: true, ctrl: false })).toBe(true) - expect(isSubmitKey({ return: true, ctrl: true })).toBe(false) - expect(isSubmitKey({ return: false, ctrl: false })).toBe(false) - }) - }) - - describe("newline insertion", () => { - it("should insert newline at cursor position", () => { - const value = "hello" - const cursorIndex = 2 - const newValue = value.slice(0, cursorIndex) + "\n" + value.slice(cursorIndex) - expect(newValue).toBe("he\nllo") - }) - - it("should insert newline at end", () => { - const value = "hello" - const cursorIndex = 5 - const newValue = value.slice(0, cursorIndex) + "\n" + value.slice(cursorIndex) - expect(newValue).toBe("hello\n") - }) - - it("should insert newline at start", () => { - const value = "hello" - const cursorIndex = 0 - const newValue = value.slice(0, cursorIndex) + "\n" + value.slice(cursorIndex) - expect(newValue).toBe("\nhello") - }) - }) - - describe("backspace behavior", () => { - it("should delete character before cursor", () => { - const value = "hello" - const cursorIndex = 3 - const newValue = value.slice(0, cursorIndex - 1) + value.slice(cursorIndex) - expect(newValue).toBe("helo") - }) - - it("should delete newline character (merge lines)", () => { - const value = "hello\nworld" - const cursorIndex = 6 // Start of "world" line - const newValue = value.slice(0, cursorIndex - 1) + value.slice(cursorIndex) - expect(newValue).toBe("helloworld") - }) - - it("should do nothing at start of input", () => { - const value = "hello" - const cursorIndex = 0 - // In real implementation, we check if cursorIndex > 0 - if (cursorIndex > 0) { - const newValue = value.slice(0, cursorIndex - 1) + value.slice(cursorIndex) - expect(newValue).not.toBe(value) - } - // At position 0, backspace does nothing - expect(value).toBe("hello") - }) - }) - - describe("arrow key navigation", () => { - describe("up arrow", () => { - it("should move to previous line preserving column", () => { - const value = "hello\nworld" - const getCursorPosition = (v: string, i: number) => { - const lines = v.split("\n") - let pos = 0 - for (let li = 0; li < lines.length; li++) { - const line = lines[li]! - const lineEnd = pos + line.length - if (i <= lineEnd) { - return { line: li, col: i - pos } - } - pos = lineEnd + 1 - } - return { line: lines.length - 1, col: (lines[lines.length - 1] || "").length } - } - - const getIndexFromPosition = (v: string, line: number, col: number) => { - const lines = v.split("\n") - let index = 0 - for (let i = 0; i < line && i < lines.length; i++) { - index += lines[i]!.length + 1 - } - const targetLine = lines[line] || "" - index += Math.min(col, targetLine.length) - return index - } - - // Cursor at "world"[2] (index 8) - const cursorIndex = 8 - const { line, col } = getCursorPosition(value, cursorIndex) - expect(line).toBe(1) - expect(col).toBe(2) - - // Move up: should go to line 0, same column - const targetLine = 0 - const newIndex = getIndexFromPosition(value, targetLine, col) - expect(newIndex).toBe(2) // "he|llo" - }) - - it("should clamp column if target line is shorter", () => { - const value = "hi\nworld" - const getIndexFromPosition = (v: string, line: number, col: number) => { - const lines = v.split("\n") - let index = 0 - for (let i = 0; i < line && i < lines.length; i++) { - index += lines[i]!.length + 1 - } - const targetLine = lines[line] || "" - index += Math.min(col, targetLine.length) - return index - } - - // Cursor at "world"[4] (index 7) - // Moving up to "hi" which is only 2 chars, should clamp to col 2 - const targetLine = 0 - const col = 4 - const newIndex = getIndexFromPosition(value, targetLine, col) - expect(newIndex).toBe(2) // End of "hi" - }) - }) - - describe("down arrow", () => { - it("should move to next line preserving column", () => { - const value = "hello\nworld" - const getIndexFromPosition = (v: string, line: number, col: number) => { - const lines = v.split("\n") - let index = 0 - for (let i = 0; i < line && i < lines.length; i++) { - index += lines[i]!.length + 1 - } - const targetLine = lines[line] || "" - index += Math.min(col, targetLine.length) - return index - } - - // Cursor at "hello"[2] (index 2) - const col = 2 - const targetLine = 1 - const newIndex = getIndexFromPosition(value, targetLine, col) - expect(newIndex).toBe(8) // "wo|rld" - }) - }) - - describe("left/right arrows", () => { - it("should move left by 1", () => { - const cursorIndex = 5 - const newIndex = Math.max(0, cursorIndex - 1) - expect(newIndex).toBe(4) - }) - - it("should not move left past 0", () => { - const cursorIndex = 0 - const newIndex = Math.max(0, cursorIndex - 1) - expect(newIndex).toBe(0) - }) - - it("should move right by 1", () => { - const value = "hello" - const cursorIndex = 2 - const newIndex = Math.min(value.length, cursorIndex + 1) - expect(newIndex).toBe(3) - }) - - it("should not move right past end", () => { - const value = "hello" - const cursorIndex = 5 - const newIndex = Math.min(value.length, cursorIndex + 1) - expect(newIndex).toBe(5) - }) - }) - }) -}) - -describe("word-boundary line wrapping", () => { - // Represents a visual row after wrapping a logical line - interface VisualRow { - text: string - logicalLineIndex: number - isFirstRowOfLine: boolean - startCol: number - } - - /** - * Wrap a logical line into visual rows based on available width. - * Uses word-boundary wrapping: prefers to break at spaces rather than - * in the middle of words. - */ - function wrapLine(lineText: string, logicalLineIndex: number, availableWidth: number): VisualRow[] { - if (availableWidth <= 0 || lineText.length <= availableWidth) { - return [ - { - text: lineText, - logicalLineIndex, - isFirstRowOfLine: true, - startCol: 0, - }, - ] - } - - const rows: VisualRow[] = [] - let remaining = lineText - let startCol = 0 - let isFirst = true - - while (remaining.length > 0) { - if (remaining.length <= availableWidth) { - rows.push({ - text: remaining, - logicalLineIndex, - isFirstRowOfLine: isFirst, - startCol, - }) - break - } - - // Find a good break point - prefer breaking at a space - let breakPoint = availableWidth - - // Look backwards from availableWidth for a space - const searchStart = Math.min(availableWidth, remaining.length) - let spaceIndex = -1 - for (let i = searchStart - 1; i >= 0; i--) { - if (remaining[i] === " ") { - spaceIndex = i - break - } - } - - if (spaceIndex > 0) { - // Found a space - break after it (include the space in this row) - breakPoint = spaceIndex + 1 - } - // else: no space found, break at availableWidth (mid-word break as fallback) - - const chunk = remaining.slice(0, breakPoint) - rows.push({ - text: chunk, - logicalLineIndex, - isFirstRowOfLine: isFirst, - startCol, - }) - - remaining = remaining.slice(breakPoint) - startCol += breakPoint - isFirst = false - } - - return rows - } - - it("should not wrap text shorter than available width", () => { - const rows = wrapLine("hello world", 0, 20) - expect(rows).toHaveLength(1) - expect(rows[0]!.text).toBe("hello world") - expect(rows[0]!.isFirstRowOfLine).toBe(true) - expect(rows[0]!.startCol).toBe(0) - }) - - it("should wrap at word boundary when possible", () => { - const rows = wrapLine("hello world foo", 0, 10) - expect(rows).toHaveLength(2) - expect(rows[0]!.text).toBe("hello ") - expect(rows[0]!.isFirstRowOfLine).toBe(true) - expect(rows[0]!.startCol).toBe(0) - expect(rows[1]!.text).toBe("world foo") - expect(rows[1]!.isFirstRowOfLine).toBe(false) - expect(rows[1]!.startCol).toBe(6) // "hello " is 6 chars - }) - - it("should break mid-word when no space found", () => { - const rows = wrapLine("superlongwordwithoutspaces", 0, 10) - expect(rows).toHaveLength(3) - // Falls back to breaking at availableWidth when no space is found - expect(rows[0]!.text).toBe("superlongw") - expect(rows[1]!.text).toBe("ordwithout") - expect(rows[2]!.text).toBe("spaces") - }) - - it("should handle multiple word wraps", () => { - const rows = wrapLine("one two three four five six", 0, 8) - expect(rows).toHaveLength(4) - expect(rows[0]!.text).toBe("one two ") - expect(rows[1]!.text).toBe("three ") - expect(rows[2]!.text).toBe("four ") - expect(rows[3]!.text).toBe("five six") - }) - - it("should preserve logical line index", () => { - const rows = wrapLine("hello world", 2, 6) - expect(rows.every((r) => r.logicalLineIndex === 2)).toBe(true) - }) - - it("should handle empty string", () => { - const rows = wrapLine("", 0, 10) - expect(rows).toHaveLength(1) - expect(rows[0]!.text).toBe("") - }) - - it("should handle string that exactly matches width", () => { - const rows = wrapLine("hello", 0, 5) - expect(rows).toHaveLength(1) - expect(rows[0]!.text).toBe("hello") - }) - - it("should track correct startCol for wrapped rows", () => { - const rows = wrapLine("aa bb cc dd", 0, 5) - // "aa bb cc dd" = 11 chars, width = 5 - // "aa bb cc dd": a(0) a(1) ' '(2) b(3) b(4) ' '(5) c(6) c(7) ' '(8) d(9) d(10) - // Search backwards from index 4: - // index 4='b', 3='b', 2=' ' -> space at 2, breakPoint=3 - // Row 0: "aa " (3 chars), startCol=0 - // Remaining: "bb cc dd" (8 chars), startCol=3 - // Search backwards from index 4: - // "bb cc dd": b(0) b(1) ' '(2) c(3) c(4)... - // index 4='c', 3='c', 2=' ' -> space at 2, breakPoint=3 - // Row 1: "bb " (3 chars), startCol=3 - // Remaining: "cc dd" (5 chars), startCol=6 - // 5 <= 5, fits in one row - // Row 2: "cc dd", startCol=6 - expect(rows).toHaveLength(3) - expect(rows[0]!.text).toBe("aa ") - expect(rows[0]!.startCol).toBe(0) - expect(rows[1]!.text).toBe("bb ") - expect(rows[1]!.startCol).toBe(3) - expect(rows[2]!.text).toBe("cc dd") - expect(rows[2]!.startCol).toBe(6) - }) -}) - -describe("multi-line history integration", () => { - it("should store multi-line entries with newlines", () => { - const entry = "foo\nbar\nbaz" - expect(entry.includes("\n")).toBe(true) - expect(entry.split("\n").length).toBe(3) - }) - - it("should restore multi-line entries correctly", () => { - const storedEntry = "foo\nbar\nbaz" - const lines = storedEntry.split("\n") - expect(lines).toEqual(["foo", "bar", "baz"]) - }) -}) - -describe("cursor overflow prevention", () => { - /** - * Tests the logic that prevents visual shift when cursor is at the end - * of a max-width row. Adding a cursor space character would overflow - * the terminal width, causing text to shift left. - */ - - it("should detect when cursor space would overflow", () => { - // Simulates the overflow detection logic from renderVisualRow - const checkWouldOverflow = ( - columns: number | undefined, - cursorAtEnd: boolean, - prefixLen: number, - textLen: number, - ): boolean => { - return columns !== undefined && cursorAtEnd && prefixLen + textLen + 1 > columns - } - - // Terminal width 80, prefix "> " (2 chars), text 78 chars = exactly full - // Adding cursor space would make it 81 chars -> overflow - expect(checkWouldOverflow(80, true, 2, 78)).toBe(true) - - // Same scenario but cursor not at end -> no overflow issue - expect(checkWouldOverflow(80, false, 2, 78)).toBe(false) - - // Text shorter than max width -> no overflow - expect(checkWouldOverflow(80, true, 2, 50)).toBe(false) - - // No columns specified -> no overflow detection - expect(checkWouldOverflow(undefined, true, 2, 78)).toBe(false) - - // Continuation line with shorter indent - expect(checkWouldOverflow(80, true, 2, 77)).toBe(false) - - // Exactly at boundary (prefixLen + textLen + 1 === columns) - expect(checkWouldOverflow(80, true, 2, 77)).toBe(false) - - // One character over would overflow - expect(checkWouldOverflow(80, true, 3, 77)).toBe(true) - }) - - it("should not overflow when cursor is in the middle of text", () => { - const checkWouldOverflow = ( - columns: number | undefined, - cursorAtEnd: boolean, - prefixLen: number, - textLen: number, - ): boolean => { - return columns !== undefined && cursorAtEnd && prefixLen + textLen + 1 > columns - } - - // Cursor in middle of max-width row - no extra space added, no overflow - expect(checkWouldOverflow(80, false, 2, 78)).toBe(false) - expect(checkWouldOverflow(80, false, 2, 100)).toBe(false) - }) - - it("should correctly identify cursor at end of row", () => { - // Simulates the cursorAtEnd check - const isCursorAtEnd = (cursorColInRow: number, textLen: number): boolean => { - return cursorColInRow >= textLen - } - - expect(isCursorAtEnd(5, 5)).toBe(true) // cursor at position 5, text length 5 - expect(isCursorAtEnd(10, 5)).toBe(true) // cursor beyond text (clamped case) - expect(isCursorAtEnd(4, 5)).toBe(false) // cursor before end - expect(isCursorAtEnd(0, 0)).toBe(true) // empty text, cursor at start/end - }) -}) diff --git a/apps/cli/src/__tests__/ScrollArea.test.ts b/apps/cli/src/__tests__/ScrollArea.test.ts deleted file mode 100644 index 61a820841c..0000000000 --- a/apps/cli/src/__tests__/ScrollArea.test.ts +++ /dev/null @@ -1,485 +0,0 @@ -/** - * Unit tests for ScrollArea component reducer logic - */ - -// Since we can't easily test React components without a proper Ink test setup, -// we'll test the reducer logic that powers the ScrollArea behavior. - -interface ScrollAreaState { - innerHeight: number - height: number - scrollTop: number - autoScroll: boolean -} - -/** - * Calculate scrollbar handle position and size - */ -function calculateScrollbar( - viewportHeight: number, - contentHeight: number, - scrollTop: number, -): { handleStart: number; handleHeight: number; maxScroll: number } { - const maxScroll = Math.max(0, contentHeight - viewportHeight) - - if (contentHeight <= viewportHeight || maxScroll === 0) { - // No scrolling needed - handle fills entire track - return { handleStart: 0, handleHeight: viewportHeight, maxScroll: 0 } - } - - // Calculate handle height as ratio of viewport to content (minimum 1 line) - const handleHeight = Math.max(1, Math.round((viewportHeight / contentHeight) * viewportHeight)) - - // Calculate handle position - const trackSpace = viewportHeight - handleHeight - const scrollRatio = maxScroll > 0 ? scrollTop / maxScroll : 0 - const handleStart = Math.round(scrollRatio * trackSpace) - - return { handleStart, handleHeight, maxScroll } -} - -type ScrollAreaAction = - | { type: "SET_INNER_HEIGHT"; innerHeight: number } - | { type: "SET_HEIGHT"; height: number } - | { type: "SCROLL_DOWN"; amount?: number } - | { type: "SCROLL_UP"; amount?: number } - | { type: "SCROLL_TO_BOTTOM" } - | { type: "SET_AUTO_SCROLL"; autoScroll: boolean } - -// Copy of the reducer from ScrollArea.tsx for testing -function reducer(state: ScrollAreaState, action: ScrollAreaAction): ScrollAreaState { - const maxScroll = Math.max(0, state.innerHeight - state.height) - - switch (action.type) { - case "SET_INNER_HEIGHT": { - const newMaxScroll = Math.max(0, action.innerHeight - state.height) - if (state.autoScroll && action.innerHeight > state.innerHeight) { - return { - ...state, - innerHeight: action.innerHeight, - scrollTop: newMaxScroll, - } - } - return { - ...state, - innerHeight: action.innerHeight, - scrollTop: Math.min(state.scrollTop, newMaxScroll), - } - } - - case "SET_HEIGHT": { - const newMaxScroll = Math.max(0, state.innerHeight - action.height) - if (state.autoScroll) { - return { - ...state, - height: action.height, - scrollTop: newMaxScroll, - } - } - return { - ...state, - height: action.height, - scrollTop: Math.min(state.scrollTop, newMaxScroll), - } - } - - case "SCROLL_DOWN": { - const amount = action.amount || 1 - const newScrollTop = Math.min(maxScroll, state.scrollTop + amount) - const atBottom = newScrollTop >= maxScroll - return { - ...state, - scrollTop: newScrollTop, - autoScroll: atBottom, - } - } - - case "SCROLL_UP": { - const amount = action.amount || 1 - const newScrollTop = Math.max(0, state.scrollTop - amount) - return { - ...state, - scrollTop: newScrollTop, - autoScroll: newScrollTop >= maxScroll, - } - } - - case "SCROLL_TO_BOTTOM": - return { - ...state, - scrollTop: maxScroll, - autoScroll: true, - } - - case "SET_AUTO_SCROLL": - return { - ...state, - autoScroll: action.autoScroll, - scrollTop: action.autoScroll ? maxScroll : state.scrollTop, - } - - default: - return state - } -} - -describe("ScrollArea reducer", () => { - const initialState: ScrollAreaState = { - innerHeight: 0, - height: 10, - scrollTop: 0, - autoScroll: true, - } - - describe("SET_INNER_HEIGHT", () => { - it("should update inner height", () => { - const state = reducer(initialState, { type: "SET_INNER_HEIGHT", innerHeight: 20 }) - expect(state.innerHeight).toBe(20) - }) - - it("should auto-scroll to bottom when content grows and autoScroll is enabled", () => { - const state: ScrollAreaState = { - ...initialState, - innerHeight: 15, - autoScroll: true, - } - const newState = reducer(state, { type: "SET_INNER_HEIGHT", innerHeight: 25 }) - expect(newState.innerHeight).toBe(25) - // maxScroll = 25 - 10 = 15 - expect(newState.scrollTop).toBe(15) - }) - - it("should NOT auto-scroll when autoScroll is disabled", () => { - const state: ScrollAreaState = { - ...initialState, - innerHeight: 15, - scrollTop: 3, - autoScroll: false, - } - const newState = reducer(state, { type: "SET_INNER_HEIGHT", innerHeight: 25 }) - expect(newState.innerHeight).toBe(25) - expect(newState.scrollTop).toBe(3) // Unchanged - }) - - it("should NOT auto-scroll when content grows if autoScroll is disabled (picker use case)", () => { - const state: ScrollAreaState = { - ...initialState, - innerHeight: 5, - scrollTop: 0, - autoScroll: false, - } - const newState = reducer(state, { type: "SET_INNER_HEIGHT", innerHeight: 20 }) - expect(newState.innerHeight).toBe(20) - // scrollTop should remain at 0, not jump to bottom - expect(newState.scrollTop).toBe(0) - }) - - it("should clamp scrollTop when content shrinks", () => { - const state: ScrollAreaState = { - ...initialState, - innerHeight: 30, - scrollTop: 15, - autoScroll: false, - } - const newState = reducer(state, { type: "SET_INNER_HEIGHT", innerHeight: 15 }) - // maxScroll = 15 - 10 = 5, scrollTop was 15 which is > 5 - expect(newState.scrollTop).toBe(5) - }) - }) - - describe("SET_HEIGHT", () => { - it("should update viewport height", () => { - const state: ScrollAreaState = { - ...initialState, - innerHeight: 20, - } - const newState = reducer(state, { type: "SET_HEIGHT", height: 15 }) - expect(newState.height).toBe(15) - }) - - it("should scroll to bottom when autoScroll is enabled and viewport changes", () => { - const state: ScrollAreaState = { - innerHeight: 30, - height: 10, - scrollTop: 20, // at bottom - autoScroll: true, - } - const newState = reducer(state, { type: "SET_HEIGHT", height: 15 }) - // maxScroll = 30 - 15 = 15 - expect(newState.scrollTop).toBe(15) - }) - - it("should clamp scrollTop when viewport grows", () => { - const state: ScrollAreaState = { - innerHeight: 20, - height: 10, - scrollTop: 10, // maxScroll was 10 - autoScroll: false, - } - const newState = reducer(state, { type: "SET_HEIGHT", height: 15 }) - // maxScroll = 20 - 15 = 5 - expect(newState.scrollTop).toBe(5) - }) - }) - - describe("SCROLL_DOWN", () => { - it("should scroll down by 1 by default", () => { - const state: ScrollAreaState = { - innerHeight: 30, - height: 10, - scrollTop: 5, - autoScroll: false, - } - const newState = reducer(state, { type: "SCROLL_DOWN" }) - expect(newState.scrollTop).toBe(6) - }) - - it("should scroll down by specified amount", () => { - const state: ScrollAreaState = { - innerHeight: 30, - height: 10, - scrollTop: 5, - autoScroll: false, - } - const newState = reducer(state, { type: "SCROLL_DOWN", amount: 5 }) - expect(newState.scrollTop).toBe(10) - }) - - it("should not scroll past maxScroll", () => { - const state: ScrollAreaState = { - innerHeight: 30, - height: 10, - scrollTop: 18, - autoScroll: false, - } - // maxScroll = 30 - 10 = 20 - const newState = reducer(state, { type: "SCROLL_DOWN", amount: 10 }) - expect(newState.scrollTop).toBe(20) - }) - - it("should re-enable autoScroll when reaching bottom", () => { - const state: ScrollAreaState = { - innerHeight: 30, - height: 10, - scrollTop: 19, - autoScroll: false, - } - const newState = reducer(state, { type: "SCROLL_DOWN" }) - expect(newState.scrollTop).toBe(20) - expect(newState.autoScroll).toBe(true) - }) - }) - - describe("SCROLL_UP", () => { - it("should scroll up by 1 by default", () => { - const state: ScrollAreaState = { - innerHeight: 30, - height: 10, - scrollTop: 10, - autoScroll: false, - } - const newState = reducer(state, { type: "SCROLL_UP" }) - expect(newState.scrollTop).toBe(9) - }) - - it("should scroll up by specified amount", () => { - const state: ScrollAreaState = { - innerHeight: 30, - height: 10, - scrollTop: 10, - autoScroll: false, - } - const newState = reducer(state, { type: "SCROLL_UP", amount: 5 }) - expect(newState.scrollTop).toBe(5) - }) - - it("should not scroll past 0", () => { - const state: ScrollAreaState = { - innerHeight: 30, - height: 10, - scrollTop: 3, - autoScroll: false, - } - const newState = reducer(state, { type: "SCROLL_UP", amount: 10 }) - expect(newState.scrollTop).toBe(0) - }) - - it("should disable autoScroll when scrolling up from bottom", () => { - const state: ScrollAreaState = { - innerHeight: 30, - height: 10, - scrollTop: 20, // at bottom - autoScroll: true, - } - const newState = reducer(state, { type: "SCROLL_UP" }) - expect(newState.scrollTop).toBe(19) - expect(newState.autoScroll).toBe(false) - }) - }) - - describe("SCROLL_TO_BOTTOM", () => { - it("should scroll to bottom and enable autoScroll", () => { - const state: ScrollAreaState = { - innerHeight: 30, - height: 10, - scrollTop: 5, - autoScroll: false, - } - const newState = reducer(state, { type: "SCROLL_TO_BOTTOM" }) - expect(newState.scrollTop).toBe(20) // maxScroll - expect(newState.autoScroll).toBe(true) - }) - }) - - describe("SET_AUTO_SCROLL", () => { - it("should enable autoScroll and scroll to bottom", () => { - const state: ScrollAreaState = { - innerHeight: 30, - height: 10, - scrollTop: 5, - autoScroll: false, - } - const newState = reducer(state, { type: "SET_AUTO_SCROLL", autoScroll: true }) - expect(newState.autoScroll).toBe(true) - expect(newState.scrollTop).toBe(20) // scrolled to bottom - }) - - it("should disable autoScroll without changing scrollTop", () => { - const state: ScrollAreaState = { - innerHeight: 30, - height: 10, - scrollTop: 20, - autoScroll: true, - } - const newState = reducer(state, { type: "SET_AUTO_SCROLL", autoScroll: false }) - expect(newState.autoScroll).toBe(false) - expect(newState.scrollTop).toBe(20) - }) - }) - - describe("edge cases", () => { - it("should handle content smaller than viewport", () => { - const state: ScrollAreaState = { - innerHeight: 5, // smaller than viewport - height: 10, - scrollTop: 0, - autoScroll: true, - } - const downState = reducer(state, { type: "SCROLL_DOWN" }) - expect(downState.scrollTop).toBe(0) // maxScroll is 0 - - const bottomState = reducer(state, { type: "SCROLL_TO_BOTTOM" }) - expect(bottomState.scrollTop).toBe(0) - }) - - it("should handle empty content", () => { - const state: ScrollAreaState = { - innerHeight: 0, - height: 10, - scrollTop: 0, - autoScroll: true, - } - const newState = reducer(state, { type: "SCROLL_DOWN" }) - expect(newState.scrollTop).toBe(0) - }) - }) -}) - -describe("calculateScrollbar", () => { - it("should return full height handle when content fits in viewport", () => { - const result = calculateScrollbar(10, 5, 0) - expect(result.handleHeight).toBe(10) - expect(result.handleStart).toBe(0) - expect(result.maxScroll).toBe(0) - }) - - it("should return full height handle when content equals viewport", () => { - const result = calculateScrollbar(10, 10, 0) - expect(result.handleHeight).toBe(10) - expect(result.handleStart).toBe(0) - expect(result.maxScroll).toBe(0) - }) - - it("should calculate handle height proportional to content ratio", () => { - // Viewport is half of content, handle should be ~half of viewport - const result = calculateScrollbar(10, 20, 0) - expect(result.handleHeight).toBe(5) // 10 / 20 * 10 = 5 - expect(result.maxScroll).toBe(10) - }) - - it("should position handle at top when scrollTop is 0", () => { - const result = calculateScrollbar(10, 20, 0) - expect(result.handleStart).toBe(0) - }) - - it("should position handle at bottom when scrolled to max", () => { - // Viewport 10, content 20, maxScroll = 10 - // Handle height = 5, track space = 10 - 5 = 5 - // At max scroll, handle should be at position 5 - const result = calculateScrollbar(10, 20, 10) - expect(result.handleStart).toBe(5) - }) - - it("should position handle in middle when scrolled halfway", () => { - // Viewport 10, content 20, maxScroll = 10 - // Handle height = 5, track space = 5 - // At scroll 5 (50%), handle should be at position 2-3 - const result = calculateScrollbar(10, 20, 5) - expect(result.handleStart).toBe(3) // Math.round(0.5 * 5) = 3 - }) - - it("should enforce minimum handle height of 1", () => { - // Very large content relative to viewport - const result = calculateScrollbar(10, 1000, 0) - expect(result.handleHeight).toBe(1) // Math.max(1, Math.round(10/1000 * 10)) = 1 - }) - - it("should handle small viewports", () => { - const result = calculateScrollbar(3, 10, 0) - expect(result.handleHeight).toBe(1) // Math.round(3/10 * 3) = 1 - expect(result.maxScroll).toBe(7) - }) - - it("should handle edge case where scrollTop exceeds maxScroll", () => { - // This shouldn't happen in practice, but test for robustness - const result = calculateScrollbar(10, 20, 15) // maxScroll is 10 - // scrollRatio = 15/10 = 1.5, but handleStart should be clamped by trackSpace - expect(result.handleStart).toBe(8) // Math.round(1.5 * 5) = 8 (will be past track but shows calculation) - }) -}) - -/** - * Helper function that mirrors the scrollbar visibility logic from ScrollArea.tsx - * This is used to test the visibility behavior without needing to render the component. - */ -function shouldShowScrollbar(showScrollbar: boolean, maxScroll: number, isActive: boolean): boolean { - // Show scrollbar when: there's content to scroll, OR when focused (to indicate focus state) - // Hide scrollbar only when: not focused AND nothing to scroll - return showScrollbar && (maxScroll > 0 || isActive) -} - -describe("scrollbar visibility", () => { - it("should show scrollbar when there is content to scroll (regardless of focus)", () => { - // When maxScroll > 0, scrollbar should show regardless of isActive - expect(shouldShowScrollbar(true, 10, true)).toBe(true) - expect(shouldShowScrollbar(true, 10, false)).toBe(true) - }) - - it("should show scrollbar when focused, even if nothing to scroll", () => { - // When isActive is true but maxScroll is 0, scrollbar should show for focus indication - expect(shouldShowScrollbar(true, 0, true)).toBe(true) - }) - - it("should hide scrollbar when not focused and nothing to scroll", () => { - // Only hide when both: not focused AND nothing to scroll - expect(shouldShowScrollbar(true, 0, false)).toBe(false) - }) - - it("should respect showScrollbar prop", () => { - // When showScrollbar is false, never show scrollbar - expect(shouldShowScrollbar(false, 10, true)).toBe(false) - expect(shouldShowScrollbar(false, 0, true)).toBe(false) - expect(shouldShowScrollbar(false, 10, false)).toBe(false) - expect(shouldShowScrollbar(false, 0, false)).toBe(false) - }) -}) diff --git a/apps/cli/src/__tests__/integration.test.ts b/apps/cli/src/__tests__/extension-host.integration.test.ts similarity index 100% rename from apps/cli/src/__tests__/integration.test.ts rename to apps/cli/src/__tests__/extension-host.integration.test.ts diff --git a/apps/cli/src/__tests__/useInputHistory.test.ts b/apps/cli/src/__tests__/useInputHistory.test.ts deleted file mode 100644 index 5d8d07d5ee..0000000000 --- a/apps/cli/src/__tests__/useInputHistory.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import * as historyStorage from "../utils/historyStorage.js" - -vi.mock("../utils/historyStorage.js") - -// Track state and callbacks for testing. -let mockState: Record = {} -let effectCallbacks: Array<() => void | (() => void)> = [] - -vi.mock("react", () => ({ - useState: vi.fn((initial: unknown) => { - const key = `state_${Object.keys(mockState).length}` - if (!(key in mockState)) { - mockState[key] = initial - } - return [ - mockState[key], - (newValue: unknown) => { - if (typeof newValue === "function") { - mockState[key] = (newValue as (prev: unknown) => unknown)(mockState[key]) - } else { - mockState[key] = newValue - } - }, - ] - }), - useEffect: vi.fn((callback: () => void | (() => void)) => { - effectCallbacks.push(callback) - }), - useCallback: vi.fn((callback: unknown) => callback), - useRef: vi.fn((initial: unknown) => ({ current: initial })), -})) - -describe("useInputHistory", () => { - beforeEach(() => { - vi.resetAllMocks() - mockState = {} - effectCallbacks = [] - - // Default mock for loadHistory - vi.mocked(historyStorage.loadHistory).mockResolvedValue([]) - vi.mocked(historyStorage.addToHistory).mockImplementation(async (entry) => [entry]) - }) - - describe("historyStorage functions", () => { - it("loadHistory should be called when hook effect runs", async () => { - vi.mocked(historyStorage.loadHistory).mockResolvedValue(["entry1", "entry2"]) - - // Import the hook (this triggers the module initialization) - const { useInputHistory } = await import("../ui/hooks/useInputHistory.js") - useInputHistory() - - // Run the effect callbacks - for (const cb of effectCallbacks) { - cb() - } - - expect(historyStorage.loadHistory).toHaveBeenCalled() - }) - - it("addToHistory should be called with trimmed entry", async () => { - vi.mocked(historyStorage.addToHistory).mockResolvedValue(["new entry"]) - - const { useInputHistory } = await import("../ui/hooks/useInputHistory.js") - const result = useInputHistory() - - await result.addEntry(" new entry ") - - expect(historyStorage.addToHistory).toHaveBeenCalledWith("new entry") - }) - - it("addToHistory should not be called for empty entries", async () => { - const { useInputHistory } = await import("../ui/hooks/useInputHistory.js") - const result = useInputHistory() - - await result.addEntry("") - - expect(historyStorage.addToHistory).not.toHaveBeenCalled() - }) - - it("addToHistory should not be called for whitespace-only entries", async () => { - const { useInputHistory } = await import("../ui/hooks/useInputHistory.js") - const result = useInputHistory() - - await result.addEntry(" ") - - expect(historyStorage.addToHistory).not.toHaveBeenCalled() - }) - }) - - describe("navigation logic", () => { - it("should have initial state with no history value", async () => { - const { useInputHistory } = await import("../ui/hooks/useInputHistory.js") - const result = useInputHistory() - - // Initial state should have null history value (not browsing) - expect(result.historyValue).toBeNull() - expect(result.isBrowsing).toBe(false) - }) - - it("should export navigateUp and navigateDown functions for manual navigation", async () => { - const { useInputHistory } = await import("../ui/hooks/useInputHistory.js") - const result = useInputHistory() - - expect(typeof result.navigateUp).toBe("function") - expect(typeof result.navigateDown).toBe("function") - }) - }) - - describe("resetBrowsing", () => { - it("should be a function", async () => { - const { useInputHistory } = await import("../ui/hooks/useInputHistory.js") - const result = useInputHistory() - - expect(typeof result.resetBrowsing).toBe("function") - }) - }) - - describe("return value structure", () => { - it("should return the expected interface", async () => { - const { useInputHistory } = await import("../ui/hooks/useInputHistory.js") - const result = useInputHistory() - - expect(result).toHaveProperty("addEntry") - expect(result).toHaveProperty("historyValue") - expect(result).toHaveProperty("isBrowsing") - expect(result).toHaveProperty("resetBrowsing") - expect(result).toHaveProperty("history") - expect(result).toHaveProperty("draft") - expect(result).toHaveProperty("navigateUp") - expect(result).toHaveProperty("navigateDown") - - expect(typeof result.addEntry).toBe("function") - expect(typeof result.resetBrowsing).toBe("function") - expect(typeof result.navigateUp).toBe("function") - expect(typeof result.navigateDown).toBe("function") - expect(Array.isArray(result.history)).toBe(true) - }) - }) -}) - -describe("historyStorage integration", () => { - // Test the actual historyStorage functions directly - // These are more reliable than hook tests with mocked React - - beforeEach(() => { - vi.resetAllMocks() - }) - - it("MAX_HISTORY_ENTRIES should be 500", async () => { - const { MAX_HISTORY_ENTRIES } = await import("../utils/historyStorage.js") - expect(MAX_HISTORY_ENTRIES).toBe(500) - }) - - it("getHistoryFilePath should return path in ~/.roo directory", async () => { - // Un-mock for this test - vi.doUnmock("../utils/historyStorage.js") - const { getHistoryFilePath } = await import("../utils/historyStorage.js") - - const path = getHistoryFilePath() - expect(path).toContain(".roo") - expect(path).toContain("cli-history.json") - }) -}) diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 7c9a0e7e7c..c610b16a94 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -18,7 +18,7 @@ import { import { setLogger } from "@roo-code/vscode-shim" import { ExtensionHost } from "./extension-host.js" -import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "./utils.js" +import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "./utils/extensionHostUtils.js" const DEFAULTS = { mode: "code", diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx index 9a9d1f2c31..0e0109fe88 100644 --- a/apps/cli/src/ui/App.tsx +++ b/apps/cli/src/ui/App.tsx @@ -8,15 +8,15 @@ import type { ExtensionMessage, ClineMessage, ClineAsk, ClineSay, TodoItem, Webv import { consolidateTokenUsage, consolidateApiRequests, consolidateCommands } from "@roo-code/core/message-utils" import { FOLLOWUP_TIMEOUT_SECONDS } from "../constants.js" -import { getGlobalCommand, getGlobalCommandsForAutocomplete } from "../globalCommands.js" +import { getGlobalCommand, getGlobalCommandsForAutocomplete } from "../utils/globalCommands.js" import { toolInspectorLog, clearToolInspectorLog } from "../utils/toolInspectorLogger.js" import { arePathsEqual } from "../utils/pathUtils.js" import { getContextWindow } from "../utils/getContextWindow.js" import type { AppProps, TUIMessage, PendingAsk, View, ToolData } from "./types.js" -import * as theme from "./utils/theme.js" -import { matchesGlobalSequence } from "./utils/globalInputSequences.js" +import * as theme from "./theme.js" +import { matchesGlobalSequence } from "../utils/globalInputSequences.js" import { useCLIStore } from "./store.js" diff --git a/apps/cli/src/__tests__/store.test.ts b/apps/cli/src/ui/__tests__/store.test.ts similarity index 99% rename from apps/cli/src/__tests__/store.test.ts rename to apps/cli/src/ui/__tests__/store.test.ts index bbb10a7c16..c111721b00 100644 --- a/apps/cli/src/__tests__/store.test.ts +++ b/apps/cli/src/ui/__tests__/store.test.ts @@ -1,4 +1,4 @@ -import { useCLIStore } from "../ui/store.js" +import { useCLIStore } from "../store.js" describe("useCLIStore", () => { beforeEach(() => { diff --git a/apps/cli/src/ui/components/ChatHistoryItem.tsx b/apps/cli/src/ui/components/ChatHistoryItem.tsx index 364304ebc6..8b52a2b160 100644 --- a/apps/cli/src/ui/components/ChatHistoryItem.tsx +++ b/apps/cli/src/ui/components/ChatHistoryItem.tsx @@ -1,7 +1,7 @@ import { memo } from "react" import { Box, Newline, Text } from "ink" -import * as theme from "../utils/theme.js" +import * as theme from "../theme.js" import type { TUIMessage } from "../types.js" import TodoDisplay from "./TodoDisplay.js" import { getToolRenderer } from "./tools/index.js" diff --git a/apps/cli/src/ui/components/Header.tsx b/apps/cli/src/ui/components/Header.tsx index 15e24e0a91..d04eaae619 100644 --- a/apps/cli/src/ui/components/Header.tsx +++ b/apps/cli/src/ui/components/Header.tsx @@ -4,7 +4,7 @@ import { Text, Box } from "ink" import type { TokenUsage } from "@roo-code/types" import { useTerminalSize } from "../hooks/TerminalSizeContext.js" -import * as theme from "../utils/theme.js" +import * as theme from "../theme.js" import MetricsDisplay from "./MetricsDisplay.js" interface HeaderProps { diff --git a/apps/cli/src/ui/components/MetricsDisplay.tsx b/apps/cli/src/ui/components/MetricsDisplay.tsx index d999b21efc..9508f07ce2 100644 --- a/apps/cli/src/ui/components/MetricsDisplay.tsx +++ b/apps/cli/src/ui/components/MetricsDisplay.tsx @@ -3,7 +3,7 @@ import { Text, Box } from "ink" import type { TokenUsage } from "@roo-code/types" -import * as theme from "../utils/theme.js" +import * as theme from "../theme.js" import ProgressBar from "./ProgressBar.js" interface MetricsDisplayProps { diff --git a/apps/cli/src/ui/components/MultilineTextInput.tsx b/apps/cli/src/ui/components/MultilineTextInput.tsx index 5e4e020445..5d727b8963 100644 --- a/apps/cli/src/ui/components/MultilineTextInput.tsx +++ b/apps/cli/src/ui/components/MultilineTextInput.tsx @@ -16,7 +16,7 @@ import { useState, useEffect, useMemo, useCallback, useRef } from "react" import { Box, Text, useInput, type Key } from "ink" -import { isGlobalInputSequence } from "../utils/globalInputSequences.js" +import { isGlobalInputSequence } from "../../utils/globalInputSequences.js" export interface MultilineTextInputProps { /** diff --git a/apps/cli/src/ui/components/ProgressBar.tsx b/apps/cli/src/ui/components/ProgressBar.tsx index 6c0e150dbe..fc02ff98a2 100644 --- a/apps/cli/src/ui/components/ProgressBar.tsx +++ b/apps/cli/src/ui/components/ProgressBar.tsx @@ -1,7 +1,7 @@ import { memo } from "react" import { Text } from "ink" -import * as theme from "../utils/theme.js" +import * as theme from "../theme.js" interface ProgressBarProps { /** Current value (e.g., contextTokens) */ diff --git a/apps/cli/src/ui/components/ScrollArea.tsx b/apps/cli/src/ui/components/ScrollArea.tsx index 3a22656940..a356125fb2 100644 --- a/apps/cli/src/ui/components/ScrollArea.tsx +++ b/apps/cli/src/ui/components/ScrollArea.tsx @@ -1,7 +1,7 @@ import { Box, DOMElement, measureElement, Text, useInput } from "ink" import { useEffect, useReducer, useRef, useCallback, useMemo, useState } from "react" -import * as theme from "../utils/theme.js" +import * as theme from "../theme.js" interface ScrollAreaState { innerHeight: number diff --git a/apps/cli/src/ui/components/ScrollIndicator.tsx b/apps/cli/src/ui/components/ScrollIndicator.tsx index 2351e81000..d86d969599 100644 --- a/apps/cli/src/ui/components/ScrollIndicator.tsx +++ b/apps/cli/src/ui/components/ScrollIndicator.tsx @@ -1,7 +1,7 @@ import { Box, Text } from "ink" import { memo } from "react" -import * as theme from "../utils/theme.js" +import * as theme from "../theme.js" interface ScrollIndicatorProps { scrollTop: number diff --git a/apps/cli/src/ui/components/ToastDisplay.tsx b/apps/cli/src/ui/components/ToastDisplay.tsx index 46eae788aa..58c99e2ed9 100644 --- a/apps/cli/src/ui/components/ToastDisplay.tsx +++ b/apps/cli/src/ui/components/ToastDisplay.tsx @@ -2,7 +2,7 @@ import { memo } from "react" import { Text, Box } from "ink" import type { Toast, ToastType } from "../hooks/useToast.js" -import * as theme from "../utils/theme.js" +import * as theme from "../theme.js" interface ToastDisplayProps { /** The current toast to display (null if no toast) */ diff --git a/apps/cli/src/ui/components/TodoChangeDisplay.tsx b/apps/cli/src/ui/components/TodoChangeDisplay.tsx index 39d9d03f4b..f41049122a 100644 --- a/apps/cli/src/ui/components/TodoChangeDisplay.tsx +++ b/apps/cli/src/ui/components/TodoChangeDisplay.tsx @@ -3,7 +3,7 @@ import { Box, Text } from "ink" import type { TodoItem } from "@roo-code/types" -import * as theme from "../utils/theme.js" +import * as theme from "../theme.js" /** * Status icons for TODO items using Unicode characters diff --git a/apps/cli/src/ui/components/TodoDisplay.tsx b/apps/cli/src/ui/components/TodoDisplay.tsx index 9eba600afb..5eb962cac3 100644 --- a/apps/cli/src/ui/components/TodoDisplay.tsx +++ b/apps/cli/src/ui/components/TodoDisplay.tsx @@ -3,7 +3,7 @@ import { Box, Text } from "ink" import type { TodoItem } from "@roo-code/types" -import * as theme from "../utils/theme.js" +import * as theme from "../theme.js" import ProgressBar from "./ProgressBar.js" import { Icon, type IconName } from "./Icon.js" diff --git a/apps/cli/src/ui/components/autocomplete/triggers/SlashCommandTrigger.tsx b/apps/cli/src/ui/components/autocomplete/triggers/SlashCommandTrigger.tsx index 168965ddbc..c699f442ff 100644 --- a/apps/cli/src/ui/components/autocomplete/triggers/SlashCommandTrigger.tsx +++ b/apps/cli/src/ui/components/autocomplete/triggers/SlashCommandTrigger.tsx @@ -2,7 +2,7 @@ import { Box, Text } from "ink" import fuzzysort from "fuzzysort" import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js" -import { GlobalCommandAction } from "../../../../globalCommands.js" +import { GlobalCommandAction } from "../../../../utils/globalCommands.js" export interface SlashCommandResult extends AutocompleteItem { name: string diff --git a/apps/cli/src/ui/components/tools/BrowserTool.tsx b/apps/cli/src/ui/components/tools/BrowserTool.tsx index 311d8f6fe2..ebbd3fb815 100644 --- a/apps/cli/src/ui/components/tools/BrowserTool.tsx +++ b/apps/cli/src/ui/components/tools/BrowserTool.tsx @@ -5,7 +5,7 @@ import { Box, Text } from "ink" -import * as theme from "../../utils/theme.js" +import * as theme from "../../theme.js" import { Icon } from "../Icon.js" import type { ToolRendererProps } from "./types.js" import { getToolDisplayName, getToolIconName } from "./utils.js" diff --git a/apps/cli/src/ui/components/tools/CommandTool.tsx b/apps/cli/src/ui/components/tools/CommandTool.tsx index 52c67a93e4..c79a6b3d26 100644 --- a/apps/cli/src/ui/components/tools/CommandTool.tsx +++ b/apps/cli/src/ui/components/tools/CommandTool.tsx @@ -1,6 +1,6 @@ import { Box, Text } from "ink" -import * as theme from "../../utils/theme.js" +import * as theme from "../../theme.js" import { Icon } from "../Icon.js" import type { ToolRendererProps } from "./types.js" import { truncateText, sanitizeContent, getToolIconName } from "./utils.js" diff --git a/apps/cli/src/ui/components/tools/CompletionTool.tsx b/apps/cli/src/ui/components/tools/CompletionTool.tsx index b1d0728097..e116c76903 100644 --- a/apps/cli/src/ui/components/tools/CompletionTool.tsx +++ b/apps/cli/src/ui/components/tools/CompletionTool.tsx @@ -1,6 +1,6 @@ import { Box, Text } from "ink" -import * as theme from "../../utils/theme.js" +import * as theme from "../../theme.js" import type { ToolRendererProps } from "./types.js" import { truncateText, sanitizeContent } from "./utils.js" diff --git a/apps/cli/src/ui/components/tools/FileReadTool.tsx b/apps/cli/src/ui/components/tools/FileReadTool.tsx index 4cfad5722a..332b5335be 100644 --- a/apps/cli/src/ui/components/tools/FileReadTool.tsx +++ b/apps/cli/src/ui/components/tools/FileReadTool.tsx @@ -5,7 +5,7 @@ import { Box, Text } from "ink" -import * as theme from "../../utils/theme.js" +import * as theme from "../../theme.js" import { Icon } from "../Icon.js" import type { ToolRendererProps } from "./types.js" import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName } from "./utils.js" diff --git a/apps/cli/src/ui/components/tools/FileWriteTool.tsx b/apps/cli/src/ui/components/tools/FileWriteTool.tsx index 531f5feb44..2264b65b83 100644 --- a/apps/cli/src/ui/components/tools/FileWriteTool.tsx +++ b/apps/cli/src/ui/components/tools/FileWriteTool.tsx @@ -5,7 +5,7 @@ import { Box, Text } from "ink" -import * as theme from "../../utils/theme.js" +import * as theme from "../../theme.js" import { Icon } from "../Icon.js" import type { ToolRendererProps } from "./types.js" import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName, parseDiff } from "./utils.js" diff --git a/apps/cli/src/ui/components/tools/GenericTool.tsx b/apps/cli/src/ui/components/tools/GenericTool.tsx index 0058c2e072..78eeaaf791 100644 --- a/apps/cli/src/ui/components/tools/GenericTool.tsx +++ b/apps/cli/src/ui/components/tools/GenericTool.tsx @@ -5,7 +5,7 @@ import { Box, Text } from "ink" -import * as theme from "../../utils/theme.js" +import * as theme from "../../theme.js" import { Icon } from "../Icon.js" import type { ToolRendererProps } from "./types.js" import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName } from "./utils.js" diff --git a/apps/cli/src/ui/components/tools/ModeTool.tsx b/apps/cli/src/ui/components/tools/ModeTool.tsx index 29a95f12be..40fe31a6f8 100644 --- a/apps/cli/src/ui/components/tools/ModeTool.tsx +++ b/apps/cli/src/ui/components/tools/ModeTool.tsx @@ -5,7 +5,7 @@ import { Box, Text } from "ink" -import * as theme from "../../utils/theme.js" +import * as theme from "../../theme.js" import { Icon } from "../Icon.js" import type { ToolRendererProps } from "./types.js" import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName } from "./utils.js" diff --git a/apps/cli/src/ui/components/tools/SearchTool.tsx b/apps/cli/src/ui/components/tools/SearchTool.tsx index 94ea01df61..6761cca217 100644 --- a/apps/cli/src/ui/components/tools/SearchTool.tsx +++ b/apps/cli/src/ui/components/tools/SearchTool.tsx @@ -5,7 +5,7 @@ import { Box, Text } from "ink" -import * as theme from "../../utils/theme.js" +import * as theme from "../../theme.js" import { Icon } from "../Icon.js" import type { ToolRendererProps } from "./types.js" import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName } from "./utils.js" diff --git a/apps/cli/src/__tests__/useToast.test.ts b/apps/cli/src/ui/hooks/__tests__/useToast.test.ts similarity index 98% rename from apps/cli/src/__tests__/useToast.test.ts rename to apps/cli/src/ui/hooks/__tests__/useToast.test.ts index 4083ee8618..67729d09f8 100644 --- a/apps/cli/src/__tests__/useToast.test.ts +++ b/apps/cli/src/ui/hooks/__tests__/useToast.test.ts @@ -1,4 +1,4 @@ -import { useToastStore } from "../ui/hooks/useToast.js" +import { useToastStore } from "../useToast.js" describe("useToastStore", () => { beforeEach(() => { diff --git a/apps/cli/src/ui/index.ts b/apps/cli/src/ui/index.ts index b4f2849c33..371665cf19 100644 --- a/apps/cli/src/ui/index.ts +++ b/apps/cli/src/ui/index.ts @@ -17,7 +17,7 @@ export type { UseInputHistoryOptions, UseInputHistoryReturn } from "./hooks/useI export { useCLIStore } from "./store.js" // Theme -export * as theme from "./utils/theme.js" +export * as theme from "./theme.js" // Types export * from "./types.js" diff --git a/apps/cli/src/ui/utils/theme.ts b/apps/cli/src/ui/theme.ts similarity index 100% rename from apps/cli/src/ui/utils/theme.ts rename to apps/cli/src/ui/theme.ts diff --git a/apps/cli/src/__tests__/utils.test.ts b/apps/cli/src/utils/__tests__/extensionHostUtils.test.ts similarity index 97% rename from apps/cli/src/__tests__/utils.test.ts rename to apps/cli/src/utils/__tests__/extensionHostUtils.test.ts index e3c57d7b60..ed3e96a416 100644 --- a/apps/cli/src/__tests__/utils.test.ts +++ b/apps/cli/src/utils/__tests__/extensionHostUtils.test.ts @@ -1,12 +1,8 @@ -/** - * Unit tests for CLI utility functions - */ - -import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "../utils.js" import fs from "fs" import path from "path" -// Mock fs module +import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "../extensionHostUtils.js" + vi.mock("fs") describe("getEnvVarName", () => { diff --git a/apps/cli/src/__tests__/globalCommands.test.ts b/apps/cli/src/utils/__tests__/globalCommands.test.ts similarity index 100% rename from apps/cli/src/__tests__/globalCommands.test.ts rename to apps/cli/src/utils/__tests__/globalCommands.test.ts diff --git a/apps/cli/src/__tests__/globalInputSequences.test.ts b/apps/cli/src/utils/__tests__/globalInputSequences.test.ts similarity index 95% rename from apps/cli/src/__tests__/globalInputSequences.test.ts rename to apps/cli/src/utils/__tests__/globalInputSequences.test.ts index 7ec8afa367..f40d942e5b 100644 --- a/apps/cli/src/__tests__/globalInputSequences.test.ts +++ b/apps/cli/src/utils/__tests__/globalInputSequences.test.ts @@ -1,14 +1,7 @@ import type { Key } from "ink" -import { - GLOBAL_INPUT_SEQUENCES, - isGlobalInputSequence, - matchesGlobalSequence, -} from "../ui/utils/globalInputSequences.js" +import { GLOBAL_INPUT_SEQUENCES, isGlobalInputSequence, matchesGlobalSequence } from "../globalInputSequences.js" -/** - * Helper to create a minimal Key object for testing - */ function createKey(overrides: Partial = {}): Key { return { upArrow: false, diff --git a/apps/cli/src/__tests__/historyStorage.test.ts b/apps/cli/src/utils/__tests__/historyStorage.test.ts similarity index 98% rename from apps/cli/src/__tests__/historyStorage.test.ts rename to apps/cli/src/utils/__tests__/historyStorage.test.ts index 225178bca2..f7c778a07c 100644 --- a/apps/cli/src/__tests__/historyStorage.test.ts +++ b/apps/cli/src/utils/__tests__/historyStorage.test.ts @@ -1,13 +1,7 @@ import * as fs from "fs/promises" import * as path from "path" -import { - getHistoryFilePath, - loadHistory, - saveHistory, - addToHistory, - MAX_HISTORY_ENTRIES, -} from "../utils/historyStorage.js" +import { getHistoryFilePath, loadHistory, saveHistory, addToHistory, MAX_HISTORY_ENTRIES } from "../historyStorage.js" vi.mock("fs/promises") diff --git a/apps/cli/src/utils.ts b/apps/cli/src/utils/extensionHostUtils.ts similarity index 100% rename from apps/cli/src/utils.ts rename to apps/cli/src/utils/extensionHostUtils.ts diff --git a/apps/cli/src/globalCommands.ts b/apps/cli/src/utils/globalCommands.ts similarity index 100% rename from apps/cli/src/globalCommands.ts rename to apps/cli/src/utils/globalCommands.ts diff --git a/apps/cli/src/ui/utils/globalInputSequences.ts b/apps/cli/src/utils/globalInputSequences.ts similarity index 100% rename from apps/cli/src/ui/utils/globalInputSequences.ts rename to apps/cli/src/utils/globalInputSequences.ts