mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-09 22:31:08 +00:00
Better model picker
This commit is contained in:
parent
c641c59160
commit
0bc9e17c1a
18 changed files with 687 additions and 1353 deletions
|
|
@ -6,7 +6,7 @@ module.exports = {
|
|||
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
|
||||
transform: { "^.+\\.(ts|tsx)$": ["ts-jest", { tsconfig: { jsx: "react-jsx" } }] },
|
||||
testMatch: ["<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}", "<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}"],
|
||||
setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts", "@testing-library/jest-dom/extend-expect"],
|
||||
setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
|
||||
moduleNameMapper: {
|
||||
"\\.(css|less|scss|sass)$": "identity-obj-proxy",
|
||||
"^vscrui$": "<rootDir>/src/__mocks__/vscrui.ts",
|
||||
|
|
|
|||
196
webview-ui/package-lock.json
generated
196
webview-ui/package-lock.json
generated
|
|
@ -42,9 +42,9 @@
|
|||
"@storybook/react": "^8.5.2",
|
||||
"@storybook/react-vite": "^8.5.2",
|
||||
"@storybook/test": "^8.5.2",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/node": "^18.0.0",
|
||||
"@types/react": "^18.3.18",
|
||||
|
|
@ -5498,24 +5498,22 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "5.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz",
|
||||
"integrity": "sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==",
|
||||
"version": "6.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz",
|
||||
"integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@adobe/css-tools": "^4.0.1",
|
||||
"@babel/runtime": "^7.9.2",
|
||||
"@types/testing-library__jest-dom": "^5.9.1",
|
||||
"@adobe/css-tools": "^4.4.0",
|
||||
"aria-query": "^5.0.0",
|
||||
"chalk": "^3.0.0",
|
||||
"css.escape": "^1.5.1",
|
||||
"dom-accessibility-api": "^0.5.6",
|
||||
"lodash": "^4.17.15",
|
||||
"dom-accessibility-api": "^0.6.3",
|
||||
"lodash": "^4.17.21",
|
||||
"redent": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8",
|
||||
"node": ">=14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1"
|
||||
}
|
||||
|
|
@ -5534,66 +5532,49 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
|
||||
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@testing-library/react": {
|
||||
"version": "13.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.4.0.tgz",
|
||||
"integrity": "sha512-sXOGON+WNTh3MLE9rve97ftaZukN3oNf2KjDy7YTx6hcTO2uuLHuCGynMDhFwGw/jYf4OJ2Qk0i4i79qMNNkyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@testing-library/dom": "^8.5.0",
|
||||
"@types/react-dom": "^18.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/react/node_modules/@testing-library/dom": {
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz",
|
||||
"integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.1.3",
|
||||
"chalk": "^4.1.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/react/node_modules/aria-query": {
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz",
|
||||
"integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"deep-equal": "^2.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/user-event": {
|
||||
"version": "13.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz",
|
||||
"integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==",
|
||||
"version": "16.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.2.0.tgz",
|
||||
"integrity": "sha512-2cSskAvA1QNtKc8Y9VJQRv0tm3hLVgxRGDB+KYhIaPQJ1I+RHbhIXcM+zClKXzMes/wshsMVzf4B9vS4IZpqDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10",
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/user-event": {
|
||||
"version": "14.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
|
||||
"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
|
@ -8255,39 +8236,6 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-equal": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz",
|
||||
"integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"array-buffer-byte-length": "^1.0.0",
|
||||
"call-bind": "^1.0.5",
|
||||
"es-get-iterator": "^1.1.3",
|
||||
"get-intrinsic": "^1.2.2",
|
||||
"is-arguments": "^1.1.1",
|
||||
"is-array-buffer": "^3.0.2",
|
||||
"is-date-object": "^1.0.5",
|
||||
"is-regex": "^1.1.4",
|
||||
"is-shared-array-buffer": "^1.0.2",
|
||||
"isarray": "^2.0.5",
|
||||
"object-is": "^1.1.5",
|
||||
"object-keys": "^1.1.1",
|
||||
"object.assign": "^4.1.4",
|
||||
"regexp.prototype.flags": "^1.5.1",
|
||||
"side-channel": "^1.0.4",
|
||||
"which-boxed-primitive": "^1.0.2",
|
||||
"which-collection": "^1.0.1",
|
||||
"which-typed-array": "^1.1.13"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
|
|
@ -8638,27 +8586,6 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-get-iterator": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz",
|
||||
"integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.2",
|
||||
"get-intrinsic": "^1.1.3",
|
||||
"has-symbols": "^1.0.3",
|
||||
"is-arguments": "^1.1.1",
|
||||
"is-map": "^2.0.2",
|
||||
"is-set": "^2.0.2",
|
||||
"is-string": "^1.0.7",
|
||||
"isarray": "^2.0.5",
|
||||
"stop-iteration-iterator": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es-iterator-helpers": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz",
|
||||
|
|
@ -13201,23 +13128,6 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/object-is": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
|
||||
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.7",
|
||||
"define-properties": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/object-keys": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||
|
|
@ -14923,20 +14833,6 @@
|
|||
"stacktrace-gps": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/stop-iteration-iterator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||
"integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"internal-slot": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/storybook": {
|
||||
"version": "8.5.2",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-8.5.2.tgz",
|
||||
|
|
|
|||
|
|
@ -48,9 +48,9 @@
|
|||
"@storybook/react": "^8.5.2",
|
||||
"@storybook/react-vite": "^8.5.2",
|
||||
"@storybook/test": "^8.5.2",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/node": "^18.0.0",
|
||||
"@types/react": "^18.3.18",
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import React from "react"
|
||||
import { render, screen, fireEvent, within, waitFor } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
// cd webview-ui && npx jest src/components/history/__tests__/HistoryView.test.ts
|
||||
|
||||
import { render, screen, fireEvent, within, act } from "@testing-library/react"
|
||||
import HistoryView from "../HistoryView"
|
||||
import { useExtensionState } from "../../../context/ExtensionStateContext"
|
||||
import { vscode } from "../../../utils/vscode"
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock("../../../context/ExtensionStateContext")
|
||||
jest.mock("../../../utils/vscode")
|
||||
|
||||
jest.mock("react-virtuoso", () => ({
|
||||
Virtuoso: ({ data, itemContent }: any) => (
|
||||
<div data-testid="virtuoso-container">
|
||||
|
|
@ -41,21 +41,21 @@ const mockTaskHistory = [
|
|||
]
|
||||
|
||||
describe("HistoryView", () => {
|
||||
beforeEach(() => {
|
||||
// Reset all mocks before each test
|
||||
jest.clearAllMocks()
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers()
|
||||
})
|
||||
|
||||
// Mock useExtensionState implementation
|
||||
afterAll(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(useExtensionState as jest.Mock).mockReturnValue({
|
||||
taskHistory: mockTaskHistory,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it("renders history items correctly", () => {
|
||||
const onDone = jest.fn()
|
||||
render(<HistoryView onDone={onDone} />)
|
||||
|
|
@ -67,7 +67,7 @@ describe("HistoryView", () => {
|
|||
expect(screen.getByText("Test task 2")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("handles search functionality", async () => {
|
||||
it("handles search functionality", () => {
|
||||
const onDone = jest.fn()
|
||||
render(<HistoryView onDone={onDone} />)
|
||||
|
||||
|
|
@ -76,17 +76,23 @@ describe("HistoryView", () => {
|
|||
const radioGroup = screen.getByRole("radiogroup")
|
||||
|
||||
// Type in search
|
||||
await userEvent.type(searchInput, "task 1")
|
||||
fireEvent.input(searchInput, { target: { value: "task 1" } })
|
||||
|
||||
// Advance timers to process search state update
|
||||
jest.advanceTimersByTime(100)
|
||||
|
||||
// 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
|
||||
// Click the radio button
|
||||
fireEvent.click(mostRelevantRadio)
|
||||
|
||||
// Wait for radio button to be checked
|
||||
const updatedRadio = await within(radioGroup).findByRole("radio", { name: "Most Relevant", checked: true })
|
||||
// Advance timers to process radio button state update
|
||||
jest.advanceTimersByTime(100)
|
||||
|
||||
// Verify radio button is checked
|
||||
const updatedRadio = within(radioGroup).getByRole("radio", { name: "Most Relevant", checked: true })
|
||||
expect(updatedRadio).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
|
@ -148,6 +154,7 @@ describe("HistoryView", () => {
|
|||
})
|
||||
|
||||
it("handles task copying", async () => {
|
||||
// Setup clipboard mock that resolves immediately
|
||||
const mockClipboard = {
|
||||
writeText: jest.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
|
@ -161,20 +168,29 @@ describe("HistoryView", () => {
|
|||
fireEvent.mouseEnter(taskContainer)
|
||||
|
||||
const copyButton = within(taskContainer).getByTitle("Copy Prompt")
|
||||
await userEvent.click(copyButton)
|
||||
|
||||
// Verify clipboard API was called
|
||||
// Click the copy button and wait for clipboard operation
|
||||
await act(async () => {
|
||||
fireEvent.click(copyButton)
|
||||
// Let the clipboard Promise resolve
|
||||
await Promise.resolve()
|
||||
// Let React process the first state update
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
// Verify clipboard 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()
|
||||
// Verify modal appears immediately after clipboard operation
|
||||
expect(screen.getByText("Prompt Copied to Clipboard")).toBeInTheDocument()
|
||||
|
||||
// Fast-forward timers and wait for modal to disappear
|
||||
jest.advanceTimersByTime(2000)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Prompt Copied to Clipboard")).not.toBeInTheDocument()
|
||||
// Advance timer to trigger the setTimeout for modal disappearance
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(2000)
|
||||
})
|
||||
|
||||
// Verify modal is gone
|
||||
expect(screen.queryByText("Prompt Copied to Clipboard")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("formats dates correctly", () => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { Checkbox, Dropdown, Pane } from "vscrui"
|
||||
import type { DropdownOption } from "vscrui"
|
||||
import { VSCodeLink, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import { Fragment, memo, useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useEvent, useInterval } from "react-use"
|
||||
import { Checkbox, Dropdown, Pane, type DropdownOption } from "vscrui"
|
||||
import { VSCodeLink, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import * as vscodemodels from "vscode"
|
||||
|
||||
import {
|
||||
ApiConfiguration,
|
||||
ModelInfo,
|
||||
|
|
@ -32,14 +33,12 @@ import {
|
|||
import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import * as vscodemodels from "vscode"
|
||||
import VSCodeButtonLink from "../common/VSCodeButtonLink"
|
||||
import OpenRouterModelPicker, {
|
||||
ModelDescriptionMarkdown,
|
||||
OPENROUTER_MODEL_PICKER_Z_INDEX,
|
||||
} from "./OpenRouterModelPicker"
|
||||
import { OpenRouterModelPicker } from "./OpenRouterModelPicker"
|
||||
import OpenAiModelPicker from "./OpenAiModelPicker"
|
||||
import GlamaModelPicker from "./GlamaModelPicker"
|
||||
import { GlamaModelPicker } from "./GlamaModelPicker"
|
||||
import { ModelInfoView } from "./ModelInfoView"
|
||||
import { DROPDOWN_Z_INDEX } from "./styles"
|
||||
|
||||
interface ApiOptionsProps {
|
||||
apiErrorMessage?: string
|
||||
|
|
@ -137,7 +136,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
|
|||
},
|
||||
})
|
||||
}}
|
||||
style={{ minWidth: 130, position: "relative", zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX + 1 }}
|
||||
style={{ minWidth: 130, position: "relative", zIndex: DROPDOWN_Z_INDEX + 1 }}
|
||||
options={[
|
||||
{ value: "openrouter", label: "OpenRouter" },
|
||||
{ value: "anthropic", label: "Anthropic" },
|
||||
|
|
@ -1386,136 +1385,6 @@ export function getOpenRouterAuthUrl(uriScheme?: string) {
|
|||
return `https://openrouter.ai/auth?callback_url=${uriScheme || "vscode"}://rooveterinaryinc.roo-cline/openrouter`
|
||||
}
|
||||
|
||||
export const formatPrice = (price: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(price)
|
||||
}
|
||||
|
||||
export const ModelInfoView = ({
|
||||
selectedModelId,
|
||||
modelInfo,
|
||||
isDescriptionExpanded,
|
||||
setIsDescriptionExpanded,
|
||||
}: {
|
||||
selectedModelId: string
|
||||
modelInfo: ModelInfo
|
||||
isDescriptionExpanded: boolean
|
||||
setIsDescriptionExpanded: (isExpanded: boolean) => void
|
||||
}) => {
|
||||
const isGemini = Object.keys(geminiModels).includes(selectedModelId)
|
||||
|
||||
const infoItems = [
|
||||
modelInfo.description && (
|
||||
<ModelDescriptionMarkdown
|
||||
key="description"
|
||||
markdown={modelInfo.description}
|
||||
isExpanded={isDescriptionExpanded}
|
||||
setIsExpanded={setIsDescriptionExpanded}
|
||||
/>
|
||||
),
|
||||
<ModelInfoSupportsItem
|
||||
key="supportsImages"
|
||||
isSupported={modelInfo.supportsImages ?? false}
|
||||
supportsLabel="Supports images"
|
||||
doesNotSupportLabel="Does not support images"
|
||||
/>,
|
||||
<ModelInfoSupportsItem
|
||||
key="supportsComputerUse"
|
||||
isSupported={modelInfo.supportsComputerUse ?? false}
|
||||
supportsLabel="Supports computer use"
|
||||
doesNotSupportLabel="Does not support computer use"
|
||||
/>,
|
||||
!isGemini && (
|
||||
<ModelInfoSupportsItem
|
||||
key="supportsPromptCache"
|
||||
isSupported={modelInfo.supportsPromptCache}
|
||||
supportsLabel="Supports prompt caching"
|
||||
doesNotSupportLabel="Does not support prompt caching"
|
||||
/>
|
||||
),
|
||||
modelInfo.maxTokens !== undefined && modelInfo.maxTokens > 0 && (
|
||||
<span key="maxTokens">
|
||||
<span style={{ fontWeight: 500 }}>Max output:</span> {modelInfo.maxTokens?.toLocaleString()} tokens
|
||||
</span>
|
||||
),
|
||||
modelInfo.inputPrice !== undefined && modelInfo.inputPrice > 0 && (
|
||||
<span key="inputPrice">
|
||||
<span style={{ fontWeight: 500 }}>Input price:</span> {formatPrice(modelInfo.inputPrice)}/million tokens
|
||||
</span>
|
||||
),
|
||||
modelInfo.supportsPromptCache && modelInfo.cacheWritesPrice && (
|
||||
<span key="cacheWritesPrice">
|
||||
<span style={{ fontWeight: 500 }}>Cache writes price:</span>{" "}
|
||||
{formatPrice(modelInfo.cacheWritesPrice || 0)}/million tokens
|
||||
</span>
|
||||
),
|
||||
modelInfo.supportsPromptCache && modelInfo.cacheReadsPrice && (
|
||||
<span key="cacheReadsPrice">
|
||||
<span style={{ fontWeight: 500 }}>Cache reads price:</span>{" "}
|
||||
{formatPrice(modelInfo.cacheReadsPrice || 0)}/million tokens
|
||||
</span>
|
||||
),
|
||||
modelInfo.outputPrice !== undefined && modelInfo.outputPrice > 0 && (
|
||||
<span key="outputPrice">
|
||||
<span style={{ fontWeight: 500 }}>Output price:</span> {formatPrice(modelInfo.outputPrice)}/million
|
||||
tokens
|
||||
</span>
|
||||
),
|
||||
isGemini && (
|
||||
<span key="geminiInfo" style={{ fontStyle: "italic" }}>
|
||||
* Free up to {selectedModelId && selectedModelId.includes("flash") ? "15" : "2"} requests per minute.
|
||||
After that, billing depends on prompt size.{" "}
|
||||
<VSCodeLink href="https://ai.google.dev/pricing" style={{ display: "inline", fontSize: "inherit" }}>
|
||||
For more info, see pricing details.
|
||||
</VSCodeLink>
|
||||
</span>
|
||||
),
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<p style={{ fontSize: "12px", marginTop: "2px", color: "var(--vscode-descriptionForeground)" }}>
|
||||
{infoItems.map((item, index) => (
|
||||
<Fragment key={index}>
|
||||
{item}
|
||||
{index < infoItems.length - 1 && <br />}
|
||||
</Fragment>
|
||||
))}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const ModelInfoSupportsItem = ({
|
||||
isSupported,
|
||||
supportsLabel,
|
||||
doesNotSupportLabel,
|
||||
}: {
|
||||
isSupported: boolean
|
||||
supportsLabel: string
|
||||
doesNotSupportLabel: string
|
||||
}) => (
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
color: isSupported ? "var(--vscode-charts-green)" : "var(--vscode-errorForeground)",
|
||||
}}>
|
||||
<i
|
||||
className={`codicon codicon-${isSupported ? "check" : "x"}`}
|
||||
style={{
|
||||
marginRight: 4,
|
||||
marginBottom: isSupported ? 1 : -1,
|
||||
fontSize: isSupported ? 11 : 13,
|
||||
fontWeight: 700,
|
||||
display: "inline-block",
|
||||
verticalAlign: "bottom",
|
||||
}}></i>
|
||||
{isSupported ? supportsLabel : doesNotSupportLabel}
|
||||
</span>
|
||||
)
|
||||
|
||||
export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
|
||||
const provider = apiConfiguration?.apiProvider || "anthropic"
|
||||
const modelId = apiConfiguration?.apiModelId
|
||||
|
|
|
|||
|
|
@ -1,415 +1,15 @@
|
|||
import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import debounce from "debounce"
|
||||
import { Fzf } from "fzf"
|
||||
import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useRemark } from "react-remark"
|
||||
import { useMount } from "react-use"
|
||||
import styled from "styled-components"
|
||||
import { ModelPicker } from "./ModelPicker"
|
||||
import { glamaDefaultModelId } from "../../../../src/shared/api"
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import { highlightFzfMatch } from "../../utils/highlight"
|
||||
import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
|
||||
|
||||
const GlamaModelPicker: React.FC = () => {
|
||||
const { apiConfiguration, setApiConfiguration, glamaModels, onUpdateApiConfig } = useExtensionState()
|
||||
const [searchTerm, setSearchTerm] = useState(apiConfiguration?.glamaModelId || glamaDefaultModelId)
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
|
||||
const dropdownListRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleModelChange = (newModelId: string) => {
|
||||
// could be setting invalid model id/undefined info but validation will catch it
|
||||
const apiConfig = {
|
||||
...apiConfiguration,
|
||||
glamaModelId: newModelId,
|
||||
glamaModelInfo: glamaModels[newModelId],
|
||||
}
|
||||
setApiConfiguration(apiConfig)
|
||||
onUpdateApiConfig(apiConfig)
|
||||
|
||||
setSearchTerm(newModelId)
|
||||
}
|
||||
|
||||
const { selectedModelId, selectedModelInfo } = useMemo(() => {
|
||||
return normalizeApiConfiguration(apiConfiguration)
|
||||
}, [apiConfiguration])
|
||||
|
||||
useEffect(() => {
|
||||
if (apiConfiguration?.glamaModelId && apiConfiguration?.glamaModelId !== searchTerm) {
|
||||
setSearchTerm(apiConfiguration?.glamaModelId)
|
||||
}
|
||||
}, [apiConfiguration, searchTerm])
|
||||
|
||||
const debouncedRefreshModels = useMemo(
|
||||
() =>
|
||||
debounce(() => {
|
||||
vscode.postMessage({ type: "refreshGlamaModels" })
|
||||
}, 50),
|
||||
[],
|
||||
)
|
||||
|
||||
useMount(() => {
|
||||
debouncedRefreshModels()
|
||||
|
||||
// Cleanup debounced function
|
||||
return () => {
|
||||
debouncedRefreshModels.clear()
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsDropdownVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const modelIds = useMemo(() => {
|
||||
return Object.keys(glamaModels).sort((a, b) => a.localeCompare(b))
|
||||
}, [glamaModels])
|
||||
|
||||
const searchableItems = useMemo(() => {
|
||||
return modelIds.map((id) => ({
|
||||
id,
|
||||
html: id,
|
||||
}))
|
||||
}, [modelIds])
|
||||
|
||||
const fzf = useMemo(() => {
|
||||
return new Fzf(searchableItems, {
|
||||
selector: (item) => item.html,
|
||||
})
|
||||
}, [searchableItems])
|
||||
|
||||
const modelSearchResults = useMemo(() => {
|
||||
if (!searchTerm) return searchableItems
|
||||
|
||||
const searchResults = fzf.find(searchTerm)
|
||||
return searchResults.map((result) => ({
|
||||
...result.item,
|
||||
html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight"),
|
||||
}))
|
||||
}, [searchableItems, searchTerm, fzf])
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!isDropdownVisible) return
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault()
|
||||
setSelectedIndex((prev) => (prev < modelSearchResults.length - 1 ? prev + 1 : prev))
|
||||
break
|
||||
case "ArrowUp":
|
||||
event.preventDefault()
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev))
|
||||
break
|
||||
case "Enter":
|
||||
event.preventDefault()
|
||||
if (selectedIndex >= 0 && selectedIndex < modelSearchResults.length) {
|
||||
handleModelChange(modelSearchResults[selectedIndex].id)
|
||||
setIsDropdownVisible(false)
|
||||
}
|
||||
break
|
||||
case "Escape":
|
||||
setIsDropdownVisible(false)
|
||||
setSelectedIndex(-1)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const hasInfo = useMemo(() => {
|
||||
return modelIds.some((id) => id.toLowerCase() === searchTerm.toLowerCase())
|
||||
}, [modelIds, searchTerm])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(-1)
|
||||
if (dropdownListRef.current) {
|
||||
dropdownListRef.current.scrollTop = 0
|
||||
}
|
||||
}, [searchTerm])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIndex >= 0 && itemRefs.current[selectedIndex]) {
|
||||
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
}, [selectedIndex])
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
.model-item-highlight {
|
||||
background-color: var(--vscode-editor-findMatchHighlightBackground);
|
||||
color: inherit;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div>
|
||||
<label htmlFor="model-search">
|
||||
<span style={{ fontWeight: 500 }}>Model</span>
|
||||
</label>
|
||||
<DropdownWrapper ref={dropdownRef}>
|
||||
<VSCodeTextField
|
||||
id="model-search"
|
||||
placeholder="Search and select a model..."
|
||||
value={searchTerm}
|
||||
onInput={(e) => {
|
||||
handleModelChange((e.target as HTMLInputElement)?.value?.toLowerCase())
|
||||
setIsDropdownVisible(true)
|
||||
}}
|
||||
onFocus={() => setIsDropdownVisible(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{ width: "100%", zIndex: GLAMA_MODEL_PICKER_Z_INDEX, position: "relative" }}>
|
||||
{searchTerm && (
|
||||
<div
|
||||
className="input-icon-button codicon codicon-close"
|
||||
aria-label="Clear search"
|
||||
onClick={() => {
|
||||
handleModelChange("")
|
||||
setIsDropdownVisible(true)
|
||||
}}
|
||||
slot="end"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</VSCodeTextField>
|
||||
{isDropdownVisible && (
|
||||
<DropdownList ref={dropdownListRef}>
|
||||
{modelSearchResults.map((item, index) => (
|
||||
<DropdownItem
|
||||
key={item.id}
|
||||
ref={(el) => (itemRefs.current[index] = el)}
|
||||
isSelected={index === selectedIndex}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
onClick={() => {
|
||||
handleModelChange(item.id)
|
||||
setIsDropdownVisible(false)
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: item.html,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</DropdownList>
|
||||
)}
|
||||
</DropdownWrapper>
|
||||
</div>
|
||||
|
||||
{hasInfo ? (
|
||||
<ModelInfoView
|
||||
selectedModelId={selectedModelId}
|
||||
modelInfo={selectedModelInfo}
|
||||
isDescriptionExpanded={isDescriptionExpanded}
|
||||
setIsDescriptionExpanded={setIsDescriptionExpanded}
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: 0,
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
The extension automatically fetches the latest list of models available on{" "}
|
||||
<VSCodeLink style={{ display: "inline", fontSize: "inherit" }} href="https://glama.ai/models">
|
||||
Glama.
|
||||
</VSCodeLink>
|
||||
If you're unsure which model to choose, Roo Code works best with{" "}
|
||||
<VSCodeLink
|
||||
style={{ display: "inline", fontSize: "inherit" }}
|
||||
onClick={() => handleModelChange("anthropic/claude-3.5-sonnet")}>
|
||||
anthropic/claude-3.5-sonnet.
|
||||
</VSCodeLink>
|
||||
You can also try searching "free" for no-cost options currently available.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GlamaModelPicker
|
||||
|
||||
// Dropdown
|
||||
|
||||
const DropdownWrapper = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
export const GLAMA_MODEL_PICKER_Z_INDEX = 1_000
|
||||
|
||||
const DropdownList = styled.div`
|
||||
position: absolute;
|
||||
top: calc(100% - 3px);
|
||||
left: 0;
|
||||
width: calc(100% - 2px);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background-color: var(--vscode-dropdown-background);
|
||||
border: 1px solid var(--vscode-list-activeSelectionBackground);
|
||||
z-index: ${GLAMA_MODEL_PICKER_Z_INDEX - 1};
|
||||
border-bottom-left-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
`
|
||||
|
||||
const DropdownItem = styled.div<{ isSelected: boolean }>`
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
|
||||
background-color: ${({ isSelected }) => (isSelected ? "var(--vscode-list-activeSelectionBackground)" : "inherit")};
|
||||
|
||||
&:hover {
|
||||
background-color: var(--vscode-list-activeSelectionBackground);
|
||||
}
|
||||
`
|
||||
|
||||
// Markdown
|
||||
|
||||
const StyledMarkdown = styled.div`
|
||||
font-family:
|
||||
var(--vscode-font-family),
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
"Open Sans",
|
||||
"Helvetica Neue",
|
||||
sans-serif;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
|
||||
p,
|
||||
li,
|
||||
ol,
|
||||
ul {
|
||||
line-height: 1.25;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
padding-left: 1.5em;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
a {
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ModelDescriptionMarkdown = memo(
|
||||
({
|
||||
markdown,
|
||||
key,
|
||||
isExpanded,
|
||||
setIsExpanded,
|
||||
}: {
|
||||
markdown?: string
|
||||
key: string
|
||||
isExpanded: boolean
|
||||
setIsExpanded: (isExpanded: boolean) => void
|
||||
}) => {
|
||||
const [reactContent, setMarkdown] = useRemark()
|
||||
const [showSeeMore, setShowSeeMore] = useState(false)
|
||||
const textContainerRef = useRef<HTMLDivElement>(null)
|
||||
const textRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setMarkdown(markdown || "")
|
||||
}, [markdown, setMarkdown])
|
||||
|
||||
useEffect(() => {
|
||||
if (textRef.current && textContainerRef.current) {
|
||||
const { scrollHeight } = textRef.current
|
||||
const { clientHeight } = textContainerRef.current
|
||||
const isOverflowing = scrollHeight > clientHeight
|
||||
setShowSeeMore(isOverflowing)
|
||||
}
|
||||
}, [reactContent, setIsExpanded])
|
||||
|
||||
return (
|
||||
<StyledMarkdown key={key} style={{ display: "inline-block", marginBottom: 0 }}>
|
||||
<div
|
||||
ref={textContainerRef}
|
||||
style={{
|
||||
overflowY: isExpanded ? "auto" : "hidden",
|
||||
position: "relative",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
}}>
|
||||
<div
|
||||
ref={textRef}
|
||||
style={{
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: isExpanded ? "unset" : 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
{reactContent}
|
||||
</div>
|
||||
{!isExpanded && showSeeMore && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: 30,
|
||||
height: "1.2em",
|
||||
background:
|
||||
"linear-gradient(to right, transparent, var(--vscode-sideBar-background))",
|
||||
}}
|
||||
/>
|
||||
<VSCodeLink
|
||||
style={{
|
||||
fontSize: "inherit",
|
||||
paddingRight: 0,
|
||||
paddingLeft: 3,
|
||||
backgroundColor: "var(--vscode-sideBar-background)",
|
||||
}}
|
||||
onClick={() => setIsExpanded(true)}>
|
||||
See more
|
||||
</VSCodeLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StyledMarkdown>
|
||||
)
|
||||
},
|
||||
export const GlamaModelPicker = () => (
|
||||
<ModelPicker
|
||||
defaultModelId={glamaDefaultModelId}
|
||||
modelsKey="glamaModels"
|
||||
configKey="glamaModelId"
|
||||
infoKey="glamaModelInfo"
|
||||
refreshMessageType="refreshGlamaModels"
|
||||
serviceName="Glama"
|
||||
serviceUrl="https://glama.ai/models"
|
||||
recommendedModel="anthropic/claude-3-5-sonnet"
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import { memo, useEffect, useRef, useState } from "react"
|
||||
import { useRemark } from "react-remark"
|
||||
|
||||
import { StyledMarkdown } from "./styles"
|
||||
|
||||
export const ModelDescriptionMarkdown = memo(
|
||||
({
|
||||
markdown,
|
||||
key,
|
||||
isExpanded,
|
||||
setIsExpanded,
|
||||
}: {
|
||||
markdown?: string
|
||||
key: string
|
||||
isExpanded: boolean
|
||||
setIsExpanded: (isExpanded: boolean) => void
|
||||
}) => {
|
||||
const [reactContent, setMarkdown] = useRemark()
|
||||
const [showSeeMore, setShowSeeMore] = useState(false)
|
||||
const textContainerRef = useRef<HTMLDivElement>(null)
|
||||
const textRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setMarkdown(markdown || "")
|
||||
}, [markdown, setMarkdown])
|
||||
|
||||
useEffect(() => {
|
||||
if (textRef.current && textContainerRef.current) {
|
||||
const { scrollHeight } = textRef.current
|
||||
const { clientHeight } = textContainerRef.current
|
||||
const isOverflowing = scrollHeight > clientHeight
|
||||
setShowSeeMore(isOverflowing)
|
||||
}
|
||||
}, [reactContent, setIsExpanded])
|
||||
|
||||
return (
|
||||
<StyledMarkdown key={key} style={{ display: "inline-block", marginBottom: 0 }}>
|
||||
<div
|
||||
ref={textContainerRef}
|
||||
style={{
|
||||
overflowY: isExpanded ? "auto" : "hidden",
|
||||
position: "relative",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
}}>
|
||||
<div
|
||||
ref={textRef}
|
||||
style={{
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: isExpanded ? "unset" : 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
{reactContent}
|
||||
</div>
|
||||
{!isExpanded && showSeeMore && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: 30,
|
||||
height: "1.2em",
|
||||
background:
|
||||
"linear-gradient(to right, transparent, var(--vscode-sideBar-background))",
|
||||
}}
|
||||
/>
|
||||
<VSCodeLink
|
||||
style={{
|
||||
fontSize: "inherit",
|
||||
paddingRight: 0,
|
||||
paddingLeft: 3,
|
||||
backgroundColor: "var(--vscode-sideBar-background)",
|
||||
}}
|
||||
onClick={() => setIsExpanded(true)}>
|
||||
See more
|
||||
</VSCodeLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StyledMarkdown>
|
||||
)
|
||||
},
|
||||
)
|
||||
124
webview-ui/src/components/settings/ModelInfoView.tsx
Normal file
124
webview-ui/src/components/settings/ModelInfoView.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import { Fragment } from "react"
|
||||
|
||||
import { ModelInfo, geminiModels } from "../../../../src/shared/api"
|
||||
import { ModelDescriptionMarkdown } from "./ModelDescriptionMarkdown"
|
||||
import { formatPrice } from "../../utils/formatPrice"
|
||||
|
||||
export const ModelInfoView = ({
|
||||
selectedModelId,
|
||||
modelInfo,
|
||||
isDescriptionExpanded,
|
||||
setIsDescriptionExpanded,
|
||||
}: {
|
||||
selectedModelId: string
|
||||
modelInfo: ModelInfo
|
||||
isDescriptionExpanded: boolean
|
||||
setIsDescriptionExpanded: (isExpanded: boolean) => void
|
||||
}) => {
|
||||
const isGemini = Object.keys(geminiModels).includes(selectedModelId)
|
||||
|
||||
const infoItems = [
|
||||
modelInfo.description && (
|
||||
<ModelDescriptionMarkdown
|
||||
key="description"
|
||||
markdown={modelInfo.description}
|
||||
isExpanded={isDescriptionExpanded}
|
||||
setIsExpanded={setIsDescriptionExpanded}
|
||||
/>
|
||||
),
|
||||
<ModelInfoSupportsItem
|
||||
isSupported={modelInfo.supportsImages ?? false}
|
||||
supportsLabel="Supports images"
|
||||
doesNotSupportLabel="Does not support images"
|
||||
/>,
|
||||
<ModelInfoSupportsItem
|
||||
isSupported={modelInfo.supportsComputerUse ?? false}
|
||||
supportsLabel="Supports computer use"
|
||||
doesNotSupportLabel="Does not support computer use"
|
||||
/>,
|
||||
!isGemini && (
|
||||
<ModelInfoSupportsItem
|
||||
isSupported={modelInfo.supportsPromptCache}
|
||||
supportsLabel="Supports prompt caching"
|
||||
doesNotSupportLabel="Does not support prompt caching"
|
||||
/>
|
||||
),
|
||||
modelInfo.maxTokens !== undefined && modelInfo.maxTokens > 0 && (
|
||||
<span key="maxTokens">
|
||||
<span style={{ fontWeight: 500 }}>Max output:</span> {modelInfo.maxTokens?.toLocaleString()} tokens
|
||||
</span>
|
||||
),
|
||||
modelInfo.inputPrice !== undefined && modelInfo.inputPrice > 0 && (
|
||||
<span key="inputPrice">
|
||||
<span style={{ fontWeight: 500 }}>Input price:</span> {formatPrice(modelInfo.inputPrice)}/million tokens
|
||||
</span>
|
||||
),
|
||||
modelInfo.supportsPromptCache && modelInfo.cacheWritesPrice && (
|
||||
<span key="cacheWritesPrice">
|
||||
<span style={{ fontWeight: 500 }}>Cache writes price:</span>{" "}
|
||||
{formatPrice(modelInfo.cacheWritesPrice || 0)}/million tokens
|
||||
</span>
|
||||
),
|
||||
modelInfo.supportsPromptCache && modelInfo.cacheReadsPrice && (
|
||||
<span key="cacheReadsPrice">
|
||||
<span style={{ fontWeight: 500 }}>Cache reads price:</span>{" "}
|
||||
{formatPrice(modelInfo.cacheReadsPrice || 0)}/million tokens
|
||||
</span>
|
||||
),
|
||||
modelInfo.outputPrice !== undefined && modelInfo.outputPrice > 0 && (
|
||||
<span key="outputPrice">
|
||||
<span style={{ fontWeight: 500 }}>Output price:</span> {formatPrice(modelInfo.outputPrice)}/million
|
||||
tokens
|
||||
</span>
|
||||
),
|
||||
isGemini && (
|
||||
<span key="geminiInfo" style={{ fontStyle: "italic" }}>
|
||||
* Free up to {selectedModelId && selectedModelId.includes("flash") ? "15" : "2"} requests per minute.
|
||||
After that, billing depends on prompt size.{" "}
|
||||
<VSCodeLink href="https://ai.google.dev/pricing" style={{ display: "inline", fontSize: "inherit" }}>
|
||||
For more info, see pricing details.
|
||||
</VSCodeLink>
|
||||
</span>
|
||||
),
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<div style={{ fontSize: "12px", marginTop: "2px", color: "var(--vscode-descriptionForeground)" }}>
|
||||
{infoItems.map((item, index) => (
|
||||
<Fragment key={index}>
|
||||
{item}
|
||||
{index < infoItems.length - 1 && <br />}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ModelInfoSupportsItem = ({
|
||||
isSupported,
|
||||
supportsLabel,
|
||||
doesNotSupportLabel,
|
||||
}: {
|
||||
isSupported: boolean
|
||||
supportsLabel: string
|
||||
doesNotSupportLabel: string
|
||||
}) => (
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
color: isSupported ? "var(--vscode-charts-green)" : "var(--vscode-errorForeground)",
|
||||
}}>
|
||||
<i
|
||||
className={`codicon codicon-${isSupported ? "check" : "x"}`}
|
||||
style={{
|
||||
marginRight: 4,
|
||||
marginBottom: isSupported ? 1 : -1,
|
||||
fontSize: isSupported ? 11 : 13,
|
||||
fontWeight: 700,
|
||||
display: "inline-block",
|
||||
verticalAlign: "bottom",
|
||||
}}></i>
|
||||
{isSupported ? supportsLabel : doesNotSupportLabel}
|
||||
</span>
|
||||
)
|
||||
130
webview-ui/src/components/settings/ModelPicker.tsx
Normal file
130
webview-ui/src/components/settings/ModelPicker.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import debounce from "debounce"
|
||||
import { useMemo, useState, useCallback, useEffect } from "react"
|
||||
import { useMount } from "react-use"
|
||||
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Button,
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui"
|
||||
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import { normalizeApiConfiguration } from "./ApiOptions"
|
||||
import { ModelInfoView } from "./ModelInfoView"
|
||||
|
||||
interface ModelPickerProps {
|
||||
defaultModelId: string
|
||||
modelsKey: "glamaModels" | "openRouterModels"
|
||||
configKey: "glamaModelId" | "openRouterModelId"
|
||||
infoKey: "glamaModelInfo" | "openRouterModelInfo"
|
||||
refreshMessageType: "refreshGlamaModels" | "refreshOpenRouterModels"
|
||||
serviceName: string
|
||||
serviceUrl: string
|
||||
recommendedModel: string
|
||||
}
|
||||
|
||||
export const ModelPicker = ({
|
||||
defaultModelId,
|
||||
modelsKey,
|
||||
configKey,
|
||||
infoKey,
|
||||
refreshMessageType,
|
||||
serviceName,
|
||||
serviceUrl,
|
||||
recommendedModel,
|
||||
}: ModelPickerProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [value, setValue] = useState(defaultModelId)
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
|
||||
|
||||
const { apiConfiguration, setApiConfiguration, [modelsKey]: models, onUpdateApiConfig } = useExtensionState()
|
||||
const modelIds = useMemo(() => Object.keys(models).sort((a, b) => a.localeCompare(b)), [models])
|
||||
|
||||
const { selectedModelId, selectedModelInfo } = useMemo(
|
||||
() => normalizeApiConfiguration(apiConfiguration),
|
||||
[apiConfiguration],
|
||||
)
|
||||
|
||||
const onSelect = useCallback(
|
||||
(modelId: string) => {
|
||||
const apiConfig = { ...apiConfiguration, [configKey]: modelId, [infoKey]: models[modelId] }
|
||||
setApiConfiguration(apiConfig)
|
||||
onUpdateApiConfig(apiConfig)
|
||||
setValue(modelId)
|
||||
setOpen(false)
|
||||
},
|
||||
[apiConfiguration, configKey, infoKey, models, onUpdateApiConfig, setApiConfiguration],
|
||||
)
|
||||
|
||||
const debouncedRefreshModels = useMemo(
|
||||
() => debounce(() => vscode.postMessage({ type: refreshMessageType }), 50),
|
||||
[refreshMessageType],
|
||||
)
|
||||
|
||||
useMount(() => {
|
||||
debouncedRefreshModels()
|
||||
return () => debouncedRefreshModels.clear()
|
||||
})
|
||||
|
||||
useEffect(() => setValue(selectedModelId), [selectedModelId])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="font-semibold">Model</div>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="combobox" role="combobox" aria-expanded={open} className="w-full justify-between">
|
||||
{value ?? "Select model..."}
|
||||
<CaretSortIcon className="opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search model..." className="h-9" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No model found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{modelIds.map((model) => (
|
||||
<CommandItem key={model} value={model} onSelect={onSelect}>
|
||||
{model}
|
||||
<CheckIcon
|
||||
className={cn("ml-auto", value === model ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{selectedModelId && selectedModelInfo && (
|
||||
<ModelInfoView
|
||||
selectedModelId={selectedModelId}
|
||||
modelInfo={selectedModelInfo}
|
||||
isDescriptionExpanded={isDescriptionExpanded}
|
||||
setIsDescriptionExpanded={setIsDescriptionExpanded}
|
||||
/>
|
||||
)}
|
||||
<p>
|
||||
The extension automatically fetches the latest list of models available on{" "}
|
||||
<VSCodeLink style={{ display: "inline", fontSize: "inherit" }} href={serviceUrl}>
|
||||
{serviceName}.
|
||||
</VSCodeLink>
|
||||
If you're unsure which model to choose, Roo Code works best with{" "}
|
||||
<VSCodeLink onClick={() => onSelect(recommendedModel)}>{recommendedModel}.</VSCodeLink>
|
||||
You can also try searching "free" for no-cost options currently available.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import { Fzf } from "fzf"
|
||||
import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import debounce from "debounce"
|
||||
import { useRemark } from "react-remark"
|
||||
import styled from "styled-components"
|
||||
import { Fzf } from "fzf"
|
||||
import React, { KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import { highlightFzfMatch } from "../../utils/highlight"
|
||||
import { DropdownWrapper, DropdownList, DropdownItem } from "./styles"
|
||||
|
||||
const OpenAiModelPicker: React.FC = () => {
|
||||
const { apiConfiguration, setApiConfiguration, openAiModels, onUpdateApiConfig } = useExtensionState()
|
||||
|
|
@ -23,6 +23,7 @@ const OpenAiModelPicker: React.FC = () => {
|
|||
...apiConfiguration,
|
||||
openAiModelId: newModelId,
|
||||
}
|
||||
|
||||
setApiConfiguration(apiConfig)
|
||||
onUpdateApiConfig(apiConfig)
|
||||
setSearchTerm(newModelId)
|
||||
|
|
@ -185,12 +186,12 @@ const OpenAiModelPicker: React.FC = () => {
|
|||
)}
|
||||
</VSCodeTextField>
|
||||
{isDropdownVisible && (
|
||||
<DropdownList ref={dropdownListRef}>
|
||||
<DropdownList ref={dropdownListRef} $zIndex={OPENAI_MODEL_PICKER_Z_INDEX - 1}>
|
||||
{modelSearchResults.map((item, index) => (
|
||||
<DropdownItem
|
||||
$selected={index === selectedIndex}
|
||||
key={item.id}
|
||||
ref={(el) => (itemRefs.current[index] = el)}
|
||||
isSelected={index === selectedIndex}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
onClick={() => {
|
||||
handleModelChange(item.id)
|
||||
|
|
@ -213,177 +214,4 @@ export default OpenAiModelPicker
|
|||
|
||||
// Dropdown
|
||||
|
||||
const DropdownWrapper = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
export const OPENAI_MODEL_PICKER_Z_INDEX = 1_000
|
||||
|
||||
const DropdownList = styled.div`
|
||||
position: absolute;
|
||||
top: calc(100% - 3px);
|
||||
left: 0;
|
||||
width: calc(100% - 2px);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background-color: var(--vscode-dropdown-background);
|
||||
border: 1px solid var(--vscode-list-activeSelectionBackground);
|
||||
z-index: ${OPENAI_MODEL_PICKER_Z_INDEX - 1};
|
||||
border-bottom-left-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
`
|
||||
|
||||
const DropdownItem = styled.div<{ isSelected: boolean }>`
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
|
||||
background-color: ${({ isSelected }) => (isSelected ? "var(--vscode-list-activeSelectionBackground)" : "inherit")};
|
||||
|
||||
&:hover {
|
||||
background-color: var(--vscode-list-activeSelectionBackground);
|
||||
}
|
||||
`
|
||||
|
||||
// Markdown
|
||||
|
||||
const StyledMarkdown = styled.div`
|
||||
font-family:
|
||||
var(--vscode-font-family),
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
"Open Sans",
|
||||
"Helvetica Neue",
|
||||
sans-serif;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
|
||||
p,
|
||||
li,
|
||||
ol,
|
||||
ul {
|
||||
line-height: 1.25;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
padding-left: 1.5em;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
a {
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ModelDescriptionMarkdown = memo(
|
||||
({
|
||||
markdown,
|
||||
key,
|
||||
isExpanded,
|
||||
setIsExpanded,
|
||||
}: {
|
||||
markdown?: string
|
||||
key: string
|
||||
isExpanded: boolean
|
||||
setIsExpanded: (isExpanded: boolean) => void
|
||||
}) => {
|
||||
const [reactContent, setMarkdown] = useRemark()
|
||||
// const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [showSeeMore, setShowSeeMore] = useState(false)
|
||||
const textContainerRef = useRef<HTMLDivElement>(null)
|
||||
const textRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setMarkdown(markdown || "")
|
||||
}, [markdown, setMarkdown])
|
||||
|
||||
useEffect(() => {
|
||||
if (textRef.current && textContainerRef.current) {
|
||||
const { scrollHeight } = textRef.current
|
||||
const { clientHeight } = textContainerRef.current
|
||||
const isOverflowing = scrollHeight > clientHeight
|
||||
setShowSeeMore(isOverflowing)
|
||||
// if (!isOverflowing) {
|
||||
// setIsExpanded(false)
|
||||
// }
|
||||
}
|
||||
}, [reactContent, setIsExpanded])
|
||||
|
||||
return (
|
||||
<StyledMarkdown key={key} style={{ display: "inline-block", marginBottom: 0 }}>
|
||||
<div
|
||||
ref={textContainerRef}
|
||||
style={{
|
||||
overflowY: isExpanded ? "auto" : "hidden",
|
||||
position: "relative",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
}}>
|
||||
<div
|
||||
ref={textRef}
|
||||
style={{
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: isExpanded ? "unset" : 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
// whiteSpace: "pre-wrap",
|
||||
// wordBreak: "break-word",
|
||||
// overflowWrap: "anywhere",
|
||||
}}>
|
||||
{reactContent}
|
||||
</div>
|
||||
{!isExpanded && showSeeMore && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: 30,
|
||||
height: "1.2em",
|
||||
background:
|
||||
"linear-gradient(to right, transparent, var(--vscode-sideBar-background))",
|
||||
}}
|
||||
/>
|
||||
<VSCodeLink
|
||||
style={{
|
||||
// cursor: "pointer",
|
||||
// color: "var(--vscode-textLink-foreground)",
|
||||
fontSize: "inherit",
|
||||
paddingRight: 0,
|
||||
paddingLeft: 3,
|
||||
backgroundColor: "var(--vscode-sideBar-background)",
|
||||
}}
|
||||
onClick={() => setIsExpanded(true)}>
|
||||
See more
|
||||
</VSCodeLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StyledMarkdown>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,437 +1,15 @@
|
|||
import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import debounce from "debounce"
|
||||
import { Fzf } from "fzf"
|
||||
import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useRemark } from "react-remark"
|
||||
import { useMount } from "react-use"
|
||||
import styled from "styled-components"
|
||||
import { ModelPicker } from "./ModelPicker"
|
||||
import { openRouterDefaultModelId } from "../../../../src/shared/api"
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import { highlightFzfMatch } from "../../utils/highlight"
|
||||
import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
|
||||
|
||||
const OpenRouterModelPicker: React.FC = () => {
|
||||
const { apiConfiguration, setApiConfiguration, openRouterModels, onUpdateApiConfig } = useExtensionState()
|
||||
const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openRouterModelId || openRouterDefaultModelId)
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
|
||||
const dropdownListRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleModelChange = (newModelId: string) => {
|
||||
// could be setting invalid model id/undefined info but validation will catch it
|
||||
const apiConfig = {
|
||||
...apiConfiguration,
|
||||
openRouterModelId: newModelId,
|
||||
openRouterModelInfo: openRouterModels[newModelId],
|
||||
}
|
||||
|
||||
setApiConfiguration(apiConfig)
|
||||
onUpdateApiConfig(apiConfig)
|
||||
setSearchTerm(newModelId)
|
||||
}
|
||||
|
||||
const { selectedModelId, selectedModelInfo } = useMemo(() => {
|
||||
return normalizeApiConfiguration(apiConfiguration)
|
||||
}, [apiConfiguration])
|
||||
|
||||
useEffect(() => {
|
||||
if (apiConfiguration?.openRouterModelId && apiConfiguration?.openRouterModelId !== searchTerm) {
|
||||
setSearchTerm(apiConfiguration?.openRouterModelId)
|
||||
}
|
||||
}, [apiConfiguration, searchTerm])
|
||||
|
||||
const debouncedRefreshModels = useMemo(
|
||||
() =>
|
||||
debounce(() => {
|
||||
vscode.postMessage({ type: "refreshOpenRouterModels" })
|
||||
}, 50),
|
||||
[],
|
||||
)
|
||||
|
||||
useMount(() => {
|
||||
debouncedRefreshModels()
|
||||
|
||||
// Cleanup debounced function
|
||||
return () => {
|
||||
debouncedRefreshModels.clear()
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsDropdownVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const modelIds = useMemo(() => {
|
||||
return Object.keys(openRouterModels).sort((a, b) => a.localeCompare(b))
|
||||
}, [openRouterModels])
|
||||
|
||||
const searchableItems = useMemo(() => {
|
||||
return modelIds.map((id) => ({
|
||||
id,
|
||||
html: id,
|
||||
}))
|
||||
}, [modelIds])
|
||||
|
||||
const fzf = useMemo(() => {
|
||||
return new Fzf(searchableItems, {
|
||||
selector: (item) => item.html,
|
||||
})
|
||||
}, [searchableItems])
|
||||
|
||||
const modelSearchResults = useMemo(() => {
|
||||
if (!searchTerm) return searchableItems
|
||||
|
||||
const searchResults = fzf.find(searchTerm)
|
||||
return searchResults.map((result) => ({
|
||||
...result.item,
|
||||
html: highlightFzfMatch(result.item.html, Array.from(result.positions), "model-item-highlight"),
|
||||
}))
|
||||
}, [searchableItems, searchTerm, fzf])
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!isDropdownVisible) return
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault()
|
||||
setSelectedIndex((prev) => (prev < modelSearchResults.length - 1 ? prev + 1 : prev))
|
||||
break
|
||||
case "ArrowUp":
|
||||
event.preventDefault()
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev))
|
||||
break
|
||||
case "Enter":
|
||||
event.preventDefault()
|
||||
if (selectedIndex >= 0 && selectedIndex < modelSearchResults.length) {
|
||||
handleModelChange(modelSearchResults[selectedIndex].id)
|
||||
setIsDropdownVisible(false)
|
||||
}
|
||||
break
|
||||
case "Escape":
|
||||
setIsDropdownVisible(false)
|
||||
setSelectedIndex(-1)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const hasInfo = useMemo(() => {
|
||||
return modelIds.some((id) => id.toLowerCase() === searchTerm.toLowerCase())
|
||||
}, [modelIds, searchTerm])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(-1)
|
||||
if (dropdownListRef.current) {
|
||||
dropdownListRef.current.scrollTop = 0
|
||||
}
|
||||
}, [searchTerm])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIndex >= 0 && itemRefs.current[selectedIndex]) {
|
||||
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
}, [selectedIndex])
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
.model-item-highlight {
|
||||
background-color: var(--vscode-editor-findMatchHighlightBackground);
|
||||
color: inherit;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div>
|
||||
<label htmlFor="model-search">
|
||||
<span style={{ fontWeight: 500 }}>Model</span>
|
||||
</label>
|
||||
<DropdownWrapper ref={dropdownRef}>
|
||||
<VSCodeTextField
|
||||
id="model-search"
|
||||
placeholder="Search and select a model..."
|
||||
value={searchTerm}
|
||||
onInput={(e) => {
|
||||
handleModelChange((e.target as HTMLInputElement)?.value?.toLowerCase())
|
||||
setIsDropdownVisible(true)
|
||||
}}
|
||||
onFocus={() => setIsDropdownVisible(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{ width: "100%", zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX, position: "relative" }}>
|
||||
{searchTerm && (
|
||||
<div
|
||||
className="input-icon-button codicon codicon-close"
|
||||
aria-label="Clear search"
|
||||
onClick={() => {
|
||||
handleModelChange("")
|
||||
setIsDropdownVisible(true)
|
||||
}}
|
||||
slot="end"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</VSCodeTextField>
|
||||
{isDropdownVisible && (
|
||||
<DropdownList ref={dropdownListRef}>
|
||||
{modelSearchResults.map((item, index) => (
|
||||
<DropdownItem
|
||||
key={item.id}
|
||||
ref={(el) => (itemRefs.current[index] = el)}
|
||||
isSelected={index === selectedIndex}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
onClick={() => {
|
||||
handleModelChange(item.id)
|
||||
setIsDropdownVisible(false)
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: item.html,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</DropdownList>
|
||||
)}
|
||||
</DropdownWrapper>
|
||||
</div>
|
||||
|
||||
{hasInfo ? (
|
||||
<ModelInfoView
|
||||
selectedModelId={selectedModelId}
|
||||
modelInfo={selectedModelInfo}
|
||||
isDescriptionExpanded={isDescriptionExpanded}
|
||||
setIsDescriptionExpanded={setIsDescriptionExpanded}
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: 0,
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
The extension automatically fetches the latest list of models available on{" "}
|
||||
<VSCodeLink style={{ display: "inline", fontSize: "inherit" }} href="https://openrouter.ai/models">
|
||||
OpenRouter.
|
||||
</VSCodeLink>
|
||||
If you're unsure which model to choose, Roo Code works best with{" "}
|
||||
<VSCodeLink
|
||||
style={{ display: "inline", fontSize: "inherit" }}
|
||||
onClick={() => handleModelChange("anthropic/claude-3.5-sonnet:beta")}>
|
||||
anthropic/claude-3.5-sonnet:beta.
|
||||
</VSCodeLink>
|
||||
You can also try searching "free" for no-cost options currently available.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default OpenRouterModelPicker
|
||||
|
||||
// Dropdown
|
||||
|
||||
const DropdownWrapper = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
export const OPENROUTER_MODEL_PICKER_Z_INDEX = 1_000
|
||||
|
||||
const DropdownList = styled.div`
|
||||
position: absolute;
|
||||
top: calc(100% - 3px);
|
||||
left: 0;
|
||||
width: calc(100% - 2px);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background-color: var(--vscode-dropdown-background);
|
||||
border: 1px solid var(--vscode-list-activeSelectionBackground);
|
||||
z-index: ${OPENROUTER_MODEL_PICKER_Z_INDEX - 1};
|
||||
border-bottom-left-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
`
|
||||
|
||||
const DropdownItem = styled.div<{ isSelected: boolean }>`
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
|
||||
background-color: ${({ isSelected }) => (isSelected ? "var(--vscode-list-activeSelectionBackground)" : "inherit")};
|
||||
|
||||
&:hover {
|
||||
background-color: var(--vscode-list-activeSelectionBackground);
|
||||
}
|
||||
`
|
||||
|
||||
// Markdown
|
||||
|
||||
const StyledMarkdown = styled.div`
|
||||
font-family:
|
||||
var(--vscode-font-family),
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
"Open Sans",
|
||||
"Helvetica Neue",
|
||||
sans-serif;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
|
||||
p,
|
||||
li,
|
||||
ol,
|
||||
ul {
|
||||
line-height: 1.25;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
padding-left: 1.5em;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
a {
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ModelDescriptionMarkdown = memo(
|
||||
({
|
||||
markdown,
|
||||
key,
|
||||
isExpanded,
|
||||
setIsExpanded,
|
||||
}: {
|
||||
markdown?: string
|
||||
key: string
|
||||
isExpanded: boolean
|
||||
setIsExpanded: (isExpanded: boolean) => void
|
||||
}) => {
|
||||
const [reactContent, setMarkdown] = useRemark()
|
||||
// const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [showSeeMore, setShowSeeMore] = useState(false)
|
||||
const textContainerRef = useRef<HTMLDivElement>(null)
|
||||
const textRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setMarkdown(markdown || "")
|
||||
}, [markdown, setMarkdown])
|
||||
|
||||
useEffect(() => {
|
||||
if (textRef.current && textContainerRef.current) {
|
||||
const { scrollHeight } = textRef.current
|
||||
const { clientHeight } = textContainerRef.current
|
||||
const isOverflowing = scrollHeight > clientHeight
|
||||
setShowSeeMore(isOverflowing)
|
||||
// if (!isOverflowing) {
|
||||
// setIsExpanded(false)
|
||||
// }
|
||||
}
|
||||
}, [reactContent, setIsExpanded])
|
||||
|
||||
return (
|
||||
<StyledMarkdown key={key} style={{ display: "inline-block", marginBottom: 0 }}>
|
||||
<div
|
||||
ref={textContainerRef}
|
||||
style={{
|
||||
overflowY: isExpanded ? "auto" : "hidden",
|
||||
position: "relative",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
}}>
|
||||
<div
|
||||
ref={textRef}
|
||||
style={{
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: isExpanded ? "unset" : 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
// whiteSpace: "pre-wrap",
|
||||
// wordBreak: "break-word",
|
||||
// overflowWrap: "anywhere",
|
||||
}}>
|
||||
{reactContent}
|
||||
</div>
|
||||
{!isExpanded && showSeeMore && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width: 30,
|
||||
height: "1.2em",
|
||||
background:
|
||||
"linear-gradient(to right, transparent, var(--vscode-sideBar-background))",
|
||||
}}
|
||||
/>
|
||||
<VSCodeLink
|
||||
style={{
|
||||
// cursor: "pointer",
|
||||
// color: "var(--vscode-textLink-foreground)",
|
||||
fontSize: "inherit",
|
||||
paddingRight: 0,
|
||||
paddingLeft: 3,
|
||||
backgroundColor: "var(--vscode-sideBar-background)",
|
||||
}}
|
||||
onClick={() => setIsExpanded(true)}>
|
||||
See more
|
||||
</VSCodeLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* {isExpanded && showSeeMore && (
|
||||
<div
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--vscode-textLink-foreground)",
|
||||
marginLeft: "auto",
|
||||
textAlign: "right",
|
||||
paddingRight: 2,
|
||||
}}
|
||||
onClick={() => setIsExpanded(false)}>
|
||||
See less
|
||||
</div>
|
||||
)} */}
|
||||
</StyledMarkdown>
|
||||
)
|
||||
},
|
||||
export const OpenRouterModelPicker = () => (
|
||||
<ModelPicker
|
||||
defaultModelId={openRouterDefaultModelId}
|
||||
modelsKey="openRouterModels"
|
||||
configKey="openRouterModelId"
|
||||
infoKey="openRouterModelInfo"
|
||||
refreshMessageType="refreshOpenRouterModels"
|
||||
serviceName="OpenRouter"
|
||||
serviceUrl="https://openrouter.ai/models"
|
||||
recommendedModel="anthropic/claude-3.5-sonnet:beta"
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
// cd webview-ui && npx jest src/components/settings/__tests__/ModelPicker.test.ts
|
||||
|
||||
import { screen, fireEvent, render } from "@testing-library/react"
|
||||
import { act } from "react"
|
||||
import { ModelPicker } from "../ModelPicker"
|
||||
import { useExtensionState } from "../../../context/ExtensionStateContext"
|
||||
|
||||
jest.mock("../../../context/ExtensionStateContext", () => ({
|
||||
useExtensionState: jest.fn(),
|
||||
}))
|
||||
|
||||
class MockResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
global.ResizeObserver = MockResizeObserver
|
||||
|
||||
Element.prototype.scrollIntoView = jest.fn()
|
||||
|
||||
describe("ModelPicker", () => {
|
||||
const mockOnUpdateApiConfig = jest.fn()
|
||||
const mockSetApiConfiguration = jest.fn()
|
||||
|
||||
const defaultProps = {
|
||||
defaultModelId: "model1",
|
||||
modelsKey: "glamaModels" as const,
|
||||
configKey: "glamaModelId" as const,
|
||||
infoKey: "glamaModelInfo" as const,
|
||||
refreshMessageType: "refreshGlamaModels" as const,
|
||||
serviceName: "Test Service",
|
||||
serviceUrl: "https://test.service",
|
||||
recommendedModel: "recommended-model",
|
||||
}
|
||||
|
||||
const mockModels = {
|
||||
model1: { name: "Model 1", description: "Test model 1" },
|
||||
model2: { name: "Model 2", description: "Test model 2" },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(useExtensionState as jest.Mock).mockReturnValue({
|
||||
apiConfiguration: {},
|
||||
setApiConfiguration: mockSetApiConfiguration,
|
||||
glamaModels: mockModels,
|
||||
onUpdateApiConfig: mockOnUpdateApiConfig,
|
||||
})
|
||||
})
|
||||
|
||||
it("calls onUpdateApiConfig when a model is selected", async () => {
|
||||
await act(async () => {
|
||||
render(<ModelPicker {...defaultProps} />)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
// Open the popover by clicking the button.
|
||||
const button = screen.getByRole("combobox")
|
||||
fireEvent.click(button)
|
||||
})
|
||||
|
||||
// Wait for popover to open and animations to complete.
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
// Find and click the model item by its value.
|
||||
const modelItem = screen.getByRole("option", { name: "model2" })
|
||||
fireEvent.click(modelItem)
|
||||
})
|
||||
|
||||
// Verify the API config was updated.
|
||||
expect(mockSetApiConfiguration).toHaveBeenCalledWith({
|
||||
glamaModelId: "model2",
|
||||
glamaModelInfo: mockModels["model2"],
|
||||
})
|
||||
|
||||
// Verify onUpdateApiConfig was called with the new config.
|
||||
expect(mockOnUpdateApiConfig).toHaveBeenCalledWith({
|
||||
glamaModelId: "model2",
|
||||
glamaModelInfo: mockModels["model2"],
|
||||
})
|
||||
})
|
||||
})
|
||||
80
webview-ui/src/components/settings/styles.ts
Normal file
80
webview-ui/src/components/settings/styles.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import styled from "styled-components"
|
||||
|
||||
export const DROPDOWN_Z_INDEX = 1_000
|
||||
|
||||
export const DropdownWrapper = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
export const DropdownList = styled.div<{ $zIndex: number }>`
|
||||
position: absolute;
|
||||
top: calc(100% - 3px);
|
||||
left: 0;
|
||||
width: calc(100% - 2px);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background-color: var(--vscode-dropdown-background);
|
||||
border: 1px solid var(--vscode-list-activeSelectionBackground);
|
||||
z-index: ${({ $zIndex }) => $zIndex};
|
||||
border-bottom-left-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
`
|
||||
|
||||
export const DropdownItem = styled.div<{ $selected: boolean }>`
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
|
||||
background-color: ${({ $selected }) => ($selected ? "var(--vscode-list-activeSelectionBackground)" : "inherit")};
|
||||
|
||||
&:hover {
|
||||
background-color: var(--vscode-list-activeSelectionBackground);
|
||||
}
|
||||
`
|
||||
|
||||
export const StyledMarkdown = styled.div`
|
||||
font-family:
|
||||
var(--vscode-font-family),
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
"Open Sans",
|
||||
"Helvetica Neue",
|
||||
sans-serif;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
|
||||
p,
|
||||
li,
|
||||
ol,
|
||||
ul {
|
||||
line-height: 1.25;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
padding-left: 1.5em;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
a {
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
|
@ -10,11 +10,14 @@ const buttonVariants = cva(
|
|||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline: "border border-input bg-foreground shadow-sm hover:bg-foreground/80",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
outline:
|
||||
"border border-vscode-dropdown-border bg-vscode-background shadow-sm hover:border-vscode-dropdown-border/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
combobox:
|
||||
"bg-vscode-dropdown-background text-vscode-dropdown-foreground border border-vscode-dropdown-border",
|
||||
},
|
||||
size: {
|
||||
default: "h-7 px-3",
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ const CommandInput = React.forwardRef<
|
|||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<div className="flex items-center border-b border-vscode-dropdown-border px-3" cmdk-input-wrapper="">
|
||||
<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
|
|
@ -93,7 +93,11 @@ const CommandSeparator = React.forwardRef<
|
|||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-vscode-dropdown-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
|
|
@ -104,7 +108,7 @@ const CommandItem = React.forwardRef<
|
|||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"relative flex cursor-pointer gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm text-vscode-dropdown-foreground outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef<
|
|||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 w-72 rounded-xs border border-vscode-dropdown-border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,28 @@
|
|||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
/**
|
||||
* Allow VSCode colors to be used with Tailwind.
|
||||
*/
|
||||
|
||||
--color-vscode-foreground: var(--vscode-foreground);
|
||||
--color-vscode-background: var(--vscode-background);
|
||||
|
||||
--color-vscode-editor-foreground: var(--vscode-editor-foreground);
|
||||
--color-vscode-editor-background: var(--vscode-editor-background);
|
||||
|
||||
--color-vscode-button-foreground: var(--vscode-button-foreground);
|
||||
--color-vscode-button-background: var(--vscode-button-background);
|
||||
--color-vscode-button-secondaryForeground: var(--vscode-button-secondaryForeground);
|
||||
--color-vscode-button-secondaryBackground: var(--vscode-button-secondaryBackground);
|
||||
|
||||
--color-vscode-dropdown-foreground: var(--vscode-dropdown-foreground);
|
||||
--color-vscode-dropdown-background: var(--vscode-dropdown-background);
|
||||
--color-vscode-dropdown-border: var(--vscode-dropdown-border);
|
||||
|
||||
--color-vscode-input-background: var(--vscode-input-background);
|
||||
--color-vscode-input-border: var(--vscode-input-border);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
|
|
|||
8
webview-ui/src/utils/formatPrice.ts
Normal file
8
webview-ui/src/utils/formatPrice.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export const formatPrice = (price: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(price)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue