mirror of
https://github.com/cline/cline.git
synced 2026-09-09 15:02:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00118e47cf | ||
|
|
57910c4fa3 | ||
|
|
246070bf8a | ||
|
|
1df8b1673a | ||
|
|
2e438e5df5 | ||
|
|
fa763ad165 | ||
|
|
a0f89959a4 | ||
|
|
2e93928c8f | ||
|
|
70def9a48d | ||
|
|
cae1d625f7 | ||
|
|
13b097d451 | ||
|
|
e78a58d77d | ||
|
|
26d7dfa8f7 | ||
|
|
a48b4feba3 | ||
|
|
56f21d7aba | ||
|
|
b687d90952 | ||
|
|
07d26779f6 | ||
|
|
22764756e6 | ||
|
|
3533dfca42 | ||
|
|
a160e8d67b | ||
|
|
98fdf898be | ||
|
|
e4a7201a14 | ||
|
|
22b9723362 | ||
|
|
d963966309 | ||
|
|
3d4e828850 | ||
|
|
5f8d3d5511 | ||
|
|
af46531e52 | ||
|
|
13af5992af | ||
|
|
083005ecef | ||
|
|
843ef29a07 | ||
|
|
ac6f712b9c | ||
|
|
3b5e018a60 | ||
|
|
e95bebacda | ||
|
|
dbc63602f9 | ||
|
|
95d57c9833 | ||
|
|
c1c8c71f3e | ||
|
|
273eb3acad | ||
|
|
63f3589f89 | ||
|
|
6b7a2894b1 | ||
|
|
ce208c8c8c | ||
|
|
b93a866d5a | ||
|
|
aa2d964c37 | ||
|
|
4f5e1f9f3c | ||
|
|
4af5eee2a5 | ||
|
|
2d5c3ad63b | ||
|
|
5f8573bb92 | ||
|
|
ae38713d5b | ||
|
|
79250e9b57 | ||
|
|
d2be6b7030 | ||
|
|
7fa7589ed0 | ||
|
|
9c26980c06 | ||
|
|
6c7f4a6533 | ||
|
|
2a0a5435bc | ||
|
|
5ef5d99433 | ||
|
|
d55c5d43c6 | ||
|
|
d7a31705a9 | ||
|
|
cfb3c20afa | ||
|
|
288cc49dbc | ||
|
|
42e9d36580 | ||
|
|
12840c40ed | ||
|
|
469898f5c2 | ||
|
|
cfd4cbfa36 | ||
|
|
650283a11f | ||
|
|
6a7ab87f68 | ||
|
|
141fa69c05 | ||
|
|
5dcf836d81 | ||
|
|
6739e4536a | ||
|
|
addbf3fd17 | ||
|
|
0298e7af7f | ||
|
|
df4e8e7afc | ||
|
|
cc9637e0fe | ||
|
|
f6fd76823b | ||
|
|
e8df2400bf | ||
|
|
10063c11b0 | ||
|
|
ec33ef5e3a | ||
|
|
ef47e3f3c9 | ||
|
|
585d15bdee | ||
|
|
5c5005d9a2 | ||
|
|
733c5670e1 | ||
|
|
f9b517c4d0 | ||
|
|
4f00f89bf3 | ||
|
|
26fb2780b5 | ||
|
|
0316f49f07 | ||
|
|
6aa04115b1 | ||
|
|
ec5a96e852 | ||
|
|
2fa519a5db | ||
|
|
84150587a7 | ||
|
|
c277f7625f | ||
|
|
c4ce3f0495 | ||
|
|
f6a69f924c | ||
|
|
d1437e6d2d | ||
|
|
38f98951d0 | ||
|
|
5b944b80e2 | ||
|
|
a0f2a08f35 | ||
|
|
6989779dd6 | ||
|
|
ec2bfa352a | ||
|
|
ace4596fe3 | ||
|
|
880424bc65 | ||
|
|
8b1f11815e | ||
|
|
7c1412aa83 | ||
|
|
2c72d4420b | ||
|
|
f2fec159db | ||
|
|
103b0a47a5 | ||
|
|
245f3d404e | ||
|
|
009616dafc | ||
|
|
69d2bb68b4 | ||
|
|
acb3ff8ded | ||
|
|
5deecc4cb2 | ||
|
|
902564d8f0 | ||
|
|
e00b63ef03 | ||
|
|
b2ced9b484 | ||
|
|
a3d345d1af | ||
|
|
23cb16afb9 | ||
|
|
069b57bf3c | ||
|
|
8e7f15a655 | ||
|
|
240d002f32 | ||
|
|
e43f4cef98 | ||
|
|
38085f156d | ||
|
|
48d2411a11 | ||
|
|
f93e7946aa | ||
|
|
ecc5eb30a5 | ||
|
|
4c173254ed | ||
|
|
6454163078 | ||
|
|
3c082e753b | ||
|
|
f54774b943 | ||
|
|
a863b26b7a | ||
|
|
9f7d6d428b | ||
|
|
c1012dcd99 | ||
|
|
71b226b5c4 | ||
|
|
7e17ad53ed | ||
|
|
60ca8baecb | ||
|
|
7da81f651d | ||
|
|
848f3fb7dd | ||
|
|
1c6136a934 | ||
|
|
b2297846d9 | ||
|
|
a5435a662e | ||
|
|
6153b68f66 | ||
|
|
b6a8c03317 | ||
|
|
77cbbbfe49 | ||
|
|
a70e7a55e1 | ||
|
|
069bb57430 | ||
|
|
d5175c8175 | ||
|
|
efad5ecd91 | ||
|
|
e949b3d5e6 | ||
|
|
d44cc99ce1 | ||
|
|
911dd159cd | ||
|
|
9acae31fbb | ||
|
|
bb5da7858e | ||
|
|
4fc9708e16 | ||
|
|
1e5ffdb6b9 | ||
|
|
4e070a44c1 | ||
|
|
c0ef27033b | ||
|
|
84f89c569a | ||
|
|
91eedd7097 | ||
|
|
08737ac119 | ||
|
|
b299803ac5 | ||
|
|
38d461b784 | ||
|
|
c823cdd4f8 | ||
|
|
09050b8800 | ||
|
|
bbdf66b45c | ||
|
|
0ac7f63332 | ||
|
|
582d178157 | ||
|
|
1ea4c9beee | ||
|
|
d7ee0c0161 | ||
|
|
fa1068d92d | ||
|
|
b32f91b0ee | ||
|
|
7f30a8bea7 | ||
|
|
7656f8d653 | ||
|
|
2d37c290fa | ||
|
|
7989410696 | ||
|
|
c09a8462d7 | ||
|
|
d441950b7f | ||
|
|
15f52696b9 | ||
|
|
c800f157da | ||
|
|
67ed5edbe6 | ||
|
|
89c242d966 | ||
|
|
c994f64c6f | ||
|
|
07f50e63a3 | ||
|
|
d84e6c6fb1 | ||
|
|
d3edb61dd3 | ||
|
|
70634ddd33 | ||
|
|
683abb1ac3 | ||
|
|
1d305b66c1 | ||
|
|
5eb0c65419 | ||
|
|
31d8472f59 | ||
|
|
624a4e4ed6 | ||
|
|
4c83833f32 | ||
|
|
3fc5f2aac0 | ||
|
|
565acd921e | ||
|
|
5c38110933 | ||
|
|
6f0579a265 | ||
|
|
c70e14c298 | ||
|
|
dc97689836 | ||
|
|
2992baaaea | ||
|
|
fd13081109 | ||
|
|
1ee733103a | ||
|
|
1dc3ba5f40 | ||
|
|
b4b99bc331 | ||
|
|
b29c512aa2 | ||
|
|
11dd56ae60 | ||
|
|
1a4bae7f0d | ||
|
|
b672eeb307 | ||
|
|
c69b515345 | ||
|
|
9467583938 | ||
|
|
838a3ae10f | ||
|
|
3daca3d68f | ||
|
|
71a9ed1d1e | ||
|
|
3fc300273d | ||
|
|
d93a08af5d | ||
|
|
45ee2c3e1c | ||
|
|
5c98a3d4af | ||
|
|
24666fed30 | ||
|
|
7afae36ab7 | ||
|
|
e5d86ffb8d | ||
|
|
d7d3f44a5d | ||
|
|
e6f6d754b2 |
@@ -4,6 +4,46 @@ All notable changes to the "claude-dev" extension will be documented in this fil
|
||||
|
||||
<!-- Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. -->
|
||||
|
||||
## [1.5.0]
|
||||
|
||||
- Adds new `search_files` tool that lets Claude perform regex searches in your project, making it easy for him to refactor code, address TODOs and FIXMEs, remove dead code, and more!
|
||||
|
||||
## [1.4.0]
|
||||
|
||||
- Adds "Always allow read-only operations" setting to let Claude read files and view directories without needing approval (off by default)
|
||||
- Implement sliding window context management to keep tasks going past 200k tokens
|
||||
- Adds Google Cloud Vertex AI support and updates Claude 3.5 Sonnet max output to 8192 tokens for all providers.
|
||||
- Improves system prompt to gaurd against lazy edits (less "//rest of code here")
|
||||
|
||||
## [1.3.0]
|
||||
|
||||
- Adds task history
|
||||
|
||||
## [1.2.0]
|
||||
|
||||
- Adds support for Prompt Caching to significantly reduce costs and response times (currently only available through Anthropic API for Claude 3.5 Sonnet and Claude 3.0 Haiku)
|
||||
|
||||
## [1.1.1]
|
||||
|
||||
- Adds option to choose other Claude models (+ GPT-4o, DeepSeek, and Mistral if you use OpenRouter)
|
||||
- Adds option to add custom instructions to the end of the system prompt
|
||||
|
||||
## [1.1.0]
|
||||
|
||||
- Paste images in chat to use Claude's vision capabilities and turn mockups into fully functional applications or fix bugs with screenshots
|
||||
|
||||
## [1.0.9]
|
||||
|
||||
- Add support for OpenRouter and AWS Bedrock
|
||||
|
||||
## [1.0.8]
|
||||
|
||||
- Shows diff view of new or edited files right in the editor
|
||||
|
||||
## [1.0.7]
|
||||
|
||||
- Replace `list_files` and `analyze_project` with more explicit `list_files_top_level`, `list_files_recursive`, and `view_source_code_definitions_top_level` to get source code definitions only for files relevant to the task
|
||||
|
||||
## [1.0.6]
|
||||
|
||||
- Interact with CLI commands by sending messages to stdin and terminating long-running processes like servers
|
||||
|
||||
@@ -5,53 +5,51 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev"><strong>Download VSCode Extension</strong></a>
|
||||
<a href="https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev" target="_blank"><strong>Download VSCode Extension</strong></a> | <a href="https://discord.gg/claudedev" target="_blank"><strong>Join the Discord</strong></a>
|
||||
</p>
|
||||
|
||||
Thanks to [Claude 3.5 Sonnet's agentic coding capabilities](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf) Claude Dev can handle complex software development tasks step-by-step. With tools that let him read & write files, analyze project source code, and execute terminal commands (after you grant permission), he can assist you in ways that go beyond simple code completion or tech support. From building software projects to running system operations, Claude Dev is only limited by your imagination.
|
||||
Thanks to [Claude 3.5 Sonnet's agentic coding capabilities](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf) Claude Dev can handle complex software development tasks step-by-step. With tools that let him create & edit files, explore complex projects, and execute terminal commands (after you grant permission), he can assist you in ways that go beyond simple code completion or tech support. While autonomous AI scripts traditionally run in sandboxed environments, Claude Dev provides a human-in-the-loop GUI to supervise every file changed and command executed, providing a safe and accessible way to explore the potential of agentic AI.
|
||||
|
||||
While autonomous AI scripts traditionally run in sandboxed environments, Claude Dev offers a human-in-the-loop GUI to supervise every file change and command executed, providing a safe and accessible way to explore the potential of agentic AI.
|
||||
|
||||
- View syntax highlighted file previews and diffs for every change Claude makes
|
||||
- Paste images in chat to use Claude's vision capabilities and turn mockups into fully functional applications or fix bugs with screenshots
|
||||
- Inspect diffs of every change Claude makes right in the editor, and provide feedback until you're satisfied with the result
|
||||
- Runs CLI commands directly in chat, so you never have to open a terminal yourself (+ respond to interactive commands by sending a message)
|
||||
- Presents permission buttons (i.e. 'Approve CLI command') before tool use or sending information to the API
|
||||
- Presents permission buttons (i.e. 'Approve terminal command') before tool use or sending information to the API
|
||||
- Keep track of total tokens and API usage cost for the entire task loop and individual requests
|
||||
- Set a maximum # of API requests allowed for a task before being prompted for permission to proceed
|
||||
- When a task is completed, Claude Dev determines if he can present the result to you with a CLI command like `open -a "Google Chrome" index.html`, which you run with a click of a button
|
||||
- When a task is completed, Claude determines if he can present the result to you with a terminal command like `open -a "Google Chrome" index.html`, which you run with a click of a button
|
||||
|
||||
_**Pro tip**: Use the `Cmd + Shift + P` shortcut to open the command palette and type `Claude Dev: Open In New Tab` to start a new task right in your editor._
|
||||
_**Pro tip**: Use the `Cmd + Shift + P` shortcut to open the command palette and type `Claude Dev: Open In New Tab` to start a new task right in the editor._
|
||||
|
||||
## How it works
|
||||
|
||||
Claude Dev uses an agentic loop style implementation with chain-of-thought prompting and access to powerful tools that give him the ability to accomplish nearly any task. Start by providing a task and the agentic loop fires off, where it might use certain tools (with your permission) to accomplish each step in its thought process.
|
||||
Claude Dev uses an autonomous task execution loop with chain-of-thought prompting and access to powerful tools that give him the ability to accomplish nearly any task. Start by providing a task and the loop fires off, where Claude might use certain tools (with your permission) to accomplish each step in his thought process.
|
||||
|
||||
### Tools
|
||||
|
||||
Claude Dev has access to the following capabilities:
|
||||
|
||||
1. **`execute_command`**: Execute CLI commands on the system (only with your permission, output is streamed into the chat and you can respond to stdin or exit long-running processes when you're ready)
|
||||
2. **`analyze_project`**: Analyze the project's source code and file structure (see more below)
|
||||
3. **`list_files`**: List all file paths at the top level of the specified directory (useful for generic file operations like retrieving a file from your Desktop)
|
||||
4. **`read_file`**: Read the contents of a file at the specified path
|
||||
5. **`write_to_file`**: Write content to a file at the specified path, automatically creating any necessary directories
|
||||
6. **`ask_followup_question`**: Ask the user a question to gather additional information needed to complete a task (due to the autonomous nature of the program, this isn't a typical chatbot–Claude Dev must explicitly interrupt his task loop to ask for more information)
|
||||
7. **`attempt_completion`**: Present the result to the user after completing a task, potentially with a CLI command to kickoff a demonstration
|
||||
1. **`execute_command`**: Execute terminal commands on the system (only with your permission, output is streamed into the chat and you can respond to stdin or exit long-running processes when you're ready)
|
||||
2. **`read_file`**: Read the contents of a file at the specified path
|
||||
3. **`write_to_file`**: Write content to a file at the specified path, automatically creating any necessary directories
|
||||
4. **`list_files`**: List all paths for files in the specified directory. When `recursive = true`, it recursively lists all files in the directory and its nested folders (excludes files in .gitignore). When `recursive = false`, it lists only top-level files (useful for generic file operations like retrieving a file from your Desktop).
|
||||
5. **`list_code_definition_names`**: Parses all source code files at the top level of the specified directory to extract names of key elements like classes and functions (see more below)
|
||||
6. **`search_files`**: Search files in a specified directory for text that matches a given regex pattern (useful for refactoring code, addressing TODOs and FIXMEs, removing dead code, etc.)
|
||||
7. **`ask_followup_question`**: Ask the user a question to gather additional information needed to complete a task (due to the autonomous nature of the program, this isn't a typical chatbot–Claude Dev must explicitly interrupt his task loop to ask for more information)
|
||||
8. **`attempt_completion`**: Present the result to the user after completing a task, potentially with a terminal command to kickoff a demonstration
|
||||
|
||||
### Working in Existing Projects
|
||||
|
||||
The `analyze_project` tool uses [tree-sitter](https://github.com/tree-sitter/tree-sitter) to parse source code with custom tag queries that extract names of classes, functions, methods, and other definitions. This approach leverages the fact that large language models are fundamentally built on natural language processing–by focusing on these named elements, we provide the LLM with a structural understanding of the codebase that aligns closely with how developers conceptualize and organize their code.
|
||||
When given a task in an existing project, Claude will look for the most relevant files to read and edit the same way you or I would–by first looking at the names of directories, files, classes, and functions since these names tend to reflect their purpose and role within the broader system, and often encapsulate high-level concepts and relationships that help understand a project's overall architecture. With tools like `list_code_definition_names` and `search_files`, Claude is able to extract names of various elements in a project to determine what files are most relevant to a given task without you having to mention `@file`s or `@folder`s yourself.
|
||||
|
||||
This method is particularly effective because:
|
||||
1. **File Structure**: When a task is started, Claude is given an overview of your project's file structure. It turns out Claude 3.5 Sonnet is _really_ good at inferring what it needs to process further just from these file names alone.
|
||||
|
||||
- Developers typically name components to reflect their purpose and role within the larger system.
|
||||
- These names often encapsulate high-level concepts and relationships that are crucial for understanding the overall architecture.
|
||||
- By effectively extracting the "language" of the codebase, we enable the LLM to grasp structure and intent without wasting context on implementation details.
|
||||
2. **Source Code Definitions**: Claude may then use the `list_code_definition_names` tool on specific directories of interest. This tool uses [tree-sitter](https://github.com/tree-sitter/tree-sitter) to parse source code with custom tag queries that extract names of classes, functions, methods, and other definitions. It works by first identifying source code files that tree-sitter can parse (currently supports `python`, `javascript`, `typescript`, `ruby`, `go`, `java`, `php`, `rust`, `c`, `c++`, `c#`, `swift`), then parsing each file into an abstract syntax tree, and finally applying a language-specific query to extract definition names (you can see the exact query used for each language in `src/parse-source-code/queries`). The results are formatted into a concise & readable output that Claude can easily interpret to quickly understand the code's structure and purpose.
|
||||
|
||||
Here's how `analyze_project` works:
|
||||
3. **Search Files**: Claude can also use the `search_files` tool to search for specific patterns or content across multiple files. This tool uses [ripgrep](https://github.com/BurntSushi/ripgrep) to perform regex searches on files in a specified directory. The results are formatted into a concise & readable output that Claude can easily interpret to quickly understand the code's structure and purpose. This can be useful for tasks like refactoring function names, updating imports, addressing TODOs and FIXMEs, etc.
|
||||
|
||||
1. It scans your project directory, identifying source code files that tree-sitter can parse. Any unparsed files' paths will be listed at the end of the output so that Claude can request to manually read them if necessary.
|
||||
2. It parses each file into an abstract syntax tree and applies a language-specific query to extract definition names. You can see the exact query used for each language in `src/analyze-project/queries`.
|
||||
3. The results are formatted into a concise & readable output that Claude can easily interpret to quickly understand the structure and purpose of your entire codebase, making it more effective at assisting with complex development tasks.
|
||||
4. **Read Relevant Files**: With insights gained from the names of various files and source code definitions, Claude can then use the `read_file` tool to examine specific files that are most relevant to the task at hand.
|
||||
|
||||
By carefully managing what information is added to context, Claude can provide valuable assistance even for complex, large-scale projects without overwhelming its context window.
|
||||
|
||||
### Only With Your Permission
|
||||
|
||||
@@ -61,7 +59,8 @@ Claude always asks for your permission first before any tools are executed or in
|
||||
|
||||
## Contribution
|
||||
|
||||
Feel free to contribute to this project by submitting issues and pull requests. Contributions are welcome and appreciated!
|
||||
Paul Graham said it best, "if you build something now that barely works with AI, the next models will make it _really_ work." I've built this project with the assumption that scaling laws will continue to improve the quality (and cost) of AI models, and what might be difficult for Claude 3.5 Sonnet today will be effortless for future generations. That is the design philosophy I'd like to develop this project with, so it will always be updated with the best models, tools, and capabilities available–without wasting effort on implementing stopgaps like cheaper agents. With that said, I'm always open to suggestions and feedback, so please feel free to contribute to this project by submitting issues and pull requests.
|
||||
|
||||
To build Claude Dev locally, follow these steps:
|
||||
|
||||
1. Clone the repository:
|
||||
@@ -81,10 +80,18 @@ To build Claude Dev locally, follow these steps:
|
||||
## Reviews
|
||||
|
||||
- ["Claude Sonnet 3.5 Artifacts in VSCode With This Extension"](https://www.youtube.com/watch?v=5FbZ8ALfSTs) by [CoderOne](https://www.youtube.com/@CoderOne)
|
||||
- ["Meet Claude Dev — An Open-Source AI Programmer In VS Code"](https://generativeai.pub/meet-claude-dev-an-open-source-autonomous-ai-programmer-in-vs-code-f457f9821b7b) by [Jim Clyde Monge](https://jimclydemonge.medium.com/)
|
||||
- ["ClaudeDev: NEW Coding Agent Can Generate Applications within VS Code!"](https://www.youtube.com/watch?v=UNsQHosbIoE) by [WorldofAI](https://www.youtube.com/@intheworldofai)
|
||||
- ["ClaudeDev: This Mind-Blowing Coding Agent Can Build SaaS Apps in Minutes!"](https://www.youtube.com/watch?v=Ki0nuOeUpT0) by [AI for Devs](https://www.youtube.com/@ai-for-devs)
|
||||
- ["Claude Dev: This Coding Agent can Generate Applications Within VS Code!"](https://www.youtube.com/watch?v=ufq6sHGe0zs) by [AICodeKing](https://www.youtube.com/@AICodeKing)
|
||||
- ["ClaudeDev + NextJS + Supabase: Generate FULL-STACK Apps with Claude 3.5 Sonnet"](https://www.youtube.com/watch?v=GeZBfO1kxA4) by [AICodeKing](https://www.youtube.com/@AICodeKing)
|
||||
- ["Meet Claude Dev — An Open-Source AI Programmer In VS Code"](https://generativeai.pub/meet-claude-dev-an-open-source-autonomous-ai-programmer-in-vs-code-f457f9821b7b) and ["Build games with zero code using Claude Dev in VS Code"](https://www.youtube.com/watch?v=VT-JYVi81ew) by [Jim Clyde Monge](https://jimclydemonge.medium.com/)
|
||||
- ["Claude Dev Builds NextJS App! Aider, Continue Dev & GitHub Copilot Open-Source Alternative"](https://www.youtube.com/watch?v=Rv0wJZRpnCQ) by [Josh Pocock](https://www.youtube.com/@joshfpocock)
|
||||
- ["Claude Dev Is Mindblowing. The Best Coding Assistant tool?"](https://www.youtube.com/watch?v=Vp1Z3VGZroA), ["Combining Claude Dev With Google Gemini To Build Fast With Low Effort"](https://www.youtube.com/watch?v=cAfunEHLees), and ["Browser Automation Made Easy With Claude Dev"](https://www.youtube.com/watch?v=EQ4O8rUOqZs) by [Yaron Been](https://www.youtube.com/@ecomxfactor-YaronBeen)
|
||||
- ["ClaudeDev: The Ultimate Coding Agent for VS Code"](https://www.youtube.com/watch?v=aq0yw_DtphQ) by [Blas](https://www.youtube.com/@blascerecer)
|
||||
- ["I Built My First Web App in One Day Using Claude Dev Extension – Mind Blown!"](https://www.reddit.com/r/ClaudeAI/comments/1eqo3nk/i_built_my_first_web_app_in_one_day_using_claude/)
|
||||
- ["AI Development with Claude Dev"](https://www.linkedin.com/pulse/ai-development-claude-dev-shannon-lal-3ql3e/) by Shannon Lal
|
||||
- ["Code Smarter with Claude Dev: An AI Programmer for Your Projects"](https://www.linkedin.com/pulse/code-smarter-claude-dev-ai-programmer-your-projects-iana-detochka-jiqpe) by Iana D.
|
||||
- [Claude Dev also hit top 10 posts of all time on r/ClaudeAI (thank you for all the lovely comments)](https://www.reddit.com/r/ClaudeAI/comments/1e3h0f1/my_submission_to_anthropics_build_with_claude/)
|
||||
- [Claude Dev also hit top 10 posts of all time on r/ClaudeAI](https://www.reddit.com/r/ClaudeAI/comments/1e3h0f1/my_submission_to_anthropics_build_with_claude/)
|
||||
|
||||
## License
|
||||
|
||||
@@ -96,4 +103,4 @@ Contact me on X <a href="https://x.com/sdrzn" target="_blank">@sdrzn</a>. Please
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Special thanks to Anthropic for providing the API that powers this extension.
|
||||
Special thanks to Anthropic for providing the model that powers this extension.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:180820972a22a69f8064e13b431184599facdc2846766df1f125ef5bd19499f5
|
||||
size 20134174
|
||||
oid sha256:3368935be83186a5c7b603646fa8cedb87f5982014be307b80f659cc4eee2fba
|
||||
size 21811087
|
||||
|
||||
@@ -29,6 +29,13 @@ const copyWasmFiles = {
|
||||
name: "copy-wasm-files",
|
||||
setup(build) {
|
||||
build.onEnd(() => {
|
||||
// tiktoken
|
||||
fs.copyFileSync(
|
||||
path.join(__dirname, "node_modules", "tiktoken", "tiktoken_bg.wasm"),
|
||||
path.join(__dirname, "dist", "tiktoken_bg.wasm")
|
||||
)
|
||||
|
||||
// tree sitter
|
||||
const sourceDir = path.join(__dirname, "node_modules", "web-tree-sitter")
|
||||
const targetDir = path.join(__dirname, "dist")
|
||||
|
||||
@@ -49,6 +56,7 @@ const copyWasmFiles = {
|
||||
"c_sharp",
|
||||
"ruby",
|
||||
"java",
|
||||
"php",
|
||||
"swift",
|
||||
]
|
||||
|
||||
|
||||
Generated
+4090
-24
File diff suppressed because it is too large
Load Diff
+26
-6
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"displayName": "Claude Dev",
|
||||
"description": "Autonomous software engineer right in your IDE, capable of reading/writing files, executing commands, and more with your permission every step of the way.",
|
||||
"version": "1.0.6",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, executing commands, and more with your permission every step of the way.",
|
||||
"version": "1.5.0",
|
||||
"icon": "icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
@@ -53,6 +53,11 @@
|
||||
"title": "New Task",
|
||||
"icon": "$(add)"
|
||||
},
|
||||
{
|
||||
"command": "claude-dev.historyButtonTapped",
|
||||
"title": "History",
|
||||
"icon": "$(history)"
|
||||
},
|
||||
{
|
||||
"command": "claude-dev.popoutButtonTapped",
|
||||
"title": "Open in Editor",
|
||||
@@ -73,17 +78,22 @@
|
||||
"view/title": [
|
||||
{
|
||||
"command": "claude-dev.plusButtonTapped",
|
||||
"group": "navigation",
|
||||
"group": "navigation@1",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "claude-dev.historyButtonTapped",
|
||||
"group": "navigation@2",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "claude-dev.popoutButtonTapped",
|
||||
"group": "navigation",
|
||||
"group": "navigation@3",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "claude-dev.settingsButtonTapped",
|
||||
"group": "navigation",
|
||||
"group": "navigation@4",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
}
|
||||
]
|
||||
@@ -122,15 +132,25 @@
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.24.3",
|
||||
"@anthropic-ai/bedrock-sdk": "^0.10.2",
|
||||
"@anthropic-ai/sdk": "^0.26.0",
|
||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||
"@anthropic-ai/vertex-sdk": "^0.4.1",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
"@vscode/codicons": "^0.0.36",
|
||||
"axios": "^1.7.4",
|
||||
"clone-deep": "^4.0.1",
|
||||
"default-shell": "^2.2.0",
|
||||
"delay": "^6.0.0",
|
||||
"diff": "^5.2.0",
|
||||
"execa": "^9.3.0",
|
||||
"globby": "^14.0.2",
|
||||
"image-size": "^1.1.1",
|
||||
"openai": "^4.54.0",
|
||||
"os-name": "^6.0.0",
|
||||
"p-wait-for": "^5.0.2",
|
||||
"serialize-error": "^11.0.3",
|
||||
"tree-kill": "^1.2.2",
|
||||
"tree-sitter-wasms": "^0.1.11",
|
||||
"web-tree-sitter": "^0.22.6"
|
||||
}
|
||||
|
||||
+1012
-267
File diff suppressed because it is too large
Load Diff
@@ -1,40 +0,0 @@
|
||||
/*
|
||||
- struct definitions
|
||||
- enum definitions
|
||||
- union definitions
|
||||
- type aliases
|
||||
- method definitions
|
||||
- function definitions
|
||||
- trait definitions
|
||||
- module definitions
|
||||
- macro definitions
|
||||
*/
|
||||
export default `
|
||||
(struct_item
|
||||
name: (type_identifier) @name.definition.class) @definition.class
|
||||
|
||||
(enum_item
|
||||
name: (type_identifier) @name.definition.class) @definition.class
|
||||
|
||||
(union_item
|
||||
name: (type_identifier) @name.definition.class) @definition.class
|
||||
|
||||
(type_item
|
||||
name: (type_identifier) @name.definition.class) @definition.class
|
||||
|
||||
(declaration_list
|
||||
(function_item
|
||||
name: (identifier) @name.definition.method)) @definition.method
|
||||
|
||||
(function_item
|
||||
name: (identifier) @name.definition.function) @definition.function
|
||||
|
||||
(trait_item
|
||||
name: (type_identifier) @name.definition.interface) @definition.interface
|
||||
|
||||
(mod_item
|
||||
name: (identifier) @name.definition.module) @definition.module
|
||||
|
||||
(macro_definition
|
||||
name: (identifier) @name.definition.macro) @definition.macro
|
||||
`
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "."
|
||||
import { anthropicDefaultModelId, AnthropicModelId, anthropicModels, ApiHandlerOptions, ModelInfo } from "../shared/api"
|
||||
|
||||
export class AnthropicHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: Anthropic
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new Anthropic({ apiKey: this.options.apiKey })
|
||||
}
|
||||
|
||||
async createMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
tools: Anthropic.Messages.Tool[]
|
||||
): Promise<ApiHandlerMessageResponse> {
|
||||
const modelId = this.getModel().id
|
||||
switch (modelId) {
|
||||
case "claude-3-5-sonnet-20240620":
|
||||
case "claude-3-opus-20240229":
|
||||
case "claude-3-haiku-20240307": {
|
||||
/*
|
||||
The latest message will be the new user message, one before will be the assistant message from a previous request, and the user message before that will be a previously cached user message. So we need to mark the latest user message as ephemeral to cache it for the next request, and mark the second to last user message as ephemeral to let the server know the last message to retrieve from the cache for the current request..
|
||||
*/
|
||||
const userMsgIndices = messages.reduce(
|
||||
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
|
||||
[] as number[]
|
||||
)
|
||||
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
|
||||
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
|
||||
const message = await this.client.beta.promptCaching.messages.create(
|
||||
{
|
||||
model: modelId,
|
||||
max_tokens: this.getModel().info.maxTokens,
|
||||
system: [{ text: systemPrompt, type: "text", cache_control: { type: "ephemeral" } }], // setting cache breakpoint for system prompt so new tasks can reuse it
|
||||
messages: messages.map((message, index) => {
|
||||
if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) {
|
||||
return {
|
||||
...message,
|
||||
content:
|
||||
typeof message.content === "string"
|
||||
? [
|
||||
{
|
||||
type: "text",
|
||||
text: message.content,
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
]
|
||||
: message.content.map((content, contentIndex) =>
|
||||
contentIndex === message.content.length - 1
|
||||
? { ...content, cache_control: { type: "ephemeral" } }
|
||||
: content
|
||||
),
|
||||
}
|
||||
}
|
||||
return message
|
||||
}),
|
||||
tools, // cache breakpoints go from tools > system > messages, and since tools dont change, we can just set the breakpoint at the end of system (this avoids having to set a breakpoint at the end of tools which by itself does not meet min requirements for haiku caching)
|
||||
tool_choice: { type: "auto" },
|
||||
},
|
||||
(() => {
|
||||
// prompt caching: https://x.com/alexalbert__/status/1823751995901272068
|
||||
// https://github.com/anthropics/anthropic-sdk-typescript?tab=readme-ov-file#default-headers
|
||||
// https://github.com/anthropics/anthropic-sdk-typescript/commit/c920b77fc67bd839bfeb6716ceab9d7c9bbe7393
|
||||
switch (modelId) {
|
||||
case "claude-3-5-sonnet-20240620":
|
||||
return {
|
||||
headers: {
|
||||
"anthropic-beta": "prompt-caching-2024-07-31",
|
||||
},
|
||||
}
|
||||
case "claude-3-haiku-20240307":
|
||||
return {
|
||||
headers: { "anthropic-beta": "prompt-caching-2024-07-31" },
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
)
|
||||
return { message }
|
||||
}
|
||||
default: {
|
||||
const message = await this.client.messages.create({
|
||||
model: modelId,
|
||||
max_tokens: this.getModel().info.maxTokens,
|
||||
system: [{ text: systemPrompt, type: "text" }],
|
||||
messages,
|
||||
tools,
|
||||
tool_choice: { type: "auto" },
|
||||
})
|
||||
return { message }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createUserReadableRequest(
|
||||
userContent: Array<
|
||||
| Anthropic.TextBlockParam
|
||||
| Anthropic.ImageBlockParam
|
||||
| Anthropic.ToolUseBlockParam
|
||||
| Anthropic.ToolResultBlockParam
|
||||
>
|
||||
): any {
|
||||
return {
|
||||
model: this.getModel().id,
|
||||
max_tokens: this.getModel().info.maxTokens,
|
||||
system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)",
|
||||
messages: [{ conversation_history: "..." }, { role: "user", content: withoutImageData(userContent) }],
|
||||
tools: "(see tools in src/ClaudeDev.ts)",
|
||||
tool_choice: { type: "auto" },
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): { id: AnthropicModelId; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in anthropicModels) {
|
||||
const id = modelId as AnthropicModelId
|
||||
return { id, info: anthropicModels[id] }
|
||||
}
|
||||
return { id: anthropicDefaultModelId, info: anthropicModels[anthropicDefaultModelId] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import AnthropicBedrock from "@anthropic-ai/bedrock-sdk"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "."
|
||||
import { ApiHandlerOptions, bedrockDefaultModelId, BedrockModelId, bedrockModels, ModelInfo } from "../shared/api"
|
||||
|
||||
// https://docs.anthropic.com/en/api/claude-on-amazon-bedrock
|
||||
export class AwsBedrockHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: AnthropicBedrock
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new AnthropicBedrock({
|
||||
// Authenticate by either providing the keys below or use the default AWS credential providers, such as
|
||||
// using ~/.aws/credentials or the "AWS_SECRET_ACCESS_KEY" and "AWS_ACCESS_KEY_ID" environment variables.
|
||||
awsAccessKey: this.options.awsAccessKey,
|
||||
awsSecretKey: this.options.awsSecretKey,
|
||||
|
||||
// awsRegion changes the aws region to which the request is made. By default, we read AWS_REGION,
|
||||
// and if that's not present, we default to us-east-1. Note that we do not read ~/.aws/config for the region.
|
||||
awsRegion: this.options.awsRegion,
|
||||
})
|
||||
}
|
||||
|
||||
async createMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
tools: Anthropic.Messages.Tool[]
|
||||
): Promise<ApiHandlerMessageResponse> {
|
||||
const message = await this.client.messages.create({
|
||||
model: this.getModel().id,
|
||||
max_tokens: this.getModel().info.maxTokens,
|
||||
system: systemPrompt,
|
||||
messages,
|
||||
tools,
|
||||
tool_choice: { type: "auto" },
|
||||
})
|
||||
return { message }
|
||||
}
|
||||
|
||||
createUserReadableRequest(
|
||||
userContent: Array<
|
||||
| Anthropic.TextBlockParam
|
||||
| Anthropic.ImageBlockParam
|
||||
| Anthropic.ToolUseBlockParam
|
||||
| Anthropic.ToolResultBlockParam
|
||||
>
|
||||
): any {
|
||||
return {
|
||||
model: this.getModel().id,
|
||||
max_tokens: this.getModel().info.maxTokens,
|
||||
system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)",
|
||||
messages: [{ conversation_history: "..." }, { role: "user", content: withoutImageData(userContent) }],
|
||||
tools: "(see tools in src/ClaudeDev.ts)",
|
||||
tool_choice: { type: "auto" },
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): { id: BedrockModelId; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in bedrockModels) {
|
||||
const id = modelId as BedrockModelId
|
||||
return { id, info: bedrockModels[id] }
|
||||
}
|
||||
return { id: bedrockDefaultModelId, info: bedrockModels[bedrockDefaultModelId] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ApiConfiguration, ApiModelId, ModelInfo } from "../shared/api"
|
||||
import { AnthropicHandler } from "./anthropic"
|
||||
import { AwsBedrockHandler } from "./bedrock"
|
||||
import { OpenRouterHandler } from "./openrouter"
|
||||
import { VertexHandler } from "./vertex"
|
||||
|
||||
export interface ApiHandlerMessageResponse {
|
||||
message: Anthropic.Messages.Message
|
||||
userCredits?: number
|
||||
}
|
||||
|
||||
export interface ApiHandler {
|
||||
createMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
tools: Anthropic.Messages.Tool[]
|
||||
): Promise<ApiHandlerMessageResponse>
|
||||
|
||||
createUserReadableRequest(
|
||||
userContent: Array<
|
||||
| Anthropic.TextBlockParam
|
||||
| Anthropic.ImageBlockParam
|
||||
| Anthropic.ToolUseBlockParam
|
||||
| Anthropic.ToolResultBlockParam
|
||||
>
|
||||
): any
|
||||
|
||||
getModel(): { id: ApiModelId; info: ModelInfo }
|
||||
}
|
||||
|
||||
export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
|
||||
const { apiProvider, ...options } = configuration
|
||||
switch (apiProvider) {
|
||||
case "anthropic":
|
||||
return new AnthropicHandler(options)
|
||||
case "openrouter":
|
||||
return new OpenRouterHandler(options)
|
||||
case "bedrock":
|
||||
return new AwsBedrockHandler(options)
|
||||
case "vertex":
|
||||
return new VertexHandler(options)
|
||||
default:
|
||||
return new AnthropicHandler(options)
|
||||
}
|
||||
}
|
||||
|
||||
export function withoutImageData(
|
||||
userContent: Array<
|
||||
| Anthropic.TextBlockParam
|
||||
| Anthropic.ImageBlockParam
|
||||
| Anthropic.ToolUseBlockParam
|
||||
| Anthropic.ToolResultBlockParam
|
||||
>
|
||||
): Array<
|
||||
Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolUseBlockParam | Anthropic.ToolResultBlockParam
|
||||
> {
|
||||
return userContent.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return { ...part, source: { ...part.source, data: "..." } }
|
||||
} else if (part.type === "tool_result" && typeof part.content !== "string") {
|
||||
return {
|
||||
...part,
|
||||
content: part.content?.map((contentPart) => {
|
||||
if (contentPart.type === "image") {
|
||||
return { ...contentPart, source: { ...contentPart.source, data: "..." } }
|
||||
}
|
||||
return contentPart
|
||||
}),
|
||||
}
|
||||
}
|
||||
return part
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "."
|
||||
import {
|
||||
ApiHandlerOptions,
|
||||
ModelInfo,
|
||||
openRouterDefaultModelId,
|
||||
OpenRouterModelId,
|
||||
openRouterModels,
|
||||
} from "../shared/api"
|
||||
import { convertToOpenAiMessages } from "../utils/openai-format"
|
||||
|
||||
export class OpenRouterHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
apiKey: this.options.openRouterApiKey,
|
||||
defaultHeaders: {
|
||||
"HTTP-Referer": "https://github.com/saoudrizwan/claude-dev", // Optional, for including your app on openrouter.ai rankings.
|
||||
"X-Title": "claude-dev", // Optional. Shows in rankings on openrouter.ai.
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async createMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
tools: Anthropic.Messages.Tool[]
|
||||
): Promise<ApiHandlerMessageResponse> {
|
||||
// Convert Anthropic messages to OpenAI format
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
|
||||
// Convert Anthropic tools to OpenAI tools
|
||||
const openAiTools: OpenAI.Chat.ChatCompletionTool[] = tools.map((tool) => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.input_schema, // matches anthropic tool input schema (see https://platform.openai.com/docs/guides/function-calling)
|
||||
},
|
||||
}))
|
||||
|
||||
const createParams: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
|
||||
model: this.getModel().id,
|
||||
max_tokens: this.getModel().info.maxTokens,
|
||||
messages: openAiMessages,
|
||||
tools: openAiTools,
|
||||
tool_choice: "auto",
|
||||
}
|
||||
|
||||
let completion: OpenAI.Chat.Completions.ChatCompletion
|
||||
try {
|
||||
completion = await this.client.chat.completions.create(createParams)
|
||||
} catch (error) {
|
||||
console.error("Error creating message from normal request. Using streaming fallback...", error)
|
||||
completion = await this.streamCompletion(createParams)
|
||||
}
|
||||
|
||||
const errorMessage = (completion as any).error?.message // openrouter returns an error object instead of the openai sdk throwing an error
|
||||
if (errorMessage) {
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
// Convert OpenAI response to Anthropic format
|
||||
const openAiMessage = completion.choices[0].message
|
||||
const anthropicMessage: Anthropic.Messages.Message = {
|
||||
id: completion.id,
|
||||
type: "message",
|
||||
role: openAiMessage.role, // always "assistant"
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: openAiMessage.content || "",
|
||||
},
|
||||
],
|
||||
model: completion.model,
|
||||
stop_reason: (() => {
|
||||
switch (completion.choices[0].finish_reason) {
|
||||
case "stop":
|
||||
return "end_turn"
|
||||
case "length":
|
||||
return "max_tokens"
|
||||
case "tool_calls":
|
||||
return "tool_use"
|
||||
case "content_filter": // Anthropic doesn't have an exact equivalent
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})(),
|
||||
stop_sequence: null, // which custom stop_sequence was generated, if any (not applicable if you don't use stop_sequence)
|
||||
usage: {
|
||||
input_tokens: completion.usage?.prompt_tokens || 0,
|
||||
output_tokens: completion.usage?.completion_tokens || 0,
|
||||
},
|
||||
}
|
||||
|
||||
if (openAiMessage.tool_calls && openAiMessage.tool_calls.length > 0) {
|
||||
anthropicMessage.content.push(
|
||||
...openAiMessage.tool_calls.map((toolCall): Anthropic.ToolUseBlock => {
|
||||
let parsedInput = {}
|
||||
try {
|
||||
parsedInput = JSON.parse(toolCall.function.arguments || "{}")
|
||||
} catch (error) {
|
||||
console.error("Failed to parse tool arguments:", error)
|
||||
}
|
||||
return {
|
||||
type: "tool_use",
|
||||
id: toolCall.id,
|
||||
name: toolCall.function.name,
|
||||
input: parsedInput,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return { message: anthropicMessage }
|
||||
}
|
||||
|
||||
/*
|
||||
Streaming the completion is a fallback behavior for when a normal request responds with an invalid JSON object ("Unexpected end of JSON input"). This would usually happen in cases where the model makes tool calls with large arguments. After talking with OpenRouter folks, streaming mitigates this issue for now until they fix the underlying problem ("some weird data from anthropic got decoded wrongly and crashed the buffer")
|
||||
*/
|
||||
async streamCompletion(
|
||||
createParams: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming
|
||||
): Promise<OpenAI.Chat.Completions.ChatCompletion> {
|
||||
const stream = await this.client.chat.completions.create({
|
||||
...createParams,
|
||||
stream: true,
|
||||
})
|
||||
|
||||
let textContent: string = ""
|
||||
let toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = []
|
||||
|
||||
try {
|
||||
let currentToolCall: (OpenAI.Chat.ChatCompletionMessageToolCall & { index?: number }) | null = null
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta
|
||||
if (delta?.content) {
|
||||
textContent += delta.content
|
||||
}
|
||||
if (delta?.tool_calls) {
|
||||
for (const toolCallDelta of delta.tool_calls) {
|
||||
if (toolCallDelta.index === undefined) {
|
||||
continue
|
||||
}
|
||||
if (!currentToolCall || currentToolCall.index !== toolCallDelta.index) {
|
||||
// new index means new tool call, so add the previous one to the list
|
||||
if (currentToolCall) {
|
||||
toolCalls.push(currentToolCall)
|
||||
}
|
||||
currentToolCall = {
|
||||
index: toolCallDelta.index,
|
||||
id: toolCallDelta.id || "",
|
||||
type: "function",
|
||||
function: { name: "", arguments: "" },
|
||||
}
|
||||
}
|
||||
if (toolCallDelta.id) {
|
||||
currentToolCall.id = toolCallDelta.id
|
||||
}
|
||||
if (toolCallDelta.type) {
|
||||
currentToolCall.type = toolCallDelta.type
|
||||
}
|
||||
if (toolCallDelta.function) {
|
||||
if (toolCallDelta.function.name) {
|
||||
currentToolCall.function.name = toolCallDelta.function.name
|
||||
}
|
||||
if (toolCallDelta.function.arguments) {
|
||||
currentToolCall.function.arguments =
|
||||
(currentToolCall.function.arguments || "") + toolCallDelta.function.arguments
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentToolCall) {
|
||||
toolCalls.push(currentToolCall)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error streaming completion:", error)
|
||||
throw error
|
||||
}
|
||||
|
||||
// Usage information is not available in streaming responses, so we need to estimate token counts
|
||||
function approximateTokenCount(text: string): number {
|
||||
return Math.ceil(new TextEncoder().encode(text).length / 4)
|
||||
}
|
||||
const promptTokens = approximateTokenCount(
|
||||
createParams.messages
|
||||
.map((m) => (typeof m.content === "string" ? m.content : JSON.stringify(m.content)))
|
||||
.join(" ")
|
||||
)
|
||||
const completionTokens = approximateTokenCount(
|
||||
textContent + toolCalls.map((toolCall) => toolCall.function.arguments || "").join(" ")
|
||||
)
|
||||
|
||||
const completion: OpenAI.Chat.Completions.ChatCompletion = {
|
||||
created: Date.now(),
|
||||
object: "chat.completion",
|
||||
id: `openrouter-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`, // this ID won't be traceable back to OpenRouter's systems if you need to debug issues
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: textContent,
|
||||
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
||||
},
|
||||
finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop",
|
||||
index: 0,
|
||||
logprobs: null,
|
||||
},
|
||||
],
|
||||
model: this.getModel().id,
|
||||
usage: {
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: completionTokens,
|
||||
total_tokens: promptTokens + completionTokens,
|
||||
},
|
||||
}
|
||||
|
||||
return completion
|
||||
}
|
||||
|
||||
createUserReadableRequest(
|
||||
userContent: Array<
|
||||
| Anthropic.TextBlockParam
|
||||
| Anthropic.ImageBlockParam
|
||||
| Anthropic.ToolUseBlockParam
|
||||
| Anthropic.ToolResultBlockParam
|
||||
>
|
||||
): any {
|
||||
return {
|
||||
model: this.getModel().id,
|
||||
max_tokens: this.getModel().info.maxTokens,
|
||||
system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)",
|
||||
messages: [{ conversation_history: "..." }, { role: "user", content: withoutImageData(userContent) }],
|
||||
tools: "(see tools in src/ClaudeDev.ts)",
|
||||
tool_choice: "auto",
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): { id: OpenRouterModelId; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in openRouterModels) {
|
||||
const id = modelId as OpenRouterModelId
|
||||
return { id, info: openRouterModels[id] }
|
||||
}
|
||||
return { id: openRouterDefaultModelId, info: openRouterModels[openRouterDefaultModelId] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ApiHandler, ApiHandlerMessageResponse, withoutImageData } from "."
|
||||
import { ApiHandlerOptions, ModelInfo, vertexDefaultModelId, VertexModelId, vertexModels } from "../shared/api"
|
||||
|
||||
// https://docs.anthropic.com/en/api/claude-on-vertex-ai
|
||||
export class VertexHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: AnthropicVertex
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new AnthropicVertex({
|
||||
projectId: this.options.vertexProjectId,
|
||||
// https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions
|
||||
region: this.options.vertexRegion,
|
||||
})
|
||||
}
|
||||
|
||||
async createMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
tools: Anthropic.Messages.Tool[]
|
||||
): Promise<ApiHandlerMessageResponse> {
|
||||
const message = await this.client.messages.create({
|
||||
model: this.getModel().id,
|
||||
max_tokens: this.getModel().info.maxTokens,
|
||||
system: systemPrompt,
|
||||
messages,
|
||||
tools,
|
||||
tool_choice: { type: "auto" },
|
||||
})
|
||||
return { message }
|
||||
}
|
||||
|
||||
createUserReadableRequest(
|
||||
userContent: Array<
|
||||
| Anthropic.TextBlockParam
|
||||
| Anthropic.ImageBlockParam
|
||||
| Anthropic.ToolUseBlockParam
|
||||
| Anthropic.ToolResultBlockParam
|
||||
>
|
||||
): any {
|
||||
return {
|
||||
model: this.getModel().id,
|
||||
max_tokens: this.getModel().info.maxTokens,
|
||||
system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)",
|
||||
messages: [{ conversation_history: "..." }, { role: "user", content: withoutImageData(userContent) }],
|
||||
tools: "(see tools in src/ClaudeDev.ts)",
|
||||
tool_choice: { type: "auto" },
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): { id: VertexModelId; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in vertexModels) {
|
||||
const id = modelId as VertexModelId
|
||||
return { id, info: vertexModels[id] }
|
||||
}
|
||||
return { id: vertexDefaultModelId, info: vertexModels[vertexDefaultModelId] }
|
||||
}
|
||||
}
|
||||
+34
-1
@@ -49,7 +49,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
outputChannel.appendLine("Plus button tapped")
|
||||
await sidebarProvider.clearTask()
|
||||
await sidebarProvider.postStateToWebview()
|
||||
await sidebarProvider.postMessageToWebview({ type: "action", action: "plusButtonTapped" })
|
||||
await sidebarProvider.postMessageToWebview({ type: "action", action: "chatButtonTapped" })
|
||||
})
|
||||
)
|
||||
|
||||
@@ -86,6 +86,39 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
sidebarProvider.postMessageToWebview({ type: "action", action: "settingsButtonTapped" })
|
||||
})
|
||||
)
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("claude-dev.historyButtonTapped", () => {
|
||||
sidebarProvider.postMessageToWebview({ type: "action", action: "historyButtonTapped" })
|
||||
})
|
||||
)
|
||||
|
||||
/*
|
||||
We use the text document content provider API to show a diff view for new files/edits by creating a virtual document for the new content.
|
||||
|
||||
- This API allows you to create readonly documents in VSCode from arbitrary sources, and works by claiming an uri-scheme for which your provider then returns text contents. The scheme must be provided when registering a provider and cannot change afterwards.
|
||||
- Note how the provider doesn't create uris for virtual documents - its role is to provide contents given such an uri. In return, content providers are wired into the open document logic so that providers are always considered.
|
||||
https://code.visualstudio.com/api/extension-guides/virtual-documents
|
||||
*/
|
||||
const diffContentProvider = new (class implements vscode.TextDocumentContentProvider {
|
||||
provideTextDocumentContent(uri: vscode.Uri): string {
|
||||
return Buffer.from(uri.query, "base64").toString("utf-8")
|
||||
}
|
||||
})()
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.registerTextDocumentContentProvider("claude-dev-diff", diffContentProvider)
|
||||
)
|
||||
|
||||
// // URI Handler
|
||||
// const handleUri = async (uri: vscode.Uri) => {
|
||||
// const query = new URLSearchParams(uri.query.replace(/\+/g, "%2B"))
|
||||
// const token = query.get("token")
|
||||
// const email = query.get("email")
|
||||
// if (token) {
|
||||
// await sidebarProvider.saveKoduApiKey(token, email || undefined)
|
||||
// }
|
||||
// }
|
||||
// context.subscriptions.push(vscode.window.registerUriHandler({ handleUri }))
|
||||
}
|
||||
|
||||
// This method is called when your extension is deactivated
|
||||
|
||||
@@ -1,47 +1,72 @@
|
||||
import * as fs from "fs/promises"
|
||||
import { globby } from "globby"
|
||||
import os from "os"
|
||||
import * as path from "path"
|
||||
import { LanguageParser, loadRequiredLanguageParsers } from "./languageParser"
|
||||
|
||||
async function analyzeProject(dirPath: string): Promise<string> {
|
||||
let result = ""
|
||||
// TODO: implement caching behavior to avoid having to keep analyzing project for new tasks.
|
||||
export async function parseSourceCodeForDefinitionsTopLevel(dirPath: string): Promise<string> {
|
||||
// check if the path exists
|
||||
const dirExists = await fs
|
||||
.access(path.resolve(dirPath))
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!dirExists) {
|
||||
return "This directory does not exist or you do not have permission to access it."
|
||||
}
|
||||
|
||||
// Get all files (not gitignored)
|
||||
const allFiles = await getAllProjectFiles(dirPath)
|
||||
// Get all files at top level (not gitignored)
|
||||
const allFiles = await listFiles(dirPath, false)
|
||||
|
||||
let result = ""
|
||||
|
||||
// Separate files to parse and remaining files
|
||||
const { filesToParse, remainingFiles } = separateFiles(allFiles)
|
||||
|
||||
// Load only the necessary language parsers
|
||||
const languageParsers = await loadRequiredLanguageParsers(filesToParse)
|
||||
|
||||
// Parse specific files we have language parsers for
|
||||
const filesWithoutDefinitions: string[] = []
|
||||
// const filesWithoutDefinitions: string[] = []
|
||||
for (const file of filesToParse) {
|
||||
const definitions = await parseFile(file, languageParsers)
|
||||
if (definitions) {
|
||||
if (!result) {
|
||||
result += "# Source code definitions:\n\n"
|
||||
}
|
||||
result += `${path.relative(dirPath, file)}\n${definitions}\n`
|
||||
} else {
|
||||
filesWithoutDefinitions.push(file)
|
||||
}
|
||||
// else {
|
||||
// filesWithoutDefinitions.push(file)
|
||||
// }
|
||||
}
|
||||
|
||||
// List remaining files' paths
|
||||
result += "# Unparsed files:\n\n"
|
||||
filesWithoutDefinitions
|
||||
.concat(remainingFiles)
|
||||
.sort()
|
||||
.forEach((file) => {
|
||||
result += `${path.relative(dirPath, file)}\n`
|
||||
})
|
||||
// let didFindUnparsedFiles = false
|
||||
// filesWithoutDefinitions
|
||||
// .concat(remainingFiles)
|
||||
// .sort()
|
||||
// .forEach((file) => {
|
||||
// if (!didFindUnparsedFiles) {
|
||||
// result += "# Unparsed Files\n\n"
|
||||
// didFindUnparsedFiles = true
|
||||
// }
|
||||
// result += `${path.relative(dirPath, file)}\n`
|
||||
// })
|
||||
|
||||
return result
|
||||
return result ? result : "No source code definitions found."
|
||||
}
|
||||
|
||||
async function getAllProjectFiles(dirPath: string): Promise<string[]> {
|
||||
export async function listFiles(dirPath: string, recursive: boolean): Promise<string[]> {
|
||||
const absolutePath = path.resolve(dirPath)
|
||||
// Do not allow listing files in root or home directory, which Claude tends to want to do when the user's prompt is vague.
|
||||
const root = process.platform === "win32" ? path.parse(absolutePath).root : "/"
|
||||
const isRoot = absolutePath === root
|
||||
if (isRoot) {
|
||||
return [root]
|
||||
}
|
||||
const homeDir = os.homedir()
|
||||
const isHomeDir = absolutePath === homeDir
|
||||
if (isHomeDir) {
|
||||
return [homeDir]
|
||||
}
|
||||
|
||||
const dirsToIgnore = [
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
@@ -65,12 +90,13 @@ async function getAllProjectFiles(dirPath: string): Promise<string[]> {
|
||||
cwd: dirPath,
|
||||
dot: true, // do not ignore hidden files/directories
|
||||
absolute: true,
|
||||
markDirectories: true,
|
||||
gitignore: true, // globby ignores any files that are gitignored
|
||||
ignore: dirsToIgnore, // just in case there is no gitignore, we ignore sensible defaults
|
||||
markDirectories: true, // Append a / on any directories matched
|
||||
gitignore: recursive, // globby ignores any files that are gitignored
|
||||
ignore: recursive ? dirsToIgnore : undefined, // just in case there is no gitignore, we ignore sensible defaults
|
||||
onlyFiles: false, // true by default, false means it will list directories on their own too
|
||||
}
|
||||
// * globs all files in one dir, ** globs files in nested directories
|
||||
const files = await globby("**", options)
|
||||
const files = await globby(recursive ? "**" : "*", options)
|
||||
return files
|
||||
}
|
||||
|
||||
@@ -98,8 +124,8 @@ function separateFiles(allFiles: string[]): { filesToParse: string[]; remainingF
|
||||
"php",
|
||||
"swift",
|
||||
].map((e) => `.${e}`)
|
||||
const filesToParse = allFiles.filter((file) => extensions.includes(path.extname(file)))
|
||||
const remainingFiles = allFiles.filter((file) => !extensions.includes(path.extname(file)))
|
||||
const filesToParse = allFiles.filter((file) => extensions.includes(path.extname(file))).slice(0, 50) // 50 files max
|
||||
const remainingFiles = allFiles.filter((file) => !filesToParse.includes(file))
|
||||
return { filesToParse, remainingFiles }
|
||||
}
|
||||
|
||||
@@ -182,5 +208,3 @@ async function parseFile(filePath: string, languageParsers: LanguageParser): Pro
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export { analyzeProject }
|
||||
@@ -26,6 +26,15 @@ async function loadLanguage(langName: string) {
|
||||
return await Parser.Language.load(path.join(__dirname, `tree-sitter-${langName}.wasm`))
|
||||
}
|
||||
|
||||
let isParserInitialized = false
|
||||
|
||||
async function initializeParser() {
|
||||
if (!isParserInitialized) {
|
||||
await Parser.init()
|
||||
isParserInitialized = true
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Using node bindings for tree-sitter is problematic in vscode extensions
|
||||
because of incompatibility with electron. Going the .wasm route has the
|
||||
@@ -49,7 +58,7 @@ Sources:
|
||||
- https://github.com/tree-sitter/tree-sitter/blob/master/lib/binding_web/test/query-test.js
|
||||
*/
|
||||
export async function loadRequiredLanguageParsers(filesToParse: string[]): Promise<LanguageParser> {
|
||||
await Parser.init()
|
||||
await initializeParser()
|
||||
const extensionsToLoad = new Set(filesToParse.map((file) => path.extname(file).toLowerCase().slice(1)))
|
||||
const parsers: LanguageParser = {}
|
||||
for (const ext of extensionsToLoad) {
|
||||
@@ -3,7 +3,6 @@
|
||||
- union declarations
|
||||
- function declarations
|
||||
- typedef declarations
|
||||
- enum declarations
|
||||
*/
|
||||
export default `
|
||||
(struct_specifier name: (type_identifier) @name.definition.class body:(_)) @definition.class
|
||||
@@ -13,6 +12,4 @@ export default `
|
||||
(function_declarator declarator: (identifier) @name.definition.function) @definition.function
|
||||
|
||||
(type_definition declarator: (type_identifier) @name.definition.type) @definition.type
|
||||
|
||||
(enum_specifier name: (type_identifier) @name.definition.type) @definition.type
|
||||
`
|
||||
@@ -4,7 +4,6 @@
|
||||
- function declarations
|
||||
- method declarations (with namespace scope)
|
||||
- typedef declarations
|
||||
- enum declarations
|
||||
- class declarations
|
||||
*/
|
||||
export default `
|
||||
@@ -20,7 +19,5 @@ export default `
|
||||
|
||||
(type_definition declarator: (type_identifier) @name.definition.type) @definition.type
|
||||
|
||||
(enum_specifier name: (type_identifier) @name.definition.type) @definition.type
|
||||
|
||||
(class_specifier name: (type_identifier) @name.definition.class) @definition.class
|
||||
`
|
||||
@@ -2,7 +2,6 @@
|
||||
- function declarations (with associated comments)
|
||||
- method declarations (with associated comments)
|
||||
- type specifications
|
||||
- type references
|
||||
*/
|
||||
export default `
|
||||
(
|
||||
@@ -25,6 +24,4 @@ export default `
|
||||
|
||||
(type_spec
|
||||
name: (type_identifier) @name.definition.type) @definition.type
|
||||
|
||||
(type_identifier) @name.reference.type @reference.type
|
||||
`
|
||||
@@ -2,7 +2,6 @@
|
||||
- class declarations
|
||||
- method declarations
|
||||
- interface declarations
|
||||
- superclass references
|
||||
*/
|
||||
export default `
|
||||
(class_declaration
|
||||
@@ -13,6 +12,4 @@ export default `
|
||||
|
||||
(interface_declaration
|
||||
name: (identifier) @name.definition.interface) @definition.interface
|
||||
|
||||
(superclass (type_identifier) @name.reference.class) @reference.class
|
||||
`
|
||||
-12
@@ -3,7 +3,6 @@
|
||||
- method definitions
|
||||
- named function declarations
|
||||
- arrow functions and function expressions assigned to variables
|
||||
- exported constants
|
||||
*/
|
||||
export default `
|
||||
(
|
||||
@@ -63,15 +62,4 @@ export default `
|
||||
(#strip! @doc "^[\\s\\*/]+|^[\\s\\*/]$")
|
||||
(#select-adjacent! @doc @definition.function)
|
||||
)
|
||||
|
||||
(export_statement value: (assignment_expression left: (identifier) @name right: ([
|
||||
(number)
|
||||
(string)
|
||||
(identifier)
|
||||
(undefined)
|
||||
(null)
|
||||
(new_expression)
|
||||
(binary_expression)
|
||||
(call_expression)
|
||||
]))) @definition.constant
|
||||
`
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
- struct definitions
|
||||
- method definitions
|
||||
- function definitions
|
||||
*/
|
||||
export default `
|
||||
(struct_item
|
||||
name: (type_identifier) @name.definition.class) @definition.class
|
||||
|
||||
(declaration_list
|
||||
(function_item
|
||||
name: (identifier) @name.definition.method)) @definition.method
|
||||
|
||||
(function_item
|
||||
name: (identifier) @name.definition.function) @definition.function
|
||||
`
|
||||
@@ -1,6 +1,5 @@
|
||||
/*
|
||||
- class declarations
|
||||
- protocol declarations
|
||||
- method declarations (including initializers and deinitializers)
|
||||
- property declarations
|
||||
- function declarations
|
||||
@@ -27,20 +26,6 @@ export default `
|
||||
)
|
||||
) @definition.method
|
||||
|
||||
(protocol_declaration
|
||||
(protocol_body
|
||||
[
|
||||
(protocol_function_declaration
|
||||
name: (simple_identifier) @name
|
||||
)
|
||||
(subscript_declaration
|
||||
(parameter (simple_identifier) @name)
|
||||
)
|
||||
(init_declaration "init" @name)
|
||||
]
|
||||
)
|
||||
) @definition.method
|
||||
|
||||
(class_declaration
|
||||
(class_body
|
||||
[
|
||||
-12
@@ -4,9 +4,6 @@
|
||||
- abstract method signatures
|
||||
- class declarations (including abstract classes)
|
||||
- module declarations
|
||||
- interface declarations
|
||||
- type alias declarations
|
||||
- enum declarations
|
||||
*/
|
||||
export default `
|
||||
(function_signature
|
||||
@@ -24,9 +21,6 @@ export default `
|
||||
(module
|
||||
name: (identifier) @name.definition.module) @definition.module
|
||||
|
||||
(interface_declaration
|
||||
name: (type_identifier) @name.definition.interface) @definition.interface
|
||||
|
||||
(function_declaration
|
||||
name: (identifier) @name.definition.function) @definition.function
|
||||
|
||||
@@ -35,10 +29,4 @@ export default `
|
||||
|
||||
(class_declaration
|
||||
name: (type_identifier) @name.definition.class) @definition.class
|
||||
|
||||
(type_alias_declaration
|
||||
name: (type_identifier) @name.definition.type) @definition.type
|
||||
|
||||
(enum_declaration
|
||||
name: (identifier) @name.definition.enum) @definition.enum
|
||||
`
|
||||
+392
-190
@@ -1,12 +1,13 @@
|
||||
import { Uri, Webview } from "vscode"
|
||||
//import * as weather from "weather-js"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import * as vscode from "vscode"
|
||||
import { ClaudeDev } from "../ClaudeDev"
|
||||
import { ClaudeMessage, ExtensionMessage } from "../shared/ExtensionMessage"
|
||||
import { ApiModelId, ApiProvider } from "../shared/api"
|
||||
import { ExtensionMessage } from "../shared/ExtensionMessage"
|
||||
import { WebviewMessage } from "../shared/WebviewMessage"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { downloadTask, getNonce, getUri, selectImages } from "../utils"
|
||||
import * as path from "path"
|
||||
import os from "os"
|
||||
import fs from "fs/promises"
|
||||
import { HistoryItem } from "../shared/HistoryItem"
|
||||
|
||||
/*
|
||||
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
|
||||
@@ -14,20 +15,51 @@ https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default
|
||||
https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts
|
||||
*/
|
||||
|
||||
type SecretKey = "apiKey" | "openRouterApiKey" | "awsAccessKey" | "awsSecretKey"
|
||||
type GlobalStateKey =
|
||||
| "apiProvider"
|
||||
| "apiModelId"
|
||||
| "awsRegion"
|
||||
| "vertexProjectId"
|
||||
| "vertexRegion"
|
||||
| "maxRequestsPerTask"
|
||||
| "lastShownAnnouncementId"
|
||||
| "customInstructions"
|
||||
| "alwaysAllowReadOnly"
|
||||
| "taskHistory"
|
||||
|
||||
export class ClaudeDevProvider implements vscode.WebviewViewProvider {
|
||||
public static readonly sideBarId = "claude-dev.SidebarProvider" // used in package.json as the view's id. This value cannot be changed due to how vscode caches views based on their id, and updating the id would break existing instances of the extension.
|
||||
public static readonly tabPanelId = "claude-dev.TabPanelProvider"
|
||||
private disposables: vscode.Disposable[] = []
|
||||
private view?: vscode.WebviewView | vscode.WebviewPanel
|
||||
private providerInstanceIdentifier = Date.now()
|
||||
private claudeDev?: ClaudeDev
|
||||
private latestAnnouncementId = "jul-29-2024" // update to some unique identifier when we add a new announcement
|
||||
private latestAnnouncementId = "aug-30-2024" // update to some unique identifier when we add a new announcement
|
||||
|
||||
constructor(
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
private readonly outputChannel: vscode.OutputChannel
|
||||
) {
|
||||
constructor(readonly context: vscode.ExtensionContext, private readonly outputChannel: vscode.OutputChannel) {
|
||||
this.outputChannel.appendLine("ClaudeDevProvider instantiated")
|
||||
this.revertKodu()
|
||||
}
|
||||
|
||||
async revertKodu() {
|
||||
const apiProvider = await this.getGlobalState("apiProvider")
|
||||
if (apiProvider === "kodu") {
|
||||
// switch back to previous provider
|
||||
const anthropicKey = await this.getSecret("apiKey")
|
||||
if (anthropicKey) {
|
||||
await this.updateGlobalState("apiProvider", "anthropic" as ApiProvider)
|
||||
} else {
|
||||
const openRouterApiKey = await this.getSecret("openRouterApiKey")
|
||||
if (openRouterApiKey) {
|
||||
await this.updateGlobalState("apiProvider", "openrouter" as ApiProvider)
|
||||
} else {
|
||||
const awsAccessKey = await this.getSecret("awsAccessKey")
|
||||
if (awsAccessKey) {
|
||||
await this.updateGlobalState("apiProvider", "bedrock" as ApiProvider)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -132,15 +164,33 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
|
||||
this.outputChannel.appendLine("Webview view resolved")
|
||||
}
|
||||
|
||||
async tryToInitClaudeDevWithTask(task: string) {
|
||||
async initClaudeDevWithTask(task?: string, images?: string[]) {
|
||||
await this.clearTask() // ensures that an exising task doesn't exist before starting a new one, although this shouldn't be possible since user must clear task before starting a new one
|
||||
const [apiKey, maxRequestsPerTask] = await Promise.all([
|
||||
this.getSecret("apiKey") as Promise<string | undefined>,
|
||||
this.getGlobalState("maxRequestsPerTask") as Promise<number | undefined>,
|
||||
])
|
||||
if (this.view && apiKey) {
|
||||
this.claudeDev = new ClaudeDev(this, task, apiKey, maxRequestsPerTask)
|
||||
}
|
||||
const { maxRequestsPerTask, apiConfiguration, customInstructions, alwaysAllowReadOnly } = await this.getState()
|
||||
this.claudeDev = new ClaudeDev(
|
||||
this,
|
||||
apiConfiguration,
|
||||
maxRequestsPerTask,
|
||||
customInstructions,
|
||||
alwaysAllowReadOnly,
|
||||
task,
|
||||
images
|
||||
)
|
||||
}
|
||||
|
||||
async initClaudeDevWithHistoryItem(historyItem: HistoryItem) {
|
||||
await this.clearTask()
|
||||
const { maxRequestsPerTask, apiConfiguration, customInstructions, alwaysAllowReadOnly } = await this.getState()
|
||||
this.claudeDev = new ClaudeDev(
|
||||
this,
|
||||
apiConfiguration,
|
||||
maxRequestsPerTask,
|
||||
customInstructions,
|
||||
alwaysAllowReadOnly,
|
||||
undefined,
|
||||
undefined,
|
||||
historyItem
|
||||
)
|
||||
}
|
||||
|
||||
// Send any JSON serializable data to the react app
|
||||
@@ -200,7 +250,8 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
|
||||
create a content security policy meta tag so that only loading scripts with a nonce is allowed
|
||||
As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicity allow for these resources. E.g.
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource}; font-src ${webview.cspSource}; img-src ${webview.cspSource} https:; script-src 'nonce-${nonce}';">
|
||||
|
||||
- 'unsafe-inline' is required for styles due to vscode-webview-toolkit's dynamic style injection
|
||||
- since we pass base64 images to the webview, we need to specify img-src ${webview.cspSource} data:;
|
||||
|
||||
in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial.
|
||||
*/
|
||||
@@ -214,7 +265,7 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
|
||||
<meta name="theme-color" content="#000000">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource}; style-src ${webview.cspSource}; script-src 'nonce-${nonce}';">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource}; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} data:; script-src 'nonce-${nonce}';">
|
||||
<link rel="stylesheet" type="text/css" href="${stylesUri}">
|
||||
<link href="${codiconsUri}" rel="stylesheet" />
|
||||
<title>Claude Dev</title>
|
||||
@@ -250,11 +301,32 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
|
||||
// Could also do this in extension .ts
|
||||
//this.postMessageToWebview({ type: "text", text: `Extension: ${Date.now()}` })
|
||||
// initializing new instance of ClaudeDev will make sure that any agentically running promises in old instance don't affect our new task. this essentially creates a fresh slate for the new task
|
||||
await this.tryToInitClaudeDevWithTask(message.text!)
|
||||
await this.initClaudeDevWithTask(message.text, message.images)
|
||||
break
|
||||
case "apiKey":
|
||||
await this.storeSecret("apiKey", message.text!)
|
||||
this.claudeDev?.updateApiKey(message.text!)
|
||||
case "apiConfiguration":
|
||||
if (message.apiConfiguration) {
|
||||
const {
|
||||
apiProvider,
|
||||
apiModelId,
|
||||
apiKey,
|
||||
openRouterApiKey,
|
||||
awsAccessKey,
|
||||
awsSecretKey,
|
||||
awsRegion,
|
||||
vertexProjectId,
|
||||
vertexRegion,
|
||||
} = message.apiConfiguration
|
||||
await this.updateGlobalState("apiProvider", apiProvider)
|
||||
await this.updateGlobalState("apiModelId", apiModelId)
|
||||
await this.storeSecret("apiKey", apiKey)
|
||||
await this.storeSecret("openRouterApiKey", openRouterApiKey)
|
||||
await this.storeSecret("awsAccessKey", awsAccessKey)
|
||||
await this.storeSecret("awsSecretKey", awsSecretKey)
|
||||
await this.updateGlobalState("awsRegion", awsRegion)
|
||||
await this.updateGlobalState("vertexProjectId", vertexProjectId)
|
||||
await this.updateGlobalState("vertexRegion", vertexRegion)
|
||||
this.claudeDev?.updateApi(message.apiConfiguration)
|
||||
}
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
case "maxRequestsPerTask":
|
||||
@@ -269,8 +341,19 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
|
||||
this.claudeDev?.updateMaxRequestsPerTask(result)
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
case "customInstructions":
|
||||
// User may be clearing the field
|
||||
await this.updateGlobalState("customInstructions", message.text || undefined)
|
||||
this.claudeDev?.updateCustomInstructions(message.text || undefined)
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
case "alwaysAllowReadOnly":
|
||||
await this.updateGlobalState("alwaysAllowReadOnly", message.bool ?? undefined)
|
||||
this.claudeDev?.updateAlwaysAllowReadOnly(message.bool ?? undefined)
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
case "askResponse":
|
||||
this.claudeDev?.handleWebviewAskResponse(message.askResponse!, message.text)
|
||||
this.claudeDev?.handleWebviewAskResponse(message.askResponse!, message.text, message.images)
|
||||
break
|
||||
case "clearTask":
|
||||
// newTask will start a new task with a given task text, while clear task resets the current session and allows for a new task to be started
|
||||
@@ -281,8 +364,27 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
|
||||
await this.updateGlobalState("lastShownAnnouncementId", this.latestAnnouncementId)
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
case "downloadTask":
|
||||
this.downloadTask()
|
||||
case "selectImages":
|
||||
const images = await selectImages()
|
||||
await this.postMessageToWebview({ type: "selectedImages", images })
|
||||
break
|
||||
case "exportCurrentTask":
|
||||
const currentTaskId = this.claudeDev?.taskId
|
||||
if (currentTaskId) {
|
||||
this.exportTaskWithId(currentTaskId)
|
||||
}
|
||||
break
|
||||
case "showTaskWithId":
|
||||
this.showTaskWithId(message.text!)
|
||||
break
|
||||
case "deleteTaskWithId":
|
||||
this.deleteTaskWithId(message.text!)
|
||||
break
|
||||
case "exportTaskWithId":
|
||||
this.exportTaskWithId(message.text!)
|
||||
break
|
||||
case "resetState":
|
||||
await this.resetState()
|
||||
break
|
||||
// Add more switch case statements here as more webview message commands
|
||||
// are created within the webview context (i.e. inside media/main.js)
|
||||
@@ -293,114 +395,129 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
|
||||
)
|
||||
}
|
||||
|
||||
async downloadTask() {
|
||||
// File name
|
||||
const date = new Date()
|
||||
const month = date.toLocaleString("en-US", { month: "short" }).toLowerCase()
|
||||
const day = date.getDate()
|
||||
const year = date.getFullYear()
|
||||
let hours = date.getHours()
|
||||
const minutes = date.getMinutes().toString().padStart(2, "0")
|
||||
const ampm = hours >= 12 ? "pm" : "am"
|
||||
hours = hours % 12
|
||||
hours = hours ? hours : 12 // the hour '0' should be '12'
|
||||
const fileName = `claude_dev_task_${month}-${day}-${year}_${hours}-${minutes}-${ampm}.md`
|
||||
// Task history
|
||||
|
||||
// Generate markdown
|
||||
const conversationHistory = await this.getApiConversationHistory()
|
||||
const markdownContent = conversationHistory
|
||||
.map((message) => {
|
||||
const role = message.role === "user" ? "**User:**" : "**Assistant:**"
|
||||
const content = Array.isArray(message.content)
|
||||
? message.content.map(this.formatContentBlockToMarkdown).join("\n")
|
||||
: message.content
|
||||
|
||||
return `${role}\n\n${content}\n\n`
|
||||
})
|
||||
.join("---\n\n")
|
||||
|
||||
// Prompt user for save location
|
||||
const saveUri = await vscode.window.showSaveDialog({
|
||||
filters: { Markdown: ["md"] },
|
||||
defaultUri: vscode.Uri.file(path.join(os.homedir(), "Downloads", fileName)),
|
||||
})
|
||||
|
||||
if (saveUri) {
|
||||
// Write content to the selected location
|
||||
await vscode.workspace.fs.writeFile(saveUri, Buffer.from(markdownContent))
|
||||
vscode.window.showTextDocument(saveUri, { preview: true })
|
||||
async getTaskWithId(id: string): Promise<{
|
||||
historyItem: HistoryItem
|
||||
taskDirPath: string
|
||||
apiConversationHistoryFilePath: string
|
||||
claudeMessagesFilePath: string
|
||||
apiConversationHistory: Anthropic.MessageParam[]
|
||||
}> {
|
||||
const history = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || []
|
||||
const historyItem = history.find((item) => item.id === id)
|
||||
if (historyItem) {
|
||||
const taskDirPath = path.join(this.context.globalStorageUri.fsPath, "tasks", id)
|
||||
const apiConversationHistoryFilePath = path.join(taskDirPath, "api_conversation_history.json")
|
||||
const claudeMessagesFilePath = path.join(taskDirPath, "claude_messages.json")
|
||||
const fileExists = await fs
|
||||
.access(apiConversationHistoryFilePath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (fileExists) {
|
||||
const apiConversationHistory = JSON.parse(await fs.readFile(apiConversationHistoryFilePath, "utf8"))
|
||||
return {
|
||||
historyItem,
|
||||
taskDirPath,
|
||||
apiConversationHistoryFilePath,
|
||||
claudeMessagesFilePath,
|
||||
apiConversationHistory,
|
||||
}
|
||||
}
|
||||
}
|
||||
// if we tried to get a task that doesn't exist, remove it from state
|
||||
await this.deleteTaskFromState(id)
|
||||
throw new Error("Task not found")
|
||||
}
|
||||
|
||||
private formatContentBlockToMarkdown(
|
||||
block:
|
||||
| Anthropic.TextBlockParam
|
||||
| Anthropic.ImageBlockParam
|
||||
| Anthropic.ToolUseBlockParam
|
||||
| Anthropic.ToolResultBlockParam
|
||||
): string {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
return block.text
|
||||
case "image":
|
||||
return `[Image: ${block.source.media_type}]`
|
||||
case "tool_use":
|
||||
let input: string
|
||||
if (typeof block.input === "object" && block.input !== null) {
|
||||
input = Object.entries(block.input)
|
||||
.map(([key, value]) => `${key.charAt(0).toUpperCase() + key.slice(1)}: ${value}`)
|
||||
.join("\n")
|
||||
} else {
|
||||
input = String(block.input)
|
||||
}
|
||||
return `[Tool Use: ${block.name}]\n${input}`
|
||||
case "tool_result":
|
||||
if (typeof block.content === "string") {
|
||||
return `[Tool Result${block.is_error ? " (Error)" : ""}]\n${block.content}`
|
||||
} else if (Array.isArray(block.content)) {
|
||||
return `[Tool Result${block.is_error ? " (Error)" : ""}]\n${block.content
|
||||
.map(this.formatContentBlockToMarkdown)
|
||||
.join("\n")}`
|
||||
} else {
|
||||
return `[Tool Result${block.is_error ? " (Error)" : ""}]`
|
||||
}
|
||||
default:
|
||||
return "[Unexpected content type]"
|
||||
async showTaskWithId(id: string) {
|
||||
if (id !== this.claudeDev?.taskId) {
|
||||
// non-current task
|
||||
const { historyItem } = await this.getTaskWithId(id)
|
||||
await this.initClaudeDevWithHistoryItem(historyItem) // clears existing task
|
||||
}
|
||||
await this.postMessageToWebview({ type: "action", action: "chatButtonTapped" })
|
||||
}
|
||||
|
||||
async exportTaskWithId(id: string) {
|
||||
const { historyItem, apiConversationHistory } = await this.getTaskWithId(id)
|
||||
await downloadTask(historyItem.ts, apiConversationHistory)
|
||||
}
|
||||
|
||||
async deleteTaskWithId(id: string) {
|
||||
if (id === this.claudeDev?.taskId) {
|
||||
await this.clearTask()
|
||||
}
|
||||
|
||||
const { taskDirPath, apiConversationHistoryFilePath, claudeMessagesFilePath } = await this.getTaskWithId(id)
|
||||
|
||||
// Delete the task files
|
||||
const apiConversationHistoryFileExists = await fs
|
||||
.access(apiConversationHistoryFilePath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (apiConversationHistoryFileExists) {
|
||||
await fs.unlink(apiConversationHistoryFilePath)
|
||||
}
|
||||
const claudeMessagesFileExists = await fs
|
||||
.access(claudeMessagesFilePath)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (claudeMessagesFileExists) {
|
||||
await fs.unlink(claudeMessagesFilePath)
|
||||
}
|
||||
await fs.rmdir(taskDirPath) // succeeds if the dir is empty
|
||||
|
||||
await this.deleteTaskFromState(id)
|
||||
}
|
||||
|
||||
async deleteTaskFromState(id: string) {
|
||||
// Remove the task from history
|
||||
const taskHistory = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || []
|
||||
const updatedTaskHistory = taskHistory.filter((task) => task.id !== id)
|
||||
await this.updateGlobalState("taskHistory", updatedTaskHistory)
|
||||
|
||||
// Notify the webview that the task has been deleted
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
|
||||
async postStateToWebview() {
|
||||
const [apiKey, maxRequestsPerTask, claudeMessages, lastShownAnnouncementId] = await Promise.all([
|
||||
this.getSecret("apiKey") as Promise<string | undefined>,
|
||||
this.getGlobalState("maxRequestsPerTask") as Promise<number | undefined>,
|
||||
this.getClaudeMessages(),
|
||||
this.getGlobalState("lastShownAnnouncementId") as Promise<string | undefined>,
|
||||
])
|
||||
this.postMessageToWebview({
|
||||
type: "state",
|
||||
state: {
|
||||
apiKey,
|
||||
maxRequestsPerTask,
|
||||
themeName: vscode.workspace.getConfiguration("workbench").get<string>("colorTheme"),
|
||||
claudeMessages,
|
||||
shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId,
|
||||
},
|
||||
})
|
||||
const state = await this.getStateToPostToWebview()
|
||||
this.postMessageToWebview({ type: "state", state })
|
||||
}
|
||||
|
||||
async getStateToPostToWebview() {
|
||||
const {
|
||||
apiConfiguration,
|
||||
maxRequestsPerTask,
|
||||
lastShownAnnouncementId,
|
||||
customInstructions,
|
||||
alwaysAllowReadOnly,
|
||||
taskHistory,
|
||||
} = await this.getState()
|
||||
return {
|
||||
version: this.context.extension?.packageJSON?.version ?? "",
|
||||
apiConfiguration,
|
||||
maxRequestsPerTask,
|
||||
customInstructions,
|
||||
alwaysAllowReadOnly,
|
||||
themeName: vscode.workspace.getConfiguration("workbench").get<string>("colorTheme"),
|
||||
uriScheme: vscode.env.uriScheme,
|
||||
claudeMessages: this.claudeDev?.claudeMessages || [],
|
||||
taskHistory: (taskHistory || []).filter((item) => item.ts && item.task).sort((a, b) => b.ts - a.ts),
|
||||
shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId,
|
||||
}
|
||||
}
|
||||
|
||||
async clearTask() {
|
||||
if (this.claudeDev) {
|
||||
this.claudeDev.abort = true // will stop any agentically running promises
|
||||
this.claudeDev = undefined // removes reference to it, so once promises end it will be garbage collected
|
||||
}
|
||||
await this.setApiConversationHistory(undefined)
|
||||
await this.setClaudeMessages(undefined)
|
||||
this.claudeDev?.abortTask()
|
||||
this.claudeDev = undefined // removes reference to it, so once promises end it will be garbage collected
|
||||
}
|
||||
|
||||
// Caching mechanism to keep track of webview messages + API conversation history per provider instance
|
||||
|
||||
/*
|
||||
Now that we use retainContextWhenHidden, we don't have to store a cache of claude messages in the user's state, but we do to reduce memory footprint in long conversations.
|
||||
Now that we use retainContextWhenHidden, we don't have to store a cache of claude messages in the user's state, but we could to reduce memory footprint in long conversations.
|
||||
|
||||
- We have to be careful of what state is shared between ClaudeDevProvider instances since there could be multiple instances of the extension running at once. For example when we cached claude messages using the same key, two instances of the extension could end up using the same key and overwriting each other's messages.
|
||||
- Some state does need to be shared between the instances, i.e. the API key--however there doesn't seem to be a good way to notfy the other instances that the API key has changed.
|
||||
@@ -411,52 +528,66 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
|
||||
However in the future when we implement task history, we'll need to use a unique identifier for each task. As well as manage a data structure that keeps track of task history with their associated identifiers and the task message itself, to present in a 'Task History' view.
|
||||
Task history is a significant undertaking as it would require refactoring how we wait for ask responses--it would need to be a hidden claudeMessage, so that user's can resume tasks that ended with an ask.
|
||||
*/
|
||||
// private providerInstanceIdentifier = Date.now()
|
||||
// getClaudeMessagesStateKey() {
|
||||
// return `claudeMessages-${this.providerInstanceIdentifier}`
|
||||
// }
|
||||
|
||||
getClaudeMessagesStateKey() {
|
||||
return `claudeMessages-${this.providerInstanceIdentifier}`
|
||||
}
|
||||
|
||||
getApiConversationHistoryStateKey() {
|
||||
return `apiConversationHistory-${this.providerInstanceIdentifier}`
|
||||
}
|
||||
// getApiConversationHistoryStateKey() {
|
||||
// return `apiConversationHistory-${this.providerInstanceIdentifier}`
|
||||
// }
|
||||
|
||||
// claude messages to present in the webview
|
||||
|
||||
async getClaudeMessages(): Promise<ClaudeMessage[]> {
|
||||
const messages = (await this.getGlobalState(this.getClaudeMessagesStateKey())) as ClaudeMessage[]
|
||||
return messages || []
|
||||
}
|
||||
// getClaudeMessages(): ClaudeMessage[] {
|
||||
// // const messages = (await this.getGlobalState(this.getClaudeMessagesStateKey())) as ClaudeMessage[]
|
||||
// // return messages || []
|
||||
// return this.claudeMessages
|
||||
// }
|
||||
|
||||
async setClaudeMessages(messages: ClaudeMessage[] | undefined) {
|
||||
await this.updateGlobalState(this.getClaudeMessagesStateKey(), messages)
|
||||
}
|
||||
// setClaudeMessages(messages: ClaudeMessage[] | undefined) {
|
||||
// // await this.updateGlobalState(this.getClaudeMessagesStateKey(), messages)
|
||||
// this.claudeMessages = messages || []
|
||||
// }
|
||||
|
||||
async addClaudeMessage(message: ClaudeMessage): Promise<ClaudeMessage[]> {
|
||||
const messages = await this.getClaudeMessages()
|
||||
messages.push(message)
|
||||
await this.setClaudeMessages(messages)
|
||||
return messages
|
||||
}
|
||||
// addClaudeMessage(message: ClaudeMessage): ClaudeMessage[] {
|
||||
// // const messages = await this.getClaudeMessages()
|
||||
// // messages.push(message)
|
||||
// // await this.setClaudeMessages(messages)
|
||||
// // return messages
|
||||
// this.claudeMessages.push(message)
|
||||
// return this.claudeMessages
|
||||
// }
|
||||
|
||||
// conversation history to send in API requests
|
||||
|
||||
async getApiConversationHistory(): Promise<Anthropic.MessageParam[]> {
|
||||
const history = (await this.getGlobalState(
|
||||
this.getApiConversationHistoryStateKey()
|
||||
)) as Anthropic.MessageParam[]
|
||||
return history || []
|
||||
}
|
||||
/*
|
||||
It seems that some API messages do not comply with vscode state requirements. Either the Anthropic library is manipulating these values somehow in the backend in a way thats creating cyclic references, or the API returns a function or a Symbol as part of the message content.
|
||||
VSCode docs about state: "The value must be JSON-stringifyable ... value — A value. MUST not contain cyclic references."
|
||||
For now we'll store the conversation history in memory, and if we need to store in state directly we'd need to do a manual conversion to ensure proper json stringification.
|
||||
*/
|
||||
|
||||
async setApiConversationHistory(history: Anthropic.MessageParam[] | undefined) {
|
||||
await this.updateGlobalState(this.getApiConversationHistoryStateKey(), history)
|
||||
}
|
||||
// getApiConversationHistory(): Anthropic.MessageParam[] {
|
||||
// // const history = (await this.getGlobalState(
|
||||
// // this.getApiConversationHistoryStateKey()
|
||||
// // )) as Anthropic.MessageParam[]
|
||||
// // return history || []
|
||||
// return this.apiConversationHistory
|
||||
// }
|
||||
|
||||
async addMessageToApiConversationHistory(message: Anthropic.MessageParam): Promise<Anthropic.MessageParam[]> {
|
||||
const history = await this.getApiConversationHistory()
|
||||
history.push(message)
|
||||
await this.setApiConversationHistory(history)
|
||||
return history
|
||||
}
|
||||
// setApiConversationHistory(history: Anthropic.MessageParam[] | undefined) {
|
||||
// // await this.updateGlobalState(this.getApiConversationHistoryStateKey(), history)
|
||||
// this.apiConversationHistory = history || []
|
||||
// }
|
||||
|
||||
// addMessageToApiConversationHistory(message: Anthropic.MessageParam): Anthropic.MessageParam[] {
|
||||
// // const history = await this.getApiConversationHistory()
|
||||
// // history.push(message)
|
||||
// // await this.setApiConversationHistory(history)
|
||||
// // return history
|
||||
// this.apiConversationHistory.push(message)
|
||||
// return this.apiConversationHistory
|
||||
// }
|
||||
|
||||
/*
|
||||
Storage
|
||||
@@ -464,13 +595,92 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
|
||||
https://www.eliostruyf.com/devhack-code-extension-storage-options/
|
||||
*/
|
||||
|
||||
async getState() {
|
||||
const [
|
||||
storedApiProvider,
|
||||
apiModelId,
|
||||
apiKey,
|
||||
openRouterApiKey,
|
||||
awsAccessKey,
|
||||
awsSecretKey,
|
||||
awsRegion,
|
||||
vertexProjectId,
|
||||
vertexRegion,
|
||||
maxRequestsPerTask,
|
||||
lastShownAnnouncementId,
|
||||
customInstructions,
|
||||
alwaysAllowReadOnly,
|
||||
taskHistory,
|
||||
] = await Promise.all([
|
||||
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
|
||||
this.getGlobalState("apiModelId") as Promise<ApiModelId | undefined>,
|
||||
this.getSecret("apiKey") as Promise<string | undefined>,
|
||||
this.getSecret("openRouterApiKey") as Promise<string | undefined>,
|
||||
this.getSecret("awsAccessKey") as Promise<string | undefined>,
|
||||
this.getSecret("awsSecretKey") as Promise<string | undefined>,
|
||||
this.getGlobalState("awsRegion") as Promise<string | undefined>,
|
||||
this.getGlobalState("vertexProjectId") as Promise<string | undefined>,
|
||||
this.getGlobalState("vertexRegion") as Promise<string | undefined>,
|
||||
this.getGlobalState("maxRequestsPerTask") as Promise<number | undefined>,
|
||||
this.getGlobalState("lastShownAnnouncementId") as Promise<string | undefined>,
|
||||
this.getGlobalState("customInstructions") as Promise<string | undefined>,
|
||||
this.getGlobalState("alwaysAllowReadOnly") as Promise<boolean | undefined>,
|
||||
this.getGlobalState("taskHistory") as Promise<HistoryItem[] | undefined>,
|
||||
])
|
||||
|
||||
let apiProvider: ApiProvider
|
||||
if (storedApiProvider) {
|
||||
apiProvider = storedApiProvider
|
||||
} else {
|
||||
// Either new user or legacy user that doesn't have the apiProvider stored in state
|
||||
// (If they're using OpenRouter or Bedrock, then apiProvider state will exist)
|
||||
if (apiKey) {
|
||||
apiProvider = "anthropic"
|
||||
} else {
|
||||
// New users should default to anthropic
|
||||
apiProvider = "anthropic"
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
apiConfiguration: {
|
||||
apiProvider,
|
||||
apiModelId,
|
||||
apiKey,
|
||||
openRouterApiKey,
|
||||
awsAccessKey,
|
||||
awsSecretKey,
|
||||
awsRegion,
|
||||
vertexProjectId,
|
||||
vertexRegion,
|
||||
},
|
||||
maxRequestsPerTask,
|
||||
lastShownAnnouncementId,
|
||||
customInstructions,
|
||||
alwaysAllowReadOnly: alwaysAllowReadOnly ?? false,
|
||||
taskHistory,
|
||||
}
|
||||
}
|
||||
|
||||
async updateTaskHistory(item: HistoryItem): Promise<HistoryItem[]> {
|
||||
const history = ((await this.getGlobalState("taskHistory")) as HistoryItem[]) || []
|
||||
const existingItemIndex = history.findIndex((h) => h.id === item.id)
|
||||
if (existingItemIndex !== -1) {
|
||||
history[existingItemIndex] = item
|
||||
} else {
|
||||
history.push(item)
|
||||
}
|
||||
await this.updateGlobalState("taskHistory", history)
|
||||
return history
|
||||
}
|
||||
|
||||
// global
|
||||
|
||||
private async updateGlobalState(key: string, value: any) {
|
||||
private async updateGlobalState(key: GlobalStateKey, value: any) {
|
||||
await this.context.globalState.update(key, value)
|
||||
}
|
||||
|
||||
private async getGlobalState(key: string) {
|
||||
private async getGlobalState(key: GlobalStateKey) {
|
||||
return await this.context.globalState.get(key)
|
||||
}
|
||||
|
||||
@@ -496,43 +706,35 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
|
||||
|
||||
// secrets
|
||||
|
||||
private async storeSecret(key: string, value: any) {
|
||||
await this.context.secrets.store(key, value)
|
||||
private async storeSecret(key: SecretKey, value?: string) {
|
||||
if (value) {
|
||||
await this.context.secrets.store(key, value)
|
||||
} else {
|
||||
await this.context.secrets.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
private async getSecret(key: string) {
|
||||
private async getSecret(key: SecretKey) {
|
||||
return await this.context.secrets.get(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper function that returns a unique alphanumeric identifier called a nonce.
|
||||
*
|
||||
* @remarks This function is primarily used to help enforce content security
|
||||
* policies for resources/scripts being executed in a webview context.
|
||||
*
|
||||
* @returns A nonce
|
||||
*/
|
||||
export function getNonce() {
|
||||
let text = ""
|
||||
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
for (let i = 0; i < 32; i++) {
|
||||
text += possible.charAt(Math.floor(Math.random() * possible.length))
|
||||
// dev
|
||||
|
||||
async resetState() {
|
||||
vscode.window.showInformationMessage("Resetting state...")
|
||||
for (const key of this.context.globalState.keys()) {
|
||||
await this.context.globalState.update(key, undefined)
|
||||
}
|
||||
const secretKeys: SecretKey[] = ["apiKey", "openRouterApiKey", "awsAccessKey", "awsSecretKey"]
|
||||
for (const key of secretKeys) {
|
||||
await this.storeSecret(key, undefined)
|
||||
}
|
||||
if (this.claudeDev) {
|
||||
this.claudeDev.abortTask()
|
||||
this.claudeDev = undefined
|
||||
}
|
||||
vscode.window.showInformationMessage("State reset")
|
||||
await this.postStateToWebview()
|
||||
await this.postMessageToWebview({ type: "action", action: "chatButtonTapped" })
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper function which will get the webview URI of a given file or resource.
|
||||
*
|
||||
* @remarks This URI can be used within a webview's HTML as a link to the
|
||||
* given file/resource.
|
||||
*
|
||||
* @param webview A reference to the extension webview
|
||||
* @param extensionUri The URI of the directory containing the extension
|
||||
* @param pathList An array of strings representing the path to a file/resource
|
||||
* @returns A URI pointing to the file/resource
|
||||
*/
|
||||
export function getUri(webview: Webview, extensionUri: Uri, pathList: string[]) {
|
||||
return webview.asWebviewUri(Uri.joinPath(extensionUri, ...pathList))
|
||||
}
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
// type that represents json data that is sent from extension to webview, called ExtensionMessage and has 'type' enum which can be 'plusButtonTapped' or 'settingsButtonTapped' or 'hello'
|
||||
|
||||
import { ApiConfiguration } from "./api"
|
||||
import { HistoryItem } from "./HistoryItem"
|
||||
|
||||
// webview will hold state
|
||||
export interface ExtensionMessage {
|
||||
type: "action" | "state"
|
||||
type: "action" | "state" | "selectedImages"
|
||||
text?: string
|
||||
action?: "plusButtonTapped" | "settingsButtonTapped" | "didBecomeVisible"
|
||||
action?: "chatButtonTapped" | "settingsButtonTapped" | "historyButtonTapped" | "didBecomeVisible"
|
||||
state?: ExtensionState
|
||||
images?: string[]
|
||||
}
|
||||
|
||||
export interface ExtensionState {
|
||||
apiKey?: string
|
||||
version: string
|
||||
apiConfiguration?: ApiConfiguration
|
||||
maxRequestsPerTask?: number
|
||||
customInstructions?: string
|
||||
alwaysAllowReadOnly?: boolean
|
||||
themeName?: string
|
||||
uriScheme?: string
|
||||
claudeMessages: ClaudeMessage[]
|
||||
taskHistory: HistoryItem[]
|
||||
shouldShowAnnouncement: boolean
|
||||
}
|
||||
|
||||
@@ -22,6 +31,7 @@ export interface ClaudeMessage {
|
||||
ask?: ClaudeAsk
|
||||
say?: ClaudeSay
|
||||
text?: string
|
||||
images?: string[]
|
||||
}
|
||||
|
||||
export type ClaudeAsk =
|
||||
@@ -32,6 +42,8 @@ export type ClaudeAsk =
|
||||
| "completion_result"
|
||||
| "tool"
|
||||
| "api_req_failed"
|
||||
| "resume_task"
|
||||
| "resume_completed_task"
|
||||
|
||||
export type ClaudeSay =
|
||||
| "task"
|
||||
@@ -43,10 +55,20 @@ export type ClaudeSay =
|
||||
| "user_feedback"
|
||||
| "api_req_retried"
|
||||
| "command_output"
|
||||
| "tool"
|
||||
|
||||
export interface ClaudeSayTool {
|
||||
tool: "editedExistingFile" | "newFileCreated" | "readFile" | "listFiles" | "analyzeProject"
|
||||
tool:
|
||||
| "editedExistingFile"
|
||||
| "newFileCreated"
|
||||
| "readFile"
|
||||
| "listFilesTopLevel"
|
||||
| "listFilesRecursive"
|
||||
| "listCodeDefinitionNames"
|
||||
| "searchFiles"
|
||||
path?: string
|
||||
diff?: string
|
||||
content?: string
|
||||
regex?: string
|
||||
filePattern?: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export type HistoryItem = {
|
||||
id: string
|
||||
ts: number
|
||||
task: string
|
||||
tokensIn: number
|
||||
tokensOut: number
|
||||
cacheWrites?: number
|
||||
cacheReads?: number
|
||||
totalCost: number
|
||||
}
|
||||
+2
-1
@@ -3,8 +3,9 @@ import { Anthropic } from "@anthropic-ai/sdk"
|
||||
export type ToolName =
|
||||
| "write_to_file"
|
||||
| "read_file"
|
||||
| "analyze_project"
|
||||
| "list_files"
|
||||
| "list_code_definition_names"
|
||||
| "search_files"
|
||||
| "execute_command"
|
||||
| "ask_followup_question"
|
||||
| "attempt_completion"
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import { ApiConfiguration, ApiProvider } from "./api"
|
||||
|
||||
export interface WebviewMessage {
|
||||
type:
|
||||
| "apiKey"
|
||||
| "apiConfiguration"
|
||||
| "maxRequestsPerTask"
|
||||
| "customInstructions"
|
||||
| "alwaysAllowReadOnly"
|
||||
| "webviewDidLaunch"
|
||||
| "newTask"
|
||||
| "askResponse"
|
||||
| "clearTask"
|
||||
| "didShowAnnouncement"
|
||||
| "downloadTask"
|
||||
| "selectImages"
|
||||
| "exportCurrentTask"
|
||||
| "showTaskWithId"
|
||||
| "deleteTaskWithId"
|
||||
| "exportTaskWithId"
|
||||
| "resetState"
|
||||
text?: string
|
||||
askResponse?: ClaudeAskResponse
|
||||
apiConfiguration?: ApiConfiguration
|
||||
images?: string[]
|
||||
bool?: boolean
|
||||
}
|
||||
|
||||
export type ClaudeAskResponse = "yesButtonTapped" | "noButtonTapped" | "textResponse"
|
||||
export type ClaudeAskResponse = "yesButtonTapped" | "noButtonTapped" | "messageResponse"
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
export type ApiProvider = "anthropic" | "openrouter" | "bedrock" | "vertex"
|
||||
|
||||
export interface ApiHandlerOptions {
|
||||
apiModelId?: ApiModelId
|
||||
apiKey?: string // anthropic
|
||||
openRouterApiKey?: string
|
||||
awsAccessKey?: string
|
||||
awsSecretKey?: string
|
||||
awsRegion?: string
|
||||
vertexProjectId?: string
|
||||
vertexRegion?: string
|
||||
}
|
||||
|
||||
export type ApiConfiguration = ApiHandlerOptions & {
|
||||
apiProvider?: ApiProvider
|
||||
}
|
||||
|
||||
// Models
|
||||
|
||||
export interface ModelInfo {
|
||||
maxTokens: number
|
||||
contextWindow: number
|
||||
supportsImages: boolean
|
||||
supportsPromptCache: boolean
|
||||
inputPrice: number
|
||||
outputPrice: number
|
||||
cacheWritesPrice?: number
|
||||
cacheReadsPrice?: number
|
||||
}
|
||||
|
||||
export type ApiModelId = AnthropicModelId | OpenRouterModelId | BedrockModelId | VertexModelId
|
||||
|
||||
// Anthropic
|
||||
// https://docs.anthropic.com/en/docs/about-claude/models
|
||||
export type AnthropicModelId = keyof typeof anthropicModels
|
||||
export const anthropicDefaultModelId: AnthropicModelId = "claude-3-5-sonnet-20240620"
|
||||
export const anthropicModels = {
|
||||
"claude-3-5-sonnet-20240620": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 3.0, // $3 per million input tokens
|
||||
outputPrice: 15.0, // $15 per million output tokens
|
||||
cacheWritesPrice: 3.75, // $3.75 per million tokens
|
||||
cacheReadsPrice: 0.3, // $0.30 per million tokens
|
||||
},
|
||||
"claude-3-opus-20240229": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 15.0,
|
||||
outputPrice: 75.0,
|
||||
cacheWritesPrice: 18.75,
|
||||
cacheReadsPrice: 1.5,
|
||||
},
|
||||
"claude-3-sonnet-20240229": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
},
|
||||
"claude-3-haiku-20240307": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.25,
|
||||
outputPrice: 1.25,
|
||||
cacheWritesPrice: 0.3,
|
||||
cacheReadsPrice: 0.03,
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo> // as const assertion makes the object deeply readonly
|
||||
|
||||
// AWS Bedrock
|
||||
// https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference.html
|
||||
export type BedrockModelId = keyof typeof bedrockModels
|
||||
export const bedrockDefaultModelId: BedrockModelId = "anthropic.claude-3-5-sonnet-20240620-v1:0"
|
||||
export const bedrockModels = {
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
},
|
||||
"anthropic.claude-3-opus-20240229-v1:0": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 15.0,
|
||||
outputPrice: 75.0,
|
||||
},
|
||||
"anthropic.claude-3-sonnet-20240229-v1:0": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
},
|
||||
"anthropic.claude-3-haiku-20240307-v1:0": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.25,
|
||||
outputPrice: 1.25,
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
// OpenRouter
|
||||
// https://openrouter.ai/models?order=newest&supported_parameters=tools
|
||||
export type OpenRouterModelId = keyof typeof openRouterModels
|
||||
export const openRouterDefaultModelId: OpenRouterModelId = "anthropic/claude-3.5-sonnet:beta"
|
||||
export const openRouterModels = {
|
||||
"anthropic/claude-3.5-sonnet:beta": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
},
|
||||
"anthropic/claude-3-opus:beta": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 15,
|
||||
outputPrice: 75,
|
||||
},
|
||||
"anthropic/claude-3-sonnet:beta": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 3,
|
||||
outputPrice: 15,
|
||||
},
|
||||
"anthropic/claude-3-haiku:beta": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.25,
|
||||
outputPrice: 1.25,
|
||||
},
|
||||
"openai/gpt-4o-2024-08-06": {
|
||||
maxTokens: 16384,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 2.5,
|
||||
outputPrice: 10,
|
||||
},
|
||||
"openai/gpt-4o-mini-2024-07-18": {
|
||||
maxTokens: 16384,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.15,
|
||||
outputPrice: 0.6,
|
||||
},
|
||||
"openai/gpt-4-turbo": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 10,
|
||||
outputPrice: 30,
|
||||
},
|
||||
// llama 3.1 models cannot use tools yet
|
||||
// "meta-llama/llama-3.1-405b-instruct": {
|
||||
// maxTokens: 2048,
|
||||
// supportsImages: false,
|
||||
// inputPrice: 2.7,
|
||||
// outputPrice: 2.7,
|
||||
// },
|
||||
// "meta-llama/llama-3.1-70b-instruct": {
|
||||
// maxTokens: 2048,
|
||||
// supportsImages: false,
|
||||
// inputPrice: 0.52,
|
||||
// outputPrice: 0.75,
|
||||
// },
|
||||
// "meta-llama/llama-3.1-8b-instruct": {
|
||||
// maxTokens: 2048,
|
||||
// supportsImages: false,
|
||||
// inputPrice: 0.06,
|
||||
// outputPrice: 0.06,
|
||||
// },
|
||||
// OpenRouter needs to fix mapping gemini 1.5 responses for tool calls properly, they return content with line breaks formatted wrong (too many escapes), and throw errors for being in the wrong order when they're not. They also cannot handle feedback given to a request with multiple tools. Giving feedback to one tool use requests works fine. ("Please ensure that function response turn comes immediately after a function call turn. And the number of function response parts should be equal to number of function call parts of the function call turn.")
|
||||
// "google/gemini-pro-1.5": {
|
||||
// maxTokens: 8192,
|
||||
// supportsImages: false, // "Function Calling is not supported with non-text input"
|
||||
// inputPrice: 2.5,
|
||||
// outputPrice: 7.5,
|
||||
// },
|
||||
// "google/gemini-flash-1.5": {
|
||||
// maxTokens: 8192,
|
||||
// supportsImages: false, // "Function Calling is not supported with non-text input"
|
||||
// inputPrice: 0.25,
|
||||
// outputPrice: 0.75,
|
||||
// },
|
||||
// "google/gemini-pro": {
|
||||
// maxTokens: 8192,
|
||||
// supportsImages: false, // "Function Calling is not supported with non-text input"
|
||||
// inputPrice: 0.125,
|
||||
// outputPrice: 0.375,
|
||||
// },
|
||||
// while deepseek coder can use tools, it may sometimes send tool invocation as a text block
|
||||
"deepseek/deepseek-coder": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.14,
|
||||
outputPrice: 0.28,
|
||||
},
|
||||
// mistral models can use tools but aren't great at going step-by-step and proceeding to the next step
|
||||
"mistralai/mistral-large": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 3,
|
||||
outputPrice: 9,
|
||||
},
|
||||
// This model is not capable of complex system/tool prompts
|
||||
// "mistralai/mistral-7b-instruct-v0.1": {
|
||||
// maxTokens: 4096,
|
||||
// supportsImages: false,
|
||||
// inputPrice: 0.06,
|
||||
// outputPrice: 0.06,
|
||||
// },
|
||||
// cohere models are not capable of complex system/tool prompts
|
||||
// "cohere/command-r-plus": {
|
||||
// maxTokens: 4000,
|
||||
// supportsImages: false,
|
||||
// inputPrice: 3,
|
||||
// outputPrice: 15,
|
||||
// },
|
||||
// "cohere/command-r": {
|
||||
// maxTokens: 4000,
|
||||
// supportsImages: false,
|
||||
// inputPrice: 0.5,
|
||||
// outputPrice: 1.5,
|
||||
// },
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
// Vertex AI
|
||||
// https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude
|
||||
export type VertexModelId = keyof typeof vertexModels
|
||||
export const vertexDefaultModelId: VertexModelId = "claude-3-5-sonnet@20240620"
|
||||
export const vertexModels = {
|
||||
"claude-3-5-sonnet@20240620": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
},
|
||||
"claude-3-opus@20240229": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 15.0,
|
||||
outputPrice: 75.0,
|
||||
},
|
||||
"claude-3-sonnet@20240229": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
},
|
||||
"claude-3-haiku@20240307": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.25,
|
||||
outputPrice: 1.25,
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
@@ -1,15 +1,15 @@
|
||||
import { ClaudeMessage } from "@shared/ExtensionMessage"
|
||||
import { ClaudeMessage } from "./ExtensionMessage"
|
||||
|
||||
/**
|
||||
* Combines API request start and finish messages in an array of ClaudeMessages.
|
||||
*
|
||||
*
|
||||
* This function looks for pairs of 'api_req_started' and 'api_req_finished' messages.
|
||||
* When it finds a pair, it combines them into a single 'api_req_combined' message.
|
||||
* The JSON data in the text fields of both messages are merged.
|
||||
*
|
||||
*
|
||||
* @param messages - An array of ClaudeMessage objects to process.
|
||||
* @returns A new array of ClaudeMessage objects with API requests combined.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* const messages = [
|
||||
* { type: "say", say: "api_req_started", text: '{"request":"GET /api/data"}', ts: 1000 },
|
||||
+6
-2
@@ -1,4 +1,4 @@
|
||||
import { ClaudeMessage } from "@shared/ExtensionMessage"
|
||||
import { ClaudeMessage } from "./ExtensionMessage"
|
||||
|
||||
/**
|
||||
* Combines sequences of command and command_output messages in an array of ClaudeMessages.
|
||||
@@ -41,7 +41,11 @@ export function combineCommandSequences(messages: ClaudeMessage[]): ClaudeMessag
|
||||
combinedText += `\n${COMMAND_OUTPUT_STRING}`
|
||||
didAddOutput = true
|
||||
}
|
||||
combinedText += "\n" + (messages[j].text || "")
|
||||
// handle cases where we receive empty command_output (ie when extension is relinquishing control over exit command button)
|
||||
const output = messages[j].text || ""
|
||||
if (output.length > 0) {
|
||||
combinedText += "\n" + output
|
||||
}
|
||||
}
|
||||
j++
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ClaudeMessage } from "@shared/ExtensionMessage"
|
||||
import { ClaudeMessage } from "./ExtensionMessage"
|
||||
|
||||
interface ApiMetrics {
|
||||
totalTokensIn: number
|
||||
totalTokensOut: number
|
||||
totalCacheWrites?: number
|
||||
totalCacheReads?: number
|
||||
totalCost: number
|
||||
}
|
||||
|
||||
@@ -11,10 +13,10 @@ interface ApiMetrics {
|
||||
*
|
||||
* This function processes 'api_req_started' messages that have been combined with their
|
||||
* corresponding 'api_req_finished' messages by the combineApiRequests function.
|
||||
* It extracts and sums up the tokensIn, tokensOut, and cost from these messages.
|
||||
* It extracts and sums up the tokensIn, tokensOut, cacheWrites, cacheReads, and cost from these messages.
|
||||
*
|
||||
* @param messages - An array of ClaudeMessage objects to process.
|
||||
* @returns An ApiMetrics object containing totalTokensIn, totalTokensOut, and totalCost.
|
||||
* @returns An ApiMetrics object containing totalTokensIn, totalTokensOut, totalCacheWrites, totalCacheReads, and totalCost.
|
||||
*
|
||||
* @example
|
||||
* const messages = [
|
||||
@@ -27,6 +29,8 @@ export function getApiMetrics(messages: ClaudeMessage[]): ApiMetrics {
|
||||
const result: ApiMetrics = {
|
||||
totalTokensIn: 0,
|
||||
totalTokensOut: 0,
|
||||
totalCacheWrites: undefined,
|
||||
totalCacheReads: undefined,
|
||||
totalCost: 0,
|
||||
}
|
||||
|
||||
@@ -34,7 +38,7 @@ export function getApiMetrics(messages: ClaudeMessage[]): ApiMetrics {
|
||||
if (message.type === "say" && message.say === "api_req_started" && message.text) {
|
||||
try {
|
||||
const parsedData = JSON.parse(message.text)
|
||||
const { tokensIn, tokensOut, cost } = parsedData
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads, cost } = parsedData
|
||||
|
||||
if (typeof tokensIn === "number") {
|
||||
result.totalTokensIn += tokensIn
|
||||
@@ -42,6 +46,12 @@ export function getApiMetrics(messages: ClaudeMessage[]): ApiMetrics {
|
||||
if (typeof tokensOut === "number") {
|
||||
result.totalTokensOut += tokensOut
|
||||
}
|
||||
if (typeof cacheWrites === "number") {
|
||||
result.totalCacheWrites = (result.totalCacheWrites ?? 0) + cacheWrites
|
||||
}
|
||||
if (typeof cacheReads === "number") {
|
||||
result.totalCacheReads = (result.totalCacheReads ?? 0) + cacheReads
|
||||
}
|
||||
if (typeof cost === "number") {
|
||||
result.totalCost += cost
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Returns the index of the last element in the array where predicate is true, and -1
|
||||
* otherwise.
|
||||
* @param array The source array to search in
|
||||
* @param predicate find calls predicate once for each element of the array, in descending
|
||||
* order, until it finds one where predicate returns true. If such an element is found,
|
||||
* findLastIndex immediately returns that element index. Otherwise, findLastIndex returns -1.
|
||||
*/
|
||||
export function findLastIndex<T>(array: Array<T>, predicate: (value: T, index: number, obj: T[]) => boolean): number {
|
||||
let l = array.length
|
||||
while (l--) {
|
||||
if (predicate(array[l], l, array)) {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { countTokens } from "@anthropic-ai/tokenizer"
|
||||
import { Buffer } from "buffer"
|
||||
import sizeOf from "image-size"
|
||||
|
||||
export function isWithinContextWindow(
|
||||
contextWindow: number,
|
||||
systemPrompt: string,
|
||||
tools: Anthropic.Messages.Tool[],
|
||||
messages: Anthropic.Messages.MessageParam[]
|
||||
): boolean {
|
||||
const adjustedContextWindow = contextWindow - 10_000 // Buffer to account for tokenizer differences
|
||||
// counting tokens is expensive, so we first try to estimate before doing a more accurate calculation
|
||||
const estimatedTotalMessageTokens = countTokens(systemPrompt + JSON.stringify(tools) + JSON.stringify(messages))
|
||||
if (estimatedTotalMessageTokens <= adjustedContextWindow) {
|
||||
return true
|
||||
}
|
||||
const systemPromptTokens = countTokens(systemPrompt)
|
||||
const toolsTokens = countTokens(JSON.stringify(tools))
|
||||
let availableTokens = adjustedContextWindow - systemPromptTokens - toolsTokens
|
||||
let accurateTotalMessageTokens = messages.reduce((sum, message) => sum + countMessageTokens(message), 0)
|
||||
return accurateTotalMessageTokens <= availableTokens
|
||||
}
|
||||
|
||||
/*
|
||||
We can't implement a dynamically updating sliding window as it would break prompt cache
|
||||
every time. To maintain the benefits of caching, we need to keep conversation history
|
||||
static. This operation should be performed as infrequently as possible. If a user reaches
|
||||
a 200k context, we can assume that the first half is likely irrelevant to their current task.
|
||||
Therefore, this function should only be called when absolutely necessary to fit within
|
||||
context limits, not as a continuous process.
|
||||
*/
|
||||
export function truncateHalfConversation(
|
||||
messages: Anthropic.Messages.MessageParam[]
|
||||
): Anthropic.Messages.MessageParam[] {
|
||||
// API expects messages to be in user-assistant order, and tool use messages must be followed by tool results. We need to maintain this structure while truncating.
|
||||
|
||||
// Always keep the first Task message (this includes the project's file structure in potentially_relevant_details)
|
||||
const truncatedMessages = [messages[0]]
|
||||
|
||||
// Remove half of user-assistant pairs
|
||||
const messagesToRemove = Math.floor(messages.length / 4) * 2 // has to be even number
|
||||
|
||||
const remainingMessages = messages.slice(messagesToRemove + 1) // has to start with assistant message since tool result cannot follow assistant message with no tool use
|
||||
truncatedMessages.push(...remainingMessages)
|
||||
|
||||
return truncatedMessages
|
||||
}
|
||||
|
||||
function countMessageTokens(message: Anthropic.Messages.MessageParam): number {
|
||||
if (typeof message.content === "string") {
|
||||
return countTokens(message.content)
|
||||
} else if (Array.isArray(message.content)) {
|
||||
return message.content.reduce((sum, item) => {
|
||||
if (typeof item === "string") {
|
||||
return sum + countTokens(item)
|
||||
} else if (item.type === "text") {
|
||||
return sum + countTokens(item.text)
|
||||
} else if (item.type === "image") {
|
||||
return sum + estimateImageTokens(item.source.data)
|
||||
} else if (item.type === "tool_use") {
|
||||
return sum + countTokens(JSON.stringify(item.input))
|
||||
} else if (item.type === "tool_result") {
|
||||
if (Array.isArray(item.content)) {
|
||||
return (
|
||||
sum +
|
||||
item.content.reduce((contentSum, contentItem) => {
|
||||
if (contentItem.type === "text") {
|
||||
return contentSum + countTokens(contentItem.text)
|
||||
} else if (contentItem.type === "image") {
|
||||
return contentSum + estimateImageTokens(contentItem.source.data)
|
||||
}
|
||||
return contentSum + countTokens(JSON.stringify(contentItem))
|
||||
}, 0)
|
||||
)
|
||||
} else {
|
||||
return sum + countTokens(item.content || "")
|
||||
}
|
||||
} else {
|
||||
return sum + countTokens(JSON.stringify(item))
|
||||
}
|
||||
}, 0)
|
||||
} else {
|
||||
return countTokens(JSON.stringify(message.content))
|
||||
}
|
||||
}
|
||||
|
||||
function estimateImageTokens(base64: string): number {
|
||||
const base64Data = base64.split(";base64,").pop()
|
||||
if (base64Data) {
|
||||
const buffer = Buffer.from(base64Data, "base64")
|
||||
const dimensions = sizeOf(buffer)
|
||||
if (dimensions.width && dimensions.height) {
|
||||
// "you can estimate the number of tokens used through this algorithm: tokens = (width px * height px)/750"
|
||||
return Math.ceil((dimensions.width * dimensions.height) / 750)
|
||||
}
|
||||
}
|
||||
return countTokens(base64)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import os from "os"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
export async function downloadTask(dateTs: number, conversationHistory: Anthropic.MessageParam[]) {
|
||||
// File name
|
||||
const date = new Date(dateTs)
|
||||
const month = date.toLocaleString("en-US", { month: "short" }).toLowerCase()
|
||||
const day = date.getDate()
|
||||
const year = date.getFullYear()
|
||||
let hours = date.getHours()
|
||||
const minutes = date.getMinutes().toString().padStart(2, "0")
|
||||
const seconds = date.getSeconds().toString().padStart(2, "0")
|
||||
const ampm = hours >= 12 ? "pm" : "am"
|
||||
hours = hours % 12
|
||||
hours = hours ? hours : 12 // the hour '0' should be '12'
|
||||
const fileName = `claude_dev_task_${month}-${day}-${year}_${hours}-${minutes}-${seconds}-${ampm}.md`
|
||||
|
||||
// Generate markdown
|
||||
const markdownContent = conversationHistory
|
||||
.map((message) => {
|
||||
const role = message.role === "user" ? "**User:**" : "**Assistant:**"
|
||||
const content = Array.isArray(message.content)
|
||||
? message.content.map(formatContentBlockToMarkdown).join("\n")
|
||||
: message.content
|
||||
|
||||
return `${role}\n\n${content}\n\n`
|
||||
})
|
||||
.join("---\n\n")
|
||||
|
||||
// Prompt user for save location
|
||||
const saveUri = await vscode.window.showSaveDialog({
|
||||
filters: { Markdown: ["md"] },
|
||||
defaultUri: vscode.Uri.file(path.join(os.homedir(), "Downloads", fileName)),
|
||||
})
|
||||
|
||||
if (saveUri) {
|
||||
// Write content to the selected location
|
||||
await vscode.workspace.fs.writeFile(saveUri, Buffer.from(markdownContent))
|
||||
vscode.window.showTextDocument(saveUri, { preview: true })
|
||||
}
|
||||
}
|
||||
|
||||
function formatContentBlockToMarkdown(
|
||||
block:
|
||||
| Anthropic.TextBlockParam
|
||||
| Anthropic.ImageBlockParam
|
||||
| Anthropic.ToolUseBlockParam
|
||||
| Anthropic.ToolResultBlockParam
|
||||
): string {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
return block.text
|
||||
case "image":
|
||||
return `[Image]`
|
||||
case "tool_use":
|
||||
let input: string
|
||||
if (typeof block.input === "object" && block.input !== null) {
|
||||
input = Object.entries(block.input)
|
||||
.map(([key, value]) => `${key.charAt(0).toUpperCase() + key.slice(1)}: ${value}`)
|
||||
.join("\n")
|
||||
} else {
|
||||
input = String(block.input)
|
||||
}
|
||||
return `[Tool Use: ${block.name}]\n${input}`
|
||||
case "tool_result":
|
||||
if (typeof block.content === "string") {
|
||||
return `[Tool Result${block.is_error ? " (Error)" : ""}]\n${block.content}`
|
||||
} else if (Array.isArray(block.content)) {
|
||||
return `[Tool Result${block.is_error ? " (Error)" : ""}]\n${block.content
|
||||
.map(formatContentBlockToMarkdown)
|
||||
.join("\n")}`
|
||||
} else {
|
||||
return `[Tool Result${block.is_error ? " (Error)" : ""}]`
|
||||
}
|
||||
default:
|
||||
return "[Unexpected content type]"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* A helper function that returns a unique alphanumeric identifier called a nonce.
|
||||
*
|
||||
* @remarks This function is primarily used to help enforce content security
|
||||
* policies for resources/scripts being executed in a webview context.
|
||||
*
|
||||
* @returns A nonce
|
||||
*/
|
||||
export function getNonce() {
|
||||
let text = ""
|
||||
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
for (let i = 0; i < 32; i++) {
|
||||
text += possible.charAt(Math.floor(Math.random() * possible.length))
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Uri, Webview } from "vscode"
|
||||
/**
|
||||
* A helper function which will get the webview URI of a given file or resource.
|
||||
*
|
||||
* @remarks This URI can be used within a webview's HTML as a link to the
|
||||
* given file/resource.
|
||||
*
|
||||
* @param webview A reference to the extension webview
|
||||
* @param extensionUri The URI of the directory containing the extension
|
||||
* @param pathList An array of strings representing the path to a file/resource
|
||||
* @returns A URI pointing to the file/resource
|
||||
*/
|
||||
export function getUri(webview: Webview, extensionUri: Uri, pathList: string[]) {
|
||||
return webview.asWebviewUri(Uri.joinPath(extensionUri, ...pathList))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./getNonce"
|
||||
export * from "./getUri"
|
||||
export * from "./process-images"
|
||||
export * from "./export-markdown"
|
||||
export * from "./array-helpers"
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
|
||||
export function convertToOpenAiMessages(
|
||||
anthropicMessages: Anthropic.Messages.MessageParam[]
|
||||
): OpenAI.Chat.ChatCompletionMessageParam[] {
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = []
|
||||
|
||||
for (const anthropicMessage of anthropicMessages) {
|
||||
if (typeof anthropicMessage.content === "string") {
|
||||
openAiMessages.push({ role: anthropicMessage.role, content: anthropicMessage.content })
|
||||
} else {
|
||||
// image_url.url is base64 encoded image data
|
||||
// ensure it contains the content-type of the image: data:image/png;base64,
|
||||
/*
|
||||
{ role: "user", content: "" | { type: "text", text: string } | { type: "image_url", image_url: { url: string } } },
|
||||
// content required unless tool_calls is present
|
||||
{ role: "assistant", content?: "" | null, tool_calls?: [{ id: "", function: { name: "", arguments: "" }, type: "function" }] },
|
||||
{ role: "tool", tool_call_id: "", content: ""}
|
||||
*/
|
||||
if (anthropicMessage.role === "user") {
|
||||
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
|
||||
toolMessages: Anthropic.ToolResultBlockParam[]
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_result") {
|
||||
acc.toolMessages.push(part)
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part)
|
||||
} // user cannot send tool_use messages
|
||||
return acc
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] }
|
||||
)
|
||||
|
||||
// Process non-tool messages
|
||||
if (nonToolMessages.length > 0) {
|
||||
openAiMessages.push({
|
||||
role: "user",
|
||||
content: nonToolMessages.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return {
|
||||
type: "image_url",
|
||||
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
|
||||
}
|
||||
}
|
||||
return { type: "text", text: part.text }
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// Process tool result messages
|
||||
let toolResultImages: Anthropic.Messages.ImageBlockParam[] = []
|
||||
toolMessages.forEach((toolMessage) => {
|
||||
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the OpenAI SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
|
||||
let content: string
|
||||
|
||||
if (typeof toolMessage.content === "string") {
|
||||
content = toolMessage.content
|
||||
} else {
|
||||
content =
|
||||
toolMessage.content
|
||||
?.map((part) => {
|
||||
if (part.type === "image") {
|
||||
toolResultImages.push(part)
|
||||
return "(see following user message for image)"
|
||||
}
|
||||
return part.text
|
||||
})
|
||||
.join("\n") ?? ""
|
||||
}
|
||||
openAiMessages.push({
|
||||
role: "tool",
|
||||
tool_call_id: toolMessage.tool_use_id,
|
||||
content: content,
|
||||
})
|
||||
})
|
||||
|
||||
// If tool results contain images, send as a separate user message
|
||||
// I ran into an issue where if I gave feedback for one of many tool uses, the request would fail.
|
||||
// "Messages following `tool_use` blocks must begin with a matching number of `tool_result` blocks."
|
||||
// Therefore we need to send these images after the tool result messages
|
||||
if (toolResultImages.length > 0) {
|
||||
openAiMessages.push({
|
||||
role: "user",
|
||||
content: toolResultImages.map((part) => ({
|
||||
type: "image_url",
|
||||
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
|
||||
})),
|
||||
})
|
||||
}
|
||||
} else if (anthropicMessage.role === "assistant") {
|
||||
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
|
||||
toolMessages: Anthropic.ToolUseBlockParam[]
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_use") {
|
||||
acc.toolMessages.push(part)
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part)
|
||||
} // assistant cannot send tool_result messages
|
||||
return acc
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] }
|
||||
)
|
||||
|
||||
// Process non-tool messages
|
||||
let content: string | undefined
|
||||
if (nonToolMessages.length > 0) {
|
||||
content = nonToolMessages
|
||||
.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return "" // impossible as the assistant cannot send images
|
||||
}
|
||||
return part.text
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
// Process tool use messages
|
||||
let tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => ({
|
||||
id: toolMessage.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: toolMessage.name,
|
||||
// json string
|
||||
arguments: JSON.stringify(toolMessage.input),
|
||||
},
|
||||
}))
|
||||
|
||||
openAiMessages.push({
|
||||
role: "assistant",
|
||||
content,
|
||||
// Cannot be an empty array. API expects an array with minimum length 1, and will respond with an error if it's empty
|
||||
tool_calls: tool_calls.length > 0 ? tool_calls : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return openAiMessages
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as vscode from "vscode"
|
||||
import fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
|
||||
export async function selectImages(): Promise<string[]> {
|
||||
const options: vscode.OpenDialogOptions = {
|
||||
canSelectMany: true,
|
||||
openLabel: "Select",
|
||||
filters: {
|
||||
Images: ["png", "jpg", "jpeg", "webp"], // supported by anthropic and openrouter
|
||||
},
|
||||
}
|
||||
|
||||
const fileUris = await vscode.window.showOpenDialog(options)
|
||||
|
||||
if (!fileUris || fileUris.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return await Promise.all(
|
||||
fileUris.map(async (uri) => {
|
||||
const imagePath = uri.fsPath
|
||||
const buffer = await fs.readFile(imagePath)
|
||||
const base64 = buffer.toString("base64")
|
||||
const mimeType = getMimeType(imagePath)
|
||||
const dataUrl = `data:${mimeType};base64,${base64}`
|
||||
return dataUrl
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function getMimeType(filePath: string): string {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
switch (ext) {
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".jpeg":
|
||||
case ".jpg":
|
||||
return "image/jpeg"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
default:
|
||||
throw new Error(`Unsupported file type: ${ext}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import * as vscode from "vscode"
|
||||
import * as childProcess from "child_process"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs"
|
||||
import * as readline from "readline"
|
||||
|
||||
/*
|
||||
This file provides functionality to perform regex searches on files using ripgrep.
|
||||
Inspired by: https://github.com/DiscreteTom/vscode-ripgrep-utils
|
||||
|
||||
Key components:
|
||||
1. getBinPath: Locates the ripgrep binary within the VSCode installation.
|
||||
2. execRipgrep: Executes the ripgrep command and returns the output.
|
||||
3. regexSearchFiles: The main function that performs regex searches on files.
|
||||
- Parameters:
|
||||
* cwd: The current working directory (for relative path calculation)
|
||||
* directoryPath: The directory to search in
|
||||
* regex: The regular expression to search for (Rust regex syntax)
|
||||
* filePattern: Optional glob pattern to filter files (default: '*')
|
||||
- Returns: A formatted string containing search results with context
|
||||
|
||||
The search results include:
|
||||
- Relative file paths
|
||||
- 2 lines of context before and after each match
|
||||
- Matches formatted with pipe characters for easy reading
|
||||
|
||||
Usage example:
|
||||
const results = await regexSearchFiles('/path/to/cwd', '/path/to/search', 'TODO:', '*.ts');
|
||||
|
||||
rel/path/to/app.ts
|
||||
│----
|
||||
│function processData(data: any) {
|
||||
│ // Some processing logic here
|
||||
│ // TODO: Implement error handling
|
||||
│ return processedData;
|
||||
│}
|
||||
│----
|
||||
|
||||
rel/path/to/helper.ts
|
||||
│----
|
||||
│ let result = 0;
|
||||
│ for (let i = 0; i < input; i++) {
|
||||
│ // TODO: Optimize this function for performance
|
||||
│ result += Math.pow(i, 2);
|
||||
│ }
|
||||
│----
|
||||
*/
|
||||
|
||||
const isWindows = /^win/.test(process.platform)
|
||||
const binName = isWindows ? "rg.exe" : "rg"
|
||||
|
||||
interface SearchResult {
|
||||
file: string
|
||||
line: number
|
||||
column: number
|
||||
match: string
|
||||
beforeContext: string[]
|
||||
afterContext: string[]
|
||||
}
|
||||
|
||||
const MAX_RESULTS = 300
|
||||
|
||||
async function getBinPath(vscodeAppRoot: string): Promise<string | undefined> {
|
||||
const checkPath = async (pkgFolder: string) => {
|
||||
const fullPath = path.join(vscodeAppRoot, pkgFolder, binName)
|
||||
return (await pathExists(fullPath)) ? fullPath : undefined
|
||||
}
|
||||
|
||||
return (
|
||||
(await checkPath("node_modules/@vscode/ripgrep/bin/")) ||
|
||||
(await checkPath("node_modules/vscode-ripgrep/bin")) ||
|
||||
(await checkPath("node_modules.asar.unpacked/vscode-ripgrep/bin/")) ||
|
||||
(await checkPath("node_modules.asar.unpacked/@vscode/ripgrep/bin/"))
|
||||
)
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
fs.access(path, (err) => {
|
||||
resolve(err === null)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function execRipgrep(bin: string, args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const rgProcess = childProcess.spawn(bin, args)
|
||||
// cross-platform alternative to head, which is ripgrep author's recommendation for limiting output.
|
||||
const rl = readline.createInterface({
|
||||
input: rgProcess.stdout,
|
||||
crlfDelay: Infinity, // treat \r\n as a single line break even if it's split across chunks. This ensures consistent behavior across different operating systems.
|
||||
})
|
||||
|
||||
let output = ""
|
||||
let lineCount = 0
|
||||
const maxLines = MAX_RESULTS * 5 // limiting ripgrep output with max lines since there's no other way to limit results. it's okay that we're outputting as json, since we're parsing it line by line and ignore anything that's not part of a match. This assumes each result is at most 5 lines.
|
||||
|
||||
rl.on("line", (line) => {
|
||||
if (lineCount < maxLines) {
|
||||
output += line + "\n"
|
||||
lineCount++
|
||||
} else {
|
||||
rl.close()
|
||||
rgProcess.kill()
|
||||
}
|
||||
})
|
||||
|
||||
let errorOutput = ""
|
||||
rgProcess.stderr.on("data", (data) => {
|
||||
errorOutput += data.toString()
|
||||
})
|
||||
rl.on("close", () => {
|
||||
if (errorOutput) {
|
||||
reject(new Error(`ripgrep process error: ${errorOutput}`))
|
||||
} else {
|
||||
resolve(output)
|
||||
}
|
||||
})
|
||||
rgProcess.on("error", (error) => {
|
||||
reject(new Error(`ripgrep process error: ${error.message}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function regexSearchFiles(
|
||||
cwd: string,
|
||||
directoryPath: string,
|
||||
regex: string,
|
||||
filePattern?: string
|
||||
): Promise<string> {
|
||||
const vscodeAppRoot = vscode.env.appRoot
|
||||
const rgPath = await getBinPath(vscodeAppRoot)
|
||||
|
||||
if (!rgPath) {
|
||||
throw new Error("Could not find ripgrep binary")
|
||||
}
|
||||
|
||||
const args = ["--json", "-e", regex, "--glob", filePattern || "*", "--context", "1", directoryPath]
|
||||
|
||||
let output: string
|
||||
try {
|
||||
output = await execRipgrep(rgPath, args)
|
||||
} catch {
|
||||
return "No results found"
|
||||
}
|
||||
const results: SearchResult[] = []
|
||||
let currentResult: Partial<SearchResult> | null = null
|
||||
|
||||
output.split("\n").forEach((line) => {
|
||||
if (line) {
|
||||
try {
|
||||
const parsed = JSON.parse(line)
|
||||
if (parsed.type === "match") {
|
||||
if (currentResult) {
|
||||
results.push(currentResult as SearchResult)
|
||||
}
|
||||
currentResult = {
|
||||
file: parsed.data.path.text,
|
||||
line: parsed.data.line_number,
|
||||
column: parsed.data.submatches[0].start,
|
||||
match: parsed.data.lines.text,
|
||||
beforeContext: [],
|
||||
afterContext: [],
|
||||
}
|
||||
} else if (parsed.type === "context" && currentResult) {
|
||||
if (parsed.data.line_number < currentResult.line!) {
|
||||
currentResult.beforeContext!.push(parsed.data.lines.text)
|
||||
} else {
|
||||
currentResult.afterContext!.push(parsed.data.lines.text)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing ripgrep output:", error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (currentResult) {
|
||||
results.push(currentResult as SearchResult)
|
||||
}
|
||||
|
||||
return formatResults(results, cwd)
|
||||
}
|
||||
|
||||
function formatResults(results: SearchResult[], cwd: string): string {
|
||||
const groupedResults: { [key: string]: SearchResult[] } = {}
|
||||
|
||||
let output = ""
|
||||
if (results.length >= MAX_RESULTS) {
|
||||
output += `Showing first ${MAX_RESULTS} of ${MAX_RESULTS}+ results. Use a more specific search if necessary.\n\n`
|
||||
} else {
|
||||
output += `Found ${results.length.toLocaleString()} results.\n\n`
|
||||
}
|
||||
|
||||
// Group results by file name
|
||||
results.slice(0, MAX_RESULTS).forEach((result) => {
|
||||
const relativeFilePath = path.relative(cwd, result.file)
|
||||
if (!groupedResults[relativeFilePath]) {
|
||||
groupedResults[relativeFilePath] = []
|
||||
}
|
||||
groupedResults[relativeFilePath].push(result)
|
||||
})
|
||||
|
||||
for (const [filePath, fileResults] of Object.entries(groupedResults)) {
|
||||
output += `${filePath}\n│----\n`
|
||||
|
||||
fileResults.forEach((result, index) => {
|
||||
const allLines = [...result.beforeContext, result.match, ...result.afterContext]
|
||||
allLines.forEach((line) => {
|
||||
output += `│${line?.trimEnd() ?? ""}\n`
|
||||
})
|
||||
|
||||
if (index < fileResults.length - 1) {
|
||||
output += "│----\n"
|
||||
}
|
||||
})
|
||||
|
||||
output += "│----\n\n"
|
||||
}
|
||||
|
||||
return output.trim()
|
||||
}
|
||||
+3
-2
@@ -12,7 +12,7 @@
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": false,
|
||||
"resolveJsonModule": true,
|
||||
"rootDir": "src",
|
||||
"rootDir": ".",
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
@@ -20,5 +20,6 @@
|
||||
"useDefineForClassFields": true,
|
||||
"useUnknownInCatchVariables": false
|
||||
},
|
||||
"include": ["src/**/*", "scripts/**/*"],
|
||||
"exclude": ["node_modules", ".vscode-test", "webview-ui"]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
-14
@@ -21,7 +21,6 @@
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"react-text-truncate": "^0.19.0",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"react-use": "^17.5.1",
|
||||
"react-virtuoso": "^4.7.13",
|
||||
@@ -17453,19 +17452,6 @@
|
||||
"react": ">= 0.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-text-truncate": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/react-text-truncate/-/react-text-truncate-0.19.0.tgz",
|
||||
"integrity": "sha512-QxHpZABfGG0Z3WEYbRTZ+rXdZn50Zvp+sWZXgVAd7FCKAMzv/kcwctTpNmWgXDTpAoHhMjOVwmgRtX3x5yeF4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.5.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^15.4.1 || ^16.0.0 || ^17.0.0 || || ^18.0.0",
|
||||
"react-dom": "^15.4.1 || ^16.0.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-textarea-autosize": {
|
||||
"version": "8.5.3",
|
||||
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.3.tgz",
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"react-text-truncate": "^0.19.0",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"react-use": "^17.5.1",
|
||||
"react-virtuoso": "^4.7.13",
|
||||
|
||||
@@ -13,6 +13,87 @@ const rewire = require("rewire")
|
||||
const defaults = rewire("react-scripts/scripts/build.js")
|
||||
const config = defaults.__get__("config")
|
||||
|
||||
/* Modifying Webpack Configuration for 'shared' dir
|
||||
This section uses Rewire to modify Create React App's webpack configuration without ejecting. Rewire allows us to inject and alter the internal build scripts of CRA at runtime. This allows us to maintain a flexible project structure that keeps shared code outside the webview-ui/src directory, while still adhering to CRA's security model that typically restricts imports to within src/.
|
||||
1. Uses the ModuleScopePlugin to whitelist files from the shared directory, allowing them to be imported despite being outside src/. (see: https://stackoverflow.com/questions/44114436/the-create-react-app-imports-restriction-outside-of-src-directory/58321458#58321458)
|
||||
2. Modifies the TypeScript rule to include the shared directory in compilation. This essentially transpiles and includes the ts files in shared dir in the output main.js file.
|
||||
Before, we would just import types from shared dir and specifying include (and alias to have cleaner paths) in tsconfig.json was enough. But now that we are creating values (i.e. models in api.ts) to import into the react app, we must also include these files in the webpack resolution.
|
||||
- Imports from the shared directory must use full paths relative to the src directory, without file extensions.
|
||||
- Example: import { someFunction } from '../../src/shared/utils/helpers'
|
||||
*/
|
||||
const ModuleScopePlugin = require("react-dev-utils/ModuleScopePlugin")
|
||||
const path = require("path")
|
||||
const fs = require("fs")
|
||||
// Get all files in the shared directory
|
||||
const sharedDir = path.resolve(__dirname, "../../src/shared")
|
||||
function getAllFiles(dir) {
|
||||
let files = []
|
||||
fs.readdirSync(dir).forEach((file) => {
|
||||
const filePath = path.join(dir, file)
|
||||
if (fs.statSync(filePath).isDirectory()) {
|
||||
files = files.concat(getAllFiles(filePath))
|
||||
} else {
|
||||
const withoutExtension = filePath.split(".")[0]
|
||||
files.push(withoutExtension)
|
||||
}
|
||||
})
|
||||
return files
|
||||
}
|
||||
const sharedFiles = getAllFiles(sharedDir)
|
||||
// config.resolve.plugins = config.resolve.plugins.filter((plugin) => !(plugin instanceof ModuleScopePlugin))
|
||||
// Instead of excluding the whole ModuleScopePlugin, we just whitelist specific files that can be imported from outside src.
|
||||
config.resolve.plugins.forEach((plugin) => {
|
||||
if (plugin instanceof ModuleScopePlugin) {
|
||||
console.log("Whitelisting shared files: ", sharedFiles)
|
||||
sharedFiles.forEach((file) => plugin.allowedFiles.add(file))
|
||||
}
|
||||
})
|
||||
/*
|
||||
Webpack configuration
|
||||
|
||||
Webpack is a module bundler for JavaScript applications. It processes your project files, resolving dependencies and generating a deployable production build.
|
||||
The webpack config is an object that tells webpack how to process and bundle your code. It defines entry points, output settings, and how to handle different file types.
|
||||
This config.module section of the webpack config deals with how different file types (modules) should be treated.
|
||||
config.module.rules:
|
||||
Rules define how module files should be processed. Each rule can:
|
||||
- Specify which files to process (test)
|
||||
When webpack "processes" a file, it performs several operations:
|
||||
1. Reads the file
|
||||
2. Parses its content and analyzes dependencies
|
||||
3. Applies transformations (e.g., converting TypeScript to JavaScript)
|
||||
4. Potentially modifies the code (e.g., applying polyfills)
|
||||
5. Includes the processed file in the final bundle
|
||||
By specifying which files to process, we're telling webpack which files should go through this pipeline and be included in our application bundle. Files that aren't processed are ignored by webpack.
|
||||
In our case, we're ensuring that TypeScript files in our shared directory are processed, allowing us to use them in our application.
|
||||
- Define which folders to include or exclude
|
||||
- Set which loaders to use for transformation
|
||||
A loader transforms certain types of files into valid modules that webpack can process. For example, the TypeScript loader converts .ts files into JavaScript that webpack can understand.
|
||||
By modifying these rules, we can change how webpack processes different files in our project, allowing us to include files from outside the standard src directory.
|
||||
|
||||
Why we need to modify the webpack config
|
||||
|
||||
Create React App (CRA) is designed to only process files within the src directory for security reasons. (CRA limits processing to the src directory to prevent accidental inclusion of sensitive files, reduce the attack surface, and ensure predictable builds, enhancing overall project security and consistency. Therefore it's essential that if you do include files outside src, you do so explicitly.)
|
||||
To use files from the shared directory, we need to:
|
||||
1. Modify ModuleScopePlugin to allow imports from the shared directory.
|
||||
2. Update the TypeScript loader rule to process TypeScript files from the shared directory.
|
||||
These changes tell webpack it's okay to import from the shared directory and ensure that TypeScript files in this directory are properly converted to JavaScript.
|
||||
|
||||
Modify webpack configuration to process TypeScript files from shared directory
|
||||
|
||||
This code modifies the webpack configuration to allow processing of TypeScript files from our shared directory, which is outside the standard src folder.
|
||||
1. config.module.rules[1]: In Create React App's webpack config, the second rule (index 1) typically contains the rules for processing JavaScript and TypeScript files.
|
||||
2. .oneOf: This array contains a list of loaders, and webpack will use the first matching loader for each file. We iterate through these to find the TypeScript loader.
|
||||
3. We check each rule to see if it applies to TypeScript files by looking for 'ts|tsx' in the test regex.
|
||||
4. When we find the TypeScript rule, we add our shared directory to its 'include' array. This tells webpack to also process TypeScript files from the shared directory.
|
||||
Note: This code assumes a specific structure in the CRA webpack config. If CRA updates its config structure in future versions, this code might need to be adjusted.
|
||||
*/
|
||||
config.module.rules[1].oneOf.forEach((rule) => {
|
||||
if (rule.test && rule.test.toString().includes("ts|tsx")) {
|
||||
// rule.include is path to src by default, but we can update rule.include to be an array as it matches an expected schema by react-scripts
|
||||
rule.include = [rule.include, sharedDir].filter(Boolean)
|
||||
}
|
||||
})
|
||||
|
||||
// Disable code splitting
|
||||
config.optimization.splitChunks = {
|
||||
cacheGroups: {
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import React from "react"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import App from "./App"
|
||||
|
||||
test("renders learn react link", () => {
|
||||
render(<App />)
|
||||
const linkElement = screen.getByText(/learn react/i)
|
||||
expect(linkElement).toBeInTheDocument()
|
||||
})
|
||||
+51
-57
@@ -1,68 +1,55 @@
|
||||
import React, { useEffect, useState, useCallback } from "react"
|
||||
import "./App.css"
|
||||
import ChatView from "./components/ChatView"
|
||||
import SettingsView from "./components/SettingsView"
|
||||
import { ClaudeMessage, ExtensionMessage } from "@shared/ExtensionMessage"
|
||||
import WelcomeView from "./components/WelcomeView"
|
||||
import { vscode } from "./utilities/vscode"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useEvent } from "react-use"
|
||||
import { ExtensionMessage } from "../../src/shared/ExtensionMessage"
|
||||
import { normalizeApiConfiguration } from "./components/ApiOptions"
|
||||
import ChatView from "./components/ChatView"
|
||||
import HistoryView from "./components/HistoryView"
|
||||
import SettingsView from "./components/SettingsView"
|
||||
import WelcomeView from "./components/WelcomeView"
|
||||
import { ExtensionStateContextProvider, useExtensionState } from "./context/ExtensionStateContext"
|
||||
import { vscode } from "./utils/vscode"
|
||||
|
||||
/*
|
||||
The contents of webviews however are created when the webview becomes visible and destroyed when the webview is moved into the background. Any state inside the webview will be lost when the webview is moved to a background tab.
|
||||
|
||||
The best way to solve this is to make your webview stateless. Use message passing to save off the webview's state and then restore the state when the webview becomes visible again.
|
||||
|
||||
|
||||
*/
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [didHydrateState, setDidHydrateState] = useState(false)
|
||||
const AppContent = () => {
|
||||
const { didHydrateState, showWelcome, apiConfiguration, shouldShowAnnouncement } = useExtensionState()
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showWelcome, setShowWelcome] = useState<boolean>(false)
|
||||
const [apiKey, setApiKey] = useState<string>("")
|
||||
const [maxRequestsPerTask, setMaxRequestsPerTask] = useState<string>("")
|
||||
const [vscodeThemeName, setVscodeThemeName] = useState<string | undefined>(undefined)
|
||||
const [claudeMessages, setClaudeMessages] = useState<ClaudeMessage[]>([])
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [showAnnouncement, setShowAnnouncement] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
vscode.postMessage({ type: "webviewDidLaunch" })
|
||||
}, [])
|
||||
|
||||
const handleMessage = useCallback((e: MessageEvent) => {
|
||||
const message: ExtensionMessage = e.data
|
||||
switch (message.type) {
|
||||
case "state":
|
||||
setShowWelcome(!message.state!.apiKey)
|
||||
setApiKey(message.state!.apiKey || "")
|
||||
setMaxRequestsPerTask(
|
||||
message.state!.maxRequestsPerTask !== undefined ? message.state!.maxRequestsPerTask.toString() : ""
|
||||
)
|
||||
setVscodeThemeName(message.state!.themeName)
|
||||
setClaudeMessages(message.state!.claudeMessages)
|
||||
// don't update showAnnouncement to false if shouldShowAnnouncement is false
|
||||
if (message.state!.shouldShowAnnouncement) {
|
||||
setShowAnnouncement(true)
|
||||
vscode.postMessage({ type: "didShowAnnouncement" })
|
||||
}
|
||||
setDidHydrateState(true)
|
||||
break
|
||||
case "action":
|
||||
switch (message.action!) {
|
||||
case "settingsButtonTapped":
|
||||
setShowSettings(true)
|
||||
setShowHistory(false)
|
||||
break
|
||||
case "plusButtonTapped":
|
||||
case "historyButtonTapped":
|
||||
setShowSettings(false)
|
||||
setShowHistory(true)
|
||||
break
|
||||
case "chatButtonTapped":
|
||||
setShowSettings(false)
|
||||
setShowHistory(false)
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
// we don't need to define any dependencies since we're not using any state in the callback. if you were to use state, you'd either have to include it in the dependency array or use the updater function `setUserText(prev => `${prev}${key}`);`. (react-use takes care of not registering the same listener multiple times even if this callback is updated.)
|
||||
}, [])
|
||||
|
||||
useEvent("message", handleMessage)
|
||||
|
||||
const { selectedModelInfo } = useMemo(() => {
|
||||
return normalizeApiConfiguration(apiConfiguration)
|
||||
}, [apiConfiguration])
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldShowAnnouncement) {
|
||||
setShowAnnouncement(true)
|
||||
vscode.postMessage({ type: "didShowAnnouncement" })
|
||||
}
|
||||
}, [shouldShowAnnouncement])
|
||||
|
||||
if (!didHydrateState) {
|
||||
return null
|
||||
}
|
||||
@@ -70,25 +57,24 @@ const App: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
{showWelcome ? (
|
||||
<WelcomeView apiKey={apiKey} setApiKey={setApiKey} />
|
||||
<WelcomeView />
|
||||
) : (
|
||||
<>
|
||||
{showSettings && (
|
||||
<SettingsView
|
||||
apiKey={apiKey}
|
||||
setApiKey={setApiKey}
|
||||
maxRequestsPerTask={maxRequestsPerTask}
|
||||
setMaxRequestsPerTask={setMaxRequestsPerTask}
|
||||
onDone={() => setShowSettings(false)}
|
||||
/>
|
||||
)}
|
||||
{showSettings && <SettingsView onDone={() => setShowSettings(false)} />}
|
||||
{showHistory && <HistoryView onDone={() => setShowHistory(false)} />}
|
||||
{/* Do not conditionally load ChatView, it's expensive and there's state we don't want to lose (user input, disableInput, askResponse promise, etc.) */}
|
||||
<ChatView
|
||||
messages={claudeMessages}
|
||||
isHidden={showSettings}
|
||||
vscodeThemeName={vscodeThemeName}
|
||||
showHistoryView={() => {
|
||||
setShowSettings(false)
|
||||
setShowHistory(true)
|
||||
}}
|
||||
isHidden={showSettings || showHistory}
|
||||
showAnnouncement={showAnnouncement}
|
||||
hideAnnouncement={() => setShowAnnouncement(false)}
|
||||
selectedModelSupportsImages={selectedModelInfo.supportsImages}
|
||||
selectedModelSupportsPromptCache={selectedModelInfo.supportsPromptCache}
|
||||
hideAnnouncement={() => {
|
||||
setShowAnnouncement(false)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -96,4 +82,12 @@ const App: React.FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<ExtensionStateContextProvider>
|
||||
<AppContent />
|
||||
</ExtensionStateContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import { ApiConfiguration } from "../../../src/shared/api"
|
||||
|
||||
interface AnnouncementProps {
|
||||
version: string
|
||||
hideAnnouncement: () => void
|
||||
apiConfiguration?: ApiConfiguration
|
||||
vscodeUriScheme?: string
|
||||
}
|
||||
/*
|
||||
You must update the latestAnnouncementId in ClaudeDevProvider for new announcements to show to users. This new id will be compared with whats in state for the 'last announcement shown', and if it's different then the announcement will render. As soon as an announcement is shown, the id will be updated in state. This ensures that announcements are not shown more than once, even if the user doesn't close it themselves.
|
||||
*/
|
||||
const Announcement = ({ hideAnnouncement }: AnnouncementProps) => {
|
||||
const Announcement = ({ version, hideAnnouncement, apiConfiguration, vscodeUriScheme }: AnnouncementProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -22,32 +26,26 @@ const Announcement = ({ hideAnnouncement }: AnnouncementProps) => {
|
||||
style={{ position: "absolute", top: "8px", right: "8px" }}>
|
||||
<span className="codicon codicon-close"></span>
|
||||
</VSCodeButton>
|
||||
<h3 style={{ margin: "0 0 8px" }}>🎉{" "}New in v1.0.6</h3>
|
||||
<ul style={{ margin: "0 0 8px", paddingLeft: "20px" }}>
|
||||
<h3 style={{ margin: "0 0 8px" }}>
|
||||
🎉{" "}New in v{version}
|
||||
</h3>
|
||||
<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
|
||||
<li>
|
||||
Open in the editor (using{" "}
|
||||
<span
|
||||
className="codicon codicon-link-external"
|
||||
style={{ display: "inline", fontSize: "12.5px", verticalAlign: "text-bottom" }}></span>{" "}
|
||||
or <code>Claude Dev: Open In New Tab</code> in command palette) to see how Claude updates your
|
||||
workspace more clearly
|
||||
Adds new <code>search_files</code> tool that lets Claude perform regex searches in your project,
|
||||
making it easy for him to refactor code, address TODOs and FIXMEs, remove dead code, and more!
|
||||
</li>
|
||||
<li>
|
||||
New <code>analyze_project</code> tool to help Claude get a comprehensive overview of your project's
|
||||
source code definitions and file structure
|
||||
<VSCodeLink
|
||||
href="https://github.com/saoudrizwan/claude-dev?tab=readme-ov-file#working-in-existing-projects"
|
||||
style={{ display: "inline" }}>
|
||||
(more on this here)
|
||||
</VSCodeLink>
|
||||
Adds "Always allow read-only operations" setting to let Claude read files and view directories
|
||||
without needing to approve (<b>off</b> by default).
|
||||
</li>
|
||||
<li>Adds sliding window context management to keep tasks going past 200k tokens.</li>
|
||||
<li>
|
||||
Adds Google Cloud Vertex AI support and updates Claude 3.5 Sonnet max output to 8192 tokens for all
|
||||
providers.
|
||||
</li>
|
||||
<li>
|
||||
Interact with CLI commands by sending messages to stdin and terminating long-running processes like
|
||||
servers
|
||||
Improves system prompt to guard against lazy edits (less "<code>{"//rest of code here"}</code>")
|
||||
</li>
|
||||
<li>Provide feedback to tool use like editing files or running commands</li>
|
||||
<li>Added ability to retry failed API requests (helpful for rate limits)</li>
|
||||
<li>Export task to a markdown file (useful as context for future tasks)</li>
|
||||
</ul>
|
||||
<p style={{ margin: "0" }}>
|
||||
Follow me for more updates!{" "}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { VSCodeDropdown, VSCodeLink, VSCodeOption, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import React, { useMemo } from "react"
|
||||
import {
|
||||
ApiConfiguration,
|
||||
ApiModelId,
|
||||
ModelInfo,
|
||||
anthropicDefaultModelId,
|
||||
anthropicModels,
|
||||
bedrockDefaultModelId,
|
||||
bedrockModels,
|
||||
openRouterDefaultModelId,
|
||||
openRouterModels,
|
||||
vertexDefaultModelId,
|
||||
vertexModels,
|
||||
} from "../../../src/shared/api"
|
||||
import { useExtensionState } from "../context/ExtensionStateContext"
|
||||
|
||||
interface ApiOptionsProps {
|
||||
showModelOptions: boolean
|
||||
apiErrorMessage?: string
|
||||
}
|
||||
|
||||
const ApiOptions: React.FC<ApiOptionsProps> = ({ showModelOptions, apiErrorMessage }) => {
|
||||
const { apiConfiguration, setApiConfiguration } = useExtensionState()
|
||||
const handleInputChange = (field: keyof ApiConfiguration) => (event: any) => {
|
||||
setApiConfiguration({ ...apiConfiguration, [field]: event.target.value })
|
||||
}
|
||||
|
||||
const { selectedProvider, selectedModelId, selectedModelInfo } = useMemo(() => {
|
||||
return normalizeApiConfiguration(apiConfiguration)
|
||||
}, [apiConfiguration])
|
||||
|
||||
/*
|
||||
VSCodeDropdown has an open bug where dynamically rendered options don't auto select the provided value prop. You can see this for yourself by comparing it with normal select/option elements, which work as expected.
|
||||
https://github.com/microsoft/vscode-webview-ui-toolkit/issues/433
|
||||
|
||||
In our case, when the user switches between providers, we recalculate the selectedModelId depending on the provider, the default model for that provider, and a modelId that the user may have selected. Unfortunately, the VSCodeDropdown component wouldn't select this calculated value, and would default to the first "Select a model..." option instead, which makes it seem like the model was cleared out when it wasn't.
|
||||
|
||||
As a workaround, we create separate instances of the dropdown for each provider, and then conditionally render the one that matches the current provider.
|
||||
*/
|
||||
const createDropdown = (models: Record<string, ModelInfo>) => {
|
||||
return (
|
||||
<VSCodeDropdown
|
||||
id="model-id"
|
||||
value={selectedModelId}
|
||||
onChange={handleInputChange("apiModelId")}
|
||||
style={{ width: "100%" }}>
|
||||
<VSCodeOption value="">Select a model...</VSCodeOption>
|
||||
{Object.keys(models).map((modelId) => (
|
||||
<VSCodeOption
|
||||
key={modelId}
|
||||
value={modelId}
|
||||
style={{
|
||||
whiteSpace: "normal",
|
||||
wordWrap: "break-word",
|
||||
maxWidth: "100%",
|
||||
}}>
|
||||
{modelId}
|
||||
</VSCodeOption>
|
||||
))}
|
||||
</VSCodeDropdown>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||
<div className="dropdown-container">
|
||||
<label htmlFor="api-provider">
|
||||
<span style={{ fontWeight: 500 }}>API Provider</span>
|
||||
</label>
|
||||
<VSCodeDropdown id="api-provider" value={selectedProvider} onChange={handleInputChange("apiProvider")}>
|
||||
<VSCodeOption value="anthropic">Anthropic</VSCodeOption>
|
||||
<VSCodeOption value="bedrock">AWS Bedrock</VSCodeOption>
|
||||
<VSCodeOption value="openrouter">OpenRouter</VSCodeOption>
|
||||
<VSCodeOption value="vertex">GCP Vertex AI</VSCodeOption>
|
||||
</VSCodeDropdown>
|
||||
</div>
|
||||
|
||||
{selectedProvider === "anthropic" && (
|
||||
<div>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.apiKey || ""}
|
||||
style={{ width: "100%" }}
|
||||
type="password"
|
||||
onInput={handleInputChange("apiKey")}
|
||||
placeholder="Enter API Key...">
|
||||
<span style={{ fontWeight: 500 }}>Anthropic API Key</span>
|
||||
</VSCodeTextField>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
This key is stored locally and only used to make API requests from this extension.
|
||||
<VSCodeLink href="https://console.anthropic.com/" style={{ display: "inline" }}>
|
||||
You can get an Anthropic API key by signing up here.
|
||||
</VSCodeLink>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedProvider === "openrouter" && (
|
||||
<div>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.openRouterApiKey || ""}
|
||||
style={{ width: "100%" }}
|
||||
type="password"
|
||||
onInput={handleInputChange("openRouterApiKey")}
|
||||
placeholder="Enter API Key...">
|
||||
<span style={{ fontWeight: 500 }}>OpenRouter API Key</span>
|
||||
</VSCodeTextField>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
This key is stored locally and only used to make API requests from this extension.
|
||||
<VSCodeLink href="https://openrouter.ai/" style={{ display: "inline" }}>
|
||||
You can get an OpenRouter API key by signing up here.
|
||||
</VSCodeLink>{" "}
|
||||
<span style={{ color: "var(--vscode-errorForeground)" }}>
|
||||
(<span style={{ fontWeight: 500 }}>Note:</span> OpenRouter support is experimental and may
|
||||
not work well with large files.)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedProvider === "bedrock" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.awsAccessKey || ""}
|
||||
style={{ width: "100%" }}
|
||||
type="password"
|
||||
onInput={handleInputChange("awsAccessKey")}
|
||||
placeholder="Enter Access Key...">
|
||||
<span style={{ fontWeight: 500 }}>AWS Access Key</span>
|
||||
</VSCodeTextField>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.awsSecretKey || ""}
|
||||
style={{ width: "100%" }}
|
||||
type="password"
|
||||
onInput={handleInputChange("awsSecretKey")}
|
||||
placeholder="Enter Secret Key...">
|
||||
<span style={{ fontWeight: 500 }}>AWS Secret Key</span>
|
||||
</VSCodeTextField>
|
||||
<div className="dropdown-container">
|
||||
<label htmlFor="aws-region-dropdown">
|
||||
<span style={{ fontWeight: 500 }}>AWS Region</span>
|
||||
</label>
|
||||
<VSCodeDropdown
|
||||
id="aws-region-dropdown"
|
||||
value={apiConfiguration?.awsRegion || ""}
|
||||
style={{ width: "100%" }}
|
||||
onChange={handleInputChange("awsRegion")}>
|
||||
<VSCodeOption value="">Select a region...</VSCodeOption>
|
||||
{/* The user will have to choose a region that supports the model they use, but this shouldn't be a problem since they'd have to request access for it in that region in the first place. */}
|
||||
<VSCodeOption value="us-east-1">us-east-1</VSCodeOption>
|
||||
{/* <VSCodeOption value="us-east-2">us-east-2</VSCodeOption> */}
|
||||
{/* <VSCodeOption value="us-west-1">us-west-1</VSCodeOption> */}
|
||||
<VSCodeOption value="us-west-2">us-west-2</VSCodeOption>
|
||||
{/* <VSCodeOption value="af-south-1">af-south-1</VSCodeOption> */}
|
||||
{/* <VSCodeOption value="ap-east-1">ap-east-1</VSCodeOption> */}
|
||||
<VSCodeOption value="ap-south-1">ap-south-1</VSCodeOption>
|
||||
<VSCodeOption value="ap-northeast-1">ap-northeast-1</VSCodeOption>
|
||||
{/* <VSCodeOption value="ap-northeast-2">ap-northeast-2</VSCodeOption> */}
|
||||
{/* <VSCodeOption value="ap-northeast-3">ap-northeast-3</VSCodeOption> */}
|
||||
<VSCodeOption value="ap-southeast-1">ap-southeast-1</VSCodeOption>
|
||||
<VSCodeOption value="ap-southeast-2">ap-southeast-2</VSCodeOption>
|
||||
<VSCodeOption value="ca-central-1">ca-central-1</VSCodeOption>
|
||||
<VSCodeOption value="eu-central-1">eu-central-1</VSCodeOption>
|
||||
<VSCodeOption value="eu-west-1">eu-west-1</VSCodeOption>
|
||||
<VSCodeOption value="eu-west-2">eu-west-2</VSCodeOption>
|
||||
<VSCodeOption value="eu-west-3">eu-west-3</VSCodeOption>
|
||||
{/* <VSCodeOption value="eu-north-1">eu-north-1</VSCodeOption> */}
|
||||
{/* <VSCodeOption value="me-south-1">me-south-1</VSCodeOption> */}
|
||||
<VSCodeOption value="sa-east-1">sa-east-1</VSCodeOption>
|
||||
</VSCodeDropdown>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
These credentials are stored locally and only used to make API requests from this extension.
|
||||
<VSCodeLink
|
||||
href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html"
|
||||
style={{ display: "inline" }}>
|
||||
You can find your AWS access key and secret key here.
|
||||
</VSCodeLink>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiConfiguration?.apiProvider === "vertex" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.vertexProjectId || ""}
|
||||
style={{ width: "100%" }}
|
||||
onInput={handleInputChange("vertexProjectId")}
|
||||
placeholder="Enter Project ID...">
|
||||
<span style={{ fontWeight: 500 }}>Google Cloud Project ID</span>
|
||||
</VSCodeTextField>
|
||||
<div className="dropdown-container">
|
||||
<label htmlFor="vertex-region-dropdown">
|
||||
<span style={{ fontWeight: 500 }}>Google Cloud Region</span>
|
||||
</label>
|
||||
<VSCodeDropdown
|
||||
id="vertex-region-dropdown"
|
||||
value={apiConfiguration?.vertexRegion || ""}
|
||||
style={{ width: "100%" }}
|
||||
onChange={handleInputChange("vertexRegion")}>
|
||||
<VSCodeOption value="">Select a region...</VSCodeOption>
|
||||
<VSCodeOption value="us-east5">us-east5</VSCodeOption>
|
||||
<VSCodeOption value="us-central1">us-central1</VSCodeOption>
|
||||
<VSCodeOption value="europe-west1">europe-west1</VSCodeOption>
|
||||
<VSCodeOption value="europe-west4">europe-west4</VSCodeOption>
|
||||
<VSCodeOption value="asia-southeast1">asia-southeast1</VSCodeOption>
|
||||
</VSCodeDropdown>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
To use Google Cloud Vertex AI, you need to
|
||||
<VSCodeLink
|
||||
href="https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#before_you_begin"
|
||||
style={{ display: "inline" }}>
|
||||
{
|
||||
"1) create a Google Cloud account › enable the Vertex AI API › enable the desired Claude models,"
|
||||
}
|
||||
</VSCodeLink>{" "}
|
||||
<VSCodeLink
|
||||
href="https://cloud.google.com/docs/authentication/provide-credentials-adc#google-idp"
|
||||
style={{ display: "inline" }}>
|
||||
{"2) install the Google Cloud CLI › configure Application Default Credentials."}
|
||||
</VSCodeLink>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiErrorMessage && (
|
||||
<p
|
||||
style={{
|
||||
margin: "-10px 0 4px 0",
|
||||
fontSize: 12,
|
||||
color: "var(--vscode-errorForeground)",
|
||||
}}>
|
||||
{apiErrorMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showModelOptions && (
|
||||
<>
|
||||
<div className="dropdown-container">
|
||||
<label htmlFor="model-id">
|
||||
<span style={{ fontWeight: 500 }}>Model</span>
|
||||
</label>
|
||||
{selectedProvider === "anthropic" && createDropdown(anthropicModels)}
|
||||
{selectedProvider === "openrouter" && createDropdown(openRouterModels)}
|
||||
{selectedProvider === "bedrock" && createDropdown(bedrockModels)}
|
||||
{selectedProvider === "vertex" && createDropdown(vertexModels)}
|
||||
</div>
|
||||
|
||||
<ModelInfoView modelInfo={selectedModelInfo} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const formatPrice = (price: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(price)
|
||||
}
|
||||
|
||||
const ModelInfoView = ({ modelInfo }: { modelInfo: ModelInfo }) => {
|
||||
return (
|
||||
<p style={{ fontSize: "12px", marginTop: "2px", color: "var(--vscode-descriptionForeground)" }}>
|
||||
<ModelInfoSupportsItem
|
||||
isSupported={modelInfo.supportsImages}
|
||||
supportsLabel="Supports images"
|
||||
doesNotSupportLabel="Does not support images"
|
||||
/>
|
||||
<br />
|
||||
<ModelInfoSupportsItem
|
||||
isSupported={modelInfo.supportsPromptCache}
|
||||
supportsLabel="Supports prompt caching"
|
||||
doesNotSupportLabel="Does not support prompt caching"
|
||||
/>
|
||||
<br />
|
||||
<span style={{ fontWeight: 500 }}>Max output:</span> {modelInfo?.maxTokens?.toLocaleString()} tokens
|
||||
<br />
|
||||
<span style={{ fontWeight: 500 }}>Input price:</span> {formatPrice(modelInfo.inputPrice)}/million tokens
|
||||
{modelInfo.supportsPromptCache && modelInfo.cacheWritesPrice && modelInfo.cacheReadsPrice && (
|
||||
<>
|
||||
<br />
|
||||
<span style={{ fontWeight: 500 }}>Cache writes price:</span>{" "}
|
||||
{formatPrice(modelInfo.cacheWritesPrice || 0)}/million tokens
|
||||
<br />
|
||||
<span style={{ fontWeight: 500 }}>Cache reads price:</span>{" "}
|
||||
{formatPrice(modelInfo.cacheReadsPrice || 0)}/million tokens
|
||||
</>
|
||||
)}
|
||||
<br />
|
||||
<span style={{ fontWeight: 500 }}>Output price:</span> {formatPrice(modelInfo.outputPrice)}/million tokens
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const ModelInfoSupportsItem = ({
|
||||
isSupported,
|
||||
supportsLabel,
|
||||
doesNotSupportLabel,
|
||||
}: {
|
||||
isSupported: boolean
|
||||
supportsLabel: string
|
||||
doesNotSupportLabel: string
|
||||
}) => (
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
color: isSupported ? "var(--vscode-testing-iconPassed)" : "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
|
||||
|
||||
const getProviderData = (models: Record<string, ModelInfo>, defaultId: ApiModelId) => {
|
||||
let selectedModelId: ApiModelId
|
||||
let selectedModelInfo: ModelInfo
|
||||
if (modelId && modelId in models) {
|
||||
selectedModelId = modelId
|
||||
selectedModelInfo = models[modelId]
|
||||
} else {
|
||||
selectedModelId = defaultId
|
||||
selectedModelInfo = models[defaultId]
|
||||
}
|
||||
return { selectedProvider: provider, selectedModelId, selectedModelInfo }
|
||||
}
|
||||
switch (provider) {
|
||||
case "anthropic":
|
||||
return getProviderData(anthropicModels, anthropicDefaultModelId)
|
||||
case "openrouter":
|
||||
return getProviderData(openRouterModels, openRouterDefaultModelId)
|
||||
case "bedrock":
|
||||
return getProviderData(bedrockModels, bedrockDefaultModelId)
|
||||
case "vertex":
|
||||
return getProviderData(vertexModels, vertexDefaultModelId)
|
||||
default:
|
||||
return getProviderData(anthropicModels, anthropicDefaultModelId)
|
||||
}
|
||||
}
|
||||
|
||||
export default ApiOptions
|
||||
@@ -1,11 +1,13 @@
|
||||
import { ClaudeAsk, ClaudeMessage, ClaudeSay, ClaudeSayTool } from "@shared/ExtensionMessage"
|
||||
import { VSCodeBadge, VSCodeButton, VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react"
|
||||
import React from "react"
|
||||
import { COMMAND_OUTPUT_STRING } from "../utilities/combineCommandSequences"
|
||||
import { SyntaxHighlighterStyle } from "../utilities/getSyntaxHighlighterStyleFromTheme"
|
||||
import CodeBlock from "./CodeBlock/CodeBlock"
|
||||
import Markdown from "react-markdown"
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
|
||||
import { ClaudeAsk, ClaudeMessage, ClaudeSay, ClaudeSayTool } from "../../../src/shared/ExtensionMessage"
|
||||
import { COMMAND_OUTPUT_STRING } from "../../../src/shared/combineCommandSequences"
|
||||
import { SyntaxHighlighterStyle } from "../utils/getSyntaxHighlighterStyleFromTheme"
|
||||
import CodeBlock from "./CodeBlock"
|
||||
import Thumbnails from "./Thumbnails"
|
||||
import { ApiProvider } from "../../../src/shared/api"
|
||||
|
||||
interface ChatRowProps {
|
||||
message: ClaudeMessage
|
||||
@@ -14,6 +16,7 @@ interface ChatRowProps {
|
||||
onToggleExpand: () => void
|
||||
lastModifiedMessage?: ClaudeMessage
|
||||
isLast: boolean
|
||||
apiProvider?: ApiProvider
|
||||
}
|
||||
|
||||
const ChatRow: React.FC<ChatRowProps> = ({
|
||||
@@ -23,6 +26,7 @@ const ChatRow: React.FC<ChatRowProps> = ({
|
||||
onToggleExpand,
|
||||
lastModifiedMessage,
|
||||
isLast,
|
||||
apiProvider,
|
||||
}) => {
|
||||
const cost = message.text != null && message.say === "api_req_started" ? JSON.parse(message.text).cost : undefined
|
||||
const apiRequestFailedMessage =
|
||||
@@ -122,78 +126,111 @@ const ChatRow: React.FC<ChatRowProps> = ({
|
||||
|
||||
const renderMarkdown = (markdown: string = "") => {
|
||||
// react-markdown lets us customize elements, so here we're using their example of replacing code blocks with SyntaxHighlighter. However when there are no language matches (` or ``` without a language specifier) then we default to a normal code element for inline code. Code blocks without a language specifier shouldn't be a common occurrence as we prompt Claude to always use a language specifier.
|
||||
// when claude wraps text in thinking tags, he doesnt use line breaks so we need to insert those ourselves to render markdown correctly
|
||||
const parsed = markdown.replace(/<thinking>([\s\S]*?)<\/thinking>/g, (match, content) => {
|
||||
return `_<thinking>_\n\n${content}\n\n_</thinking>_`
|
||||
})
|
||||
return (
|
||||
<Markdown
|
||||
children={markdown}
|
||||
components={{
|
||||
p(props) {
|
||||
const { style, ...rest } = props
|
||||
return (
|
||||
<p
|
||||
style={{
|
||||
...style,
|
||||
margin: 0,
|
||||
marginTop: 0,
|
||||
marginBottom: 0,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
},
|
||||
ol(props) {
|
||||
const { style, ...rest } = props
|
||||
return <ol style={{ ...style, padding: "0 0 0 20px", margin: "10px 0" }} {...rest} />
|
||||
},
|
||||
ul(props) {
|
||||
const { style, ...rest } = props
|
||||
return <ul style={{ ...style, padding: "0 0 0 20px", margin: "10px 0" }} {...rest} />
|
||||
},
|
||||
// https://github.com/remarkjs/react-markdown?tab=readme-ov-file#use-custom-components-syntax-highlight
|
||||
code(props) {
|
||||
const { children, className, node, ...rest } = props
|
||||
const match = /language-(\w+)/.exec(className || "")
|
||||
return match ? (
|
||||
<SyntaxHighlighter
|
||||
{...(rest as any)} // will be passed down to pre
|
||||
PreTag="div"
|
||||
children={String(children).replace(/\n$/, "")}
|
||||
language={match[1]}
|
||||
style={{
|
||||
...syntaxHighlighterStyle,
|
||||
'code[class*="language-"]': {
|
||||
background: "var(--vscode-editor-background)",
|
||||
},
|
||||
'pre[class*="language-"]': {
|
||||
background: "var(--vscode-editor-background)",
|
||||
},
|
||||
}}
|
||||
customStyle={{
|
||||
overflowX: "auto",
|
||||
overflowY: "hidden",
|
||||
maxWidth: "100%",
|
||||
margin: 0,
|
||||
padding: "10px",
|
||||
// important to note that min-width: max-content is not required here how it is in CodeBlock.tsx
|
||||
borderRadius: 3,
|
||||
border: "1px solid var(--vscode-sideBar-border)",
|
||||
fontSize: "var(--vscode-editor-font-size)",
|
||||
lineHeight: "var(--vscode-editor-line-height)",
|
||||
fontFamily: "var(--vscode-editor-font-family)",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<code
|
||||
{...rest}
|
||||
className={className}
|
||||
style={{ whiteSpace: "pre-line", wordBreak: "break-word" }}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<div style={{ wordBreak: "break-word", overflowWrap: "anywhere" }}>
|
||||
<Markdown
|
||||
children={parsed}
|
||||
components={{
|
||||
p(props) {
|
||||
const { style, ...rest } = props
|
||||
return (
|
||||
<p
|
||||
style={{
|
||||
...style,
|
||||
margin: 0,
|
||||
marginTop: 0,
|
||||
marginBottom: 0,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
},
|
||||
ol(props) {
|
||||
const { style, ...rest } = props
|
||||
return (
|
||||
<ol
|
||||
style={{
|
||||
...style,
|
||||
padding: "0 0 0 20px",
|
||||
margin: "10px 0",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
},
|
||||
ul(props) {
|
||||
const { style, ...rest } = props
|
||||
return (
|
||||
<ul
|
||||
style={{
|
||||
...style,
|
||||
padding: "0 0 0 20px",
|
||||
margin: "10px 0",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
},
|
||||
// https://github.com/remarkjs/react-markdown?tab=readme-ov-file#use-custom-components-syntax-highlight
|
||||
code(props) {
|
||||
const { children, className, node, ...rest } = props
|
||||
const match = /language-(\w+)/.exec(className || "")
|
||||
return match ? (
|
||||
<SyntaxHighlighter
|
||||
{...(rest as any)} // will be passed down to pre
|
||||
PreTag="div"
|
||||
children={String(children).replace(/\n$/, "")}
|
||||
language={match[1]}
|
||||
style={{
|
||||
...syntaxHighlighterStyle,
|
||||
'code[class*="language-"]': {
|
||||
background: "var(--vscode-editor-background)",
|
||||
},
|
||||
'pre[class*="language-"]': {
|
||||
background: "var(--vscode-editor-background)",
|
||||
},
|
||||
}}
|
||||
customStyle={{
|
||||
overflowX: "auto",
|
||||
overflowY: "hidden",
|
||||
maxWidth: "100%",
|
||||
margin: 0,
|
||||
padding: "10px",
|
||||
// important to note that min-width: max-content is not required here how it is in CodeBlock.tsx
|
||||
borderRadius: 3,
|
||||
border: "1px solid var(--vscode-sideBar-border)",
|
||||
fontSize: "var(--vscode-editor-font-size)",
|
||||
lineHeight: "var(--vscode-editor-line-height)",
|
||||
fontFamily: "var(--vscode-editor-font-family)",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<code
|
||||
{...rest}
|
||||
className={className}
|
||||
style={{
|
||||
whiteSpace: "pre-line",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
}}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -209,8 +246,9 @@ const ChatRow: React.FC<ChatRowProps> = ({
|
||||
|
||||
const pStyle: React.CSSProperties = {
|
||||
margin: 0,
|
||||
whiteSpace: "pre-line",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
@@ -228,7 +266,7 @@ const ChatRow: React.FC<ChatRowProps> = ({
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
|
||||
{icon}
|
||||
{title}
|
||||
{cost && <VSCodeBadge>${Number(cost).toFixed(4)}</VSCodeBadge>}
|
||||
{cost && <VSCodeBadge>${Number(cost)?.toFixed(4)}</VSCodeBadge>}
|
||||
</div>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
@@ -239,9 +277,43 @@ const ChatRow: React.FC<ChatRowProps> = ({
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
{cost == null && apiRequestFailedMessage && (
|
||||
<p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>
|
||||
{apiRequestFailedMessage}
|
||||
</p>
|
||||
<>
|
||||
<p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>
|
||||
{apiRequestFailedMessage}
|
||||
</p>
|
||||
{/* {apiProvider === "kodu" && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--vscode-errorForeground) 20%, transparent)",
|
||||
color: "var(--vscode-editor-foreground)",
|
||||
padding: "6px 8px",
|
||||
borderRadius: "3px",
|
||||
margin: "10px 0 0 0",
|
||||
fontSize: "12px",
|
||||
}}>
|
||||
<i
|
||||
className="codicon codicon-warning"
|
||||
style={{
|
||||
marginRight: 6,
|
||||
fontSize: 16,
|
||||
color: "var(--vscode-errorForeground)",
|
||||
}}></i>
|
||||
<span>
|
||||
Uh-oh, this could be a problem on Kodu's end. We've been alerted and
|
||||
will resolve this ASAP. You can also{" "}
|
||||
<a
|
||||
href="https://discord.gg/claudedev"
|
||||
style={{ color: "inherit", textDecoration: "underline" }}>
|
||||
contact us on discord
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
)} */}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
@@ -260,7 +332,10 @@ const ChatRow: React.FC<ChatRowProps> = ({
|
||||
whiteSpace: "pre-line",
|
||||
wordWrap: "break-word",
|
||||
}}>
|
||||
<span>{message.text}</span>
|
||||
<span style={{ display: "block" }}>{message.text}</span>
|
||||
{message.images && message.images.length > 0 && (
|
||||
<Thumbnails images={message.images} style={{ marginTop: "8px" }} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
case "error":
|
||||
@@ -287,6 +362,8 @@ const ChatRow: React.FC<ChatRowProps> = ({
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
case "tool":
|
||||
return renderTool(message, headerStyle)
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
@@ -303,103 +380,7 @@ const ChatRow: React.FC<ChatRowProps> = ({
|
||||
case "ask":
|
||||
switch (message.ask) {
|
||||
case "tool":
|
||||
const tool = JSON.parse(message.text || "{}") as ClaudeSayTool
|
||||
const toolIcon = (name: string) => (
|
||||
<span
|
||||
className={`codicon codicon-${name}`}
|
||||
style={{ color: "var(--vscode-foreground)", marginBottom: "-1.5px" }}></span>
|
||||
)
|
||||
|
||||
switch (tool.tool) {
|
||||
case "editedExistingFile":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{toolIcon("edit")}
|
||||
<span style={{ fontWeight: "bold" }}>Claude wants to edit this file:</span>
|
||||
</div>
|
||||
<CodeBlock
|
||||
diff={tool.diff!}
|
||||
path={tool.path!}
|
||||
syntaxHighlighterStyle={syntaxHighlighterStyle}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
case "newFileCreated":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{toolIcon("new-file")}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
Claude wants to create a new file:
|
||||
</span>
|
||||
</div>
|
||||
<CodeBlock
|
||||
code={tool.content!}
|
||||
path={tool.path!}
|
||||
syntaxHighlighterStyle={syntaxHighlighterStyle}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
case "readFile":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{toolIcon("file-code")}
|
||||
<span style={{ fontWeight: "bold" }}>Claude wants to read this file:</span>
|
||||
</div>
|
||||
<CodeBlock
|
||||
code={tool.content!}
|
||||
path={tool.path!}
|
||||
syntaxHighlighterStyle={syntaxHighlighterStyle}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
case "listFiles":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{toolIcon("folder-opened")}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
Claude wants to view this directory:
|
||||
</span>
|
||||
</div>
|
||||
<CodeBlock
|
||||
code={tool.content!}
|
||||
path={tool.path!}
|
||||
language="shell-session"
|
||||
syntaxHighlighterStyle={syntaxHighlighterStyle}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
case "analyzeProject":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{toolIcon("file-code")}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
Claude wants to analyze this project:
|
||||
</span>
|
||||
</div>
|
||||
<CodeBlock
|
||||
code={tool.content!}
|
||||
path={tool.path!}
|
||||
syntaxHighlighterStyle={syntaxHighlighterStyle}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
break
|
||||
return renderTool(message, headerStyle)
|
||||
case "request_limit_reached":
|
||||
return (
|
||||
<>
|
||||
@@ -489,6 +470,159 @@ const ChatRow: React.FC<ChatRowProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const renderTool = (message: ClaudeMessage, headerStyle: React.CSSProperties) => {
|
||||
const tool = JSON.parse(message.text || "{}") as ClaudeSayTool
|
||||
const toolIcon = (name: string) => (
|
||||
<span
|
||||
className={`codicon codicon-${name}`}
|
||||
style={{ color: "var(--vscode-foreground)", marginBottom: "-1.5px" }}></span>
|
||||
)
|
||||
|
||||
switch (tool.tool) {
|
||||
case "editedExistingFile":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{toolIcon("edit")}
|
||||
<span style={{ fontWeight: "bold" }}>Claude wants to edit this file:</span>
|
||||
</div>
|
||||
<CodeBlock
|
||||
diff={tool.diff!}
|
||||
path={tool.path!}
|
||||
syntaxHighlighterStyle={syntaxHighlighterStyle}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
case "newFileCreated":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{toolIcon("new-file")}
|
||||
<span style={{ fontWeight: "bold" }}>Claude wants to create a new file:</span>
|
||||
</div>
|
||||
<CodeBlock
|
||||
code={tool.content!}
|
||||
path={tool.path!}
|
||||
syntaxHighlighterStyle={syntaxHighlighterStyle}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
case "readFile":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{toolIcon("file-code")}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
{message.type === "ask" ? "Claude wants to read this file:" : "Claude read this file:"}
|
||||
</span>
|
||||
</div>
|
||||
<CodeBlock
|
||||
code={tool.content!}
|
||||
path={tool.path!}
|
||||
syntaxHighlighterStyle={syntaxHighlighterStyle}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
case "listFilesTopLevel":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{toolIcon("folder-opened")}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
{message.type === "ask"
|
||||
? "Claude wants to view the top level files in this directory:"
|
||||
: "Claude viewed the top level files in this directory:"}
|
||||
</span>
|
||||
</div>
|
||||
<CodeBlock
|
||||
code={tool.content!}
|
||||
path={tool.path!}
|
||||
language="shell-session"
|
||||
syntaxHighlighterStyle={syntaxHighlighterStyle}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
case "listFilesRecursive":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{toolIcon("folder-opened")}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
{message.type === "ask"
|
||||
? "Claude wants to recursively view all files in this directory:"
|
||||
: "Claude recursively viewed all files in this directory:"}
|
||||
</span>
|
||||
</div>
|
||||
<CodeBlock
|
||||
code={tool.content!}
|
||||
path={tool.path!}
|
||||
language="shell-session"
|
||||
syntaxHighlighterStyle={syntaxHighlighterStyle}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
case "listCodeDefinitionNames":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{toolIcon("file-code")}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
{message.type === "ask"
|
||||
? "Claude wants to view source code definition names used in this directory:"
|
||||
: "Claude viewed source code definition names used in this directory:"}
|
||||
</span>
|
||||
</div>
|
||||
<CodeBlock
|
||||
code={tool.content!}
|
||||
path={tool.path!}
|
||||
syntaxHighlighterStyle={syntaxHighlighterStyle}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
case "searchFiles":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{toolIcon("search")}
|
||||
<span style={{ fontWeight: "bold" }}>
|
||||
{message.type === "ask" ? (
|
||||
<>
|
||||
Claude wants to search this directory for <code>{tool.regex}</code>:
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Claude searched this directory for <code>{tool.regex}</code>:
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<CodeBlock
|
||||
code={tool.content!}
|
||||
path={tool.path! + (tool.filePattern ? `/(${tool.filePattern})` : "")}
|
||||
language="plaintext"
|
||||
syntaxHighlighterStyle={syntaxHighlighterStyle}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: we cannot return null as virtuoso does not support it, so we must use a separate visibleMessages array to filter out messages that should not be rendered
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,29 +1,51 @@
|
||||
import { ClaudeAsk, ClaudeMessage, ExtensionMessage } from "@shared/ExtensionMessage"
|
||||
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import { KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { KeyboardEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
|
||||
import vsDarkPlus from "react-syntax-highlighter/dist/esm/styles/prism/vsc-dark-plus"
|
||||
import DynamicTextArea from "react-textarea-autosize"
|
||||
import { useEvent, useMount } from "react-use"
|
||||
import { combineApiRequests } from "../utilities/combineApiRequests"
|
||||
import { combineCommandSequences } from "../utilities/combineCommandSequences"
|
||||
import { getApiMetrics } from "../utilities/getApiMetrics"
|
||||
import { getSyntaxHighlighterStyleFromTheme } from "../utilities/getSyntaxHighlighterStyleFromTheme"
|
||||
import { vscode } from "../utilities/vscode"
|
||||
import ChatRow from "./ChatRow"
|
||||
import TaskHeader from "./TaskHeader"
|
||||
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
|
||||
import { ClaudeAsk, ExtensionMessage } from "../../../src/shared/ExtensionMessage"
|
||||
import { combineApiRequests } from "../../../src/shared/combineApiRequests"
|
||||
import { combineCommandSequences } from "../../../src/shared/combineCommandSequences"
|
||||
import { getApiMetrics } from "../../../src/shared/getApiMetrics"
|
||||
import { useExtensionState } from "../context/ExtensionStateContext"
|
||||
import { getSyntaxHighlighterStyleFromTheme } from "../utils/getSyntaxHighlighterStyleFromTheme"
|
||||
import { vscode } from "../utils/vscode"
|
||||
import Announcement from "./Announcement"
|
||||
import ChatRow from "./ChatRow"
|
||||
import HistoryPreview from "./HistoryPreview"
|
||||
import TaskHeader from "./TaskHeader"
|
||||
import Thumbnails from "./Thumbnails"
|
||||
|
||||
interface ChatViewProps {
|
||||
messages: ClaudeMessage[]
|
||||
isHidden: boolean
|
||||
vscodeThemeName?: string
|
||||
showAnnouncement: boolean
|
||||
selectedModelSupportsImages: boolean
|
||||
selectedModelSupportsPromptCache: boolean
|
||||
hideAnnouncement: () => void
|
||||
showHistoryView: () => void
|
||||
}
|
||||
// maybe instead of storing state in App, just make chatview always show so dont conditionally load/unload? need to make sure messages are persisted (i remember seeing something about how webviews can be frozen in docs)
|
||||
const ChatView = ({ messages, isHidden, vscodeThemeName, showAnnouncement, hideAnnouncement }: ChatViewProps) => {
|
||||
//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined
|
||||
|
||||
const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images
|
||||
|
||||
const ChatView = ({
|
||||
isHidden,
|
||||
showAnnouncement,
|
||||
selectedModelSupportsImages,
|
||||
selectedModelSupportsPromptCache,
|
||||
hideAnnouncement,
|
||||
showHistoryView,
|
||||
}: ChatViewProps) => {
|
||||
const {
|
||||
version,
|
||||
claudeMessages: messages,
|
||||
taskHistory,
|
||||
themeName: vscodeThemeName,
|
||||
apiConfiguration,
|
||||
uriScheme,
|
||||
} = useExtensionState()
|
||||
|
||||
//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
|
||||
const task = messages.length > 0 ? messages[0] : undefined // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see ClaudeDev.abort)
|
||||
const modifiedMessages = useMemo(() => combineApiRequests(combineCommandSequences(messages.slice(1))), [messages])
|
||||
// has to be after api_req_finished are all reduced into api_req_started messages
|
||||
@@ -31,8 +53,12 @@ const ChatView = ({ messages, isHidden, vscodeThemeName, showAnnouncement, hideA
|
||||
|
||||
const [inputValue, setInputValue] = useState("")
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [textAreaHeight, setTextAreaHeight] = useState<number | undefined>(undefined)
|
||||
const [textAreaDisabled, setTextAreaDisabled] = useState(false)
|
||||
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
|
||||
const textAreaContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [textAreaContainerHeight, setTextAreaContainerHeight] = useState(51)
|
||||
const [selectedImages, setSelectedImages] = useState<string[]>([])
|
||||
const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
|
||||
|
||||
// we need to hold on to the ask because useEffect > lastMessage will always let us know when an ask comes in and handle it, but by the time handleMessage is called, the last message might not be the ask anymore (it could be a say that followed)
|
||||
const [claudeAsk, setClaudeAsk] = useState<ClaudeAsk | undefined>(undefined)
|
||||
@@ -40,11 +66,8 @@ const ChatView = ({ messages, isHidden, vscodeThemeName, showAnnouncement, hideA
|
||||
const [enableButtons, setEnableButtons] = useState<boolean>(false)
|
||||
const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>(undefined)
|
||||
const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>(undefined)
|
||||
|
||||
const [syntaxHighlighterStyle, setSyntaxHighlighterStyle] = useState(vsDarkPlus)
|
||||
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||
|
||||
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
|
||||
|
||||
const toggleRowExpansion = (ts: number) => {
|
||||
@@ -123,24 +146,42 @@ const ChatView = ({ messages, isHidden, vscodeThemeName, showAnnouncement, hideA
|
||||
setPrimaryButtonText("Start New Task")
|
||||
setSecondaryButtonText(undefined)
|
||||
break
|
||||
case "resume_task":
|
||||
setTextAreaDisabled(false)
|
||||
setClaudeAsk("resume_task")
|
||||
setEnableButtons(true)
|
||||
setPrimaryButtonText("Resume Task")
|
||||
setSecondaryButtonText(undefined)
|
||||
break
|
||||
case "resume_completed_task":
|
||||
setTextAreaDisabled(false)
|
||||
setClaudeAsk("resume_completed_task")
|
||||
setEnableButtons(true)
|
||||
setPrimaryButtonText("Start New Task")
|
||||
setSecondaryButtonText(undefined)
|
||||
break
|
||||
}
|
||||
break
|
||||
case "say":
|
||||
// don't want to reset since there could be a "say" after an "ask" while ask is waiting for response
|
||||
switch (lastMessage.say) {
|
||||
case "task":
|
||||
break
|
||||
case "error":
|
||||
break
|
||||
case "api_req_started":
|
||||
if (messages.at(-2)?.ask === "command_output") {
|
||||
// if the last ask is a command_output, and we receive an api_req_started, then that means the command has finished and we don't need input from the user anymore (in every other case, the user has to interact with input field or buttons to continue, which does the following automatically)
|
||||
setInputValue("")
|
||||
setTextAreaDisabled(true)
|
||||
setSelectedImages([])
|
||||
setClaudeAsk(undefined)
|
||||
setEnableButtons(false)
|
||||
}
|
||||
break
|
||||
case "task":
|
||||
case "error":
|
||||
case "api_req_finished":
|
||||
break
|
||||
case "text":
|
||||
break
|
||||
case "command_output":
|
||||
break
|
||||
case "completion_result":
|
||||
case "tool":
|
||||
break
|
||||
}
|
||||
break
|
||||
@@ -167,9 +208,9 @@ const ChatView = ({ messages, isHidden, vscodeThemeName, showAnnouncement, hideA
|
||||
|
||||
const handleSendMessage = () => {
|
||||
const text = inputValue.trim()
|
||||
if (text) {
|
||||
if (text || selectedImages.length > 0) {
|
||||
if (messages.length === 0) {
|
||||
vscode.postMessage({ type: "newTask", text })
|
||||
vscode.postMessage({ type: "newTask", text, images: selectedImages })
|
||||
} else if (claudeAsk) {
|
||||
switch (claudeAsk) {
|
||||
case "followup":
|
||||
@@ -177,13 +218,21 @@ const ChatView = ({ messages, isHidden, vscodeThemeName, showAnnouncement, hideA
|
||||
case "command": // user can provide feedback to a tool or command use
|
||||
case "command_output": // user can send input to command stdin
|
||||
case "completion_result": // if this happens then the user has feedback for the completion result
|
||||
vscode.postMessage({ type: "askResponse", askResponse: "textResponse", text })
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
vscode.postMessage({
|
||||
type: "askResponse",
|
||||
askResponse: "messageResponse",
|
||||
text,
|
||||
images: selectedImages,
|
||||
})
|
||||
break
|
||||
// there is no other case that a textfield should be enabled
|
||||
}
|
||||
}
|
||||
setInputValue("")
|
||||
setTextAreaDisabled(true)
|
||||
setSelectedImages([])
|
||||
setClaudeAsk(undefined)
|
||||
setEnableButtons(false)
|
||||
// setPrimaryButtonText(undefined)
|
||||
@@ -201,9 +250,11 @@ const ChatView = ({ messages, isHidden, vscodeThemeName, showAnnouncement, hideA
|
||||
case "command":
|
||||
case "command_output":
|
||||
case "tool":
|
||||
case "resume_task":
|
||||
vscode.postMessage({ type: "askResponse", askResponse: "yesButtonTapped" })
|
||||
break
|
||||
case "completion_result":
|
||||
case "resume_completed_task":
|
||||
// extension waiting for feedback. but we can just present a new task button
|
||||
startNewTask()
|
||||
break
|
||||
@@ -235,7 +286,8 @@ const ChatView = ({ messages, isHidden, vscodeThemeName, showAnnouncement, hideA
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
const isComposing = event.nativeEvent?.isComposing ?? false
|
||||
if (event.key === "Enter" && !event.shiftKey && !isComposing) {
|
||||
event.preventDefault()
|
||||
handleSendMessage()
|
||||
}
|
||||
@@ -249,6 +301,65 @@ const ChatView = ({ messages, isHidden, vscodeThemeName, showAnnouncement, hideA
|
||||
vscode.postMessage({ type: "clearTask" })
|
||||
}
|
||||
|
||||
const selectImages = () => {
|
||||
vscode.postMessage({ type: "selectImages" })
|
||||
}
|
||||
|
||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||
if (shouldDisableImages) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const items = e.clipboardData.items
|
||||
const acceptedTypes = ["png", "jpeg", "webp"] // supported by anthropic and openrouter (jpg is just a file extension but the image will be recognized as jpeg)
|
||||
const imageItems = Array.from(items).filter((item) => {
|
||||
const [type, subtype] = item.type.split("/")
|
||||
return type === "image" && acceptedTypes.includes(subtype)
|
||||
})
|
||||
if (imageItems.length > 0) {
|
||||
e.preventDefault()
|
||||
const imagePromises = imageItems.map((item) => {
|
||||
return new Promise<string | null>((resolve) => {
|
||||
const blob = item.getAsFile()
|
||||
if (!blob) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => {
|
||||
if (reader.error) {
|
||||
console.error("Error reading file:", reader.error)
|
||||
resolve(null)
|
||||
} else {
|
||||
const result = reader.result
|
||||
resolve(typeof result === "string" ? result : null)
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
})
|
||||
const imageDataArray = await Promise.all(imagePromises)
|
||||
const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
|
||||
//.map((dataUrl) => dataUrl.split(",")[1]) // strip the mime type prefix, sharp doesn't need it
|
||||
if (dataUrls.length > 0) {
|
||||
setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE))
|
||||
} else {
|
||||
console.warn("No valid images were processed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedImages.length === 0) {
|
||||
setThumbnailsHeight(0)
|
||||
}
|
||||
}, [selectedImages])
|
||||
|
||||
const handleThumbnailsHeightChange = useCallback((height: number) => {
|
||||
setThumbnailsHeight(height)
|
||||
}, [])
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(e: MessageEvent) => {
|
||||
const message: ExtensionMessage = e.data
|
||||
@@ -262,6 +373,14 @@ const ChatView = ({ messages, isHidden, vscodeThemeName, showAnnouncement, hideA
|
||||
break
|
||||
}
|
||||
break
|
||||
case "selectedImages":
|
||||
const newImages = message.images ?? []
|
||||
if (newImages.length > 0) {
|
||||
setSelectedImages((prevImages) =>
|
||||
[...prevImages, ...newImages].slice(0, MAX_IMAGES_PER_MESSAGE)
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
// textAreaRef.current is not explicitly required here since react gaurantees that ref will be stable across re-renders, and we're not using its value but its reference.
|
||||
},
|
||||
@@ -275,12 +394,6 @@ const ChatView = ({ messages, isHidden, vscodeThemeName, showAnnouncement, hideA
|
||||
textAreaRef.current?.focus()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (textAreaRef.current && !textAreaHeight) {
|
||||
setTextAreaHeight(textAreaRef.current.offsetHeight)
|
||||
}
|
||||
}, [textAreaHeight])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (!isHidden && !textAreaDisabled && !enableButtons) {
|
||||
@@ -302,12 +415,20 @@ const ChatView = ({ messages, isHidden, vscodeThemeName, showAnnouncement, hideA
|
||||
}
|
||||
break
|
||||
case "api_req_failed": // this message is used to update the latest api_req_started that the request failed
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
return false
|
||||
}
|
||||
switch (message.say) {
|
||||
case "api_req_finished": // combineApiRequests removes this from modifiedMessages anyways
|
||||
case "api_req_retried": // this message is used to update the latest api_req_started that the request was retried
|
||||
return false
|
||||
case "text":
|
||||
// Sometimes Claude returns an empty text message, we don't want to render these. (We also use a say text for user messages, so in case they just sent images we still render that)
|
||||
if ((message.text ?? "") === "" && (message.images?.length ?? 0) === 0) {
|
||||
return false
|
||||
}
|
||||
break
|
||||
}
|
||||
return true
|
||||
})
|
||||
@@ -324,13 +445,31 @@ const ChatView = ({ messages, isHidden, vscodeThemeName, showAnnouncement, hideA
|
||||
return () => clearTimeout(timer)
|
||||
}, [visibleMessages])
|
||||
|
||||
const placeholderText = useMemo(() => {
|
||||
const [placeholderText, isInputPipingToStdin] = useMemo(() => {
|
||||
if (messages.at(-1)?.ask === "command_output") {
|
||||
return "Type input to command stdin..."
|
||||
return ["Type input to command stdin...", true]
|
||||
}
|
||||
return task ? "Type a message..." : "Type your task here..."
|
||||
const text = task ? "Type a message..." : "Type your task here..."
|
||||
return [text, false]
|
||||
}, [task, messages])
|
||||
|
||||
const shouldDisableImages =
|
||||
!selectedModelSupportsImages ||
|
||||
textAreaDisabled ||
|
||||
selectedImages.length >= MAX_IMAGES_PER_MESSAGE ||
|
||||
isInputPipingToStdin
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (textAreaContainerRef.current) {
|
||||
let height = textAreaContainerRef.current.clientHeight
|
||||
// some browsers return 0 for clientHeight
|
||||
if (!height) {
|
||||
height = textAreaContainerRef.current.getBoundingClientRect().height
|
||||
}
|
||||
setTextAreaContainerHeight(height)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -345,17 +484,29 @@ const ChatView = ({ messages, isHidden, vscodeThemeName, showAnnouncement, hideA
|
||||
}}>
|
||||
{task ? (
|
||||
<TaskHeader
|
||||
taskText={task.text || ""}
|
||||
task={task}
|
||||
tokensIn={apiMetrics.totalTokensIn}
|
||||
tokensOut={apiMetrics.totalTokensOut}
|
||||
doesModelSupportPromptCache={selectedModelSupportsPromptCache}
|
||||
cacheWrites={apiMetrics.totalCacheWrites}
|
||||
cacheReads={apiMetrics.totalCacheReads}
|
||||
totalCost={apiMetrics.totalCost}
|
||||
onClose={handleTaskCloseButtonClick}
|
||||
isHidden={isHidden}
|
||||
vscodeUriScheme={uriScheme}
|
||||
apiProvider={apiConfiguration?.apiProvider}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{showAnnouncement && <Announcement hideAnnouncement={hideAnnouncement} />}
|
||||
<div style={{ padding: "0 20px" }}>
|
||||
{showAnnouncement && (
|
||||
<Announcement
|
||||
version={version}
|
||||
hideAnnouncement={hideAnnouncement}
|
||||
apiConfiguration={apiConfiguration}
|
||||
vscodeUriScheme={uriScheme}
|
||||
/>
|
||||
)}
|
||||
<div style={{ padding: "0 20px", flexGrow: taskHistory.length > 0 ? undefined : 1 }}>
|
||||
<h2>What can I do for you?</h2>
|
||||
<p>
|
||||
Thanks to{" "}
|
||||
@@ -364,76 +515,104 @@ const ChatView = ({ messages, isHidden, vscodeThemeName, showAnnouncement, hideA
|
||||
style={{ display: "inline" }}>
|
||||
Claude 3.5 Sonnet's agentic coding capabilities,
|
||||
</VSCodeLink>{" "}
|
||||
I can handle complex software development tasks step-by-step. With tools that let me read &
|
||||
write files, analyze project source code, and execute terminal commands (after you grant
|
||||
I can handle complex software development tasks step-by-step. With tools that let me create
|
||||
& edit files, explore complex projects, and execute terminal commands (after you grant
|
||||
permission), I can assist you in ways that go beyond simple code completion or tech support.
|
||||
</p>
|
||||
</div>
|
||||
{taskHistory.length > 0 && <HistoryPreview showHistoryView={showHistoryView} />}
|
||||
</>
|
||||
)}
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
className="scrollable"
|
||||
{task && (
|
||||
<>
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
className="scrollable"
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
overflowY: "scroll", // always show scrollbar
|
||||
}}
|
||||
// followOutput={(isAtBottom) => {
|
||||
// const lastMessage = modifiedMessages.at(-1)
|
||||
// if (lastMessage && shouldShowChatRow(lastMessage)) {
|
||||
// return "smooth"
|
||||
// }
|
||||
// return false
|
||||
// }}
|
||||
increaseViewportBy={{ top: 0, bottom: Number.MAX_SAFE_INTEGER }} // hack to make sure the last message is always rendered to get truly perfect scroll to bottom animation when new messages are added (Number.MAX_SAFE_INTEGER is safe for arithmetic operations, which is all virtuoso uses this value for in src/sizeRangeSystem.ts)
|
||||
data={visibleMessages} // messages is the raw format returned by extension, modifiedMessages is the manipulated structure that combines certain messages of related type, and visibleMessages is the filtered structure that removes messages that should not be rendered
|
||||
itemContent={(index, message) => (
|
||||
<ChatRow
|
||||
key={message.ts}
|
||||
message={message}
|
||||
syntaxHighlighterStyle={syntaxHighlighterStyle}
|
||||
isExpanded={expandedRows[message.ts] || false}
|
||||
onToggleExpand={() => toggleRowExpansion(message.ts)}
|
||||
lastModifiedMessage={modifiedMessages.at(-1)}
|
||||
isLast={index === visibleMessages.length - 1}
|
||||
apiProvider={apiConfiguration?.apiProvider}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
opacity: primaryButtonText || secondaryButtonText ? (enableButtons ? 1 : 0.5) : 0,
|
||||
display: "flex",
|
||||
padding: "10px 15px 0px 15px",
|
||||
}}>
|
||||
{primaryButtonText && (
|
||||
<VSCodeButton
|
||||
appearance="primary"
|
||||
disabled={!enableButtons}
|
||||
style={{
|
||||
flex: secondaryButtonText ? 1 : 2,
|
||||
marginRight: secondaryButtonText ? "6px" : "0",
|
||||
}}
|
||||
onClick={handlePrimaryButtonClick}>
|
||||
{primaryButtonText}
|
||||
</VSCodeButton>
|
||||
)}
|
||||
{secondaryButtonText && (
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
disabled={!enableButtons}
|
||||
style={{ flex: 1, marginLeft: "6px" }}
|
||||
onClick={handleSecondaryButtonClick}>
|
||||
{secondaryButtonText}
|
||||
</VSCodeButton>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={textAreaContainerRef}
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
overflowY: "scroll", // always show scrollbar
|
||||
}}
|
||||
// followOutput={(isAtBottom) => {
|
||||
// const lastMessage = modifiedMessages.at(-1)
|
||||
// if (lastMessage && shouldShowChatRow(lastMessage)) {
|
||||
// return "smooth"
|
||||
// }
|
||||
// return false
|
||||
// }}
|
||||
increaseViewportBy={{ top: 0, bottom: Number.MAX_SAFE_INTEGER }} // hack to make sure the last message is always rendered to get truly perfect scroll to bottom animation when new messages are added (Number.MAX_SAFE_INTEGER is safe for arithmetic operations, which is all virtuoso uses this value for in src/sizeRangeSystem.ts)
|
||||
data={visibleMessages} // messages is the raw format returned by extension, modifiedMessages is the manipulated structure that combines certain messages of related type, and visibleMessages is the filtered structure that removes messages that should not be rendered
|
||||
itemContent={(index, message) => (
|
||||
<ChatRow
|
||||
key={message.ts}
|
||||
message={message}
|
||||
syntaxHighlighterStyle={syntaxHighlighterStyle}
|
||||
isExpanded={expandedRows[message.ts] || false}
|
||||
onToggleExpand={() => toggleRowExpansion(message.ts)}
|
||||
lastModifiedMessage={modifiedMessages.at(-1)}
|
||||
isLast={index === visibleMessages.length - 1}
|
||||
padding: "10px 15px",
|
||||
opacity: textAreaDisabled ? 0.5 : 1,
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
}}>
|
||||
{!isTextAreaFocused && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: "10px 15px",
|
||||
border: "1px solid var(--vscode-input-border)",
|
||||
borderRadius: 2,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
opacity: primaryButtonText || secondaryButtonText ? (enableButtons ? 1 : 0.5) : 0,
|
||||
display: "flex",
|
||||
padding: "10px 15px 0px 15px",
|
||||
}}>
|
||||
{primaryButtonText && (
|
||||
<VSCodeButton
|
||||
appearance="primary"
|
||||
disabled={!enableButtons}
|
||||
style={{
|
||||
flex: secondaryButtonText ? 1 : 2,
|
||||
marginRight: secondaryButtonText ? "6px" : "0",
|
||||
}}
|
||||
onClick={handlePrimaryButtonClick}>
|
||||
{primaryButtonText}
|
||||
</VSCodeButton>
|
||||
)}
|
||||
{secondaryButtonText && (
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
disabled={!enableButtons}
|
||||
style={{ flex: 1, marginLeft: "6px" }}
|
||||
onClick={handleSecondaryButtonClick}>
|
||||
{secondaryButtonText}
|
||||
</VSCodeButton>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: "10px 15px", opacity: textAreaDisabled ? 0.5 : 1, position: "relative" }}>
|
||||
<DynamicTextArea
|
||||
ref={textAreaRef}
|
||||
value={inputValue}
|
||||
disabled={textAreaDisabled}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsTextAreaFocused(true)}
|
||||
onBlur={() => setIsTextAreaFocused(false)}
|
||||
onPaste={handlePaste}
|
||||
onHeightChange={() =>
|
||||
//virtuosoRef.current?.scrollToIndex({ index: "LAST", align: "end", behavior: "auto" })
|
||||
virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "auto" })
|
||||
@@ -446,32 +625,67 @@ const ChatView = ({ messages, isHidden, vscodeThemeName, showAnnouncement, hideA
|
||||
boxSizing: "border-box",
|
||||
backgroundColor: "var(--vscode-input-background)",
|
||||
color: "var(--vscode-input-foreground)",
|
||||
border: "1px solid var(--vscode-input-border)",
|
||||
//border: "1px solid var(--vscode-input-border)",
|
||||
borderRadius: 2,
|
||||
fontFamily: "var(--vscode-font-family)",
|
||||
fontSize: "var(--vscode-editor-font-size)",
|
||||
lineHeight: "var(--vscode-editor-line-height)",
|
||||
resize: "none",
|
||||
overflow: "hidden",
|
||||
padding: "8px 36px 8px 8px",
|
||||
// Since we have maxRows, when text is long enough it starts to overflow the bottom padding, appearing behind the thumbnails. To fix this, we use a transparent border to push the text up instead. (https://stackoverflow.com/questions/42631947/maintaining-a-padding-inside-of-text-area/52538410#52538410)
|
||||
borderTop: "9px solid transparent",
|
||||
borderBottom: `${thumbnailsHeight + 9}px solid transparent`,
|
||||
borderRight: "54px solid transparent",
|
||||
borderLeft: "9px solid transparent",
|
||||
// Instead of using boxShadow, we use a div with a border to better replicate the behavior when the textarea is focused
|
||||
// boxShadow: "0px 0px 0px 1px var(--vscode-input-border)",
|
||||
padding: 0,
|
||||
cursor: textAreaDisabled ? "not-allowed" : undefined,
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
{selectedImages.length > 0 && (
|
||||
<Thumbnails
|
||||
images={selectedImages}
|
||||
setImages={setSelectedImages}
|
||||
onHeightChange={handleThumbnailsHeightChange}
|
||||
style={{
|
||||
position: "absolute",
|
||||
paddingTop: 4,
|
||||
bottom: 14,
|
||||
left: 22,
|
||||
right: 67, // (54 + 9) + 4 extra padding
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 20,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
...(!!textAreaHeight ? { height: textAreaHeight, bottom: 12 } : { top: 0, bottom: 1.5 }),
|
||||
alignItems: "flex-center",
|
||||
height: textAreaContainerHeight - 20,
|
||||
bottom: 10,
|
||||
}}>
|
||||
<VSCodeButton
|
||||
disabled={textAreaDisabled}
|
||||
appearance="icon"
|
||||
aria-label="Send Message"
|
||||
onClick={handleSendMessage}>
|
||||
<span className="codicon codicon-send"></span>
|
||||
</VSCodeButton>
|
||||
<div style={{ display: "flex", flexDirection: "row", alignItems: "center" }}>
|
||||
<VSCodeButton
|
||||
disabled={shouldDisableImages}
|
||||
appearance="icon"
|
||||
aria-label="Attach Images"
|
||||
onClick={selectImages}
|
||||
style={{ marginRight: "2px" }}>
|
||||
<span
|
||||
className="codicon codicon-device-camera"
|
||||
style={{ fontSize: 18, marginLeft: -2 }}></span>
|
||||
</VSCodeButton>
|
||||
<VSCodeButton
|
||||
disabled={textAreaDisabled}
|
||||
appearance="icon"
|
||||
aria-label="Send Message"
|
||||
onClick={handleSendMessage}>
|
||||
<span className="codicon codicon-send" style={{ fontSize: 16, marginBottom: -1 }}></span>
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from "react"
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
|
||||
import { getLanguageFromPath } from "../../utilities/getLanguageFromPath"
|
||||
import { SyntaxHighlighterStyle } from "../../utilities/getSyntaxHighlighterStyleFromTheme"
|
||||
import { getLanguageFromPath } from "../utils/getLanguageFromPath"
|
||||
import { SyntaxHighlighterStyle } from "../utils/getSyntaxHighlighterStyleFromTheme"
|
||||
|
||||
/*
|
||||
const vscodeSyntaxStyle: React.CSSProperties = {
|
||||
@@ -0,0 +1,147 @@
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useExtensionState } from "../context/ExtensionStateContext"
|
||||
import { vscode } from "../utils/vscode"
|
||||
|
||||
type HistoryPreviewProps = {
|
||||
showHistoryView: () => void
|
||||
}
|
||||
|
||||
const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
|
||||
const { taskHistory } = useExtensionState()
|
||||
const handleHistorySelect = (id: string) => {
|
||||
vscode.postMessage({ type: "showTaskWithId", text: id })
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return date
|
||||
?.toLocaleString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
})
|
||||
.replace(", ", " ")
|
||||
.replace(" at", ",")
|
||||
.toUpperCase()
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ flexGrow: 1, overflowY: "auto" }}>
|
||||
<style>
|
||||
{`
|
||||
.history-preview-item {
|
||||
background-color: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 50%, transparent);
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
opacity: 0.8;
|
||||
cursor: pointer;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.history-preview-item:hover {
|
||||
background-color: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 100%, transparent);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div
|
||||
style={{
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
margin: "10px 20px 10px 20px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<span
|
||||
className="codicon codicon-comment-discussion"
|
||||
style={{ marginRight: "4px", transform: "scale(0.9)" }}></span>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
fontSize: "0.85em",
|
||||
textTransform: "uppercase",
|
||||
}}>
|
||||
Recent Tasks
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "0px 20px 0 20px" }}>
|
||||
{taskHistory
|
||||
.filter((item) => item.ts && item.task)
|
||||
.slice(0, 3)
|
||||
.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="history-preview-item"
|
||||
onClick={() => handleHistorySelect(item.id)}>
|
||||
<div style={{ padding: "12px" }}>
|
||||
<div style={{ marginBottom: "8px" }}>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
fontWeight: 500,
|
||||
fontSize: "0.85em",
|
||||
textTransform: "uppercase",
|
||||
}}>
|
||||
{formatDate(item.ts)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "var(--vscode-font-size)",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
marginBottom: "8px",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
}}>
|
||||
{item.task}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.85em", color: "var(--vscode-descriptionForeground)" }}>
|
||||
<span>
|
||||
Tokens: ↑{item.tokensIn?.toLocaleString()} ↓{item.tokensOut?.toLocaleString()}
|
||||
</span>
|
||||
{" • "}
|
||||
{item.cacheWrites && item.cacheReads && (
|
||||
<>
|
||||
<span>
|
||||
Cache: +{item.cacheWrites?.toLocaleString()} →{" "}
|
||||
{item.cacheReads?.toLocaleString()}
|
||||
</span>
|
||||
{" • "}
|
||||
</>
|
||||
)}
|
||||
<span>API Cost: ${item.totalCost?.toFixed(4)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
onClick={() => showHistoryView()}
|
||||
style={{
|
||||
opacity: 0.9,
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "var(--vscode-font-size)",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
View all history
|
||||
</div>
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HistoryPreview
|
||||
@@ -0,0 +1,303 @@
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useExtensionState } from "../context/ExtensionStateContext"
|
||||
import { vscode } from "../utils/vscode"
|
||||
|
||||
type HistoryViewProps = {
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
const { taskHistory } = useExtensionState()
|
||||
const handleHistorySelect = (id: string) => {
|
||||
vscode.postMessage({ type: "showTaskWithId", text: id })
|
||||
}
|
||||
|
||||
const handleDeleteHistoryItem = (id: string) => {
|
||||
vscode.postMessage({ type: "deleteTaskWithId", text: id })
|
||||
}
|
||||
|
||||
const handleExportMd = (id: string) => {
|
||||
vscode.postMessage({ type: "exportTaskWithId", text: id })
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return date
|
||||
?.toLocaleString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
})
|
||||
.replace(", ", " ")
|
||||
.replace(" at", ",")
|
||||
.toUpperCase()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
.history-item:hover {
|
||||
background-color: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
.delete-button {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.history-item:hover .delete-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "10px 17px 10px 20px",
|
||||
}}>
|
||||
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>History</h3>
|
||||
<VSCodeButton onClick={onDone}>Done</VSCodeButton>
|
||||
</div>
|
||||
<div style={{ flexGrow: 1, overflowY: "auto", margin: 0 }}>
|
||||
{taskHistory.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
fontStyle: "italic",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
textAlign: "center",
|
||||
padding: "0px 10px",
|
||||
}}>
|
||||
<span
|
||||
className="codicon codicon-archive"
|
||||
style={{ fontSize: "50px", marginBottom: "15px" }}></span>
|
||||
<div>
|
||||
No history found,
|
||||
<br />
|
||||
start a new task to see it here...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{taskHistory
|
||||
.filter((item) => item.ts && item.task)
|
||||
.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="history-item"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderBottom:
|
||||
index < taskHistory.length - 1
|
||||
? "1px solid var(--vscode-panel-border)"
|
||||
: "none",
|
||||
}}
|
||||
onClick={() => handleHistorySelect(item.id)}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
padding: "12px 20px",
|
||||
position: "relative",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
fontWeight: 500,
|
||||
fontSize: "0.85em",
|
||||
textTransform: "uppercase",
|
||||
}}>
|
||||
{formatDate(item.ts)}
|
||||
</span>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteHistoryItem(item.id)
|
||||
}}
|
||||
className="delete-button">
|
||||
<span className="codicon codicon-trash"></span>
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "var(--vscode-font-size)",
|
||||
color: "var(--vscode-foreground)",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
}}>
|
||||
{item.task}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
flexWrap: "wrap",
|
||||
}}>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
Tokens:
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "3px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
<i
|
||||
className="codicon codicon-arrow-up"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "-2px",
|
||||
}}
|
||||
/>
|
||||
{item.tokensIn?.toLocaleString()}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "3px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
<i
|
||||
className="codicon codicon-arrow-down"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "-2px",
|
||||
}}
|
||||
/>
|
||||
{item.tokensOut?.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{item.cacheWrites && item.cacheReads && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
flexWrap: "wrap",
|
||||
}}>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
Cache:
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "3px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
<i
|
||||
className="codicon codicon-database"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "-1px",
|
||||
}}
|
||||
/>
|
||||
+{item.cacheWrites?.toLocaleString()}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "3px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
<i
|
||||
className="codicon codicon-arrow-right"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: 0,
|
||||
}}
|
||||
/>
|
||||
{item.cacheReads?.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginTop: -2,
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
API Cost:
|
||||
</span>
|
||||
<span style={{ color: "var(--vscode-descriptionForeground)" }}>
|
||||
${item.totalCost?.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleExportMd(item.id)
|
||||
}}>
|
||||
<div style={{ fontSize: "11px", fontWeight: 500, opacity: 1 }}>
|
||||
EXPORT .MD
|
||||
</div>
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default HistoryView
|
||||
@@ -1,64 +1,62 @@
|
||||
import { VSCodeButton, VSCodeDivider, VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import React, { useState } from "react"
|
||||
import { useEffectOnce } from "react-use"
|
||||
import { vscode } from "../utilities/vscode"
|
||||
import {
|
||||
VSCodeButton,
|
||||
VSCodeCheckbox,
|
||||
VSCodeLink,
|
||||
VSCodeTextArea,
|
||||
VSCodeTextField,
|
||||
} from "@vscode/webview-ui-toolkit/react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useExtensionState } from "../context/ExtensionStateContext"
|
||||
import { validateApiConfiguration, validateMaxRequestsPerTask } from "../utils/validate"
|
||||
import { vscode } from "../utils/vscode"
|
||||
import ApiOptions from "./ApiOptions"
|
||||
|
||||
const IS_DEV = false // FIXME: use flags when packaging
|
||||
|
||||
type SettingsViewProps = {
|
||||
apiKey: string
|
||||
setApiKey: React.Dispatch<React.SetStateAction<string>>
|
||||
maxRequestsPerTask: string
|
||||
setMaxRequestsPerTask: React.Dispatch<React.SetStateAction<string>>
|
||||
onDone: () => void // Define the type of the onDone prop
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
const SettingsView = ({ apiKey, setApiKey, maxRequestsPerTask, setMaxRequestsPerTask, onDone }: SettingsViewProps) => {
|
||||
const [apiKeyErrorMessage, setApiKeyErrorMessage] = useState<string | undefined>(undefined)
|
||||
const SettingsView = ({ onDone }: SettingsViewProps) => {
|
||||
const {
|
||||
apiConfiguration,
|
||||
version,
|
||||
maxRequestsPerTask,
|
||||
customInstructions,
|
||||
setCustomInstructions,
|
||||
alwaysAllowReadOnly,
|
||||
setAlwaysAllowReadOnly,
|
||||
} = useExtensionState()
|
||||
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
|
||||
const [maxRequestsErrorMessage, setMaxRequestsErrorMessage] = useState<string | undefined>(undefined)
|
||||
|
||||
const disableDoneButton = apiKeyErrorMessage != null || maxRequestsErrorMessage != null
|
||||
|
||||
const handleApiKeyChange = (event: any) => {
|
||||
const input = event.target.value
|
||||
setApiKey(input)
|
||||
validateApiKey(input)
|
||||
}
|
||||
|
||||
const validateApiKey = (value: string) => {
|
||||
if (value.trim() === "") {
|
||||
setApiKeyErrorMessage("API Key cannot be empty")
|
||||
} else {
|
||||
setApiKeyErrorMessage(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMaxRequestsChange = (event: any) => {
|
||||
const input = event.target.value
|
||||
setMaxRequestsPerTask(input)
|
||||
validateMaxRequests(input)
|
||||
}
|
||||
|
||||
const validateMaxRequests = (value: string | undefined) => {
|
||||
if (value?.trim()) {
|
||||
const num = Number(value)
|
||||
if (isNaN(num)) {
|
||||
setMaxRequestsErrorMessage("Maximum requests must be a number")
|
||||
} else if (num < 3 || num > 100) {
|
||||
setMaxRequestsErrorMessage("Maximum requests must be between 3 and 100")
|
||||
} else {
|
||||
setMaxRequestsErrorMessage(undefined)
|
||||
}
|
||||
} else {
|
||||
setMaxRequestsErrorMessage(undefined)
|
||||
}
|
||||
}
|
||||
const [maxRequestsPerTaskString, setMaxRequestsPerTaskString] = useState<string>(
|
||||
maxRequestsPerTask?.toString() || ""
|
||||
)
|
||||
|
||||
const handleSubmit = () => {
|
||||
vscode.postMessage({ type: "apiKey", text: apiKey })
|
||||
vscode.postMessage({ type: "maxRequestsPerTask", text: maxRequestsPerTask })
|
||||
const apiValidationResult = validateApiConfiguration(apiConfiguration)
|
||||
const maxRequestsValidationResult = validateMaxRequestsPerTask(maxRequestsPerTaskString)
|
||||
|
||||
onDone()
|
||||
setApiErrorMessage(apiValidationResult)
|
||||
setMaxRequestsErrorMessage(maxRequestsValidationResult)
|
||||
|
||||
if (!apiValidationResult && !maxRequestsValidationResult) {
|
||||
vscode.postMessage({ type: "apiConfiguration", apiConfiguration })
|
||||
vscode.postMessage({ type: "maxRequestsPerTask", text: maxRequestsPerTaskString })
|
||||
vscode.postMessage({ type: "customInstructions", text: customInstructions })
|
||||
vscode.postMessage({ type: "alwaysAllowReadOnly", bool: alwaysAllowReadOnly })
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setApiErrorMessage(undefined)
|
||||
}, [apiConfiguration])
|
||||
|
||||
useEffect(() => {
|
||||
setMaxRequestsErrorMessage(undefined)
|
||||
}, [maxRequestsPerTask])
|
||||
|
||||
// validate as soon as the component is mounted
|
||||
/*
|
||||
useEffect will use stale values of variables if they are not included in the dependency array. so trying to use useEffect with a dependency array of only one value for example will use any other variables' old values. In most cases you don't want this, and should opt to use react-use hooks.
|
||||
@@ -70,106 +68,142 @@ const SettingsView = ({ apiKey, setApiKey, maxRequestsPerTask, setMaxRequestsPer
|
||||
|
||||
If we only want to run code once on mount we can use react-use's useEffectOnce or useMount
|
||||
*/
|
||||
useEffectOnce(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
validateApiKey(apiKey)
|
||||
validateMaxRequests(maxRequestsPerTask)
|
||||
}, 1000)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
})
|
||||
const handleResetState = () => {
|
||||
vscode.postMessage({ type: "resetState" })
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ margin: "0 auto", paddingTop: "10px" }}>
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
padding: "10px 0px 0px 20px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "17px",
|
||||
paddingRight: 17,
|
||||
}}>
|
||||
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>Settings</h3>
|
||||
<VSCodeButton onClick={handleSubmit} disabled={disableDoneButton}>
|
||||
Done
|
||||
</VSCodeButton>
|
||||
<VSCodeButton onClick={handleSubmit}>Done</VSCodeButton>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<VSCodeTextField
|
||||
value={apiKey}
|
||||
style={{ width: "100%" }}
|
||||
placeholder="Enter your Anthropic API Key"
|
||||
onInput={handleApiKeyChange}>
|
||||
<span style={{ fontWeight: "500" }}>Anthropic API Key</span>
|
||||
</VSCodeTextField>
|
||||
{apiKeyErrorMessage && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-errorForeground)",
|
||||
}}>
|
||||
{apiKeyErrorMessage}
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
This key is not shared with anyone and only used to make API requests from the extension.
|
||||
<VSCodeLink href="https://console.anthropic.com/" style={{ display: "inline" }}>
|
||||
You can get an API key by signing up here.
|
||||
</VSCodeLink>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<VSCodeTextField
|
||||
value={maxRequestsPerTask}
|
||||
style={{ width: "100%" }}
|
||||
placeholder="20"
|
||||
onInput={handleMaxRequestsChange}>
|
||||
<span style={{ fontWeight: "500" }}>Maximum # Requests Per Task</span>
|
||||
</VSCodeTextField>
|
||||
{maxRequestsErrorMessage && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-errorForeground)",
|
||||
}}>
|
||||
{maxRequestsErrorMessage}
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
If Claude Dev reaches this limit, it will pause and ask for your permission before making additional
|
||||
requests.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<VSCodeDivider />
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: "20px",
|
||||
textAlign: "center",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
fontSize: "12px",
|
||||
lineHeight: "1.2",
|
||||
fontStyle: "italic",
|
||||
}}>
|
||||
<p>
|
||||
<VSCodeLink href="https://github.com/saoudrizwan/claude-dev">
|
||||
https://github.com/saoudrizwan/claude-dev
|
||||
</VSCodeLink>
|
||||
</p>
|
||||
style={{ flexGrow: 1, overflowY: "scroll", paddingRight: 8, display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
<ApiOptions showModelOptions={true} apiErrorMessage={apiErrorMessage} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
<VSCodeCheckbox
|
||||
checked={alwaysAllowReadOnly}
|
||||
onChange={(e: any) => setAlwaysAllowReadOnly(e.target.checked)}>
|
||||
<span style={{ fontWeight: "500" }}>Always allow read-only operations</span>
|
||||
</VSCodeCheckbox>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
When enabled, Claude will automatically read files and view directories without requiring you to
|
||||
click the Allow button.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
<VSCodeTextArea
|
||||
value={customInstructions ?? ""}
|
||||
style={{ width: "100%" }}
|
||||
rows={4}
|
||||
placeholder={
|
||||
'e.g. "Run unit tests at the end", "Use TypeScript with async/await", "Speak in Spanish"'
|
||||
}
|
||||
onInput={(e: any) => setCustomInstructions(e.target?.value ?? "")}>
|
||||
<span style={{ fontWeight: "500" }}>Custom Instructions</span>
|
||||
</VSCodeTextArea>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
These instructions are added to the end of the system prompt sent with every request.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<VSCodeTextField
|
||||
value={maxRequestsPerTaskString}
|
||||
style={{ width: "100%" }}
|
||||
placeholder="20"
|
||||
onInput={(e: any) => setMaxRequestsPerTaskString(e.target?.value ?? "")}>
|
||||
<span style={{ fontWeight: "500" }}>Maximum # Requests Per Task</span>
|
||||
</VSCodeTextField>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
If Claude Dev reaches this limit, it will pause and ask for your permission before making
|
||||
additional requests.
|
||||
</p>
|
||||
{maxRequestsErrorMessage && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-errorForeground)",
|
||||
}}>
|
||||
{maxRequestsErrorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{IS_DEV && (
|
||||
<>
|
||||
<div style={{ marginTop: "10px", marginBottom: "4px" }}>Debug</div>
|
||||
<VSCodeButton onClick={handleResetState} style={{ marginTop: "5px", width: "auto" }}>
|
||||
Reset State
|
||||
</VSCodeButton>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
This will reset all global state and secret storage in the extension.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
fontSize: "12px",
|
||||
lineHeight: "1.2",
|
||||
marginTop: "auto",
|
||||
padding: "10px 8px 15px 0px",
|
||||
}}>
|
||||
<p style={{ wordWrap: "break-word", margin: 0, padding: 0 }}>
|
||||
If you have any questions or feedback, feel free to open an issue at{" "}
|
||||
<VSCodeLink href="https://github.com/saoudrizwan/claude-dev" style={{ display: "inline" }}>
|
||||
https://github.com/saoudrizwan/claude-dev
|
||||
</VSCodeLink>
|
||||
</p>
|
||||
<p style={{ fontStyle: "italic", margin: "10px 0 0 0", padding: 0 }}>v{version}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import React, { useState } from "react"
|
||||
|
||||
export const TAB_NAVBAR_HEIGHT = 24
|
||||
const BUTTON_MARGIN_RIGHT = "3px"
|
||||
const LAST_BUTTON_MARGIN_RIGHT = "13px"
|
||||
|
||||
type TabNavbarProps = {
|
||||
onPlusClick: () => void
|
||||
onHistoryClick: () => void
|
||||
onSettingsClick: () => void
|
||||
}
|
||||
|
||||
type TooltipProps = {
|
||||
text: string
|
||||
isVisible: boolean
|
||||
position: { x: number; y: number }
|
||||
align?: "left" | "center" | "right"
|
||||
}
|
||||
|
||||
const Tooltip: React.FC<TooltipProps> = ({ text, isVisible, position, align = "center" }) => {
|
||||
let leftPosition = position.x
|
||||
let triangleStyle: React.CSSProperties = {
|
||||
left: "50%",
|
||||
marginLeft: "-5px",
|
||||
}
|
||||
|
||||
if (align === "right") {
|
||||
leftPosition = position.x - 10 // Adjust this value as needed
|
||||
triangleStyle = {
|
||||
right: "10px", // Adjust this value to match the tooltip's right padding
|
||||
marginLeft: "0",
|
||||
}
|
||||
} else if (align === "left") {
|
||||
leftPosition = position.x + 10 // Adjust this value as needed
|
||||
triangleStyle = {
|
||||
left: "10px", // Adjust this value to match the tooltip's left padding
|
||||
marginLeft: "0",
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: `${position.y}px`,
|
||||
left: align === "center" ? leftPosition + "px" : "auto",
|
||||
right: align === "right" ? "10px" : "auto", // Ensure 10px from screen edge
|
||||
transform: align === "center" ? "translateX(-50%)" : "none",
|
||||
opacity: isVisible ? 1 : 0,
|
||||
visibility: isVisible ? "visible" : "hidden",
|
||||
transition: "opacity 0.1s ease-out 0.1s, visibility 0.1s ease-out 0.1s",
|
||||
backgroundColor: "var(--vscode-editorHoverWidget-background)",
|
||||
color: "var(--vscode-editorHoverWidget-foreground)",
|
||||
padding: "4px 8px",
|
||||
borderRadius: "3px",
|
||||
fontSize: "12px",
|
||||
pointerEvents: "none",
|
||||
zIndex: 1000,
|
||||
boxShadow: "0 2px 8px var(--vscode-widget-shadow)",
|
||||
border: "1px solid var(--vscode-editorHoverWidget-border)",
|
||||
textAlign: "center",
|
||||
whiteSpace: "nowrap",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-5px",
|
||||
...triangleStyle,
|
||||
borderLeft: "5px solid transparent",
|
||||
borderRight: "5px solid transparent",
|
||||
borderBottom: "5px solid var(--vscode-editorHoverWidget-border)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-4px",
|
||||
...triangleStyle,
|
||||
borderLeft: "5px solid transparent",
|
||||
borderRight: "5px solid transparent",
|
||||
borderBottom: "5px solid var(--vscode-editorHoverWidget-background)",
|
||||
}}
|
||||
/>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TabNavbar = ({ onPlusClick, onHistoryClick, onSettingsClick }: TabNavbarProps) => {
|
||||
const [tooltip, setTooltip] = useState<TooltipProps>({
|
||||
text: "",
|
||||
isVisible: false,
|
||||
position: { x: 0, y: 0 },
|
||||
align: "center",
|
||||
})
|
||||
|
||||
const showTooltip = (text: string, event: React.MouseEvent, align: "left" | "center" | "right" = "center") => {
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
setTooltip({
|
||||
text,
|
||||
isVisible: true,
|
||||
position: { x: rect.left + rect.width / 2, y: rect.bottom + 7 },
|
||||
align,
|
||||
})
|
||||
}
|
||||
|
||||
const hideTooltip = () => {
|
||||
setTooltip((prev) => ({ ...prev, isVisible: false }))
|
||||
}
|
||||
|
||||
const buttonStyle = {
|
||||
marginRight: BUTTON_MARGIN_RIGHT,
|
||||
}
|
||||
|
||||
const lastButtonStyle = {
|
||||
...buttonStyle,
|
||||
marginRight: LAST_BUTTON_MARGIN_RIGHT,
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 4,
|
||||
right: 0,
|
||||
left: 0,
|
||||
height: TAB_NAVBAR_HEIGHT,
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
onClick={onPlusClick}
|
||||
style={buttonStyle}
|
||||
onMouseEnter={(e) => showTooltip("New Chat", e, "center")}
|
||||
onMouseLeave={hideTooltip}
|
||||
onMouseMove={(e) => showTooltip("New Chat", e, "center")}>
|
||||
<span className="codicon codicon-add"></span>
|
||||
</VSCodeButton>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
onClick={onHistoryClick}
|
||||
style={buttonStyle}
|
||||
onMouseEnter={(e) => showTooltip("History", e, "center")}
|
||||
onMouseLeave={hideTooltip}
|
||||
onMouseMove={(e) => showTooltip("History", e, "center")}>
|
||||
<span className="codicon codicon-history"></span>
|
||||
</VSCodeButton>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
onClick={onSettingsClick}
|
||||
style={lastButtonStyle}
|
||||
onMouseEnter={(e) => showTooltip("Settings", e, "right")}
|
||||
onMouseLeave={hideTooltip}
|
||||
onMouseMove={(e) => showTooltip("Settings", e, "right")}>
|
||||
<span className="codicon codicon-settings-gear"></span>
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
<Tooltip {...tooltip} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TabNavbar
|
||||
@@ -1,22 +1,42 @@
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import React, { useEffect, useRef, useState } from "react"
|
||||
import TextTruncate from "react-text-truncate"
|
||||
import { useWindowSize } from "react-use"
|
||||
import { vscode } from "../utilities/vscode"
|
||||
import { ApiProvider } from "../../../src/shared/api"
|
||||
import { ClaudeMessage } from "../../../src/shared/ExtensionMessage"
|
||||
import { vscode } from "../utils/vscode"
|
||||
import Thumbnails from "./Thumbnails"
|
||||
|
||||
interface TaskHeaderProps {
|
||||
taskText: string
|
||||
task: ClaudeMessage
|
||||
tokensIn: number
|
||||
tokensOut: number
|
||||
doesModelSupportPromptCache: boolean
|
||||
cacheWrites?: number
|
||||
cacheReads?: number
|
||||
totalCost: number
|
||||
onClose: () => void
|
||||
isHidden: boolean
|
||||
vscodeUriScheme?: string
|
||||
apiProvider?: ApiProvider
|
||||
}
|
||||
|
||||
const TaskHeader: React.FC<TaskHeaderProps> = ({ taskText, tokensIn, tokensOut, totalCost, onClose, isHidden }) => {
|
||||
const TaskHeader: React.FC<TaskHeaderProps> = ({
|
||||
task,
|
||||
tokensIn,
|
||||
tokensOut,
|
||||
doesModelSupportPromptCache,
|
||||
cacheWrites,
|
||||
cacheReads,
|
||||
totalCost,
|
||||
onClose,
|
||||
isHidden,
|
||||
vscodeUriScheme,
|
||||
apiProvider,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [textTruncateKey, setTextTruncateKey] = useState(0)
|
||||
const [showSeeMore, setShowSeeMore] = useState(false)
|
||||
const textContainerRef = useRef<HTMLDivElement>(null)
|
||||
const textRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
/*
|
||||
When dealing with event listeners in React components that depend on state variables, we face a challenge. We want our listener to always use the most up-to-date version of a callback function that relies on current state, but we don't want to constantly add and remove event listeners as that function updates. This scenario often arises with resize listeners or other window events. Simply adding the listener in a useEffect with an empty dependency array risks using stale state, while including the callback in the dependencies can lead to unnecessary re-registrations of the listener. There are react hook libraries that provide a elegant solution to this problem by utilizing the useRef hook to maintain a reference to the latest callback function without triggering re-renders or effect re-runs. This approach ensures that our event listener always has access to the most current state while minimizing performance overhead and potential memory leaks from multiple listener registrations.
|
||||
@@ -50,34 +70,38 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({ taskText, tokensIn, tokensOut,
|
||||
After:
|
||||
*/
|
||||
|
||||
const { height: windowHeight } = useWindowSize()
|
||||
const { height: windowHeight, width: windowWidth } = useWindowSize()
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && textContainerRef.current) {
|
||||
const maxHeight = windowHeight * (3 / 5)
|
||||
const maxHeight = windowHeight * (1 / 2)
|
||||
textContainerRef.current.style.maxHeight = `${maxHeight}px`
|
||||
}
|
||||
}, [isExpanded, windowHeight])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHidden) {
|
||||
/*
|
||||
There's an issue with TextTruncate where it is hidden when removed from the screen. It uses canvas to measure text width and adjusts the content accordingly. However, when the component is hidden, the canvas is not rendered and the text is not measured.
|
||||
We can fix this by forcing re-render after navigation by using a key prop that changes when you navigate back to the page.
|
||||
- https://github.com/ShinyChang/React-Text-Truncate?tab=readme-ov-file#faq
|
||||
*/
|
||||
setTextTruncateKey((prev) => prev + 1)
|
||||
if (textRef.current && textContainerRef.current) {
|
||||
let textContainerHeight = textContainerRef.current.clientHeight
|
||||
if (!textContainerHeight) {
|
||||
textContainerHeight = textContainerRef.current.getBoundingClientRect().height
|
||||
}
|
||||
const isOverflowing = textRef.current.scrollHeight > textContainerHeight
|
||||
// necessary to show see more button again if user resizes window to expand and then back to collapse
|
||||
if (!isOverflowing) {
|
||||
setIsExpanded(false)
|
||||
}
|
||||
setShowSeeMore(isOverflowing)
|
||||
}
|
||||
}, [isHidden])
|
||||
}, [task.text, windowWidth])
|
||||
|
||||
const toggleExpand = () => setIsExpanded(!isExpanded)
|
||||
|
||||
const handleDownload = () => {
|
||||
vscode.postMessage({ type: "downloadTask" })
|
||||
vscode.postMessage({ type: "exportCurrentTask" })
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: "15px 15px 10px 15px" }}>
|
||||
<div style={{ padding: "10px 13px 10px 13px" }}>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "var(--vscode-badge-background)",
|
||||
@@ -88,6 +112,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({ taskText, tokensIn, tokensOut,
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
@@ -99,7 +124,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({ taskText, tokensIn, tokensOut,
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
onClick={onClose}
|
||||
style={{ marginTop: "-5px", marginRight: "-5px" }}>
|
||||
style={{ marginTop: "-6px", marginRight: "-4px" }}>
|
||||
<span className="codicon codicon-close"></span>
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
@@ -109,71 +134,154 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({ taskText, tokensIn, tokensOut,
|
||||
fontSize: "var(--vscode-font-size)",
|
||||
overflowY: isExpanded ? "auto" : "hidden",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
position: "relative",
|
||||
}}>
|
||||
<TextTruncate
|
||||
key={textTruncateKey}
|
||||
line={isExpanded ? 0 : 3}
|
||||
element="span"
|
||||
truncateText="…"
|
||||
text={taskText}
|
||||
textTruncateChild={
|
||||
<span
|
||||
<div
|
||||
ref={textRef}
|
||||
style={{
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: isExpanded ? "unset" : 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "anywhere",
|
||||
}}>
|
||||
{task.text}
|
||||
</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-badge-background))",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--vscode-textLink-foreground)",
|
||||
marginLeft: "5px",
|
||||
paddingRight: 0,
|
||||
paddingLeft: 3,
|
||||
backgroundColor: "var(--vscode-badge-background)",
|
||||
}}
|
||||
onClick={toggleExpand}>
|
||||
See more
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{isExpanded && (
|
||||
<span
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--vscode-textLink-foreground)",
|
||||
marginLeft: "5px",
|
||||
}}
|
||||
onClick={toggleExpand}>
|
||||
See less
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && showSeeMore && (
|
||||
<div
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: "var(--vscode-textLink-foreground)",
|
||||
marginLeft: "auto",
|
||||
textAlign: "right",
|
||||
paddingRight: 0,
|
||||
}}
|
||||
onClick={toggleExpand}>
|
||||
See less
|
||||
</div>
|
||||
)}
|
||||
{task.images && task.images.length > 0 && <Thumbnails images={task.images} />}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "5px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px", flexWrap: "wrap" }}>
|
||||
<span style={{ fontWeight: "bold" }}>Tokens:</span>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
|
||||
<i
|
||||
className="codicon codicon-arrow-up"
|
||||
style={{ fontSize: "12px", fontWeight: "bold", marginBottom: "-2px" }}
|
||||
/>
|
||||
{tokensIn?.toLocaleString()}
|
||||
</span>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
|
||||
<i
|
||||
className="codicon codicon-arrow-down"
|
||||
style={{ fontSize: "12px", fontWeight: "bold", marginBottom: "-2px" }}
|
||||
/>
|
||||
{tokensOut.toLocaleString()}
|
||||
</span>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
|
||||
<i
|
||||
className="codicon codicon-arrow-up"
|
||||
style={{ fontSize: "12px", fontWeight: "bold", marginBottom: "-1.5px" }}
|
||||
/>
|
||||
{tokensIn.toLocaleString()}
|
||||
{tokensOut?.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
<span style={{ fontWeight: "bold" }}>API Cost:</span>
|
||||
<span>${totalCost.toFixed(4)}</span>
|
||||
{(doesModelSupportPromptCache || cacheReads !== undefined || cacheWrites !== undefined) && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px", flexWrap: "wrap" }}>
|
||||
<span style={{ fontWeight: "bold" }}>Cache:</span>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
|
||||
<i
|
||||
className="codicon codicon-database"
|
||||
style={{ fontSize: "12px", fontWeight: "bold", marginBottom: "-1px" }}
|
||||
/>
|
||||
+{(cacheWrites || 0)?.toLocaleString()}
|
||||
</span>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
|
||||
<i
|
||||
className="codicon codicon-arrow-right"
|
||||
style={{ fontSize: "12px", fontWeight: "bold", marginBottom: 0 }}
|
||||
/>
|
||||
{(cacheReads || 0)?.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
<span style={{ fontWeight: "bold" }}>API Cost:</span>
|
||||
<span>${totalCost?.toFixed(4)}</span>
|
||||
</div>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
onClick={handleDownload}
|
||||
style={{
|
||||
marginBottom: "-2px",
|
||||
marginRight: "-2.5px",
|
||||
}}>
|
||||
<div style={{ fontSize: "10.5px", fontWeight: "bold", opacity: 0.6 }}>EXPORT .MD</div>
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
</div>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
onClick={handleDownload}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "9.5px",
|
||||
right: "9px",
|
||||
}}>
|
||||
<div style={{ fontSize: "10.5px", fontWeight: "bold", opacity: 0.6 }}>EXPORT .MD</div>
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
{/* {apiProvider === "kodu" && (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "color-mix(in srgb, var(--vscode-badge-background) 50%, transparent)",
|
||||
color: "var(--vscode-badge-foreground)",
|
||||
borderRadius: "0 0 3px 3px",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "4px 12px 6px 12px",
|
||||
fontSize: "0.9em",
|
||||
marginLeft: "10px",
|
||||
marginRight: "10px",
|
||||
}}>
|
||||
<div style={{ fontWeight: "500" }}>Credits Remaining:</div>
|
||||
<div>
|
||||
{formatPrice(koduCredits || 0)}
|
||||
{(koduCredits || 0) < 1 && (
|
||||
<>
|
||||
{" "}
|
||||
<VSCodeLink style={{ fontSize: "0.9em" }} href={getKoduAddCreditsUrl(vscodeUriScheme)}>
|
||||
(get more?)
|
||||
</VSCodeLink>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { useState, useRef, useLayoutEffect } from "react"
|
||||
import { useWindowSize } from "react-use"
|
||||
|
||||
interface ThumbnailsProps {
|
||||
images: string[]
|
||||
style?: React.CSSProperties
|
||||
setImages?: React.Dispatch<React.SetStateAction<string[]>>
|
||||
onHeightChange?: (height: number) => void
|
||||
}
|
||||
|
||||
const Thumbnails: React.FC<ThumbnailsProps> = ({ images, style, setImages, onHeightChange }) => {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { width } = useWindowSize()
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (containerRef.current) {
|
||||
let height = containerRef.current.clientHeight
|
||||
// some browsers return 0 for clientHeight
|
||||
if (!height) {
|
||||
height = containerRef.current.getBoundingClientRect().height
|
||||
}
|
||||
onHeightChange?.(height)
|
||||
}
|
||||
setHoveredIndex(null)
|
||||
}, [images, width, onHeightChange])
|
||||
|
||||
const handleDelete = (index: number) => {
|
||||
setImages?.((prevImages) => prevImages.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const isDeletable = setImages !== undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 5,
|
||||
rowGap: 3,
|
||||
...style,
|
||||
}}>
|
||||
{images.map((image, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{ position: "relative" }}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}>
|
||||
<img
|
||||
src={image}
|
||||
alt={`Thumbnail ${index + 1}`}
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
objectFit: "cover",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
{isDeletable && hoveredIndex === index && (
|
||||
<div
|
||||
onClick={() => handleDelete(index)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -4,
|
||||
right: -4,
|
||||
width: 13,
|
||||
height: 13,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "var(--vscode-badge-background)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
}}>
|
||||
<span
|
||||
className="codicon codicon-close"
|
||||
style={{
|
||||
color: "var(--vscode-foreground)",
|
||||
fontSize: 10,
|
||||
fontWeight: "bold",
|
||||
}}></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Thumbnails
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
|
||||
interface VSCodeButtonLinkProps {
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
const VSCodeButtonLink: React.FC<VSCodeButtonLinkProps> = ({ href, children, ...props }) => {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
style={{
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
}}>
|
||||
<VSCodeButton {...props}>{children}</VSCodeButton>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default VSCodeButtonLink
|
||||
@@ -1,32 +1,26 @@
|
||||
import React, { useState, useEffect } from "react"
|
||||
import { VSCodeButton, VSCodeTextField, VSCodeLink, VSCodeDivider } from "@vscode/webview-ui-toolkit/react"
|
||||
import { vscode } from "../utilities/vscode"
|
||||
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { useExtensionState } from "../context/ExtensionStateContext"
|
||||
import { validateApiConfiguration } from "../utils/validate"
|
||||
import { vscode } from "../utils/vscode"
|
||||
import ApiOptions from "./ApiOptions"
|
||||
|
||||
interface WelcomeViewProps {
|
||||
apiKey: string
|
||||
setApiKey: React.Dispatch<React.SetStateAction<string>>
|
||||
}
|
||||
interface WelcomeViewProps {}
|
||||
|
||||
const WelcomeView: React.FC<WelcomeViewProps> = ({ apiKey, setApiKey }) => {
|
||||
const [apiKeyErrorMessage, setApiKeyErrorMessage] = useState<string | undefined>(undefined)
|
||||
const WelcomeView: React.FC<WelcomeViewProps> = () => {
|
||||
const { apiConfiguration } = useExtensionState()
|
||||
|
||||
const disableLetsGoButton = apiKeyErrorMessage != null
|
||||
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
|
||||
|
||||
const validateApiKey = (value: string) => {
|
||||
if (value.trim() === "") {
|
||||
setApiKeyErrorMessage("API Key cannot be empty")
|
||||
} else {
|
||||
setApiKeyErrorMessage(undefined)
|
||||
}
|
||||
}
|
||||
const disableLetsGoButton = apiErrorMessage != null
|
||||
|
||||
const handleSubmit = () => {
|
||||
vscode.postMessage({ type: "apiKey", text: apiKey })
|
||||
vscode.postMessage({ type: "apiConfiguration", apiConfiguration })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
validateApiKey(apiKey)
|
||||
}, [apiKey])
|
||||
setApiErrorMessage(validateApiConfiguration(apiConfiguration))
|
||||
}, [apiConfiguration])
|
||||
|
||||
return (
|
||||
<div style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, padding: "0 20px" }}>
|
||||
@@ -36,41 +30,20 @@ const WelcomeView: React.FC<WelcomeViewProps> = ({ apiKey, setApiKey }) => {
|
||||
<VSCodeLink
|
||||
href="https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf"
|
||||
style={{ display: "inline" }}>
|
||||
Claude 3.5 Sonnet's agentic coding capabilities.
|
||||
Claude 3.5 Sonnet's agentic coding capabilities
|
||||
</VSCodeLink>{" "}
|
||||
I am prompted to think through tasks step-by-step and have access to tools that let me read & write
|
||||
files, analyze project source code, and execute terminal commands (with your permission, of course).
|
||||
and access to tools that let me create & edit files, explore complex projects, and execute terminal
|
||||
commands (with your permission, of course).
|
||||
</p>
|
||||
|
||||
<b>To get started, this extension needs an Anthropic API key:</b>
|
||||
<ol style={{ paddingLeft: "15px" }}>
|
||||
<li>
|
||||
Go to{" "}
|
||||
<VSCodeLink href="https://console.anthropic.com" style={{ display: "inline" }}>
|
||||
https://console.anthropic.com
|
||||
</VSCodeLink>
|
||||
</li>
|
||||
<li>You may need to buy some credits (although Anthropic is offering $5 free credit for new users)</li>
|
||||
<li>Click 'Get API Keys' and create a new key (you can delete it any time)</li>
|
||||
</ol>
|
||||
<b>To get started, this extension needs an API provider for Claude 3.5 Sonnet.</b>
|
||||
|
||||
<VSCodeDivider />
|
||||
|
||||
<div style={{ marginTop: "20px", display: "flex", alignItems: "center" }}>
|
||||
<VSCodeTextField
|
||||
style={{ flexGrow: 1, marginRight: "10px" }}
|
||||
placeholder="Enter API Key..."
|
||||
value={apiKey}
|
||||
onInput={(e: any) => setApiKey(e.target.value)}
|
||||
/>
|
||||
<VSCodeButton onClick={handleSubmit} disabled={disableLetsGoButton}>
|
||||
Submit
|
||||
<div style={{ marginTop: "10px" }}>
|
||||
<ApiOptions showModelOptions={false} />
|
||||
<VSCodeButton onClick={handleSubmit} disabled={disableLetsGoButton} style={{ marginTop: "3px" }}>
|
||||
Let's go!
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: "12px", marginTop: "10px", color: "var(--vscode-descriptionForeground)" }}>
|
||||
Your API key is stored securely on your computer and used only for interacting with the Anthropic API.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useState } from "react"
|
||||
import { useEvent } from "react-use"
|
||||
import { ExtensionMessage, ExtensionState } from "../../../src/shared/ExtensionMessage"
|
||||
import { ApiConfiguration } from "../../../src/shared/api"
|
||||
import { vscode } from "../utils/vscode"
|
||||
|
||||
interface ExtensionStateContextType extends ExtensionState {
|
||||
didHydrateState: boolean
|
||||
showWelcome: boolean
|
||||
setApiConfiguration: (config: ApiConfiguration) => void
|
||||
setMaxRequestsPerTask: (value?: number) => void
|
||||
setCustomInstructions: (value?: string) => void
|
||||
setAlwaysAllowReadOnly: (value: boolean) => void
|
||||
setShowAnnouncement: (value: boolean) => void
|
||||
}
|
||||
|
||||
const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
|
||||
|
||||
export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [state, setState] = useState<ExtensionState>({
|
||||
version: "",
|
||||
claudeMessages: [],
|
||||
taskHistory: [],
|
||||
shouldShowAnnouncement: false,
|
||||
})
|
||||
const [didHydrateState, setDidHydrateState] = useState(false)
|
||||
const [showWelcome, setShowWelcome] = useState(false)
|
||||
|
||||
const handleMessage = useCallback((event: MessageEvent) => {
|
||||
const message: ExtensionMessage = event.data
|
||||
if (message.type === "state" && message.state) {
|
||||
setState(message.state)
|
||||
const config = message.state?.apiConfiguration
|
||||
const hasKey = config
|
||||
? [config.apiKey, config.openRouterApiKey, config.awsAccessKey, config.vertexProjectId].some(
|
||||
(key) => key !== undefined
|
||||
)
|
||||
: false
|
||||
setShowWelcome(!hasKey)
|
||||
setDidHydrateState(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEvent("message", handleMessage)
|
||||
|
||||
useEffect(() => {
|
||||
vscode.postMessage({ type: "webviewDidLaunch" })
|
||||
}, [])
|
||||
|
||||
const contextValue: ExtensionStateContextType = {
|
||||
...state,
|
||||
didHydrateState,
|
||||
showWelcome,
|
||||
setApiConfiguration: (value) => setState((prevState) => ({ ...prevState, apiConfiguration: value })),
|
||||
setMaxRequestsPerTask: (value) => setState((prevState) => ({ ...prevState, maxRequestsPerTask: value })),
|
||||
setCustomInstructions: (value) => setState((prevState) => ({ ...prevState, customInstructions: value })),
|
||||
setAlwaysAllowReadOnly: (value) => setState((prevState) => ({ ...prevState, alwaysAllowReadOnly: value })),
|
||||
setShowAnnouncement: (value) => setState((prevState) => ({ ...prevState, shouldShowAnnouncement: value })),
|
||||
}
|
||||
|
||||
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>
|
||||
}
|
||||
|
||||
export const useExtensionState = () => {
|
||||
const context = useContext(ExtensionStateContext)
|
||||
if (context === undefined) {
|
||||
throw new Error("useExtensionState must be used within an ExtensionStateContextProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
+28
-12
@@ -1,15 +1,3 @@
|
||||
/* body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
|
||||
"Droid Sans", "Helvetica Neue", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
} */
|
||||
|
||||
textarea:focus {
|
||||
outline: 1.5px solid var(--vscode-focusBorder, #007fd4);
|
||||
}
|
||||
@@ -109,3 +97,31 @@ The above scrollbar styling uses some transparent background color magic to acco
|
||||
.code-block-scrollable::-webkit-scrollbar-corner {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/*
|
||||
Dropdown label
|
||||
https://github.com/microsoft/vscode-webview-ui-toolkit/tree/main/src/dropdown#with-label
|
||||
*/
|
||||
.dropdown-container {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.dropdown-container label {
|
||||
display: block;
|
||||
color: var(--vscode-foreground);
|
||||
cursor: pointer;
|
||||
font-size: var(--vscode-font-size);
|
||||
line-height: normal;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* Fix scrollbar in dropdown */
|
||||
|
||||
vscode-dropdown::part(listbox) {
|
||||
border-color: var(--vscode-scrollbarSlider-background);
|
||||
transition: none;
|
||||
scrollbar-color: var(--vscode-scrollbarSlider-background) transparent;
|
||||
}
|
||||
|
||||
-1
@@ -85,6 +85,5 @@ const extensionToLanguage: { [key: string]: string } = {
|
||||
|
||||
export function getLanguageFromPath(path: string): string | undefined {
|
||||
const extension = path.split(".").pop()?.toLowerCase() || ""
|
||||
console.log(`converted path to language: ${path} -> ${extensionToLanguage[extension]}`)
|
||||
return extensionToLanguage[extension]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ClaudeMessage } from "@shared/ExtensionMessage";
|
||||
import { ClaudeMessage } from "../../../src/shared/ExtensionMessage"
|
||||
|
||||
export const mockMessages: ClaudeMessage[] = [
|
||||
{
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ApiConfiguration } from "../../../src/shared/api"
|
||||
|
||||
export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): string | undefined {
|
||||
if (apiConfiguration) {
|
||||
switch (apiConfiguration.apiProvider) {
|
||||
case "anthropic":
|
||||
if (!apiConfiguration.apiKey) {
|
||||
return "You must provide a valid API key or choose a different provider."
|
||||
}
|
||||
break
|
||||
case "bedrock":
|
||||
if (!apiConfiguration.awsAccessKey || !apiConfiguration.awsSecretKey || !apiConfiguration.awsRegion) {
|
||||
return "You must provide a valid AWS access key, secret key, and region."
|
||||
}
|
||||
break
|
||||
case "openrouter":
|
||||
if (!apiConfiguration.openRouterApiKey) {
|
||||
return "You must provide a valid API key or choose a different provider."
|
||||
}
|
||||
break
|
||||
case "vertex":
|
||||
if (!apiConfiguration.vertexProjectId || !apiConfiguration.vertexRegion) {
|
||||
return "You must provide a valid Google Cloud Project ID and Region."
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function validateMaxRequestsPerTask(maxRequestsPerTask?: string): string | undefined {
|
||||
if (maxRequestsPerTask && maxRequestsPerTask.trim()) {
|
||||
const num = Number(maxRequestsPerTask)
|
||||
if (isNaN(num) || num < 3 || num > 100) {
|
||||
return "Maximum requests must be between 3 and 100"
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WebviewMessage } from "@shared/WebviewMessage"
|
||||
import { WebviewMessage } from "../../../src/shared/WebviewMessage"
|
||||
import type { WebviewApi } from "vscode-webview"
|
||||
|
||||
/**
|
||||
@@ -14,10 +14,7 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@shared/*": ["../src/shared/*"]
|
||||
}
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src", "../src/shared"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user