Browse Source

Merge branch 'main' into jq/sound-setting-improvements

Justin Quan 1 year ago
parent
commit
42715c1db6

+ 1 - 1
.github/workflows/code-qa.yml

@@ -42,4 +42,4 @@ jobs:
         run: npm run install:all
 
       - name: Run unit tests
-        run: npx jest
+        run: npm test

+ 1 - 13
CHANGELOG.md

@@ -1,21 +1,9 @@
 # Roo Cline Changelog
 
-## [2.2.9]
-
--   Fix a bug where Gemini was including line numbers in the search/replace content
-
-## [2.2.8]
-
--   More work on diff editing (better matching, indentation, logging)
-
-## [2.2.7]
+## [2.2.6 - 2.2.10]
 
 -   More fixes to search/replace diffs
 
-## [2.2.6]
-
--   Add a fuzzy match tolerance when applying diffs
-
 ## [2.2.5]
 
 -   Allow MCP servers to be enabled/disabled

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "roo-cline",
-  "version": "2.2.9",
+  "version": "2.2.10",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "roo-cline",
-      "version": "2.2.9",
+      "version": "2.2.10",
       "dependencies": {
         "@anthropic-ai/bedrock-sdk": "^0.10.2",
         "@anthropic-ai/sdk": "^0.26.0",

+ 2 - 2
package.json

@@ -3,7 +3,7 @@
   "displayName": "Roo Cline",
   "description": "A fork of Cline, an autonomous coding agent, with some added experimental configuration and automation features.",
   "publisher": "RooVeterinaryInc",
-  "version": "2.2.9",
+  "version": "2.2.10",
   "icon": "assets/icons/rocket.png",
   "galleryBanner": {
     "color": "#617A91",
@@ -157,7 +157,7 @@
     "package": "npm run build:webview && npm run check-types && npm run lint && node esbuild.js --production",
     "pretest": "npm run compile-tests && npm run compile && npm run lint",
     "start:webview": "cd webview-ui && npm run start",
-    "test": "jest",
+    "test": "jest && npm run test:webview",
     "test:webview": "cd webview-ui && npm run test",
     "prepare": "husky",
     "publish:marketplace": "vsce publish",

+ 147 - 0
src/core/diff/strategies/__tests__/search-replace.test.ts

@@ -564,6 +564,153 @@ this.init();
         });
     })
 
+    describe('line number stripping', () => {
+        describe('line number stripping', () => {
+            let strategy: SearchReplaceDiffStrategy
+        
+            beforeEach(() => {
+                strategy = new SearchReplaceDiffStrategy()
+            })
+        
+            it('should strip line numbers from both search and replace sections', () => {
+                const originalContent = 'function test() {\n    return true;\n}\n'
+                const diffContent = `test.ts
+<<<<<<< SEARCH
+1 | function test() {
+2 |     return true;
+3 | }
+=======
+1 | function test() {
+2 |     return false;
+3 | }
+>>>>>>> REPLACE`
+        
+                const result = strategy.applyDiff(originalContent, diffContent)
+                expect(result.success).toBe(true)
+                if (result.success) {
+                    expect(result.content).toBe('function test() {\n    return false;\n}\n')
+                }
+            })
+        
+            it('should not strip when not all lines have numbers in either section', () => {
+                const originalContent = 'function test() {\n    return true;\n}\n'
+                const diffContent = `test.ts
+<<<<<<< SEARCH
+1 | function test() {
+2 |     return true;
+3 | }
+=======
+1 | function test() {
+    return false;
+3 | }
+>>>>>>> REPLACE`
+        
+                const result = strategy.applyDiff(originalContent, diffContent)
+                expect(result.success).toBe(false)
+            })
+        
+            it('should preserve content that naturally starts with pipe', () => {
+                const originalContent = '|header|another|\n|---|---|\n|data|more|\n'
+                const diffContent = `test.ts
+<<<<<<< SEARCH
+1 | |header|another|
+2 | |---|---|
+3 | |data|more|
+=======
+1 | |header|another|
+2 | |---|---|
+3 | |data|updated|
+>>>>>>> REPLACE`
+        
+                const result = strategy.applyDiff(originalContent, diffContent)
+                expect(result.success).toBe(true)
+                if (result.success) {
+                    expect(result.content).toBe('|header|another|\n|---|---|\n|data|updated|\n')
+                }
+            })
+        
+            it('should preserve indentation when stripping line numbers', () => {
+                const originalContent = '    function test() {\n        return true;\n    }\n'
+                const diffContent = `test.ts
+<<<<<<< SEARCH
+1 |     function test() {
+2 |         return true;
+3 |     }
+=======
+1 |     function test() {
+2 |         return false;
+3 |     }
+>>>>>>> REPLACE`
+        
+                const result = strategy.applyDiff(originalContent, diffContent)
+                expect(result.success).toBe(true)
+                if (result.success) {
+                    expect(result.content).toBe('    function test() {\n        return false;\n    }\n')
+                }
+            })
+        
+            it('should handle different line numbers between sections', () => {
+                const originalContent = 'function test() {\n    return true;\n}\n'
+                const diffContent = `test.ts
+<<<<<<< SEARCH
+10 | function test() {
+11 |     return true;
+12 | }
+=======
+20 | function test() {
+21 |     return false;
+22 | }
+>>>>>>> REPLACE`
+        
+                const result = strategy.applyDiff(originalContent, diffContent)
+                expect(result.success).toBe(true)
+                if (result.success) {
+                    expect(result.content).toBe('function test() {\n    return false;\n}\n')
+                }
+            })
+
+            it('should not strip content that starts with pipe but no line number', () => {
+                const originalContent = '| Pipe\n|---|\n| Data\n'
+                const diffContent = `test.ts
+<<<<<<< SEARCH
+| Pipe
+|---|
+| Data
+=======
+| Pipe
+|---|
+| Updated
+>>>>>>> REPLACE`
+            
+                const result = strategy.applyDiff(originalContent, diffContent)
+                expect(result.success).toBe(true)
+                if (result.success) {
+                    expect(result.content).toBe('| Pipe\n|---|\n| Updated\n')
+                }
+            })
+            
+            it('should handle mix of line-numbered and pipe-only content', () => {
+                const originalContent = '| Pipe\n|---|\n| Data\n'
+                const diffContent = `test.ts
+<<<<<<< SEARCH
+| Pipe
+|---|
+| Data
+=======
+1 | | Pipe
+2 | |---|
+3 | | NewData
+>>>>>>> REPLACE`
+            
+                const result = strategy.applyDiff(originalContent, diffContent)
+                expect(result.success).toBe(true)
+                if (result.success) {
+                    expect(result.content).toBe('1 | | Pipe\n2 | |---|\n3 | | NewData\n')
+                }
+            })
+        })
+    });
+
     describe('fuzzy matching', () => {
         let strategy: SearchReplaceDiffStrategy
 

+ 16 - 2
src/core/diff/strategies/search-replace.ts

@@ -62,7 +62,6 @@ The tool will maintain proper indentation and formatting while making changes.
 Only a single operation is allowed per tool use.
 The SEARCH section must exactly match existing content including whitespace and indentation.
 If you're not confident in the exact content to search for, use the read_file tool first to get the exact content.
-IMPORTANT: The read_file tool returns the file content with line numbers prepended to each line. However, DO NOT include line numbers in the SEARCH and REPLACE sections of the diff content.
 
 Parameters:
 - path: (required) The path of the file to modify (relative to the current working directory ${cwd})
@@ -132,10 +131,25 @@ Your search/replace content here
             };
         }
 
-        const [_, searchContent, replaceContent] = match;
+        let [_, searchContent, replaceContent] = match;
         
         // Detect line ending from original content
         const lineEnding = originalContent.includes('\r\n') ? '\r\n' : '\n';
+
+        // Strip line numbers from search and replace content if every line starts with a line number
+        const hasLineNumbers = (content: string) => {
+            const lines = content.split(/\r?\n/);
+            return lines.length > 0 && lines.every(line => /^\d+\s+\|(?!\|)/.test(line));
+        };
+
+        if (hasLineNumbers(searchContent) && hasLineNumbers(replaceContent)) {
+            const stripLineNumbers = (content: string) => {
+                return content.replace(/^\d+\s+\|(?!\|)/gm, '') 
+            };
+
+            searchContent = stripLineNumbers(searchContent);
+            replaceContent = stripLineNumbers(replaceContent);
+        }
         
         // Split content into lines, handling both \n and \r\n
         const searchLines = searchContent.split(/\r?\n/);

+ 2 - 2
webview-ui/package.json

@@ -30,7 +30,7 @@
 	"scripts": {
 		"start": "react-scripts start",
 		"build": "node ./scripts/build-react-no-split.js",
-		"test": "react-scripts test",
+		"test": "react-scripts test --watchAll=false",
 		"eject": "react-scripts eject"
 	},
 	"eslintConfig": {
@@ -57,7 +57,7 @@
 	},
 	"jest": {
 		"transformIgnorePatterns": [
-			"/node_modules/(?!(rehype-highlight|react-remark|unist-util-visit|vfile|unified|bail|is-plain-obj|trough|vfile-message|unist-util-stringify-position|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|character-entities|markdown-table|zwitch|longest-streak|escape-string-regexp|unist-util-is|hast-util-to-text)/)"
+			"/node_modules/(?!(rehype-highlight|react-remark|unist-util-visit|vfile|unified|bail|is-plain-obj|trough|vfile-message|unist-util-stringify-position|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|character-entities|markdown-table|zwitch|longest-streak|escape-string-regexp|unist-util-is|hast-util-to-text|@vscode/webview-ui-toolkit|@microsoft/fast-react-wrapper|@microsoft/fast-element|@microsoft/fast-foundation|@microsoft/fast-web-utilities|exenv-es6)/)"
 		],
 		"moduleNameMapper": {
 			"\\.(css|less|scss|sass)$": "identity-obj-proxy"

+ 84 - 56
webview-ui/src/components/history/HistoryView.tsx

@@ -2,7 +2,7 @@ import { VSCodeButton, VSCodeTextField, VSCodeRadioGroup, VSCodeRadio } from "@v
 import { useExtensionState } from "../../context/ExtensionStateContext"
 import { vscode } from "../../utils/vscode"
 import { Virtuoso } from "react-virtuoso"
-import { memo, useMemo, useState, useEffect } from "react"
+import React, { memo, useMemo, useState, useEffect } from "react"
 import Fuse, { FuseResult } from "fuse.js"
 import { formatLargeNumber } from "../../utils/format"
 
@@ -82,30 +82,28 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 	const taskHistorySearchResults = useMemo(() => {
 		let results = searchQuery ? highlight(fuse.search(searchQuery)) : presentableTasks
 
-		results.sort((a, b) => {
+		// First apply search if needed
+		const searchResults = searchQuery ? results : presentableTasks;
+		
+		// Then sort the results
+		return [...searchResults].sort((a, b) => {
 			switch (sortOption) {
 				case "oldest":
-					return a.ts - b.ts
+					return (a.ts || 0) - (b.ts || 0);
 				case "mostExpensive":
-					return (b.totalCost || 0) - (a.totalCost || 0)
+					return (b.totalCost || 0) - (a.totalCost || 0);
 				case "mostTokens":
-					return (
-						(b.tokensIn || 0) +
-						(b.tokensOut || 0) +
-						(b.cacheWrites || 0) +
-						(b.cacheReads || 0) -
-						((a.tokensIn || 0) + (a.tokensOut || 0) + (a.cacheWrites || 0) + (a.cacheReads || 0))
-					)
+					const aTokens = (a.tokensIn || 0) + (a.tokensOut || 0) + (a.cacheWrites || 0) + (a.cacheReads || 0);
+					const bTokens = (b.tokensIn || 0) + (b.tokensOut || 0) + (b.cacheWrites || 0) + (b.cacheReads || 0);
+					return bTokens - aTokens;
 				case "mostRelevant":
-					// NOTE: you must never sort directly on object since it will cause members to be reordered
-					return searchQuery ? 0 : b.ts - a.ts // Keep fuse order if searching, otherwise sort by newest
+					// Keep fuse order if searching, otherwise sort by newest
+					return searchQuery ? 0 : (b.ts || 0) - (a.ts || 0);
 				case "newest":
 				default:
-					return b.ts - a.ts
+					return (b.ts || 0) - (a.ts || 0);
 			}
-		})
-
-		return results
+		});
 	}, [presentableTasks, searchQuery, fuse, sortOption])
 
 	return (
@@ -227,9 +225,16 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 							overflowY: "scroll",
 						}}
 						data={taskHistorySearchResults}
+						data-testid="virtuoso-container"
+						components={{
+							List: React.forwardRef((props, ref) => (
+								<div {...props} ref={ref} data-testid="virtuoso-item-list" />
+							))
+						}}
 						itemContent={(index, item) => (
 							<div
 								key={item.id}
+								data-testid={`task-item-${item.id}`}
 								className="history-item"
 								style={{
 									cursor: "pointer",
@@ -263,23 +268,23 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 											{formatDate(item.ts)}
 										</span>
 										<div style={{ display: "flex", gap: "4px" }}>
-											<VSCodeButton
-												appearance="icon"
-												title="Copy Prompt"
-												className="copy-button"
-												onClick={(e) => handleCopyTask(e, item.task)}>
-												<span className="codicon codicon-copy"></span>
-											</VSCodeButton>
-											<VSCodeButton
-												appearance="icon"
-												title="Delete Task"
-												onClick={(e) => {
-													e.stopPropagation()
-													handleDeleteHistoryItem(item.id)
-												}}
-												className="delete-button">
-												<span className="codicon codicon-trash"></span>
-											</VSCodeButton>
+											<button
+											  title="Copy Prompt"
+											  className="copy-button"
+											  data-appearance="icon"
+											  onClick={(e) => handleCopyTask(e, item.task)}>
+											  <span className="codicon codicon-copy"></span>
+											</button>
+											<button
+											  title="Delete Task"
+											  className="delete-button"
+											  data-appearance="icon"
+											  onClick={(e) => {
+											    e.stopPropagation()
+											    handleDeleteHistoryItem(item.id)
+											  }}>
+											  <span className="codicon codicon-trash"></span>
+											</button>
 										</div>
 									</div>
 									<div
@@ -298,6 +303,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 									/>
 									<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
 										<div
+											data-testid="tokens-container"
 											style={{
 												display: "flex",
 												justifyContent: "space-between",
@@ -318,6 +324,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 													Tokens:
 												</span>
 												<span
+													data-testid="tokens-in"
 													style={{
 														display: "flex",
 														alignItems: "center",
@@ -335,6 +342,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 													{formatLargeNumber(item.tokensIn || 0)}
 												</span>
 												<span
+													data-testid="tokens-out"
 													style={{
 														display: "flex",
 														alignItems: "center",
@@ -357,6 +365,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 
 										{!!item.cacheWrites && (
 											<div
+												data-testid="cache-container"
 												style={{
 													display: "flex",
 													alignItems: "center",
@@ -371,6 +380,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 													Cache:
 												</span>
 												<span
+													data-testid="cache-writes"
 													style={{
 														display: "flex",
 														alignItems: "center",
@@ -388,6 +398,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
 													+{formatLargeNumber(item.cacheWrites || 0)}
 												</span>
 												<span
+													data-testid="cache-reads"
 													style={{
 														display: "flex",
 														alignItems: "center",
@@ -499,31 +510,48 @@ export const highlight = (
 		if (regions.length === 0) {
 			return inputText
 		}
-
+	
 		// Sort and merge overlapping regions
 		const mergedRegions = mergeRegions(regions)
-
-		let content = ""
-		let nextUnhighlightedRegionStartingIndex = 0
-
-		mergedRegions.forEach((region) => {
-			const start = region[0]
-			const end = region[1]
-			const lastRegionNextIndex = end + 1
-
-			content += [
-				inputText.substring(nextUnhighlightedRegionStartingIndex, start),
-				`<span class="${highlightClassName}">`,
-				inputText.substring(start, lastRegionNextIndex),
-				"</span>",
-			].join("")
-
-			nextUnhighlightedRegionStartingIndex = lastRegionNextIndex
+	
+		// Convert regions to a list of parts with their highlight status
+		const parts: { text: string; highlight: boolean }[] = []
+		let lastIndex = 0
+	
+		mergedRegions.forEach(([start, end]) => {
+			// Add non-highlighted text before this region
+			if (start > lastIndex) {
+				parts.push({
+					text: inputText.substring(lastIndex, start),
+					highlight: false
+				})
+			}
+	
+			// Add highlighted text
+			parts.push({
+				text: inputText.substring(start, end + 1),
+				highlight: true
+			})
+	
+			lastIndex = end + 1
 		})
-
-		content += inputText.substring(nextUnhighlightedRegionStartingIndex)
-
-		return content
+	
+		// Add any remaining text
+		if (lastIndex < inputText.length) {
+			parts.push({
+				text: inputText.substring(lastIndex),
+				highlight: false
+			})
+		}
+	
+		// Build final string
+		return parts
+			.map(part =>
+				part.highlight
+					? `<span class="${highlightClassName}">${part.text}</span>`
+					: part.text
+			)
+			.join('')
 	}
 
 	return fuseSearchResult

+ 172 - 302
webview-ui/src/components/history/__tests__/HistoryView.test.tsx

@@ -1,362 +1,232 @@
 import React from 'react'
-import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
 import HistoryView from '../HistoryView'
-import { ExtensionStateContextProvider } from '../../../context/ExtensionStateContext'
+import { useExtensionState } from '../../../context/ExtensionStateContext'
 import { vscode } from '../../../utils/vscode'
-import { highlight } from '../HistoryView'
-import { FuseResult } from 'fuse.js'
 
-// Mock vscode API
-jest.mock('../../../utils/vscode', () => ({
-  vscode: {
-    postMessage: jest.fn(),
-  },
+// Mock dependencies
+jest.mock('../../../context/ExtensionStateContext')
+jest.mock('../../../utils/vscode')
+jest.mock('react-virtuoso', () => ({
+  Virtuoso: ({ data, itemContent }: any) => (
+    <div data-testid="virtuoso-container">
+      {data.map((item: any, index: number) => (
+        <div key={item.id} data-testid={`virtuoso-item-${item.id}`}>
+          {itemContent(index, item)}
+        </div>
+      ))}
+    </div>
+  ),
 }))
 
-interface VSCodeButtonProps {
-  children: React.ReactNode;
-  onClick?: (e: any) => void;
-  appearance?: string;
-  className?: string;
-}
-
-interface VSCodeTextFieldProps {
-  value?: string;
-  onInput?: (e: { target: { value: string } }) => void;
-  placeholder?: string;
-  style?: React.CSSProperties;
-}
-
-interface VSCodeRadioGroupProps {
-  children?: React.ReactNode;
-  value?: string;
-  onChange?: (e: { target: { value: string } }) => void;
-  style?: React.CSSProperties;
-}
-
-interface VSCodeRadioProps {
-  value: string;
-  children: React.ReactNode;
-  disabled?: boolean;
-  style?: React.CSSProperties;
-}
-
-// Mock VSCode components
-jest.mock('@vscode/webview-ui-toolkit/react', () => ({
-  VSCodeButton: function MockVSCodeButton({ 
-    children,
-    onClick,
-    appearance,
-    className 
-  }: VSCodeButtonProps) {
-    return (
-      <button 
-        onClick={onClick} 
-        data-appearance={appearance}
-        className={className}
-      >
-        {children}
-      </button>
-    )
-  },
-  VSCodeTextField: function MockVSCodeTextField({ 
-    value,
-    onInput,
-    placeholder,
-    style 
-  }: VSCodeTextFieldProps) {
-    return (
-      <input
-        type="text"
-        value={value}
-        onChange={(e) => onInput?.({ target: { value: e.target.value } })}
-        placeholder={placeholder}
-        style={style}
-      />
-    )
+const mockTaskHistory = [
+  {
+    id: '1',
+    task: 'Test task 1',
+    ts: new Date('2022-02-16T00:00:00').getTime(),
+    tokensIn: 100,
+    tokensOut: 50,
+    totalCost: 0.002,
   },
-  VSCodeRadioGroup: function MockVSCodeRadioGroup({
-    children,
-    value,
-    onChange,
-    style
-  }: VSCodeRadioGroupProps) {
-    return (
-      <div style={style} role="radiogroup" data-current-value={value}>
-        {children}
-      </div>
-    )
+  {
+    id: '2',
+    task: 'Test task 2',
+    ts: new Date('2022-02-17T00:00:00').getTime(),
+    tokensIn: 200,
+    tokensOut: 100,
+    cacheWrites: 50,
+    cacheReads: 25,
   },
-  VSCodeRadio: function MockVSCodeRadio({
-    value,
-    children,
-    disabled,
-    style
-  }: VSCodeRadioProps) {
-    return (
-      <label style={style}>
-        <input
-          type="radio"
-          value={value}
-          disabled={disabled}
-          data-testid={`radio-${value}`}
-        />
-        {children}
-      </label>
-    )
-  }
-}))
-
-// Mock window.navigator.clipboard
-Object.assign(navigator, {
-  clipboard: {
-    writeText: jest.fn(),
-  },
-})
-
-// Mock window.postMessage to trigger state hydration
-const mockPostMessage = (state: any) => {
-  window.postMessage({
-    type: 'state',
-    state: {
-      version: '1.0.0',
-      taskHistory: [],
-      ...state
-    }
-  }, '*')
-}
+]
 
 describe('HistoryView', () => {
-  const mockOnDone = jest.fn()
-  const sampleHistory = [
-    {
-      id: '1',
-      task: 'First task',
-      ts: Date.now() - 3000,
-      tokensIn: 100,
-      tokensOut: 50,
-      totalCost: 0.002
-    },
-    {
-      id: '2',
-      task: 'Second task',
-      ts: Date.now() - 2000,
-      tokensIn: 200,
-      tokensOut: 100,
-      totalCost: 0.004
-    },
-    {
-      id: '3',
-      task: 'Third task',
-      ts: Date.now() - 1000,
-      tokensIn: 300,
-      tokensOut: 150,
-      totalCost: 0.006
-    }
-  ]
-
   beforeEach(() => {
+    // Reset all mocks before each test
     jest.clearAllMocks()
+    jest.useFakeTimers()
+    
+    // Mock useExtensionState implementation
+    ;(useExtensionState as jest.Mock).mockReturnValue({
+      taskHistory: mockTaskHistory,
+    })
   })
 
-  it('renders history items in correct order', () => {
-    render(
-      <ExtensionStateContextProvider>
-        <HistoryView onDone={mockOnDone} />
-      </ExtensionStateContextProvider>
-    )
+  afterEach(() => {
+    jest.useRealTimers()
+  })
 
-    mockPostMessage({ taskHistory: sampleHistory })
+  it('renders history items correctly', () => {
+    const onDone = jest.fn()
+    render(<HistoryView onDone={onDone} />)
 
-    const historyItems = screen.getAllByText(/task/i)
-    expect(historyItems).toHaveLength(3)
-    expect(historyItems[0]).toHaveTextContent('Third task')
-    expect(historyItems[1]).toHaveTextContent('Second task')
-    expect(historyItems[2]).toHaveTextContent('First task')
+    // Check if both tasks are rendered
+    expect(screen.getByTestId('virtuoso-item-1')).toBeInTheDocument()
+    expect(screen.getByTestId('virtuoso-item-2')).toBeInTheDocument()
+    expect(screen.getByText('Test task 1')).toBeInTheDocument()
+    expect(screen.getByText('Test task 2')).toBeInTheDocument()
   })
 
-  it('handles sorting by different criteria', async () => {
-    render(
-      <ExtensionStateContextProvider>
-        <HistoryView onDone={mockOnDone} />
-      </ExtensionStateContextProvider>
-    )
+  it('handles search functionality', async () => {
+    const onDone = jest.fn()
+    render(<HistoryView onDone={onDone} />)
 
-    mockPostMessage({ taskHistory: sampleHistory })
+    // Get search input and radio group
+    const searchInput = screen.getByPlaceholderText('Fuzzy search history...')
+    const radioGroup = screen.getByRole('radiogroup')
+    
+    // Type in search
+    await userEvent.type(searchInput, 'task 1')
 
-    // Test oldest sort
-    const oldestRadio = screen.getByTestId('radio-oldest')
-    fireEvent.click(oldestRadio)
+    // Check if sort option automatically changes to "Most Relevant"
+    const mostRelevantRadio = within(radioGroup).getByLabelText('Most Relevant')
+    expect(mostRelevantRadio).not.toBeDisabled()
     
-    let historyItems = screen.getAllByText(/task/i)
-    expect(historyItems[0]).toHaveTextContent('First task')
-    expect(historyItems[2]).toHaveTextContent('Third task')
+    // Click and wait for radio update
+    fireEvent.click(mostRelevantRadio)
+
+    // Wait for radio button to be checked
+    const updatedRadio = await within(radioGroup).findByRole('radio', { name: 'Most Relevant', checked: true })
+    expect(updatedRadio).toBeInTheDocument()
+  })
+
+  it('handles sort options correctly', async () => {
+    const onDone = jest.fn()
+    render(<HistoryView onDone={onDone} />)
+
+    const radioGroup = screen.getByRole('radiogroup')
 
-    // Test most expensive sort
-    const expensiveRadio = screen.getByTestId('radio-mostExpensive')
-    fireEvent.click(expensiveRadio)
+    // Test changing sort options
+    const oldestRadio = within(radioGroup).getByLabelText('Oldest')
+    fireEvent.click(oldestRadio)
     
-    historyItems = screen.getAllByText(/task/i)
-    expect(historyItems[0]).toHaveTextContent('Third task')
-    expect(historyItems[2]).toHaveTextContent('First task')
+    // Wait for oldest radio to be checked
+    const checkedOldestRadio = await within(radioGroup).findByRole('radio', { name: 'Oldest', checked: true })
+    expect(checkedOldestRadio).toBeInTheDocument()
 
-    // Test most tokens sort
-    const tokensRadio = screen.getByTestId('radio-mostTokens')
-    fireEvent.click(tokensRadio)
+    const mostExpensiveRadio = within(radioGroup).getByLabelText('Most Expensive')
+    fireEvent.click(mostExpensiveRadio)
     
-    historyItems = screen.getAllByText(/task/i)
-    expect(historyItems[0]).toHaveTextContent('Third task')
-    expect(historyItems[2]).toHaveTextContent('First task')
+    // Wait for most expensive radio to be checked
+    const checkedExpensiveRadio = await within(radioGroup).findByRole('radio', { name: 'Most Expensive', checked: true })
+    expect(checkedExpensiveRadio).toBeInTheDocument()
   })
 
-  it('handles search functionality and auto-switches to most relevant sort', async () => {
-    render(
-      <ExtensionStateContextProvider>
-        <HistoryView onDone={mockOnDone} />
-      </ExtensionStateContextProvider>
-    )
+  it('handles task selection', () => {
+    const onDone = jest.fn()
+    render(<HistoryView onDone={onDone} />)
 
-    mockPostMessage({ taskHistory: sampleHistory })
+    // Click on first task
+    fireEvent.click(screen.getByText('Test task 1'))
 
-    const searchInput = screen.getByPlaceholderText('Fuzzy search history...')
-    fireEvent.change(searchInput, { target: { value: 'First' } })
+    // Verify vscode message was sent
+    expect(vscode.postMessage).toHaveBeenCalledWith({
+      type: 'showTaskWithId',
+      text: '1',
+    })
+  })
 
-    const historyItems = screen.getAllByText(/task/i)
-    expect(historyItems).toHaveLength(1)
-    expect(historyItems[0]).toHaveTextContent('First task')
+  it('handles task deletion', () => {
+    const onDone = jest.fn()
+    render(<HistoryView onDone={onDone} />)
 
-    // Verify sort switched to Most Relevant
-    const radioGroup = screen.getByRole('radiogroup')
-    expect(radioGroup.getAttribute('data-current-value')).toBe('mostRelevant')
+    // Find and hover over first task
+    const taskContainer = screen.getByTestId('virtuoso-item-1')
+    fireEvent.mouseEnter(taskContainer)
+    
+    const deleteButton = within(taskContainer).getByTitle('Delete Task')
+    fireEvent.click(deleteButton)
 
-    // Clear search and verify sort reverts
-    fireEvent.change(searchInput, { target: { value: '' } })
-    expect(radioGroup.getAttribute('data-current-value')).toBe('newest')
+    // Verify vscode message was sent
+    expect(vscode.postMessage).toHaveBeenCalledWith({
+      type: 'deleteTaskWithId',
+      text: '1',
+    })
   })
 
-  it('handles copy functionality and shows/hides modal', async () => {
-    render(
-      <ExtensionStateContextProvider>
-        <HistoryView onDone={mockOnDone} />
-      </ExtensionStateContextProvider>
-    )
+  it('handles task copying', async () => {
+    const mockClipboard = {
+      writeText: jest.fn().mockResolvedValue(undefined),
+    }
+    Object.assign(navigator, { clipboard: mockClipboard })
 
-    mockPostMessage({ taskHistory: sampleHistory })
+    const onDone = jest.fn()
+    render(<HistoryView onDone={onDone} />)
 
-    const copyButtons = screen.getAllByRole('button', { hidden: true })
-      .filter(button => button.className.includes('copy-button'))
+    // Find and hover over first task
+    const taskContainer = screen.getByTestId('virtuoso-item-1')
+    fireEvent.mouseEnter(taskContainer)
     
-    fireEvent.click(copyButtons[0])
+    const copyButton = within(taskContainer).getByTitle('Copy Prompt')
+    await userEvent.click(copyButton)
 
-    expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Third task')
+    // Verify clipboard API was called
+    expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Test task 1')
     
-    // Verify modal appears
-    await waitFor(() => {
-      expect(screen.getByText('Prompt Copied to Clipboard')).toBeInTheDocument()
-    })
+    // Wait for copy modal to appear
+    const copyModal = await screen.findByText('Prompt Copied to Clipboard')
+    expect(copyModal).toBeInTheDocument()
 
-    // Verify modal disappears
+    // Fast-forward timers and wait for modal to disappear
+    jest.advanceTimersByTime(2000)
     await waitFor(() => {
       expect(screen.queryByText('Prompt Copied to Clipboard')).not.toBeInTheDocument()
-    }, { timeout: 2500 })
+    })
   })
 
-  it('handles delete functionality', () => {
-    render(
-      <ExtensionStateContextProvider>
-        <HistoryView onDone={mockOnDone} />
-      </ExtensionStateContextProvider>
-    )
-
-    mockPostMessage({ taskHistory: sampleHistory })
-
-    const deleteButtons = screen.getAllByRole('button', { hidden: true })
-      .filter(button => button.className.includes('delete-button'))
-    
-    fireEvent.click(deleteButtons[0])
+  it('formats dates correctly', () => {
+    const onDone = jest.fn()
+    render(<HistoryView onDone={onDone} />)
 
-    expect(vscode.postMessage).toHaveBeenCalledWith({
-      type: 'deleteTaskWithId',
-      text: '3'
+    // Find first task container and check date format
+    const taskContainer = screen.getByTestId('virtuoso-item-1')
+    const dateElement = within(taskContainer).getByText((content) => {
+      return content.includes('FEBRUARY 16') && content.includes('12:00 AM')
     })
+    expect(dateElement).toBeInTheDocument()
   })
 
-  it('handles export functionality', () => {
-    render(
-      <ExtensionStateContextProvider>
-        <HistoryView onDone={mockOnDone} />
-      </ExtensionStateContextProvider>
-    )
-
-    mockPostMessage({ taskHistory: sampleHistory })
+  it('displays token counts correctly', () => {
+    const onDone = jest.fn()
+    render(<HistoryView onDone={onDone} />)
 
-    const exportButtons = screen.getAllByRole('button', { hidden: true })
-      .filter(button => button.className.includes('export-button'))
-    
-    fireEvent.click(exportButtons[0])
+    // Find first task container
+    const taskContainer = screen.getByTestId('virtuoso-item-1')
 
-    expect(vscode.postMessage).toHaveBeenCalledWith({
-      type: 'exportTaskWithId',
-      text: '3'
-    })
+    // Find token counts within the task container
+    const tokensContainer = within(taskContainer).getByTestId('tokens-container')
+    expect(within(tokensContainer).getByTestId('tokens-in')).toHaveTextContent('100')
+    expect(within(tokensContainer).getByTestId('tokens-out')).toHaveTextContent('50')
   })
 
-  it('calls onDone when Done button is clicked', () => {
-    render(
-      <ExtensionStateContextProvider>
-        <HistoryView onDone={mockOnDone} />
-      </ExtensionStateContextProvider>
-    )
+  it('displays cache information when available', () => {
+    const onDone = jest.fn()
+    render(<HistoryView onDone={onDone} />)
 
-    const doneButton = screen.getByText('Done')
-    fireEvent.click(doneButton)
+    // Find second task container
+    const taskContainer = screen.getByTestId('virtuoso-item-2')
 
-    expect(mockOnDone).toHaveBeenCalled()
+    // Find cache info within the task container
+    const cacheContainer = within(taskContainer).getByTestId('cache-container')
+    expect(within(cacheContainer).getByTestId('cache-writes')).toHaveTextContent('+50')
+    expect(within(cacheContainer).getByTestId('cache-reads')).toHaveTextContent('25')
   })
 
-  describe('highlight function', () => {
-    it('correctly highlights search matches', () => {
-      const testData = [{
-        item: { text: 'Hello world' },
-        matches: [{ key: 'text', value: 'Hello world', indices: [[0, 4]] }],
-        refIndex: 0
-      }] as FuseResult<any>[]
-
-      const result = highlight(testData)
-      expect(result[0].text).toBe('<span class="history-item-highlight">Hello</span> world')
-    })
+  it('handles export functionality', () => {
+    const onDone = jest.fn()
+    render(<HistoryView onDone={onDone} />)
 
-    it('handles multiple matches', () => {
-      const testData = [{
-        item: { text: 'Hello world Hello' },
-        matches: [{ 
-          key: 'text', 
-          value: 'Hello world Hello', 
-          indices: [[0, 4], [11, 15]] 
-        }],
-        refIndex: 0
-      }] as FuseResult<any>[]
-
-      const result = highlight(testData)
-      expect(result[0].text).toBe(
-        '<span class="history-item-highlight">Hello</span> world ' +
-        '<span class="history-item-highlight">Hello</span>'
-      )
-    })
+    // Find and hover over second task
+    const taskContainer = screen.getByTestId('virtuoso-item-2')
+    fireEvent.mouseEnter(taskContainer)
+    
+    const exportButton = within(taskContainer).getByText('EXPORT')
+    fireEvent.click(exportButton)
 
-    it('handles overlapping matches', () => {
-      const testData = [{
-        item: { text: 'Hello' },
-        matches: [{ 
-          key: 'text', 
-          value: 'Hello', 
-          indices: [[0, 2], [1, 4]] 
-        }],
-        refIndex: 0
-      }] as FuseResult<any>[]
-
-      const result = highlight(testData)
-      expect(result[0].text).toBe('<span class="history-item-highlight">Hello</span>')
+    // Verify vscode message was sent
+    expect(vscode.postMessage).toHaveBeenCalledWith({
+      type: 'exportTaskWithId',
+      text: '2',
     })
   })
-})
+})

+ 44 - 18
webview-ui/src/components/mcp/__tests__/McpToolRow.test.tsx

@@ -9,6 +9,30 @@ jest.mock('../../../utils/vscode', () => ({
   }
 }))
 
+jest.mock('@vscode/webview-ui-toolkit/react', () => ({
+  VSCodeCheckbox: function MockVSCodeCheckbox({
+    children,
+    checked,
+    onChange
+  }: {
+    children?: React.ReactNode;
+    checked?: boolean;
+    onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
+  }) {
+    return (
+      <label>
+        <input
+          type="checkbox"
+          role="checkbox"
+          checked={checked}
+          onChange={onChange}
+        />
+        {children}
+      </label>
+    )
+  }
+}))
+
 describe('McpToolRow', () => {
   const mockTool = {
     name: 'test-tool',
@@ -33,18 +57,18 @@ describe('McpToolRow', () => {
     expect(screen.queryByText('Always allow')).not.toBeInTheDocument()
   })
 
-  it('shows always allow checkbox when serverName is provided', () => {
-    render(<McpToolRow tool={mockTool} serverName="test-server" />)
+  it('shows always allow checkbox when serverName and alwaysAllowMcp are provided', () => {
+    render(<McpToolRow tool={mockTool} serverName="test-server" alwaysAllowMcp={true} />)
     
     expect(screen.getByText('Always allow')).toBeInTheDocument()
   })
-
+  
   it('sends message to toggle always allow when checkbox is clicked', () => {
-    render(<McpToolRow tool={mockTool} serverName="test-server" />)
+    render(<McpToolRow tool={mockTool} serverName="test-server" alwaysAllowMcp={true} />)
     
     const checkbox = screen.getByRole('checkbox')
     fireEvent.click(checkbox)
-
+  
     expect(vscode.postMessage).toHaveBeenCalledWith({
       type: 'toggleToolAlwaysAllow',
       serverName: 'test-server',
@@ -52,29 +76,31 @@ describe('McpToolRow', () => {
       alwaysAllow: true
     })
   })
-
+  
   it('reflects always allow state in checkbox', () => {
     const alwaysAllowedTool = {
       ...mockTool,
       alwaysAllow: true
     }
-
-    render(<McpToolRow tool={alwaysAllowedTool} serverName="test-server" />)
+  
+    render(<McpToolRow tool={alwaysAllowedTool} serverName="test-server" alwaysAllowMcp={true} />)
     
-    const checkbox = screen.getByRole('checkbox')
-    expect(checkbox).toBeChecked()
+    const checkbox = screen.getByRole('checkbox') as HTMLInputElement
+    expect(checkbox.checked).toBe(true)
   })
-
+  
   it('prevents event propagation when clicking the checkbox', () => {
-    const mockStopPropagation = jest.fn()
-    render(<McpToolRow tool={mockTool} serverName="test-server" />)
+    const mockOnClick = jest.fn()
+    render(
+      <div onClick={mockOnClick}>
+        <McpToolRow tool={mockTool} serverName="test-server" alwaysAllowMcp={true} />
+      </div>
+    )
     
     const container = screen.getByTestId('tool-row-container')
-    fireEvent.click(container, {
-      stopPropagation: mockStopPropagation
-    })
-
-    expect(mockStopPropagation).toHaveBeenCalled()
+    fireEvent.click(container)
+    
+    expect(mockOnClick).not.toHaveBeenCalled()
   })
 
   it('displays input schema parameters when provided', () => {

+ 6 - 0
webview-ui/src/components/settings/SettingsView.tsx

@@ -248,6 +248,12 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
 									<VSCodeTextField
 										value={commandInput}
 										onInput={(e: any) => setCommandInput(e.target.value)}
+										onKeyDown={(e: any) => {
+											if (e.key === 'Enter') {
+												e.preventDefault()
+												handleAddCommand()
+											}
+										}}
 										placeholder="Enter command prefix (e.g., 'git ')"
 										style={{ flexGrow: 1 }}
 									/>

+ 16 - 5
webview-ui/src/setupTests.ts

@@ -1,5 +1,16 @@
-// jest-dom adds custom jest matchers for asserting on DOM nodes.
-// allows you to do things like:
-// expect(element).toHaveTextContent(/react/i)
-// learn more: https://github.com/testing-library/jest-dom
-import "@testing-library/jest-dom"
+import '@testing-library/jest-dom';
+
+// Mock window.matchMedia
+Object.defineProperty(window, 'matchMedia', {
+  writable: true,
+  value: jest.fn().mockImplementation(query => ({
+    matches: false,
+    media: query,
+    onchange: null,
+    addListener: jest.fn(), // Deprecated
+    removeListener: jest.fn(), // Deprecated
+    addEventListener: jest.fn(),
+    removeEventListener: jest.fn(),
+    dispatchEvent: jest.fn(),
+  })),
+});