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

This commit is contained in:
Justin Quan
2024-12-16 00:08:21 -08:00
12 changed files with 508 additions and 418 deletions

View File

@@ -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

View File

@@ -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>
)
const mockTaskHistory = [
{
id: '1',
task: 'Test task 1',
ts: new Date('2022-02-16T00:00:00').getTime(),
tokensIn: 100,
tokensOut: 50,
totalCost: 0.002,
},
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}
/>
)
{
id: '2',
task: 'Test task 2',
ts: new Date('2022-02-17T00:00:00').getTime(),
tokensIn: 200,
tokensOut: 100,
cacheWrites: 50,
cacheReads: 25,
},
VSCodeRadioGroup: function MockVSCodeRadioGroup({
children,
value,
onChange,
style
}: VSCodeRadioGroupProps) {
return (
<div style={style} role="radiogroup" data-current-value={value}>
{children}
</div>
)
},
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>
)
mockPostMessage({ taskHistory: sampleHistory })
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')
afterEach(() => {
jest.useRealTimers()
})
it('handles sorting by different criteria', async () => {
render(
<ExtensionStateContextProvider>
<HistoryView onDone={mockOnDone} />
</ExtensionStateContextProvider>
)
it('renders history items correctly', () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
mockPostMessage({ taskHistory: sampleHistory })
// 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()
})
// Test oldest sort
const oldestRadio = screen.getByTestId('radio-oldest')
it('handles search functionality', async () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// 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')
// Check if sort option automatically changes to "Most Relevant"
const mostRelevantRadio = within(radioGroup).getByLabelText('Most Relevant')
expect(mostRelevantRadio).not.toBeDisabled()
// 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 changing sort options
const oldestRadio = within(radioGroup).getByLabelText('Oldest')
fireEvent.click(oldestRadio)
let historyItems = screen.getAllByText(/task/i)
expect(historyItems[0]).toHaveTextContent('First task')
expect(historyItems[2]).toHaveTextContent('Third task')
// Wait for oldest radio to be checked
const checkedOldestRadio = await within(radioGroup).findByRole('radio', { name: 'Oldest', checked: true })
expect(checkedOldestRadio).toBeInTheDocument()
// Test most expensive sort
const expensiveRadio = screen.getByTestId('radio-mostExpensive')
fireEvent.click(expensiveRadio)
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')
// Test most tokens sort
const tokensRadio = screen.getByTestId('radio-mostTokens')
fireEvent.click(tokensRadio)
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' } })
const historyItems = screen.getAllByText(/task/i)
expect(historyItems).toHaveLength(1)
expect(historyItems[0]).toHaveTextContent('First task')
// Verify sort switched to Most Relevant
const radioGroup = screen.getByRole('radiogroup')
expect(radioGroup.getAttribute('data-current-value')).toBe('mostRelevant')
// Clear search and verify sort reverts
fireEvent.change(searchInput, { target: { value: '' } })
expect(radioGroup.getAttribute('data-current-value')).toBe('newest')
})
it('handles copy functionality and shows/hides modal', async () => {
render(
<ExtensionStateContextProvider>
<HistoryView onDone={mockOnDone} />
</ExtensionStateContextProvider>
)
mockPostMessage({ taskHistory: sampleHistory })
const copyButtons = screen.getAllByRole('button', { hidden: true })
.filter(button => button.className.includes('copy-button'))
fireEvent.click(copyButtons[0])
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Third task')
// Verify modal appears
await waitFor(() => {
expect(screen.getByText('Prompt Copied to Clipboard')).toBeInTheDocument()
// Verify vscode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'showTaskWithId',
text: '1',
})
// Verify modal disappears
await waitFor(() => {
expect(screen.queryByText('Prompt Copied to Clipboard')).not.toBeInTheDocument()
}, { timeout: 2500 })
})
it('handles delete functionality', () => {
render(
<ExtensionStateContextProvider>
<HistoryView onDone={mockOnDone} />
</ExtensionStateContextProvider>
)
it('handles task deletion', () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
mockPostMessage({ taskHistory: sampleHistory })
const deleteButtons = screen.getAllByRole('button', { hidden: true })
.filter(button => button.className.includes('delete-button'))
// Find and hover over first task
const taskContainer = screen.getByTestId('virtuoso-item-1')
fireEvent.mouseEnter(taskContainer)
fireEvent.click(deleteButtons[0])
const deleteButton = within(taskContainer).getByTitle('Delete Task')
fireEvent.click(deleteButton)
// Verify vscode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'deleteTaskWithId',
text: '3'
text: '1',
})
})
it('handles task copying', async () => {
const mockClipboard = {
writeText: jest.fn().mockResolvedValue(undefined),
}
Object.assign(navigator, { clipboard: mockClipboard })
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find and hover over first task
const taskContainer = screen.getByTestId('virtuoso-item-1')
fireEvent.mouseEnter(taskContainer)
const copyButton = within(taskContainer).getByTitle('Copy Prompt')
await userEvent.click(copyButton)
// Verify clipboard API was called
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Test task 1')
// Wait for copy modal to appear
const copyModal = await screen.findByText('Prompt Copied to Clipboard')
expect(copyModal).toBeInTheDocument()
// Fast-forward timers and wait for modal to disappear
jest.advanceTimersByTime(2000)
await waitFor(() => {
expect(screen.queryByText('Prompt Copied to Clipboard')).not.toBeInTheDocument()
})
})
it('formats dates correctly', () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// 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('displays token counts correctly', () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find first task container
const taskContainer = screen.getByTestId('virtuoso-item-1')
// 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('displays cache information when available', () => {
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
// Find second task container
const taskContainer = screen.getByTestId('virtuoso-item-2')
// 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')
})
it('handles export functionality', () => {
render(
<ExtensionStateContextProvider>
<HistoryView onDone={mockOnDone} />
</ExtensionStateContextProvider>
)
const onDone = jest.fn()
render(<HistoryView onDone={onDone} />)
mockPostMessage({ taskHistory: sampleHistory })
const exportButtons = screen.getAllByRole('button', { hidden: true })
.filter(button => button.className.includes('export-button'))
// Find and hover over second task
const taskContainer = screen.getByTestId('virtuoso-item-2')
fireEvent.mouseEnter(taskContainer)
fireEvent.click(exportButtons[0])
const exportButton = within(taskContainer).getByText('EXPORT')
fireEvent.click(exportButton)
// Verify vscode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'exportTaskWithId',
text: '3'
text: '2',
})
})
it('calls onDone when Done button is clicked', () => {
render(
<ExtensionStateContextProvider>
<HistoryView onDone={mockOnDone} />
</ExtensionStateContextProvider>
)
const doneButton = screen.getByText('Done')
fireEvent.click(doneButton)
expect(mockOnDone).toHaveBeenCalled()
})
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 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>'
)
})
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>')
})
})
})
})

View File

@@ -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', () => {

View File

@@ -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 }}
/>