diff --git a/docs/AGENTS.md b/docs/AGENTS.md index e35e3c261..dd6eea799 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -71,6 +71,7 @@ Write PR bodies for **busy reviewers**. Be concise and avoid redundancy: - **If it's obvious, omit it** - Problem obvious from solution? Don't state it. Solution obvious from problem? Skip to implementation details. ❌ **Bad** (redundant): + ``` Problem: Markdown rendering is slow, causing 50ms tasks Solution: Make markdown rendering faster @@ -78,6 +79,7 @@ Impact: Reduces task time to <16ms ``` ✅ **Good** (each section adds value): + ``` ReactMarkdown was re-parsing content on every parent render because plugin arrays were created fresh each time. Moved to module scope for stable references. @@ -104,8 +106,8 @@ Verify with React DevTools Profiler - MarkdownCore should only re-render when co - User docs are built with mdbook and deployed to https://cmux.io - Must be added to `docs/SUMMARY.md` to appear in the docs - Use standard markdown + mermaid diagrams -- **Developer docs** → inline with the code its documenting as comments. Consider them notes as notes to future Assistants to understand the logic more quickly. -**DO NOT** create standalone documentation files in the project root or random locations. +- **Developer docs** → inline with the code its documenting as comments. Consider them notes as notes to future Assistants to understand the logic more quickly. + **DO NOT** create standalone documentation files in the project root or random locations. ### External API Docs @@ -162,10 +164,10 @@ This project uses **Make** as the primary build orchestrator. See `Makefile` for ### Test-Driven Development (TDD) -**TDD is the preferred development style for agents.** +**TDD is the preferred development style for agents.** - Prefer relocated complex logic into places where they're easily tested - - E.g. pure functions in `utils` are easier to test than complex logic in a React component + - E.g. pure functions in `utils` are easier to test than complex logic in a React component - Strive for broad coverage with minimal tests - Prefer testing large blocks of composite logic - Tests should be written with the end-user experience in mind diff --git a/docs/vim-mode.md b/docs/vim-mode.md index 0c94e5df0..9e3a854a0 100644 --- a/docs/vim-mode.md +++ b/docs/vim-mode.md @@ -15,11 +15,13 @@ Vim mode is always enabled. Press **ESC** to enter normal mode from insert mode. ## Modes ### Insert Mode (Default) + - This is the default mode when typing in the chat input - Type normally, all characters are inserted - Press **ESC** or **Ctrl-[** to enter normal mode ### Normal Mode + - Command mode for navigation and editing - Indicated by "NORMAL" text above the input - Pending commands are shown (e.g., "NORMAL d" when delete is pending) @@ -28,12 +30,14 @@ Vim mode is always enabled. Press **ESC** to enter normal mode from insert mode. ## Navigation ### Basic Movement + - **h** - Move left one character - **j** - Move down one line - **k** - Move up one line - **l** - Move right one character ### Word Movement + - **w** - Move forward to start of next word - **W** - Move forward to start of next WORD (whitespace-separated) - **b** - Move backward to start of previous word @@ -42,12 +46,14 @@ Vim mode is always enabled. Press **ESC** to enter normal mode from insert mode. - **E** - Move to end of current/next WORD ### Line Movement + - **0** - Move to beginning of line - **$** - Move to end of line - **Home** - Same as **0** - **End** - Same as **$** ### Column Preservation + When moving up/down with **j**/**k**, the cursor attempts to stay in the same column position. If a line is shorter, the cursor moves to the end of that line, but will return to the original column on longer lines. ## Entering Insert Mode @@ -62,15 +68,18 @@ When moving up/down with **j**/**k**, the cursor attempts to stay in the same co ## Editing Commands ### Simple Edits + - **x** - Delete character under cursor - **p** - Paste after cursor - **P** - Paste before cursor ### Undo/Redo + - **u** - Undo last change - **Ctrl-r** - Redo ### Line Operations + - **dd** - Delete line (yank to clipboard) - **yy** - Yank (copy) line - **cc** - Change line (delete and enter insert mode) @@ -80,11 +89,13 @@ When moving up/down with **j**/**k**, the cursor attempts to stay in the same co Vim's power comes from combining operators with motions. All operators work with all motions: ### Operators + - **d** - Delete - **c** - Change (delete and enter insert mode) - **y** - Yank (copy) ### Motions + - **w** - To next word - **b** - To previous word - **e** - To end of word @@ -92,6 +103,7 @@ Vim's power comes from combining operators with motions. All operators work with - **0** - To beginning of line ### Examples + - **dw** - Delete to next word - **de** - Delete to end of word - **d$** - Delete to end of line @@ -103,6 +115,7 @@ Vim's power comes from combining operators with motions. All operators work with - **yy** - Yank line (doubled operator) ### Shortcuts + - **D** - Same as **d$** (delete to end of line) - **C** - Same as **c$** (change to end of line) @@ -111,6 +124,7 @@ Vim's power comes from combining operators with motions. All operators work with Text objects let you operate on semantic units: ### Inner Word (iw) + - **diw** - Delete inner word (word under cursor) - **ciw** - Change inner word - **yiw** - Yank inner word @@ -125,13 +139,13 @@ Text objects work from anywhere within the word - you don't need to be at the st ## Keybind Conflicts ### ESC Key + ESC is used for: + 1. Exiting Vim normal mode (highest priority) 2. NOT used for canceling edits (use **Ctrl-Q** instead) 3. NOT used for interrupting streams (use **Ctrl-C** instead) - - ## Tips 1. **Learn operators + motions**: Instead of memorizing every command, learn the operators (d, c, y) and motions (w, b, $, 0). They combine naturally. @@ -143,6 +157,7 @@ ESC is used for: ## Not Yet Implemented Features that may be added in the future: + - **ge** - Backward end of word motion - **f{char}**, **t{char}** - Find character motions - **i"**, **i'**, **i(**, **i[**, **i{** - More text objects diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index 050cc08b9..01e60f3e8 100644 --- a/src/components/AIView.tsx +++ b/src/components/AIView.tsx @@ -320,7 +320,12 @@ const AIViewInner: React.FC = ({ } return ( - + diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index ad47eb5c8..f0ab2231f 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -742,7 +742,8 @@ export const ChatInput: React.FC = ({ ? - Click to edit or use {formatKeybind(KEYBINDS.OPEN_MODEL_SELECTOR)} + Click to edit or use{" "} + {formatKeybind(KEYBINDS.OPEN_MODEL_SELECTOR)}

Abbreviations: diff --git a/src/components/ChatMetaSidebar/CostsTab.tsx b/src/components/ChatMetaSidebar/CostsTab.tsx index 3e02cca4b..35979f381 100644 --- a/src/components/ChatMetaSidebar/CostsTab.tsx +++ b/src/components/ChatMetaSidebar/CostsTab.tsx @@ -237,8 +237,6 @@ const SectionHeader = styled.div` margin-bottom: 12px; `; - - // Format token display - show k for thousands with 1 decimal const formatTokens = (tokens: number) => tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : tokens.toLocaleString(); @@ -263,11 +261,7 @@ const formatCostWithDollar = (cost: number | undefined): string => { * Calculate cost with elevated pricing for 1M context (200k-1M tokens) * For tokens above 200k, use elevated pricing rates */ -const calculateElevatedCost = ( - tokens: number, - standardRate: number, - isInput: boolean -): number => { +const calculateElevatedCost = (tokens: number, standardRate: number, isInput: boolean): number => { if (tokens <= 200_000) { return tokens * standardRate; } @@ -408,19 +402,19 @@ export const CostsTab: React.FC = () => { adjustedInputCost = calculateElevatedCost( displayUsage.input.tokens, modelStats.input_cost_per_token, - true // isInput + true // isInput ); // Recalculate output cost with elevated pricing adjustedOutputCost = calculateElevatedCost( displayUsage.output.tokens, modelStats.output_cost_per_token, - false // isOutput + false // isOutput ); // Recalculate reasoning cost with elevated pricing adjustedReasoningCost = calculateElevatedCost( displayUsage.reasoning.tokens, modelStats.output_cost_per_token, - false // isOutput + false // isOutput ); } diff --git a/src/components/ChatToggles.tsx b/src/components/ChatToggles.tsx index f410aa210..1ac5e7c20 100644 --- a/src/components/ChatToggles.tsx +++ b/src/components/ChatToggles.tsx @@ -15,11 +15,7 @@ interface ChatTogglesProps { children: React.ReactNode; } -export const ChatToggles: React.FC = ({ - workspaceId, - modelString, - children, -}) => { +export const ChatToggles: React.FC = ({ workspaceId, modelString, children }) => { return ( {children} diff --git a/src/components/Context1MCheckbox.tsx b/src/components/Context1MCheckbox.tsx index 6df23a6f4..10642070b 100644 --- a/src/components/Context1MCheckbox.tsx +++ b/src/components/Context1MCheckbox.tsx @@ -35,18 +35,18 @@ const Checkbox = styled.input` border-radius: 2px; background: #1e1e1e; position: relative; - + &:hover { border-color: #007acc; } - + &:checked { background: #007acc; border-color: #007acc; } - + &:checked::after { - content: ''; + content: ""; position: absolute; left: 3px; top: 0px; @@ -77,15 +77,22 @@ export const Context1MCheckbox: React.FC = ({ return ( - setUse1M(e.target.checked)} - /> + setUse1M(e.target.checked)} /> 1M Context - ? + + ? + Enable 1M token context window (beta feature for Claude Sonnet 4/4.5) diff --git a/src/components/Messages/MarkdownCore.tsx b/src/components/Messages/MarkdownCore.tsx index ef828ff39..f5e90d76d 100644 --- a/src/components/Messages/MarkdownCore.tsx +++ b/src/components/Messages/MarkdownCore.tsx @@ -20,7 +20,7 @@ const REHYPE_PLUGINS = [rehypeKatex]; /** * Core markdown rendering component that handles all markdown processing. * This is the single source of truth for markdown configuration. - * + * * Memoized to prevent expensive re-parsing when content hasn't changed. */ export const MarkdownCore = React.memo(({ content, children }) => { diff --git a/src/components/Messages/MarkdownRenderer.tsx b/src/components/Messages/MarkdownRenderer.tsx index 0d74218ea..5e85d48b3 100644 --- a/src/components/Messages/MarkdownRenderer.tsx +++ b/src/components/Messages/MarkdownRenderer.tsx @@ -38,7 +38,10 @@ interface PlanMarkdownRendererProps { className?: string; } -export const PlanMarkdownRenderer: React.FC = ({ content, className }) => { +export const PlanMarkdownRenderer: React.FC = ({ + content, + className, +}) => { return ( diff --git a/src/components/Messages/ToolMessage.tsx b/src/components/Messages/ToolMessage.tsx index e53667db4..607a1e397 100644 --- a/src/components/Messages/ToolMessage.tsx +++ b/src/components/Messages/ToolMessage.tsx @@ -37,10 +37,7 @@ function isBashTool(toolName: string, args: unknown): args is BashToolArgs { // Type guard for file_read tool function isFileReadTool(toolName: string, args: unknown): args is FileReadToolArgs { return ( - toolName === "file_read" && - typeof args === "object" && - args !== null && - "filePath" in args + toolName === "file_read" && typeof args === "object" && args !== null && "filePath" in args ); } diff --git a/src/components/VimTextArea.tsx b/src/components/VimTextArea.tsx index 3dc79997f..d189ad9ae 100644 --- a/src/components/VimTextArea.tsx +++ b/src/components/VimTextArea.tsx @@ -106,13 +106,16 @@ export const VimTextArea = React.forwardRef { if (!ref) return; if (typeof ref === "function") ref(textareaRef.current); - else - (ref).current = textareaRef.current; + else ref.current = textareaRef.current; }, [ref]); const [vimMode, setVimMode] = useState("insert"); const [desiredColumn, setDesiredColumn] = useState(null); - const [pendingOp, setPendingOp] = useState(null); + const [pendingOp, setPendingOp] = useState(null); const yankBufferRef = useRef(""); // Auto-resize when value changes @@ -124,8 +127,6 @@ export const VimTextArea = React.forwardRef new Set(suppressKeys ?? []), [suppressKeys]); const withSelection = () => { @@ -189,7 +190,7 @@ export const VimTextArea = React.forwardRef setCursor(newState.cursor, newState.mode), 0); @@ -226,10 +227,21 @@ export const VimTextArea = React.forwardRefVim Mode Enabled

- Press ESC for normal mode, i to return to insert mode. + Press ESC for normal mode, i to return to insert + mode.

- See { e.preventDefault(); window.open('/docs/vim-mode.md'); }}>Vim Mode docs for full command reference. + See{" "} + { + e.preventDefault(); + window.open("/docs/vim-mode.md"); + }} + > + Vim Mode docs + {" "} + for full command reference.
normal diff --git a/src/components/tools/FileReadToolCall.tsx b/src/components/tools/FileReadToolCall.tsx index edbeddf62..3fbb2a5ee 100644 --- a/src/components/tools/FileReadToolCall.tsx +++ b/src/components/tools/FileReadToolCall.tsx @@ -159,8 +159,7 @@ export const FileReadToolCall: React.FC = ({ const fileName = args.filePath.split("/").pop() ?? args.filePath; // Parse the file content to extract line numbers and actual content - const parsedContent = - result?.success && result.content ? parseFileContent(result.content) : null; + const parsedContent = result?.success && result.content ? parseFileContent(result.content) : null; return ( diff --git a/src/main.ts b/src/main.ts index 7d00ecfe9..e13d611c0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -40,7 +40,9 @@ if (!app.isPackaged) { const config = new Config(); const ipcMain = new IpcMain(config); -console.log(`Cmux starting - version: ${(VERSION as { git?: string; buildTime?: string }).git ?? "(dev)"} (built: ${(VERSION as { git?: string; buildTime?: string }).buildTime ?? "dev-mode"})`); +console.log( + `Cmux starting - version: ${(VERSION as { git?: string; buildTime?: string }).git ?? "(dev)"} (built: ${(VERSION as { git?: string; buildTime?: string }).buildTime ?? "dev-mode"})` +); console.log("Main process starting..."); // Global error handlers for better error reporting @@ -189,7 +191,7 @@ function createWindow() { // Development mode: load from vite dev server void mainWindow.loadURL("http://localhost:5173"); // Open DevTools after React content loads - mainWindow.webContents.once('did-finish-load', () => { + mainWindow.webContents.once("did-finish-load", () => { mainWindow?.webContents.openDevTools(); }); } @@ -203,7 +205,7 @@ function createWindow() { if (gotTheLock) { void app.whenReady().then(async () => { console.log("App ready, creating window..."); - + // Install React DevTools in development if (!app.isPackaged && installExtension && REACT_DEVELOPER_TOOLS) { try { @@ -215,7 +217,7 @@ if (gotTheLock) { console.log("❌ Error installing React DevTools:", err); } } - + createMenu(); createWindow(); // No need to auto-start workspaces anymore - they start on demand diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 2b69ea932..0f6cf6ab7 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -181,17 +181,15 @@ export class AIService extends EventEmitter { // Add 1M context beta header if requested const use1MContext = cmuxProviderOptions?.anthropic?.use1MContext; const existingHeaders = providerConfig.headers as Record | undefined; - const headers = use1MContext && existingHeaders - ? { ...existingHeaders, "anthropic-beta": "context-1m-2025-08-07" } - : use1MContext - ? { "anthropic-beta": "context-1m-2025-08-07" } - : existingHeaders; - - + const headers = + use1MContext && existingHeaders + ? { ...existingHeaders, "anthropic-beta": "context-1m-2025-08-07" } + : use1MContext + ? { "anthropic-beta": "context-1m-2025-08-07" } + : existingHeaders; // Pass configuration verbatim to the provider, ensuring parity with Vercel AI SDK - const provider = createAnthropic({ ...providerConfig, headers }); return Ok(provider(modelId)); } diff --git a/src/services/tools/bash.test.ts b/src/services/tools/bash.test.ts index 5feb0e57f..14e34dcb9 100644 --- a/src/services/tools/bash.test.ts +++ b/src/services/tools/bash.test.ts @@ -78,12 +78,14 @@ describe("bash tool", () => { if (!result.success) { expect(result.error).toContain("[OUTPUT OVERFLOW"); // Should contain specific overflow reason (one of the three types) - expect(result.error).toMatch(/Line count exceeded limit|Total output exceeded limit|exceeded per-line limit/); + expect(result.error).toMatch( + /Line count exceeded limit|Total output exceeded limit|exceeded per-line limit/ + ); expect(result.error).toContain("Full output"); expect(result.error).toContain("lines) saved to"); expect(result.error).toContain("bash-"); expect(result.error).toContain(".txt"); - + // Verify helpful filtering instructions are included expect(result.error).toContain("grep ''"); expect(result.error).toContain("head -n 300"); @@ -95,29 +97,27 @@ describe("bash tool", () => { expect(match).toBeDefined(); if (match) { const overflowPath = match[1]; - + // Verify file has short ID format (bash-<8 hex chars>.txt) const filename = overflowPath.split("/").pop(); expect(filename).toMatch(/^bash-[0-9a-f]{8}\.txt$/); - + // Verify file exists and read contents expect(fs.existsSync(overflowPath)).toBe(true); - + // Verify file contains collected lines (at least 300, may be slightly more) const fileContent = fs.readFileSync(overflowPath, "utf-8"); const fileLines = fileContent.split("\n").filter((l: string) => l.length > 0); expect(fileLines.length).toBeGreaterThanOrEqual(300); expect(fileContent).toContain("line1"); expect(fileContent).toContain("line300"); - + // Clean up temp file fs.unlinkSync(overflowPath); } } }); - - it("should fail early when max_lines is reached", async () => { const tool = createBashTool({ cwd: process.cwd() }); const startTime = performance.now(); @@ -597,5 +597,4 @@ describe("bash tool", () => { expect(result.wall_duration_ms).toBe(0); } }); - }); diff --git a/src/utils/ai/models.ts b/src/utils/ai/models.ts index d6664ae5d..907414ba3 100644 --- a/src/utils/ai/models.ts +++ b/src/utils/ai/models.ts @@ -30,7 +30,6 @@ export function supports1MContext(modelString: string): boolean { } // Check for Sonnet 4 and Sonnet 4.5 models return ( - modelName?.includes("claude-sonnet-4") && - !modelName.includes("claude-sonnet-3") // Exclude Sonnet 3.x models + modelName?.includes("claude-sonnet-4") && !modelName.includes("claude-sonnet-3") // Exclude Sonnet 3.x models ); } diff --git a/src/utils/tokens/modelStats.test.ts b/src/utils/tokens/modelStats.test.ts index fe14747c4..fc9a85aee 100644 --- a/src/utils/tokens/modelStats.test.ts +++ b/src/utils/tokens/modelStats.test.ts @@ -3,7 +3,7 @@ import { getModelStats } from "./modelStats"; describe("getModelStats", () => { it("should return model stats for claude-sonnet-4-5", () => { const stats = getModelStats("anthropic:claude-sonnet-4-5"); - + expect(stats).not.toBeNull(); expect(stats?.input_cost_per_token).toBe(0.000003); expect(stats?.output_cost_per_token).toBe(0.000015); @@ -12,21 +12,21 @@ describe("getModelStats", () => { it("should handle model without provider prefix", () => { const stats = getModelStats("claude-sonnet-4-5"); - + expect(stats).not.toBeNull(); expect(stats?.input_cost_per_token).toBe(0.000003); }); it("should return cache pricing when available", () => { const stats = getModelStats("anthropic:claude-sonnet-4-5"); - + expect(stats?.cache_creation_input_token_cost).toBe(0.00000375); expect(stats?.cache_read_input_token_cost).toBe(3e-7); }); it("should return null for unknown models", () => { const stats = getModelStats("unknown:model"); - + expect(stats).toBeNull(); }); }); diff --git a/src/utils/vim.test.ts b/src/utils/vim.test.ts index 199dace30..bb4d44e0b 100644 --- a/src/utils/vim.test.ts +++ b/src/utils/vim.test.ts @@ -36,7 +36,7 @@ function executeVimCommands(initial: vim.VimState, keys: string[]): vim.VimState const actualKey = ctrl ? key.slice(5) : key; const result = vim.handleKeyPress(state, actualKey, { ctrl }); - + if (result.handled) { // Ignore undo/redo actions in tests (they require browser execCommand) if (result.action === "undo" || result.action === "redo") { @@ -64,7 +64,7 @@ describe("Vim Command Integration Tests", () => { test("ESC enters normal mode from insert", () => { const state = executeVimCommands( { ...initialState, text: "hello", cursor: 5, mode: "insert" }, - ["Escape"], + ["Escape"] ); expect(state.mode).toBe("normal"); expect(state.cursor).toBe(4); // Clamps to last char @@ -73,7 +73,7 @@ describe("Vim Command Integration Tests", () => { test("i enters insert mode at cursor", () => { const state = executeVimCommands( { ...initialState, text: "hello", cursor: 2, mode: "normal" }, - ["i"], + ["i"] ); expect(state.mode).toBe("insert"); expect(state.cursor).toBe(2); @@ -82,7 +82,7 @@ describe("Vim Command Integration Tests", () => { test("a enters insert mode after cursor", () => { const state = executeVimCommands( { ...initialState, text: "hello", cursor: 2, mode: "normal" }, - ["a"], + ["a"] ); expect(state.mode).toBe("insert"); expect(state.cursor).toBe(3); @@ -91,7 +91,7 @@ describe("Vim Command Integration Tests", () => { test("o opens line below", () => { const state = executeVimCommands( { ...initialState, text: "hello\nworld", cursor: 2, mode: "normal" }, - ["o"], + ["o"] ); expect(state.mode).toBe("insert"); expect(state.text).toBe("hello\n\nworld"); @@ -103,7 +103,7 @@ describe("Vim Command Integration Tests", () => { test("w moves to next word", () => { const state = executeVimCommands( { ...initialState, text: "hello world foo", cursor: 0, mode: "normal" }, - ["w"], + ["w"] ); expect(state.cursor).toBe(6); }); @@ -111,7 +111,7 @@ describe("Vim Command Integration Tests", () => { test("b moves to previous word", () => { const state = executeVimCommands( { ...initialState, text: "hello world foo", cursor: 12, mode: "normal" }, - ["b"], + ["b"] ); expect(state.cursor).toBe(6); }); @@ -119,7 +119,7 @@ describe("Vim Command Integration Tests", () => { test("$ moves to end of line", () => { const state = executeVimCommands( { ...initialState, text: "hello world", cursor: 0, mode: "normal" }, - ["$"], + ["$"] ); expect(state.cursor).toBe(10); // On last char, not past it }); @@ -127,7 +127,7 @@ describe("Vim Command Integration Tests", () => { test("0 moves to start of line", () => { const state = executeVimCommands( { ...initialState, text: "hello world", cursor: 10, mode: "normal" }, - ["0"], + ["0"] ); expect(state.cursor).toBe(0); }); @@ -137,7 +137,7 @@ describe("Vim Command Integration Tests", () => { test("x deletes character under cursor", () => { const state = executeVimCommands( { ...initialState, text: "hello", cursor: 1, mode: "normal" }, - ["x"], + ["x"] ); expect(state.text).toBe("hllo"); expect(state.cursor).toBe(1); @@ -147,7 +147,7 @@ describe("Vim Command Integration Tests", () => { test("p pastes after cursor", () => { const state = executeVimCommands( { ...initialState, text: "hello", cursor: 2, mode: "normal", yankBuffer: "XX" }, - ["p"], + ["p"] ); expect(state.text).toBe("helXXlo"); expect(state.cursor).toBe(4); @@ -156,7 +156,7 @@ describe("Vim Command Integration Tests", () => { test("P pastes before cursor", () => { const state = executeVimCommands( { ...initialState, text: "hello", cursor: 2, mode: "normal", yankBuffer: "XX" }, - ["P"], + ["P"] ); expect(state.text).toBe("heXXllo"); expect(state.cursor).toBe(2); @@ -167,7 +167,7 @@ describe("Vim Command Integration Tests", () => { test("dd deletes line", () => { const state = executeVimCommands( { ...initialState, text: "hello\nworld\nfoo", cursor: 8, mode: "normal" }, - ["d", "d"], + ["d", "d"] ); expect(state.text).toBe("hello\nfoo"); expect(state.yankBuffer).toBe("world\n"); @@ -176,7 +176,7 @@ describe("Vim Command Integration Tests", () => { test("yy yanks line", () => { const state = executeVimCommands( { ...initialState, text: "hello\nworld", cursor: 2, mode: "normal" }, - ["y", "y"], + ["y", "y"] ); expect(state.text).toBe("hello\nworld"); // Text unchanged expect(state.yankBuffer).toBe("hello\n"); @@ -185,7 +185,7 @@ describe("Vim Command Integration Tests", () => { test("cc changes line", () => { const state = executeVimCommands( { ...initialState, text: "hello\nworld\nfoo", cursor: 8, mode: "normal" }, - ["c", "c"], + ["c", "c"] ); expect(state.text).toBe("hello\n\nfoo"); expect(state.mode).toBe("insert"); @@ -197,7 +197,7 @@ describe("Vim Command Integration Tests", () => { test("d$ deletes to end of line", () => { const state = executeVimCommands( { ...initialState, text: "hello world", cursor: 6, mode: "normal" }, - ["d", "$"], + ["d", "$"] ); expect(state.text).toBe("hello "); expect(state.cursor).toBe(6); @@ -207,7 +207,7 @@ describe("Vim Command Integration Tests", () => { test("D deletes to end of line (shortcut)", () => { const state = executeVimCommands( { ...initialState, text: "hello world", cursor: 6, mode: "normal" }, - ["D"], + ["D"] ); expect(state.text).toBe("hello "); expect(state.cursor).toBe(6); @@ -216,7 +216,7 @@ describe("Vim Command Integration Tests", () => { test("d0 deletes to beginning of line", () => { const state = executeVimCommands( { ...initialState, text: "hello world", cursor: 6, mode: "normal" }, - ["d", "0"], + ["d", "0"] ); expect(state.text).toBe("world"); expect(state.yankBuffer).toBe("hello "); @@ -225,7 +225,7 @@ describe("Vim Command Integration Tests", () => { test("dw deletes to next word", () => { const state = executeVimCommands( { ...initialState, text: "hello world foo", cursor: 0, mode: "normal" }, - ["d", "w"], + ["d", "w"] ); expect(state.text).toBe("world foo"); expect(state.yankBuffer).toBe("hello "); @@ -234,7 +234,7 @@ describe("Vim Command Integration Tests", () => { test("db deletes to previous word", () => { const state = executeVimCommands( { ...initialState, text: "hello world foo", cursor: 12, mode: "normal" }, - ["d", "b"], + ["d", "b"] ); expect(state.text).toBe("hello foo"); }); @@ -244,7 +244,7 @@ describe("Vim Command Integration Tests", () => { test("c$ changes to end of line", () => { const state = executeVimCommands( { ...initialState, text: "hello world", cursor: 6, mode: "normal" }, - ["c", "$"], + ["c", "$"] ); expect(state.text).toBe("hello "); expect(state.mode).toBe("insert"); @@ -254,7 +254,7 @@ describe("Vim Command Integration Tests", () => { test("C changes to end of line (shortcut)", () => { const state = executeVimCommands( { ...initialState, text: "hello world", cursor: 6, mode: "normal" }, - ["C"], + ["C"] ); expect(state.text).toBe("hello "); expect(state.mode).toBe("insert"); @@ -263,7 +263,7 @@ describe("Vim Command Integration Tests", () => { test("c0 changes to beginning of line", () => { const state = executeVimCommands( { ...initialState, text: "hello world", cursor: 6, mode: "normal" }, - ["c", "0"], + ["c", "0"] ); expect(state.text).toBe("world"); expect(state.mode).toBe("insert"); @@ -272,7 +272,7 @@ describe("Vim Command Integration Tests", () => { test("cw changes to next word", () => { const state = executeVimCommands( { ...initialState, text: "hello world", cursor: 0, mode: "normal" }, - ["c", "w"], + ["c", "w"] ); expect(state.text).toBe("world"); expect(state.mode).toBe("insert"); @@ -283,7 +283,7 @@ describe("Vim Command Integration Tests", () => { test("y$ yanks to end of line", () => { const state = executeVimCommands( { ...initialState, text: "hello world", cursor: 6, mode: "normal" }, - ["y", "$"], + ["y", "$"] ); expect(state.text).toBe("hello world"); // Text unchanged expect(state.yankBuffer).toBe("world"); @@ -293,7 +293,7 @@ describe("Vim Command Integration Tests", () => { test("y0 yanks to beginning of line", () => { const state = executeVimCommands( { ...initialState, text: "hello world", cursor: 6, mode: "normal" }, - ["y", "0"], + ["y", "0"] ); expect(state.text).toBe("hello world"); expect(state.yankBuffer).toBe("hello "); @@ -302,7 +302,7 @@ describe("Vim Command Integration Tests", () => { test("yw yanks to next word", () => { const state = executeVimCommands( { ...initialState, text: "hello world", cursor: 0, mode: "normal" }, - ["y", "w"], + ["y", "w"] ); expect(state.text).toBe("hello world"); expect(state.yankBuffer).toBe("hello "); @@ -313,7 +313,7 @@ describe("Vim Command Integration Tests", () => { test("ESC then d$ deletes from insert cursor to end", () => { const state = executeVimCommands( { ...initialState, text: "hello world", cursor: 6, mode: "insert" }, - ["Escape", "d", "$"], + ["Escape", "d", "$"] ); // Cursor at 6 in insert mode stays at 6 after ESC (on 'w') // d$ deletes from 'w' to end of line @@ -324,7 +324,7 @@ describe("Vim Command Integration Tests", () => { test("navigate with w, then delete with dw", () => { const state = executeVimCommands( { ...initialState, text: "one two three", cursor: 0, mode: "normal" }, - ["w", "d", "w"], + ["w", "d", "w"] ); expect(state.text).toBe("one three"); }); @@ -332,7 +332,7 @@ describe("Vim Command Integration Tests", () => { test("yank line, navigate, paste", () => { const state = executeVimCommands( { ...initialState, text: "first\nsecond\nthird", cursor: 0, mode: "normal" }, - ["y", "y", "j", "j", "p"], + ["y", "y", "j", "j", "p"] ); expect(state.yankBuffer).toBe("first\n"); // After yy: cursor at 0, yank "first\n" @@ -345,7 +345,7 @@ describe("Vim Command Integration Tests", () => { test("delete word, move, paste", () => { const state = executeVimCommands( { ...initialState, text: "hello world foo", cursor: 0, mode: "normal" }, - ["d", "w", "w", "p"], + ["d", "w", "w", "p"] ); expect(state.yankBuffer).toBe("hello "); // After dw: text = "world foo", cursor at 0, yank "hello " @@ -359,7 +359,7 @@ describe("Vim Command Integration Tests", () => { test("$ on empty line", () => { const state = executeVimCommands( { ...initialState, text: "hello\n\nworld", cursor: 6, mode: "normal" }, - ["$"], + ["$"] ); expect(state.cursor).toBe(6); // Empty line, stays at newline char }); @@ -367,7 +367,7 @@ describe("Vim Command Integration Tests", () => { test("w at end of text", () => { const state = executeVimCommands( { ...initialState, text: "hello", cursor: 4, mode: "normal" }, - ["w"], + ["w"] ); expect(state.cursor).toBe(4); // Clamps to last char }); @@ -375,7 +375,7 @@ describe("Vim Command Integration Tests", () => { test("d$ at end of line deletes last char", () => { const state = executeVimCommands( { ...initialState, text: "hello", cursor: 4, mode: "normal" }, - ["d", "$"], + ["d", "$"] ); // Cursor at 4 (on 'o'), d$ deletes from 'o' to line end expect(state.text).toBe("hell"); @@ -384,11 +384,10 @@ describe("Vim Command Integration Tests", () => { test("x at end of text does nothing", () => { const state = executeVimCommands( { ...initialState, text: "hello", cursor: 5, mode: "normal" }, - ["x"], + ["x"] ); expect(state.text).toBe("hello"); }); - }); describe("Reported Issues", () => { @@ -398,7 +397,7 @@ describe("Vim Command Integration Tests", () => { // This caused 'ciw' to behave like 'cw' (change word forward) const state = executeVimCommands( { ...initialState, text: "hello world foo", cursor: 6, mode: "normal" }, - ["c", "i", "w"], + ["c", "i", "w"] ); expect(state.text).toBe("hello foo"); // Only "world" deleted, both spaces remain expect(state.mode).toBe("insert"); @@ -409,20 +408,19 @@ describe("Vim Command Integration Tests", () => { // In Vim: o opens new line below current line, even on last line const state = executeVimCommands( { ...initialState, text: "first\nsecond\nthird", cursor: 15, mode: "normal" }, - ["o"], + ["o"] ); expect(state.mode).toBe("insert"); expect(state.text).toBe("first\nsecond\nthird\n"); // New line added expect(state.cursor).toBe(19); // Cursor on new line }); - }); describe("e/E motion", () => { test("e moves to end of current word", () => { const state = executeVimCommands( { ...initialState, text: "hello world", cursor: 1, mode: "normal" }, - ["e"], + ["e"] ); expect(state.cursor).toBe(4); }); @@ -430,7 +428,7 @@ describe("Vim Command Integration Tests", () => { test("de deletes to end of word", () => { const state = executeVimCommands( { ...initialState, text: "hello world", cursor: 1, mode: "normal" }, - ["d", "e"], + ["d", "e"] ); expect(state.text).toBe("h world"); expect(state.yankBuffer).toBe("ello"); @@ -439,7 +437,7 @@ describe("Vim Command Integration Tests", () => { test("ce changes to end of word", () => { const state = executeVimCommands( { ...initialState, text: "hello world", cursor: 1, mode: "normal" }, - ["c", "e"], + ["c", "e"] ); expect(state.text).toBe("h world"); expect(state.mode).toBe("insert"); diff --git a/src/utils/vim.ts b/src/utils/vim.ts index 6e834f9cd..d1344cf14 100644 --- a/src/utils/vim.ts +++ b/src/utils/vim.ts @@ -123,7 +123,6 @@ export function moveWordForward(text: string, cursor: number): number { return Math.min(i, Math.max(0, n - 1)); } - /** * Move cursor to end of current/next word (like 'e'). * If on a word character, goes to end of current word. @@ -132,20 +131,20 @@ export function moveWordForward(text: string, cursor: number): number { export function moveWordEnd(text: string, cursor: number): number { const n = text.length; if (cursor >= n - 1) return Math.max(0, n - 1); - + let i = cursor; const isWord = (ch: string) => /[A-Za-z0-9_]/.test(ch); - + // If on a word char, move to end of this word if (isWord(text[i])) { while (i < n - 1 && isWord(text[i + 1])) i++; return i; } - + // If on whitespace, skip to next word then go to its end while (i < n - 1 && !isWord(text[i])) i++; while (i < n - 1 && isWord(text[i + 1])) i++; - + return Math.min(i, Math.max(0, n - 1)); } @@ -383,7 +382,7 @@ function handleInsertModeKey(state: VimState, key: string, modifiers: KeyModifie */ function handleNormalModeKey(state: VimState, key: string, modifiers: KeyModifiers): VimKeyResult { const now = Date.now(); - + // Check for timeout on pending operator (800ms like Vim) let pending = state.pendingOp; if (pending && now - pending.at > 800) { @@ -488,10 +487,7 @@ function handlePendingOperator( /** * Helper to complete an operation and clear pending state. */ -function completeOperation( - state: VimState, - updates: Partial -): VimState { +function completeOperation(state: VimState, updates: Partial): VimState { return { ...state, ...updates, @@ -612,11 +608,7 @@ function applyOperatorMotion( * Apply operator + text object combination. * Currently only supports "iw" (inner word). */ -function applyOperatorTextObject( - state: VimState, - op: "d" | "c" | "y", - textObj: "iw" -): VimState { +function applyOperatorTextObject(state: VimState, op: "d" | "c" | "y", textObj: "iw"): VimState { if (textObj !== "iw") return state; const { text, cursor, yankBuffer } = state; @@ -684,41 +676,41 @@ function tryHandleNavigation(state: VimState, key: string): VimKeyResult | null switch (key) { case "h": return handleKey(state, { cursor: Math.max(0, cursor - 1), desiredColumn: null }); - + case "l": - return handleKey(state, { - cursor: Math.min(cursor + 1, Math.max(0, text.length - 1)), - desiredColumn: null + return handleKey(state, { + cursor: Math.min(cursor + 1, Math.max(0, text.length - 1)), + desiredColumn: null, }); - + case "j": { const result = moveVertical(text, cursor, 1, desiredColumn); return handleKey(state, { cursor: result.cursor, desiredColumn: result.desiredColumn }); } - + case "k": { const result = moveVertical(text, cursor, -1, desiredColumn); return handleKey(state, { cursor: result.cursor, desiredColumn: result.desiredColumn }); } - + case "w": case "W": return handleKey(state, { cursor: moveWordForward(text, cursor), desiredColumn: null }); - + case "b": case "B": return handleKey(state, { cursor: moveWordBackward(text, cursor), desiredColumn: null }); - + case "e": case "E": return handleKey(state, { cursor: moveWordEnd(text, cursor), desiredColumn: null }); - + case "0": case "Home": { const { lineStart } = getLineBounds(text, cursor); return handleKey(state, { cursor: lineStart, desiredColumn: null }); } - + case "$": case "End": { const { lineStart, lineEnd } = getLineBounds(text, cursor); @@ -749,7 +741,7 @@ function tryHandleEdit(state: VimState, key: string): VimKeyResult | null { desiredColumn: null, }); } - + case "p": { // In normal mode, cursor is ON a character. Paste AFTER means after that character. const result = pasteAfter(text, cursor + 1, yankBuffer); @@ -759,7 +751,7 @@ function tryHandleEdit(state: VimState, key: string): VimKeyResult | null { desiredColumn: null, }); } - + case "P": { const result = pasteBefore(text, cursor, yankBuffer); return handleKey(state, { @@ -780,16 +772,16 @@ function tryHandleOperator(state: VimState, key: string, now: number): VimKeyRes switch (key) { case "d": return handleKey(state, { pendingOp: { op: "d", at: now, args: [] } }); - + case "c": return handleKey(state, { pendingOp: { op: "c", at: now, args: [] } }); - + case "y": return handleKey(state, { pendingOp: { op: "y", at: now, args: [] } }); - + case "D": return { handled: true, newState: applyOperatorMotion(state, "d", "$") }; - + case "C": return { handled: true, newState: applyOperatorMotion(state, "c", "$") }; } @@ -797,7 +789,6 @@ function tryHandleOperator(state: VimState, key: string, now: number): VimKeyRes return null; } - /** * Format pending operator command for display in mode indicator. * Returns empty string if no pending command. @@ -808,4 +799,3 @@ export function formatPendingCommand(pendingOp: VimState["pendingOp"]): string { const args = pendingOp.args?.join("") ?? ""; return `${pendingOp.op}${args}`; } - diff --git a/tests/ipcMain/anthropic1MContext.test.ts b/tests/ipcMain/anthropic1MContext.test.ts index 2425d456c..f3c0d6fcd 100644 --- a/tests/ipcMain/anthropic1MContext.test.ts +++ b/tests/ipcMain/anthropic1MContext.test.ts @@ -35,7 +35,7 @@ describeIntegration("IpcMain anthropic 1M context integration tests", () => { messageCount: 20, textPrefix: "Context test: ", }); - + // Phase 1: Try without 1M context flag - should fail with context limit error env.sentEvents.length = 0; const resultWithout1M = await sendMessageWithModel( @@ -52,23 +52,23 @@ describeIntegration("IpcMain anthropic 1M context integration tests", () => { }, } ); - + expect(resultWithout1M.success).toBe(true); - + const collectorWithout1M = createEventCollector(env.sentEvents, workspaceId); const resultType = await Promise.race([ collectorWithout1M.waitForEvent("stream-end", 30000).then(() => "success"), collectorWithout1M.waitForEvent("stream-error", 30000).then(() => "error"), ]); - + // Should get an error due to exceeding 200k token limit expect(resultType).toBe("error"); - const errorEvent = collectorWithout1M.getEvents().find( - (e) => "type" in e && e.type === "stream-error" - ) as { error: string } | undefined; + const errorEvent = collectorWithout1M + .getEvents() + .find((e) => "type" in e && e.type === "stream-error") as { error: string } | undefined; expect(errorEvent).toBeDefined(); expect(errorEvent!.error).toMatch(/too long|200000|maximum/i); - + // Phase 2: Try WITH 1M context flag // Should handle the large context better with beta header env.sentEvents.length = 0; @@ -86,18 +86,18 @@ describeIntegration("IpcMain anthropic 1M context integration tests", () => { }, } ); - + expect(resultWith1M.success).toBe(true); - + const collectorWith1M = createEventCollector(env.sentEvents, workspaceId); await collectorWith1M.waitForEvent("stream-end", 30000); - + // With 1M context, should succeed assertStreamSuccess(collectorWith1M); - + const messageWith1M = collectorWith1M.getFinalMessage(); expect(messageWith1M).toBeDefined(); - + // The key test: with 1M context, we should get a valid response // that processed the large context if (messageWith1M && "parts" in messageWith1M && Array.isArray(messageWith1M.parts)) { diff --git a/tests/ipcMain/helpers.ts b/tests/ipcMain/helpers.ts index e427fc230..6027aa4ad 100644 --- a/tests/ipcMain/helpers.ts +++ b/tests/ipcMain/helpers.ts @@ -352,11 +352,11 @@ export async function cleanupTempGitRepo(repoPath: string): Promise { /** * Build large conversation history to test context limits - * + * * This is a test-only utility that uses HistoryService directly to quickly * populate history without making API calls. Real application code should * NEVER bypass IPC like this. - * + * * @param workspaceId - Workspace to populate * @param config - Config instance for HistoryService * @param options - Configuration for history size @@ -373,26 +373,25 @@ export async function buildLargeHistory( ): Promise { const { HistoryService } = await import("../../src/services/historyService"); const { createCmuxMessage } = await import("../../src/types/message"); - + // HistoryService only needs getSessionDir, so we can cast the partial config const historyService = new HistoryService(config as any); - + const messageSize = options.messageSize ?? 50_000; const messageCount = options.messageCount ?? 80; const textPrefix = options.textPrefix ?? ""; - + const largeText = textPrefix + "A".repeat(messageSize); - + // Build conversation history with alternating user/assistant messages for (let i = 0; i < messageCount; i++) { const isUser = i % 2 === 0; const role = isUser ? "user" : "assistant"; const message = createCmuxMessage(`history-msg-${i}`, role, largeText, {}); - + const result = await historyService.appendToHistory(workspaceId, message); if (!result.success) { throw new Error(`Failed to append message ${i} to history: ${result.error}`); } } } - diff --git a/tests/ipcMain/sendMessage.test.ts b/tests/ipcMain/sendMessage.test.ts index c3ab34d12..bdbab1502 100644 --- a/tests/ipcMain/sendMessage.test.ts +++ b/tests/ipcMain/sendMessage.test.ts @@ -17,7 +17,6 @@ import { buildLargeHistory, } from "./helpers"; - // Skip all tests if TEST_INTEGRATION is not set const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip;