feat: add LexAI status bar and suggestion panel
Some checks failed
CI — Test & Build / Test & Build (push) Has been cancelled
- Implemented a status bar item for LexAI with dynamic status updates (ready, processing, notReady). - Created a suggestion panel for displaying and interacting with AI-generated suggestions. - Added functionality for accepting, regenerating, and discarding suggestions within the suggestion zone. - Introduced configuration options for writing style, prompt patterns, personas, and formats. - Integrated progress indicators for long-running tasks and improved user feedback. - Established TypeScript configuration for the vscode package.
4
packages/vscode/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
out/
|
||||
*.vsix
|
||||
*.tsbuildinfo
|
||||
13
packages/vscode/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Run LexAI Extension",
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
|
||||
"outFiles": ["${workspaceFolder}/out/**/*.js"],
|
||||
"preLaunchTask": "npm: compile"
|
||||
}
|
||||
]
|
||||
}
|
||||
15
packages/vscode/.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "compile",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"problemMatcher": ["$tsc"],
|
||||
"label": "npm: compile"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
packages/vscode/.vscodeignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.vscode/**
|
||||
.vscode-test/**
|
||||
src/**
|
||||
node_modules/**
|
||||
esbuild.mjs
|
||||
tsconfig.json
|
||||
**/*.ts
|
||||
**/*.map
|
||||
.gitignore
|
||||
*.vsix
|
||||
124
packages/vscode/DEPLOY.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# LexAI for VS Code — Install & Deploy
|
||||
|
||||
## Installable package (already built)
|
||||
|
||||
| Item | Value |
|
||||
| --- | --- |
|
||||
| File | `packages/vscode/lexai-vscode-1.0.0.vsix` |
|
||||
| Extension id | `JuanKibin.lexai-vscode` |
|
||||
| Rebuild | from repo root: `npm run vscode:package` |
|
||||
|
||||
---
|
||||
|
||||
## A. Install on your machine (sideload)
|
||||
|
||||
### VS Code
|
||||
|
||||
1. Rebuild if needed: `npm run vscode:package` (repo root).
|
||||
2. Open VS Code → **Extensions** (`Ctrl+Shift+X`).
|
||||
3. `…` menu (top of Extensions) → **Install from VSIX…**
|
||||
4. Pick `packages/vscode/lexai-vscode-1.0.0.vsix`.
|
||||
5. Reload when prompted.
|
||||
6. **LexAI: Open Settings** → set provider + API key.
|
||||
|
||||
### Cursor
|
||||
|
||||
Same flow: **Extensions → … → Install from VSIX…** → select the `.vsix`.
|
||||
|
||||
### CLI (optional)
|
||||
|
||||
```powershell
|
||||
# VS Code
|
||||
code --install-extension packages\vscode\lexai-vscode-1.0.0.vsix
|
||||
|
||||
# Cursor (if `cursor` CLI is on PATH)
|
||||
cursor --install-extension packages\vscode\lexai-vscode-1.0.0.vsix
|
||||
```
|
||||
|
||||
### Share with teammates
|
||||
|
||||
Send them the `.vsix` file (email, Drive, Slack, release artifact). They use **Install from VSIX…** — no Marketplace account required.
|
||||
|
||||
---
|
||||
|
||||
## B. Publish to the Visual Studio Marketplace (public)
|
||||
|
||||
So anyone can install via search: “LexAI”.
|
||||
|
||||
### 1. Create a publisher
|
||||
|
||||
1. Go to [https://marketplace.visualstudio.com/manage](https://marketplace.visualstudio.com/manage)
|
||||
(sign in with a Microsoft account).
|
||||
2. Create a **publisher** whose id matches `package.json` → `"publisher": "JuanKibin"`.
|
||||
- The Marketplace publisher id must be exactly `JuanKibin` (case-sensitive).
|
||||
3. Under the publisher, create a **Personal Access Token** (Azure DevOps):
|
||||
- Organization: all accessible / the one tied to Marketplace
|
||||
- Scopes: **Marketplace → Manage**
|
||||
- Copy the token once.
|
||||
|
||||
### 2. Login & publish
|
||||
|
||||
```powershell
|
||||
cd packages\vscode
|
||||
npx vsce login JuanKibin
|
||||
# paste the PAT when prompted
|
||||
|
||||
npm run package
|
||||
npx vsce publish
|
||||
# or: npx vsce publish patch # bumps 1.0.0 → 1.0.1 and publishes
|
||||
```
|
||||
|
||||
`"private"` is `false` so Marketplace publish is allowed.
|
||||
|
||||
### 3. After publish
|
||||
|
||||
- Listing: `https://marketplace.visualstudio.com/items?itemName=JuanKibin.lexai-vscode`
|
||||
- Users install from Extensions search, or:
|
||||
|
||||
```powershell
|
||||
code --install-extension JuanKibin.lexai-vscode
|
||||
```
|
||||
|
||||
### 4. Version bumps
|
||||
|
||||
Edit `version` in `packages/vscode/package.json` (or use `vsce publish patch|minor|major`), rebuild/package, publish again. Keep README/DEPLOY in sync with the version you ship.
|
||||
|
||||
---
|
||||
|
||||
## C. Optional: Open VSX (Cursor / VSCodium catalogs)
|
||||
|
||||
Some editors prefer [Open VSX](https://open-vsx.org/) instead of (or in addition to) the Microsoft Marketplace.
|
||||
|
||||
1. Create an account at [https://open-vsx.org](https://open-vsx.org).
|
||||
2. Create an access token in your profile.
|
||||
3. Publish:
|
||||
|
||||
```powershell
|
||||
cd packages\vscode
|
||||
npm run package
|
||||
npx ovsx publish lexai-vscode-1.0.0.vsix -p <OPEN_VSX_TOKEN>
|
||||
```
|
||||
|
||||
(`npx ovsx` uses the `ovsx` CLI; install once with `npm i -D ovsx` if you prefer.)
|
||||
|
||||
---
|
||||
|
||||
## D. Checklist before a public release
|
||||
|
||||
- [ ] Manual smoke: Code Assist + one writing action + API key in Secret Storage
|
||||
- [ ] `npm run vscode:typecheck` and `npm run vscode:package` succeed
|
||||
- [ ] Publisher id is yours and matches `package.json`
|
||||
- [ ] `"private": false` for Marketplace
|
||||
- [ ] Add `"repository"` URL in `package.json` when the repo is public (clears the vsce warning)
|
||||
- [ ] Confirm LICENSE is correct for your org
|
||||
|
||||
---
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Goal | Command / action |
|
||||
| --- | --- |
|
||||
| Build `.vsix` | `npm run vscode:package` (repo root) |
|
||||
| Install locally | Extensions → Install from VSIX… |
|
||||
| Publish Marketplace | `npx vsce login JuanKibin` then `npx vsce publish` |
|
||||
| Publish Open VSX | `npx ovsx publish <file.vsix> -p <token>` |
|
||||
21
packages/vscode/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 LexAI contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
102
packages/vscode/README.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# LexAI — AI Writing & Code Assist
|
||||
|
||||
**Bring your own LLM key.** LexAI helps you write and work with code in VS Code / Cursor — no LexAI account, no subscription, no LexAI servers.
|
||||
|
||||
Select text or code → run an action or ask in **Code Assist** → **Accept** / **Regenerate** / **Discard** in the editor.
|
||||
|
||||
---
|
||||
|
||||
## Also available for Chrome
|
||||
|
||||
Use the same LexAI writing assistant on any webpage:
|
||||
|
||||
**[LexAI — AI Writing Assistant (Chrome Web Store)](https://chromewebstore.google.com/detail/bagpcheidbkfgijnnmolnkgagibbjfnk)**
|
||||
|
||||
On the web: select text → Fix / Rephrase / Shorten / Expand / Explain / Make Prompt → Replace or Copy. Same BYO-key idea as this VS Code extension.
|
||||
|
||||
---
|
||||
|
||||
## All features (VS Code / Cursor)
|
||||
|
||||
### Code Assist (workspace-aware)
|
||||
- Select code → **Code Assist** → type what you want in an **inline prompt** above the selection (Copilot-style zone), then **Ask LexAI**.
|
||||
- Examples: explain, refactor, add error handling, rewrite for clarity, review a function.
|
||||
- LexAI gathers **workspace context**: selected code, surrounding lines, import/require targets, and symbol definitions from **other files** (via the language server).
|
||||
- Result opens in the suggestion zone with Accept / Regenerate / Discard.
|
||||
- Available from CodeLens, editor context menu, Command Palette, and the Activity Bar sidebar.
|
||||
|
||||
### Writing actions
|
||||
| Action | What it does |
|
||||
| --- | --- |
|
||||
| **Fix Grammar** | Correct spelling, grammar, and punctuation |
|
||||
| **Rephrase** | Make text clearer / more professional |
|
||||
| **Shorten** | Cut fluff while keeping the message |
|
||||
| **Expand** | Add detail, context, and supporting points |
|
||||
| **Explain** | Break down complex text into plain language |
|
||||
| **Make Prompt** | Turn a rough idea into a structured AI prompt |
|
||||
|
||||
### Prompt Builder (Make Prompt)
|
||||
- Structural **patterns** (auto, zero-shot, role, few-shot, and more)
|
||||
- **Persona** presets, including custom persona text
|
||||
- Preferred **output format**
|
||||
- Optional **model override** for Make Prompt only
|
||||
- Adjust in Settings, or via **inline option chips** in the suggestion zone (click a chip to regenerate)
|
||||
|
||||
### Writing styles
|
||||
Apply to Fix / Rephrase / Shorten / Expand / Explain:
|
||||
|
||||
**Default · Formal · Casual · Academic · Creative · Concise**
|
||||
|
||||
Change in Settings or via option chips in the suggestion zone.
|
||||
|
||||
### Where you can start an action
|
||||
- **CodeLens** — select ≥10 characters; click **LexAI** on the first selected line to expand actions in place (Code Assist + writing actions)
|
||||
- **Editor context menu** — right-click selection → **LexAI** submenu
|
||||
- **Gutter / line-number context** — LexAI submenu when a selection is active
|
||||
- **Command Palette** — `LexAI: Fix Grammar`, Rephrase, Shorten, Expand, Explain, Make Prompt, Code Assist, Show Actions, …
|
||||
- **Activity Bar sidebar** — LexAI icon → Workspace panel: paste or type text, pick an action (including Code Assist), run, **Copy** or reuse as input; **Insert selection** pulls from the editor
|
||||
- **Status bar** — **✓ LexAI** ready · **⟳ LexAI** processing · **⚠ LexAI** not ready (click opens Settings)
|
||||
|
||||
### Suggestion UI
|
||||
- **In-editor zone** (default) — comment widget above the selection with Accept / Regenerate / Discard and option chips
|
||||
- **Side panel** — optional suggestion panel
|
||||
- Setting `lexai.suggestionUi`: `zone` | `panel` | `both`
|
||||
- `lexai.previewBeforeReplace` — preview before replacing the selection (default on)
|
||||
- `lexai.askOptionsBeforeGenerate` — optional Quick Picks for style / Prompt Builder before each run
|
||||
|
||||
### Settings & account-free setup
|
||||
- **LexAI: Open Settings** — branded settings UI for provider, model, writing style, Prompt Builder, and API key
|
||||
- **LexAI: Set API Key** / **Clear API Key**
|
||||
- **LexAI: Show Configuration Status**
|
||||
- Providers: **OpenAI · Anthropic · Groq · OpenRouter**
|
||||
- API key stored in **VS Code Secret Storage** (not plain settings)
|
||||
- Non-secret prefs use `lexai.*` settings
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Install this extension (Marketplace, Open VSX, or **Install from VSIX**).
|
||||
2. Command Palette → **LexAI: Open Settings** → provider, model, API key.
|
||||
3. Select text or code → click **LexAI** on the line → pick an action or **Code Assist**.
|
||||
4. Review in the suggestion zone → Accept to replace the selection (or Copy from the sidebar / panel).
|
||||
|
||||
Install / publish details: [DEPLOY.md](./DEPLOY.md).
|
||||
|
||||
---
|
||||
|
||||
## Privacy
|
||||
|
||||
- No LexAI backend and no LexAI telemetry.
|
||||
- API key lives in the editor’s Secret Storage.
|
||||
- Your text is sent only to the LLM provider you configure.
|
||||
|
||||
---
|
||||
|
||||
## Links
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Chrome extension** | [Chrome Web Store](https://chromewebstore.google.com/detail/bagpcheidbkfgijnnmolnkgagibbjfnk) |
|
||||
| **Source** | [LexAI repository](https://git.juankibin.space/kibin/LexAI) |
|
||||
| **Publisher** | JuanKibin |
|
||||
34
packages/vscode/esbuild.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import * as esbuild from 'esbuild';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const watch = process.argv.includes('--watch');
|
||||
const libRoot = resolve(__dirname, '../../src/lib');
|
||||
|
||||
/** @type {import('esbuild').BuildOptions} */
|
||||
const options = {
|
||||
entryPoints: [resolve(__dirname, 'src/extension.ts')],
|
||||
bundle: true,
|
||||
outfile: resolve(__dirname, 'out/extension.js'),
|
||||
external: ['vscode'],
|
||||
format: 'cjs',
|
||||
platform: 'node',
|
||||
target: 'node22',
|
||||
sourcemap: true,
|
||||
sourcesContent: false,
|
||||
logLevel: 'info',
|
||||
// Share the Chrome extension's portable core without relocating it.
|
||||
alias: {
|
||||
'@lib': libRoot,
|
||||
},
|
||||
};
|
||||
|
||||
if (watch) {
|
||||
const ctx = await esbuild.context(options);
|
||||
await ctx.watch();
|
||||
console.log('[lexai-vscode] watching…');
|
||||
} else {
|
||||
await esbuild.build(options);
|
||||
console.log('[lexai-vscode] compiled → out/extension.js');
|
||||
}
|
||||
BIN
packages/vscode/media/icon-128.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
packages/vscode/media/icon-16.png
Normal file
|
After Width: | Height: | Size: 544 B |
BIN
packages/vscode/media/icon-32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
packages/vscode/media/icon-48.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
27
packages/vscode/media/icon.svg
Normal file
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
|
||||
<!-- Background circle with gradient -->
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#6366f1;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="boltGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#fde68a;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#fbbf24;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background rounded square -->
|
||||
<rect x="4" y="4" width="120" height="120" rx="26" ry="26" fill="url(#bgGrad)" />
|
||||
|
||||
<!-- Subtle inner glow -->
|
||||
<rect x="4" y="4" width="120" height="120" rx="26" ry="26"
|
||||
fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="2"/>
|
||||
|
||||
<!-- Lightning bolt shape -->
|
||||
<polygon points="72,14 38,68 62,68 56,114 90,60 66,60"
|
||||
fill="url(#boltGrad)"
|
||||
stroke="rgba(255,255,255,0.3)"
|
||||
stroke-width="1.5"
|
||||
stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
4
packages/vscode/media/lexai-activity.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none">
|
||||
<!-- Monochrome bolt for VS Code Activity Bar (host recolors fill) -->
|
||||
<path fill="#ffffff" d="M14.2 2.2L7.1 13.2h4.7l-1.2 8.6 7.1-11h-4.7l1.2-8.6z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 258 B |
3
packages/vscode/media/lexai-gutter.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M8 1.5L9.6 5.4L13.8 5.8L10.6 8.6L11.5 12.8L8 10.6L4.5 12.8L5.4 8.6L2.2 5.8L6.4 5.4L8 1.5Z" stroke="#89b4fa" stroke-width="1.2" fill="#89b4fa" fill-opacity="0.25"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 278 B |
4339
packages/vscode/package-lock.json
generated
Normal file
381
packages/vscode/package.json
Normal file
@@ -0,0 +1,381 @@
|
||||
{
|
||||
"name": "lexai-vscode",
|
||||
"displayName": "LexAI — AI Writing & Code Assist",
|
||||
"description": "BYO-LLM writing + workspace-aware Code Assist: Fix, Rephrase, Shorten, Expand, Explain, Make Prompt, inline assist, sidebar, settings. Twin of the LexAI Chrome extension.",
|
||||
"version": "1.0.0",
|
||||
"publisher": "JuanKibin",
|
||||
"license": "MIT",
|
||||
"icon": "media/icon-128.png",
|
||||
"private": false,
|
||||
"homepage": "https://chromewebstore.google.com/detail/bagpcheidbkfgijnnmolnkgagibbjfnk",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.juankibin.space/kibin/LexAI.git"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.85.0",
|
||||
"node": ">=22"
|
||||
},
|
||||
"categories": [
|
||||
"Programming Languages",
|
||||
"Machine Learning",
|
||||
"Other"
|
||||
],
|
||||
"keywords": [
|
||||
"lexai",
|
||||
"ai",
|
||||
"writing",
|
||||
"grammar",
|
||||
"code assist",
|
||||
"refactor",
|
||||
"llm",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"chrome extension",
|
||||
"byok"
|
||||
],
|
||||
"activationEvents": [
|
||||
"onStartupFinished"
|
||||
],
|
||||
"main": "./out/extension.js",
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "lexai.fix",
|
||||
"title": "LexAI: Fix Grammar"
|
||||
},
|
||||
{
|
||||
"command": "lexai.rephrase",
|
||||
"title": "LexAI: Rephrase"
|
||||
},
|
||||
{
|
||||
"command": "lexai.shorten",
|
||||
"title": "LexAI: Shorten"
|
||||
},
|
||||
{
|
||||
"command": "lexai.expand",
|
||||
"title": "LexAI: Expand"
|
||||
},
|
||||
{
|
||||
"command": "lexai.explain",
|
||||
"title": "LexAI: Explain"
|
||||
},
|
||||
{
|
||||
"command": "lexai.prompt",
|
||||
"title": "LexAI: Make Prompt"
|
||||
},
|
||||
{
|
||||
"command": "lexai.codeAssist",
|
||||
"title": "LexAI: Code Assist"
|
||||
},
|
||||
{
|
||||
"command": "lexai.codeAssist.submit",
|
||||
"title": "Ask LexAI",
|
||||
"enablement": "commentController == lexaiAssist && commentThread == lexaiCodeAssistPrompt"
|
||||
},
|
||||
{
|
||||
"command": "lexai.codeAssist.cancel",
|
||||
"title": "Cancel Code Assist",
|
||||
"enablement": "commentController == lexaiAssist && commentThread == lexaiCodeAssistPrompt"
|
||||
},
|
||||
{
|
||||
"command": "lexai.openSidebar",
|
||||
"title": "LexAI: Open Sidebar"
|
||||
},
|
||||
{
|
||||
"command": "lexai.openSettings",
|
||||
"title": "LexAI: Open Settings"
|
||||
},
|
||||
{
|
||||
"command": "lexai.setApiKey",
|
||||
"title": "LexAI: Set API Key"
|
||||
},
|
||||
{
|
||||
"command": "lexai.clearApiKey",
|
||||
"title": "LexAI: Clear API Key"
|
||||
},
|
||||
{
|
||||
"command": "lexai.showStatus",
|
||||
"title": "LexAI: Show Configuration Status"
|
||||
},
|
||||
{
|
||||
"command": "lexai.showSelectionActions",
|
||||
"title": "LexAI: Show Actions"
|
||||
},
|
||||
{
|
||||
"command": "lexai.zone.accept",
|
||||
"title": "LexAI: Accept Suggestion",
|
||||
"enablement": "commentController == lexai"
|
||||
},
|
||||
{
|
||||
"command": "lexai.zone.regenerate",
|
||||
"title": "LexAI: Regenerate Suggestion",
|
||||
"enablement": "commentController == lexai"
|
||||
},
|
||||
{
|
||||
"command": "lexai.zone.discard",
|
||||
"title": "LexAI: Discard Suggestion",
|
||||
"enablement": "commentController == lexai"
|
||||
},
|
||||
{
|
||||
"command": "lexai.zone.openPanel",
|
||||
"title": "LexAI: Open Suggestion Panel",
|
||||
"enablement": "commentController == lexai"
|
||||
}
|
||||
],
|
||||
"submenus": [
|
||||
{
|
||||
"id": "lexai.submenu",
|
||||
"label": "LexAI"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
"editor/context": [
|
||||
{
|
||||
"submenu": "lexai.submenu",
|
||||
"when": "editorHasSelection",
|
||||
"group": "1_modification@9"
|
||||
}
|
||||
],
|
||||
"editor/lineNumber/context": [
|
||||
{
|
||||
"submenu": "lexai.submenu",
|
||||
"when": "lexai.hasSelection",
|
||||
"group": "navigation@1"
|
||||
}
|
||||
],
|
||||
"lexai.submenu": [
|
||||
{
|
||||
"command": "lexai.showSelectionActions",
|
||||
"group": "0_pick@1"
|
||||
},
|
||||
{
|
||||
"command": "lexai.codeAssist",
|
||||
"group": "0_pick@2",
|
||||
"when": "editorHasSelection"
|
||||
},
|
||||
{
|
||||
"command": "lexai.fix",
|
||||
"group": "1_actions@1"
|
||||
},
|
||||
{
|
||||
"command": "lexai.rephrase",
|
||||
"group": "1_actions@2"
|
||||
},
|
||||
{
|
||||
"command": "lexai.shorten",
|
||||
"group": "1_actions@3"
|
||||
},
|
||||
{
|
||||
"command": "lexai.expand",
|
||||
"group": "1_actions@4"
|
||||
},
|
||||
{
|
||||
"command": "lexai.explain",
|
||||
"group": "1_actions@5"
|
||||
},
|
||||
{
|
||||
"command": "lexai.prompt",
|
||||
"group": "1_actions@6"
|
||||
},
|
||||
{
|
||||
"command": "lexai.openSettings",
|
||||
"group": "9_settings@1"
|
||||
}
|
||||
],
|
||||
"comments/commentThread/title": [
|
||||
{
|
||||
"command": "lexai.codeAssist.cancel",
|
||||
"group": "inline@1",
|
||||
"when": "commentController == lexaiAssist && commentThread == lexaiCodeAssistPrompt"
|
||||
},
|
||||
{
|
||||
"command": "lexai.zone.accept",
|
||||
"group": "inline@1",
|
||||
"when": "commentController == lexai && commentThread == lexaiSuggestion"
|
||||
},
|
||||
{
|
||||
"command": "lexai.zone.regenerate",
|
||||
"group": "inline@2",
|
||||
"when": "commentController == lexai && commentThread == lexaiSuggestion"
|
||||
},
|
||||
{
|
||||
"command": "lexai.zone.discard",
|
||||
"group": "inline@3",
|
||||
"when": "commentController == lexai && commentThread == lexaiSuggestion"
|
||||
}
|
||||
],
|
||||
"comments/commentThread/context": [
|
||||
{
|
||||
"command": "lexai.codeAssist.submit",
|
||||
"group": "inline@1",
|
||||
"when": "commentController == lexaiAssist && commentThread == lexaiCodeAssistPrompt"
|
||||
}
|
||||
],
|
||||
"commandPalette": [
|
||||
{
|
||||
"command": "lexai.openSidebar"
|
||||
},
|
||||
{
|
||||
"command": "lexai.openSettings"
|
||||
},
|
||||
{
|
||||
"command": "lexai.codeAssist",
|
||||
"when": "editorHasSelection"
|
||||
},
|
||||
{
|
||||
"command": "lexai.codeAssist.submit",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "lexai.codeAssist.cancel",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "lexai.showSelectionActions",
|
||||
"when": "lexai.hasSelection"
|
||||
},
|
||||
{
|
||||
"command": "lexai.fix"
|
||||
},
|
||||
{
|
||||
"command": "lexai.rephrase"
|
||||
},
|
||||
{
|
||||
"command": "lexai.shorten"
|
||||
},
|
||||
{
|
||||
"command": "lexai.expand"
|
||||
},
|
||||
{
|
||||
"command": "lexai.explain"
|
||||
},
|
||||
{
|
||||
"command": "lexai.prompt"
|
||||
},
|
||||
{
|
||||
"command": "lexai.setApiKey"
|
||||
},
|
||||
{
|
||||
"command": "lexai.clearApiKey"
|
||||
},
|
||||
{
|
||||
"command": "lexai.showStatus"
|
||||
}
|
||||
]
|
||||
},
|
||||
"viewsContainers": {
|
||||
"activitybar": [
|
||||
{
|
||||
"id": "lexai",
|
||||
"title": "LexAI",
|
||||
"icon": "media/lexai-activity.svg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"views": {
|
||||
"lexai": [
|
||||
{
|
||||
"type": "webview",
|
||||
"id": "lexai.sidebar",
|
||||
"name": "Workspace",
|
||||
"contextualTitle": "LexAI"
|
||||
}
|
||||
]
|
||||
},
|
||||
"configuration": {
|
||||
"title": "LexAI",
|
||||
"properties": {
|
||||
"lexai.provider": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"openai",
|
||||
"anthropic",
|
||||
"groq",
|
||||
"openrouter"
|
||||
],
|
||||
"default": "openai",
|
||||
"description": "LLM provider. Prefer LexAI: Open Settings; change the API key when switching providers."
|
||||
},
|
||||
"lexai.model": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Model id for the selected provider. Leave empty to use the provider default."
|
||||
},
|
||||
"lexai.writingStyle": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Default",
|
||||
"Formal",
|
||||
"Casual",
|
||||
"Academic",
|
||||
"Creative",
|
||||
"Concise"
|
||||
],
|
||||
"default": "Default",
|
||||
"description": "Writing style applied to Fix / Rephrase / Shorten / Expand / Explain."
|
||||
},
|
||||
"lexai.promptPattern": {
|
||||
"type": "string",
|
||||
"default": "auto",
|
||||
"description": "Prompt Builder structural pattern id (auto, zero-shot, role, few-shot, …)."
|
||||
},
|
||||
"lexai.promptPersona": {
|
||||
"type": "string",
|
||||
"default": "Auto",
|
||||
"description": "Prompt Builder persona preset."
|
||||
},
|
||||
"lexai.customPersona": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Custom persona text when promptPersona is Custom…"
|
||||
},
|
||||
"lexai.promptFormat": {
|
||||
"type": "string",
|
||||
"default": "Auto",
|
||||
"description": "Prompt Builder preferred output format."
|
||||
},
|
||||
"lexai.promptModel": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Optional model override for Make Prompt only."
|
||||
},
|
||||
"lexai.previewBeforeReplace": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "When true, preview the suggestion before replacing the selection."
|
||||
},
|
||||
"lexai.suggestionUi": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"zone",
|
||||
"panel",
|
||||
"both"
|
||||
],
|
||||
"default": "zone",
|
||||
"description": "Where to show suggestions: in-editor zone (comment widget), side panel, or both."
|
||||
},
|
||||
"lexai.askOptionsBeforeGenerate": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Ask for writing-style / Prompt Builder options in Quick Picks before each generation."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"compile": "node esbuild.mjs",
|
||||
"build": "node esbuild.mjs",
|
||||
"watch": "node esbuild.mjs --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"package": "npm run compile && vsce package --no-dependencies --baseContentUrl https://git.juankibin.space/kibin/LexAI/src/branch/main/packages/vscode --baseImagesUrl https://git.juankibin.space/kibin/LexAI/raw/branch/main/packages/vscode",
|
||||
"vscode:prepublish": "npm run compile"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.4",
|
||||
"@types/vscode": "^1.85.0",
|
||||
"@vscode/vsce": "^3.2.2",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
114
packages/vscode/src/analyze.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import * as vscode from 'vscode';
|
||||
import {
|
||||
ACTION_LABELS,
|
||||
MIN_SELECTION_LENGTH,
|
||||
resolvePromptPersona,
|
||||
type ActionId,
|
||||
} from '@lib/actions';
|
||||
import { resolveConfig } from './config';
|
||||
import { generateSuggestion } from './llm';
|
||||
import { openSuggestionPanel } from './suggestionPanel';
|
||||
import { withLexAIProgress } from './statusBar';
|
||||
import { maybeTuneOptionsBeforeRun, openSuggestionZone } from './suggestionZone';
|
||||
|
||||
export async function runActionOnSelection(
|
||||
context: vscode.ExtensionContext,
|
||||
action: ActionId,
|
||||
): Promise<void> {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) {
|
||||
void vscode.window.showErrorMessage('LexAI: open a text editor and select text first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = editor.selection;
|
||||
if (selection.isEmpty) {
|
||||
void vscode.window.showErrorMessage('LexAI: select some text first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const text = editor.document.getText(selection);
|
||||
if (text.trim().length < MIN_SELECTION_LENGTH) {
|
||||
void vscode.window.showErrorMessage(
|
||||
`LexAI: select at least ${MIN_SELECTION_LENGTH} characters.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = await resolveConfig(context);
|
||||
if (resolved.error || !resolved.config) {
|
||||
void vscode.window.showErrorMessage(`LexAI: ${resolved.error ?? 'configuration error'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const options = await maybeTuneOptionsBeforeRun(action);
|
||||
if (!options) return;
|
||||
|
||||
const preview = vscode.workspace
|
||||
.getConfiguration('lexai')
|
||||
.get<boolean>('previewBeforeReplace', true);
|
||||
const ui = vscode.workspace
|
||||
.getConfiguration('lexai')
|
||||
.get<'zone' | 'panel' | 'both'>('suggestionUi', 'zone');
|
||||
|
||||
const label = ACTION_LABELS[action];
|
||||
const response = await withLexAIProgress(`LexAI: ${label}…`, () =>
|
||||
generateSuggestion({
|
||||
action,
|
||||
text,
|
||||
config: resolved.config!,
|
||||
writingStyle: options.writingStyle,
|
||||
promptParams: {
|
||||
pattern: options.promptPattern,
|
||||
persona: resolvePromptPersona(options.promptPersona, options.customPersona),
|
||||
format: options.promptFormat,
|
||||
},
|
||||
promptModel: options.promptModel || undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
void vscode.window.showErrorMessage(`LexAI: ${response.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = response.result ?? '';
|
||||
if (!result) {
|
||||
void vscode.window.showErrorMessage('LexAI: empty response from provider.');
|
||||
return;
|
||||
}
|
||||
|
||||
const range = new vscode.Range(selection.start, selection.end);
|
||||
const session = {
|
||||
action,
|
||||
documentUri: editor.document.uri,
|
||||
range,
|
||||
originalText: text,
|
||||
suggestion: result,
|
||||
options,
|
||||
};
|
||||
|
||||
if (!preview) {
|
||||
const ok = await editor.edit((editBuilder) => {
|
||||
editBuilder.replace(selection, result);
|
||||
});
|
||||
if (!ok) {
|
||||
void vscode.window.showErrorMessage('LexAI: could not replace the selection (editor busy?).');
|
||||
return;
|
||||
}
|
||||
void vscode.window.showInformationMessage(`LexAI: ${label} applied.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ui === 'panel') {
|
||||
await openSuggestionPanel(context, session);
|
||||
return;
|
||||
}
|
||||
|
||||
await openSuggestionZone(context, session);
|
||||
// Side panel only when explicitly requested — auto-opening it alongside
|
||||
// the in-editor zone was confusing during testing.
|
||||
if (ui === 'both') {
|
||||
await openSuggestionPanel(context, session);
|
||||
}
|
||||
}
|
||||
240
packages/vscode/src/codeAssist.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { MIN_SELECTION_LENGTH } from '@lib/actions';
|
||||
import { callProvider } from '@lib/providers';
|
||||
import { readSettings, resolveConfig } from './config';
|
||||
import { gatherCodeContext } from './codeContext';
|
||||
import type { SuggestionSession } from './session';
|
||||
import { withLexAIProgress } from './statusBar';
|
||||
import { openSuggestionZone } from './suggestionZone';
|
||||
|
||||
const CODE_ASSIST_SYSTEM = [
|
||||
'You are LexAI Code Assist, a senior engineer working inside the user\'s IDE.',
|
||||
'The user highlighted code and described what they want. Follow their instruction precisely.',
|
||||
'You are given the selection plus workspace context (surrounding code, imports, and symbol definitions from other files when available).',
|
||||
'Use that context to resolve references — do not invent APIs that contradict the provided definitions.',
|
||||
'If the instruction asks to change code: return ONLY the replacement code for the selection (no markdown fences, no preamble).',
|
||||
'If the instruction asks to explain, review, or answer a question: return a clear explanation; use short code citations when helpful.',
|
||||
'If context is incomplete, say what is missing instead of guessing.',
|
||||
].join(' ');
|
||||
|
||||
interface PendingCodeAssist {
|
||||
documentUri: vscode.Uri;
|
||||
range: vscode.Range;
|
||||
originalText: string;
|
||||
}
|
||||
|
||||
let promptController: vscode.CommentController | undefined;
|
||||
let promptThread: vscode.CommentThread | undefined;
|
||||
let pending: PendingCodeAssist | undefined;
|
||||
let extContext: vscode.ExtensionContext | undefined;
|
||||
|
||||
export function registerCodeAssistUi(context: vscode.ExtensionContext): void {
|
||||
extContext = context;
|
||||
promptController = vscode.comments.createCommentController('lexaiAssist', 'LexAI Code Assist');
|
||||
promptController.options = {
|
||||
placeHolder: 'Ask LexAI to explain, refactor, or fix the selection…',
|
||||
prompt: 'Code Assist',
|
||||
};
|
||||
// No free-form commenting ranges — we only open our own prompt thread.
|
||||
promptController.commentingRangeProvider = {
|
||||
provideCommentingRanges: () => [],
|
||||
};
|
||||
context.subscriptions.push(promptController);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('lexai.codeAssist.submit', (reply?: vscode.CommentReply) =>
|
||||
void submitPrompt(reply),
|
||||
),
|
||||
vscode.commands.registerCommand('lexai.codeAssist.cancel', () => cancelPrompt()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Code Assist. Without a preset instruction, opens an in-editor prompt
|
||||
* (comment zone + reply textbox) above the selection — not the top InputBox.
|
||||
*/
|
||||
export async function runCodeAssist(
|
||||
context: vscode.ExtensionContext,
|
||||
presetInstruction?: string,
|
||||
): Promise<void> {
|
||||
extContext = context;
|
||||
if (!promptController) registerCodeAssistUi(context);
|
||||
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) {
|
||||
void vscode.window.showErrorMessage('LexAI: open a text editor and select code first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = editor.selection;
|
||||
if (selection.isEmpty) {
|
||||
void vscode.window.showErrorMessage('LexAI: select some code first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedText = editor.document.getText(selection);
|
||||
if (selectedText.trim().length < MIN_SELECTION_LENGTH) {
|
||||
void vscode.window.showErrorMessage(
|
||||
`LexAI: select at least ${MIN_SELECTION_LENGTH} characters.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot before UI focus moves away from the editor.
|
||||
const snap: PendingCodeAssist = {
|
||||
documentUri: editor.document.uri,
|
||||
range: new vscode.Range(selection.start, selection.end),
|
||||
originalText: selectedText,
|
||||
};
|
||||
|
||||
const goal = presetInstruction?.trim() ?? '';
|
||||
if (goal) {
|
||||
await executeCodeAssist(context, snap, goal);
|
||||
return;
|
||||
}
|
||||
|
||||
await openInlinePrompt(snap);
|
||||
}
|
||||
|
||||
async function openInlinePrompt(snap: PendingCodeAssist): Promise<void> {
|
||||
if (!promptController) return;
|
||||
cancelPrompt();
|
||||
pending = snap;
|
||||
|
||||
const doc =
|
||||
vscode.workspace.textDocuments.find((d) => d.uri.toString() === snap.documentUri.toString()) ??
|
||||
(await vscode.workspace.openTextDocument(snap.documentUri));
|
||||
|
||||
const startLine = snap.range.start.line;
|
||||
let anchor: vscode.Range;
|
||||
if (startLine > 0) {
|
||||
const prev = startLine - 1;
|
||||
const col = doc.lineAt(prev).text.length;
|
||||
anchor = new vscode.Range(prev, col, prev, col);
|
||||
} else {
|
||||
anchor = new vscode.Range(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
const md = new vscode.MarkdownString(undefined, true);
|
||||
md.isTrusted = true;
|
||||
md.appendMarkdown('**What should LexAI do with this selection?**\n\n');
|
||||
md.appendMarkdown(
|
||||
'Type in the box below, then click **Ask LexAI** (or use the thread action).\n\n',
|
||||
);
|
||||
md.appendMarkdown(
|
||||
'_Examples: Explain how this uses AuthService · Add error handling · Refactor to async_\n\n',
|
||||
);
|
||||
md.appendMarkdown(`[Cancel](command:lexai.codeAssist.cancel)`);
|
||||
|
||||
const header: vscode.Comment = {
|
||||
body: md,
|
||||
mode: vscode.CommentMode.Preview,
|
||||
author: { name: 'LexAI' },
|
||||
label: 'code assist',
|
||||
};
|
||||
|
||||
promptThread = promptController.createCommentThread(snap.documentUri, anchor, [header]);
|
||||
promptThread.label = 'LexAI Code Assist';
|
||||
promptThread.contextValue = 'lexaiCodeAssistPrompt';
|
||||
promptThread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded;
|
||||
promptThread.canReply = true;
|
||||
|
||||
// Best-effort: put keyboard focus in the comment reply box (Copilot-like).
|
||||
setTimeout(() => {
|
||||
void vscode.commands.executeCommand('workbench.action.focusCommentsInput');
|
||||
}, 50);
|
||||
}
|
||||
|
||||
async function submitPrompt(reply?: vscode.CommentReply): Promise<void> {
|
||||
const text = (reply?.text ?? '').trim();
|
||||
const snap = pending;
|
||||
if (!snap) {
|
||||
void vscode.window.showErrorMessage('LexAI: no Code Assist selection — select code and try again.');
|
||||
cancelPrompt();
|
||||
return;
|
||||
}
|
||||
if (text.length < 3) {
|
||||
void vscode.window.showErrorMessage('LexAI: describe what you want (a few words is enough).');
|
||||
return;
|
||||
}
|
||||
|
||||
cancelPrompt();
|
||||
const ctx = extContext;
|
||||
if (!ctx) return;
|
||||
await executeCodeAssist(ctx, snap, text);
|
||||
}
|
||||
|
||||
function cancelPrompt(): void {
|
||||
promptThread?.dispose();
|
||||
promptThread = undefined;
|
||||
pending = undefined;
|
||||
}
|
||||
|
||||
async function executeCodeAssist(
|
||||
context: vscode.ExtensionContext,
|
||||
snap: PendingCodeAssist,
|
||||
goal: string,
|
||||
): Promise<void> {
|
||||
const resolved = await resolveConfig(context);
|
||||
if (resolved.error || !resolved.config) {
|
||||
void vscode.window.showErrorMessage(`LexAI: ${resolved.error ?? 'configuration error'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const doc =
|
||||
vscode.workspace.textDocuments.find((d) => d.uri.toString() === snap.documentUri.toString()) ??
|
||||
(await vscode.workspace.openTextDocument(snap.documentUri));
|
||||
|
||||
const bundle = await withLexAIProgress('LexAI: gathering workspace context…', () =>
|
||||
gatherCodeContext(doc, snap.range),
|
||||
);
|
||||
|
||||
const userPayload = [
|
||||
`### User instruction`,
|
||||
goal,
|
||||
'',
|
||||
bundle.contextText,
|
||||
].join('\n');
|
||||
|
||||
const response = await withLexAIProgress('LexAI: Code Assist…', () =>
|
||||
callProvider(resolved.config!, userPayload, CODE_ASSIST_SYSTEM, {
|
||||
maxTokens: Math.max(2048, Math.min(8192, Math.ceil(snap.originalText.length + 1500))),
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.error || !response.result) {
|
||||
void vscode.window.showErrorMessage(
|
||||
`LexAI: ${response.error ?? 'Empty response from provider.'}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = readSettings();
|
||||
const session: SuggestionSession = {
|
||||
action: 'codeAssist',
|
||||
documentUri: snap.documentUri,
|
||||
range: snap.range,
|
||||
originalText: snap.originalText,
|
||||
suggestion: stripCodeFences(response.result),
|
||||
options: {
|
||||
writingStyle: settings.writingStyle,
|
||||
promptPattern: settings.promptPattern,
|
||||
promptPersona: settings.promptPersona,
|
||||
customPersona: settings.customPersona,
|
||||
promptFormat: settings.promptFormat,
|
||||
promptModel: settings.promptModel,
|
||||
},
|
||||
instruction: goal,
|
||||
contextSummary: bundle.summary,
|
||||
contextText: bundle.contextText,
|
||||
};
|
||||
|
||||
void vscode.window.showInformationMessage(`LexAI Code Assist: ${bundle.summary}`);
|
||||
await openSuggestionZone(context, session);
|
||||
}
|
||||
|
||||
function stripCodeFences(text: string): string {
|
||||
const t = text.replace(/\r\n/g, '\n').trim();
|
||||
const m = t.match(/^```(?:[\w.+-]+)?\n([\s\S]*?)\n```$/);
|
||||
return m ? m[1] : t;
|
||||
}
|
||||
304
packages/vscode/src/codeContext.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const MAX_DEFS = 8;
|
||||
const MAX_IMPORT_FILES = 6;
|
||||
const MAX_SNIPPET_CHARS = 2400;
|
||||
const MAX_TOTAL_CONTEXT_CHARS = 14000;
|
||||
const MAX_IDENTIFIERS = 24;
|
||||
const SURROUND_LINES = 40;
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break', 'return', 'const', 'let', 'var',
|
||||
'function', 'class', 'interface', 'type', 'enum', 'import', 'export', 'from', 'default', 'async',
|
||||
'await', 'try', 'catch', 'finally', 'throw', 'new', 'this', 'super', 'typeof', 'instanceof',
|
||||
'true', 'false', 'null', 'undefined', 'void', 'in', 'of', 'as', 'is', 'public', 'private',
|
||||
'protected', 'static', 'readonly', 'extends', 'implements', 'package', 'yield', 'with',
|
||||
'string', 'number', 'boolean', 'any', 'unknown', 'never', 'object', 'Record', 'Partial',
|
||||
'Promise', 'Array', 'Map', 'Set', 'Error', 'console', 'window', 'document', 'module',
|
||||
'require', 'exports', 'process', 'Buffer', 'self', 'global', 'Math', 'JSON', 'Date',
|
||||
]);
|
||||
|
||||
export interface CodeContextBundle {
|
||||
/** Human-readable pack for the model user message */
|
||||
contextText: string;
|
||||
/** Short summary for UI status */
|
||||
summary: string;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build workspace-aware context for a selection: surrounding code, import
|
||||
* targets, and definition-provider hits for identifiers in the selection.
|
||||
*/
|
||||
export async function gatherCodeContext(
|
||||
document: vscode.TextDocument,
|
||||
selection: vscode.Range,
|
||||
): Promise<CodeContextBundle> {
|
||||
const selected = document.getText(selection);
|
||||
const wsFolder = vscode.workspace.getWorkspaceFolder(document.uri);
|
||||
const rel = (uri: vscode.Uri) =>
|
||||
wsFolder ? path.relative(wsFolder.uri.fsPath, uri.fsPath).replace(/\\/g, '/') : uri.fsPath;
|
||||
|
||||
const parts: string[] = [];
|
||||
const files = new Set<string>();
|
||||
let budget = MAX_TOTAL_CONTEXT_CHARS;
|
||||
|
||||
const push = (block: string) => {
|
||||
if (budget <= 0) return;
|
||||
const slice = block.length > budget ? block.slice(0, budget) + '\n…[truncated]' : block;
|
||||
parts.push(slice);
|
||||
budget -= slice.length;
|
||||
};
|
||||
|
||||
const currentPath = rel(document.uri);
|
||||
files.add(currentPath);
|
||||
|
||||
push(
|
||||
[
|
||||
'### Current file',
|
||||
`Path: ${currentPath}`,
|
||||
`Language: ${document.languageId}`,
|
||||
`Selection lines: ${selection.start.line + 1}–${selection.end.line + 1}`,
|
||||
'',
|
||||
'### Selected code',
|
||||
fence(document.languageId, selected),
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const surroundStart = Math.max(0, selection.start.line - SURROUND_LINES);
|
||||
const surroundEnd = Math.min(document.lineCount - 1, selection.end.line + SURROUND_LINES);
|
||||
const surround = document.getText(new vscode.Range(surroundStart, 0, surroundEnd, document.lineAt(surroundEnd).text.length));
|
||||
push(
|
||||
[
|
||||
'',
|
||||
`### Surrounding code in ${currentPath} (lines ${surroundStart + 1}–${surroundEnd + 1})`,
|
||||
fence(document.languageId, surround),
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
// Import / require targets in the current file
|
||||
const importUris = await resolveImportUris(document);
|
||||
let importCount = 0;
|
||||
for (const uri of importUris) {
|
||||
if (importCount >= MAX_IMPORT_FILES || budget <= 0) break;
|
||||
if (uri.toString() === document.uri.toString()) continue;
|
||||
try {
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
const excerpt = excerptForSelection(doc, selected);
|
||||
const p = rel(uri);
|
||||
files.add(p);
|
||||
push(['', `### Imported module: ${p}`, fence(doc.languageId, excerpt)].join('\n'));
|
||||
importCount += 1;
|
||||
} catch {
|
||||
// ignore unresolved / binary
|
||||
}
|
||||
}
|
||||
|
||||
// Definition provider for identifiers in the selection
|
||||
const idents = extractIdentifiers(selected);
|
||||
let defCount = 0;
|
||||
const seenDefKeys = new Set<string>();
|
||||
|
||||
for (const ident of idents) {
|
||||
if (defCount >= MAX_DEFS || budget <= 0) break;
|
||||
const pos = findIdentifierPosition(document, selection, ident);
|
||||
if (!pos) continue;
|
||||
|
||||
let locs: vscode.Location[] = [];
|
||||
try {
|
||||
const raw = await vscode.commands.executeCommand<
|
||||
vscode.Location | vscode.Location[] | vscode.LocationLink[] | undefined
|
||||
>('vscode.executeDefinitionProvider', document.uri, pos);
|
||||
locs = normalizeLocations(raw);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const loc of locs) {
|
||||
if (defCount >= MAX_DEFS || budget <= 0) break;
|
||||
const key = `${loc.uri.toString()}:${loc.range.start.line}:${loc.range.start.character}`;
|
||||
if (seenDefKeys.has(key)) continue;
|
||||
seenDefKeys.add(key);
|
||||
if (loc.uri.toString() === document.uri.toString() && selection.contains(loc.range.start)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const doc = await vscode.workspace.openTextDocument(loc.uri);
|
||||
const snippet = expandDefinitionSnippet(doc, loc.range);
|
||||
const p = rel(loc.uri);
|
||||
files.add(p);
|
||||
push(
|
||||
[
|
||||
'',
|
||||
`### Definition of \`${ident}\` — ${p}:${loc.range.start.line + 1}`,
|
||||
fence(doc.languageId, snippet),
|
||||
].join('\n'),
|
||||
);
|
||||
defCount += 1;
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const summary =
|
||||
defCount || importCount
|
||||
? `Included ${defCount} definition(s) and ${importCount} import file(s) from the workspace.`
|
||||
: 'No extra workspace definitions found (language support may be unavailable for this file).';
|
||||
|
||||
return {
|
||||
contextText: parts.join('\n'),
|
||||
summary,
|
||||
files: [...files],
|
||||
};
|
||||
}
|
||||
|
||||
function fence(lang: string, body: string): string {
|
||||
const safe = body.replace(/\r\n/g, '\n');
|
||||
const clipped =
|
||||
safe.length > MAX_SNIPPET_CHARS ? safe.slice(0, MAX_SNIPPET_CHARS) + '\n…[truncated]' : safe;
|
||||
return '```' + (lang || '') + '\n' + clipped + '\n```';
|
||||
}
|
||||
|
||||
function extractIdentifiers(text: string): string[] {
|
||||
const matches = text.match(/\b[_A-Za-z][_A-Za-z0-9]*\b/g) ?? [];
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const m of matches) {
|
||||
if (STOP_WORDS.has(m) || m.length < 2) continue;
|
||||
if (seen.has(m)) continue;
|
||||
seen.add(m);
|
||||
out.push(m);
|
||||
if (out.length >= MAX_IDENTIFIERS) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function findIdentifierPosition(
|
||||
document: vscode.TextDocument,
|
||||
selection: vscode.Range,
|
||||
ident: string,
|
||||
): vscode.Position | undefined {
|
||||
const text = document.getText(selection);
|
||||
const idx = text.indexOf(ident);
|
||||
if (idx < 0) return undefined;
|
||||
const startOffset = document.offsetAt(selection.start) + idx;
|
||||
return document.positionAt(startOffset);
|
||||
}
|
||||
|
||||
function normalizeLocations(
|
||||
raw: vscode.Location | vscode.Location[] | vscode.LocationLink[] | undefined,
|
||||
): vscode.Location[] {
|
||||
if (!raw) return [];
|
||||
const arr = Array.isArray(raw) ? raw : [raw];
|
||||
return arr.map((item) => {
|
||||
if (item instanceof vscode.Location) return item;
|
||||
const link = item as vscode.LocationLink;
|
||||
return new vscode.Location(link.targetUri, link.targetSelectionRange ?? link.targetRange);
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveImportUris(document: vscode.TextDocument): Promise<vscode.Uri[]> {
|
||||
const text = document.getText();
|
||||
const specs = new Set<string>();
|
||||
|
||||
// ES / TS imports
|
||||
const reFrom = /from\s+['"]([^'"]+)['"]/g;
|
||||
const reImport = /import\s+['"]([^'"]+)['"]/g;
|
||||
const reRequire = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||
for (const re of [reFrom, reImport, reRequire]) {
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text))) {
|
||||
const spec = m[1];
|
||||
if (spec.startsWith('.') || spec.startsWith('/')) specs.add(spec);
|
||||
}
|
||||
}
|
||||
|
||||
const uris: vscode.Uri[] = [];
|
||||
for (const spec of specs) {
|
||||
const resolved = await resolveRelativeModule(document.uri, spec);
|
||||
if (resolved) uris.push(resolved);
|
||||
}
|
||||
return uris;
|
||||
}
|
||||
|
||||
async function resolveRelativeModule(
|
||||
from: vscode.Uri,
|
||||
spec: string,
|
||||
): Promise<vscode.Uri | undefined> {
|
||||
const baseDir = path.posix.dirname(from.path);
|
||||
const joined = path.posix.normalize(path.posix.join(baseDir, spec));
|
||||
const candidates = [
|
||||
joined,
|
||||
`${joined}.ts`,
|
||||
`${joined}.tsx`,
|
||||
`${joined}.js`,
|
||||
`${joined}.jsx`,
|
||||
`${joined}.mjs`,
|
||||
`${joined}.cjs`,
|
||||
`${joined}.json`,
|
||||
`${joined}/index.ts`,
|
||||
`${joined}/index.tsx`,
|
||||
`${joined}/index.js`,
|
||||
];
|
||||
|
||||
for (const p of candidates) {
|
||||
const uri = from.with({ path: p });
|
||||
try {
|
||||
await vscode.workspace.fs.stat(uri);
|
||||
return uri;
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Prefer exporting / matching snippets from an imported file. */
|
||||
function excerptForSelection(doc: vscode.TextDocument, selected: string): string {
|
||||
const idents = extractIdentifiers(selected);
|
||||
const full = doc.getText();
|
||||
if (!idents.length) {
|
||||
return full.slice(0, MAX_SNIPPET_CHARS);
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
for (const id of idents.slice(0, 10)) {
|
||||
const patterns = [
|
||||
new RegExp(
|
||||
`(?:export\\s+)?(?:async\\s+)?function\\s+${id}\\b[\\s\\S]{0,800}?\\n\\}`,
|
||||
'm',
|
||||
),
|
||||
new RegExp(
|
||||
`(?:export\\s+)?(?:const|let|var|class|interface|type|enum)\\s+${id}\\b[\\s\\S]{0,600}`,
|
||||
'm',
|
||||
),
|
||||
];
|
||||
for (const re of patterns) {
|
||||
const m = full.match(re);
|
||||
if (m) {
|
||||
chunks.push(m[0]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!chunks.length) {
|
||||
// Fall back to first N lines (often exports barrel / header)
|
||||
return full.split('\n').slice(0, 80).join('\n');
|
||||
}
|
||||
return chunks.join('\n\n');
|
||||
}
|
||||
|
||||
function expandDefinitionSnippet(doc: vscode.TextDocument, range: vscode.Range): string {
|
||||
const start = Math.max(0, range.start.line - 2);
|
||||
let end = Math.min(doc.lineCount - 1, range.end.line);
|
||||
const maxEnd = Math.min(doc.lineCount - 1, range.start.line + 80);
|
||||
while (end < maxEnd) {
|
||||
const line = doc.lineAt(end).text;
|
||||
if (line.includes('}') || line.includes(';')) break;
|
||||
end += 1;
|
||||
}
|
||||
return doc.getText(new vscode.Range(start, 0, end, doc.lineAt(end).text.length));
|
||||
}
|
||||
160
packages/vscode/src/config.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import * as vscode from 'vscode';
|
||||
import type { LexAIConfig, PromptParams } from '@lib/types';
|
||||
import { PROVIDER_SPECS, providerLabel } from '@lib/providers';
|
||||
import type { WritingStyle } from '@lib/actions';
|
||||
import {
|
||||
PROMPT_FORMATS,
|
||||
PROMPT_PATTERNS,
|
||||
PROMPT_PERSONAS,
|
||||
WRITING_STYLES,
|
||||
resolvePromptPattern,
|
||||
resolvePromptPersona,
|
||||
} from '@lib/actions';
|
||||
|
||||
export const SECRET_API_KEY = 'lexai.apiKey';
|
||||
export const GLOBAL_KEY_PROVIDER = 'lexai.keyProvider';
|
||||
|
||||
export interface VsCodeLexAISettings {
|
||||
provider: string;
|
||||
model: string;
|
||||
writingStyle: WritingStyle;
|
||||
promptPattern: string;
|
||||
promptPersona: string;
|
||||
customPersona: string;
|
||||
promptFormat: string;
|
||||
promptModel: string;
|
||||
}
|
||||
|
||||
function pickEnum<T extends string>(value: string | undefined, allowed: readonly T[], fallback: T): T {
|
||||
return value && (allowed as readonly string[]).includes(value) ? (value as T) : fallback;
|
||||
}
|
||||
|
||||
export function readSettings(): VsCodeLexAISettings {
|
||||
const cfg = vscode.workspace.getConfiguration('lexai');
|
||||
const provider = cfg.get<string>('provider') || 'openai';
|
||||
const model = (cfg.get<string>('model') || '').trim();
|
||||
const writingStyle = pickEnum(cfg.get<string>('writingStyle'), WRITING_STYLES, 'Default');
|
||||
const promptPattern = resolvePromptPattern(cfg.get<string>('promptPattern') || undefined);
|
||||
const promptPersona = pickEnum(
|
||||
cfg.get<string>('promptPersona'),
|
||||
PROMPT_PERSONAS,
|
||||
'Auto',
|
||||
);
|
||||
const customPersona = (cfg.get<string>('customPersona') || '').trim();
|
||||
const promptFormat = pickEnum(cfg.get<string>('promptFormat'), PROMPT_FORMATS, 'Auto');
|
||||
const promptModel = (cfg.get<string>('promptModel') || '').trim();
|
||||
return {
|
||||
provider,
|
||||
model,
|
||||
writingStyle,
|
||||
promptPattern,
|
||||
promptPersona,
|
||||
customPersona,
|
||||
promptFormat,
|
||||
promptModel,
|
||||
};
|
||||
}
|
||||
|
||||
export function readPromptParams(): PromptParams {
|
||||
const s = readSettings();
|
||||
return {
|
||||
pattern: s.promptPattern,
|
||||
persona: resolvePromptPersona(s.promptPersona, s.customPersona),
|
||||
format: s.promptFormat,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateSettings(
|
||||
patch: Partial<{
|
||||
provider: string;
|
||||
model: string;
|
||||
writingStyle: string;
|
||||
promptPattern: string;
|
||||
promptPersona: string;
|
||||
customPersona: string;
|
||||
promptFormat: string;
|
||||
promptModel: string;
|
||||
}>,
|
||||
): Promise<void> {
|
||||
const cfg = vscode.workspace.getConfiguration('lexai');
|
||||
const target = vscode.ConfigurationTarget.Global;
|
||||
const entries = Object.entries(patch) as [keyof typeof patch, string | undefined][];
|
||||
for (const [key, value] of entries) {
|
||||
if (value === undefined) continue;
|
||||
await cfg.update(key, value, target);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveConfig(
|
||||
context: vscode.ExtensionContext,
|
||||
): Promise<{ config?: LexAIConfig; error?: string }> {
|
||||
const settings = readSettings();
|
||||
const apiKey = (await context.secrets.get(SECRET_API_KEY))?.trim();
|
||||
if (!apiKey) {
|
||||
return {
|
||||
error:
|
||||
'No API key configured. Run “LexAI: Open Settings” or “LexAI: Set API Key”, then try again.',
|
||||
};
|
||||
}
|
||||
|
||||
const keyProvider = context.globalState.get<string>(GLOBAL_KEY_PROVIDER);
|
||||
if (keyProvider && keyProvider !== settings.provider) {
|
||||
return {
|
||||
error:
|
||||
`Your saved API key was entered for ${providerLabel(keyProvider)}, but the selected provider is ${providerLabel(settings.provider)}. ` +
|
||||
`Open LexAI Settings and enter a ${providerLabel(settings.provider)} key (or change the provider).`,
|
||||
};
|
||||
}
|
||||
|
||||
const spec = PROVIDER_SPECS[settings.provider];
|
||||
if (!spec) {
|
||||
return { error: `Unknown provider: "${settings.provider}". Open LexAI Settings.` };
|
||||
}
|
||||
|
||||
return {
|
||||
config: {
|
||||
provider: settings.provider,
|
||||
apiKey,
|
||||
model: settings.model || spec.defaultModel,
|
||||
keyProvider: keyProvider || settings.provider,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function storeApiKey(
|
||||
context: vscode.ExtensionContext,
|
||||
apiKey: string,
|
||||
provider: string,
|
||||
): Promise<void> {
|
||||
await context.secrets.store(SECRET_API_KEY, apiKey.trim());
|
||||
await context.globalState.update(GLOBAL_KEY_PROVIDER, provider);
|
||||
}
|
||||
|
||||
export async function clearApiKey(context: vscode.ExtensionContext): Promise<void> {
|
||||
await context.secrets.delete(SECRET_API_KEY);
|
||||
await context.globalState.update(GLOBAL_KEY_PROVIDER, undefined);
|
||||
}
|
||||
|
||||
export async function hasApiKey(context: vscode.ExtensionContext): Promise<boolean> {
|
||||
const key = await context.secrets.get(SECRET_API_KEY);
|
||||
return Boolean(key && key.trim());
|
||||
}
|
||||
|
||||
export function settingsCatalog() {
|
||||
return {
|
||||
providers: Object.keys(PROVIDER_SPECS).map((id) => ({
|
||||
id,
|
||||
label: providerLabel(id),
|
||||
defaultModel: PROVIDER_SPECS[id].defaultModel,
|
||||
})),
|
||||
writingStyles: [...WRITING_STYLES],
|
||||
promptPatterns: PROMPT_PATTERNS.map((p) => ({
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
group: p.group,
|
||||
hint: p.hint,
|
||||
})),
|
||||
promptPersonas: [...PROMPT_PERSONAS],
|
||||
promptFormats: [...PROMPT_FORMATS],
|
||||
};
|
||||
}
|
||||
89
packages/vscode/src/extension.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { ACTIONS, ACTION_LABELS, type ActionId } from '@lib/actions';
|
||||
import { PROVIDER_SPECS, providerLabel } from '@lib/providers';
|
||||
import { clearApiKey, hasApiKey, readSettings } from './config';
|
||||
import { runActionOnSelection } from './analyze';
|
||||
import { registerCodeAssistUi, runCodeAssist } from './codeAssist';
|
||||
import {
|
||||
refreshSelectionAffordance,
|
||||
registerSelectionAffordance,
|
||||
} from './selectionAffordance';
|
||||
import { registerSidebarView } from './sidebarView';
|
||||
import { openSettingsPanel } from './settingsPanel';
|
||||
import { refreshStatusBar, registerStatusBar } from './statusBar';
|
||||
import { registerSuggestionZone } from './suggestionZone';
|
||||
|
||||
export function activate(context: vscode.ExtensionContext): void {
|
||||
registerStatusBar(context);
|
||||
registerSidebarView(context);
|
||||
registerSuggestionZone(context);
|
||||
registerCodeAssistUi(context);
|
||||
registerSelectionAffordance(context, async (action) => {
|
||||
if (action === 'codeAssist') {
|
||||
await runCodeAssist(context);
|
||||
return;
|
||||
}
|
||||
await runActionOnSelection(context, action);
|
||||
});
|
||||
|
||||
// Extension Host sometimes mounts CodeLens after the first selection event;
|
||||
// nudge a refresh so the LexAI lens appears without opening Settings first.
|
||||
setTimeout(() => refreshSelectionAffordance(), 0);
|
||||
setTimeout(() => refreshSelectionAffordance(), 300);
|
||||
|
||||
for (const action of ACTIONS) {
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(`lexai.${action}`, () =>
|
||||
runActionOnSelection(context, action as ActionId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('lexai.codeAssist', () => runCodeAssist(context)),
|
||||
vscode.commands.registerCommand('lexai.openSettings', async () => {
|
||||
openSettingsPanel(context);
|
||||
await refreshStatusBar();
|
||||
}),
|
||||
vscode.commands.registerCommand('lexai.setApiKey', async () => {
|
||||
openSettingsPanel(context);
|
||||
await refreshStatusBar();
|
||||
}),
|
||||
vscode.commands.registerCommand('lexai.clearApiKey', () => clearApiKeyCommand(context)),
|
||||
vscode.commands.registerCommand('lexai.showStatus', () => showStatusCommand(context)),
|
||||
);
|
||||
}
|
||||
|
||||
export function deactivate(): void {
|
||||
// nothing to tear down
|
||||
}
|
||||
|
||||
async function clearApiKeyCommand(context: vscode.ExtensionContext): Promise<void> {
|
||||
const confirm = await vscode.window.showWarningMessage(
|
||||
'Clear the stored LexAI API key from Secret Storage?',
|
||||
{ modal: true },
|
||||
'Clear',
|
||||
);
|
||||
if (confirm !== 'Clear') return;
|
||||
await clearApiKey(context);
|
||||
await refreshStatusBar();
|
||||
void vscode.window.showInformationMessage('LexAI: API key cleared.');
|
||||
}
|
||||
|
||||
async function showStatusCommand(context: vscode.ExtensionContext): Promise<void> {
|
||||
const settings = readSettings();
|
||||
const keyed = await hasApiKey(context);
|
||||
const spec = PROVIDER_SPECS[settings.provider];
|
||||
const model = settings.model || spec?.defaultModel || '(none)';
|
||||
const lines = [
|
||||
`Provider: ${providerLabel(settings.provider)} (${settings.provider})`,
|
||||
`Model: ${model}`,
|
||||
`Writing style: ${settings.writingStyle}`,
|
||||
`Prompt: ${settings.promptPattern} / ${settings.promptPersona} / ${settings.promptFormat}`,
|
||||
`API key: ${keyed ? 'set (Secret Storage)' : 'not set'}`,
|
||||
'',
|
||||
'Select code → LexAI → Code Assist for workspace-aware help.',
|
||||
'Writing actions: ' + ACTIONS.map((a) => ACTION_LABELS[a]).join(', '),
|
||||
];
|
||||
void vscode.window.showInformationMessage(lines.join(' · '), { modal: true });
|
||||
}
|
||||
31
packages/vscode/src/llm.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { ActionId } from '@lib/actions';
|
||||
import { callProvider, defaultMaxTokens, getSystemPrompt } from '@lib/providers';
|
||||
import type { LexAIConfig, PromptParams } from '@lib/types';
|
||||
import type { WritingStyle } from '@lib/actions';
|
||||
|
||||
export interface GenerateOptions {
|
||||
action: ActionId;
|
||||
text: string;
|
||||
config: LexAIConfig;
|
||||
writingStyle?: WritingStyle;
|
||||
promptParams?: PromptParams;
|
||||
promptModel?: string;
|
||||
}
|
||||
|
||||
export async function generateSuggestion(opts: GenerateOptions): Promise<{
|
||||
result?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
const style = opts.action === 'prompt' ? undefined : opts.writingStyle;
|
||||
const promptParams = opts.action === 'prompt' ? opts.promptParams : undefined;
|
||||
const systemPrompt = getSystemPrompt(opts.action, style, promptParams);
|
||||
const config =
|
||||
opts.action === 'prompt' && opts.promptModel
|
||||
? { ...opts.config, model: opts.promptModel }
|
||||
: opts.config;
|
||||
const callOpts =
|
||||
opts.action === 'prompt'
|
||||
? { maxTokens: Math.max(2048, defaultMaxTokens(opts.text)) }
|
||||
: undefined;
|
||||
return callProvider(config, opts.text, systemPrompt, callOpts);
|
||||
}
|
||||
143
packages/vscode/src/selectionAffordance.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { ACTIONS, ACTION_LABELS, MIN_SELECTION_LENGTH, type ActionId } from '@lib/actions';
|
||||
import type { LexAIRunAction } from './session';
|
||||
|
||||
let refreshSelection: (() => void) | undefined;
|
||||
|
||||
/** Force CodeLens refresh (e.g. right after activation). */
|
||||
export function refreshSelectionAffordance(): void {
|
||||
refreshSelection?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection affordance: CodeLens on the first selected line.
|
||||
* Click LexAI → expands into action lenses *on that same line*.
|
||||
*/
|
||||
export function registerSelectionAffordance(
|
||||
context: vscode.ExtensionContext,
|
||||
onPickAction: (action: LexAIRunAction) => void | Promise<void>,
|
||||
): void {
|
||||
const codeLensEmitter = new vscode.EventEmitter<void>();
|
||||
let active: { uri: vscode.Uri; line: number; selected: boolean } | undefined;
|
||||
let menuOpen = false;
|
||||
|
||||
const collapseMenu = () => {
|
||||
if (!menuOpen) return;
|
||||
menuOpen = false;
|
||||
codeLensEmitter.fire();
|
||||
};
|
||||
|
||||
const refresh = (editor: vscode.TextEditor | undefined = vscode.window.activeTextEditor) => {
|
||||
if (!editor) {
|
||||
active = undefined;
|
||||
menuOpen = false;
|
||||
void vscode.commands.executeCommand('setContext', 'lexai.hasSelection', false);
|
||||
codeLensEmitter.fire();
|
||||
return;
|
||||
}
|
||||
|
||||
const sel = editor.selection;
|
||||
const text = editor.document.getText(sel);
|
||||
const selected = !sel.isEmpty && text.trim().length >= MIN_SELECTION_LENGTH;
|
||||
|
||||
void vscode.commands.executeCommand('setContext', 'lexai.hasSelection', selected);
|
||||
|
||||
if (!selected) {
|
||||
active = undefined;
|
||||
menuOpen = false;
|
||||
codeLensEmitter.fire();
|
||||
return;
|
||||
}
|
||||
|
||||
const line = sel.start.line;
|
||||
if (active && (active.uri.toString() !== editor.document.uri.toString() || active.line !== line)) {
|
||||
menuOpen = false;
|
||||
}
|
||||
active = { uri: editor.document.uri, line, selected: true };
|
||||
codeLensEmitter.fire();
|
||||
};
|
||||
|
||||
refreshSelection = () => refresh(vscode.window.activeTextEditor);
|
||||
|
||||
context.subscriptions.push(
|
||||
codeLensEmitter,
|
||||
{ dispose: () => { refreshSelection = undefined; } },
|
||||
vscode.window.onDidChangeActiveTextEditor((e) => refresh(e)),
|
||||
vscode.window.onDidChangeTextEditorSelection((e) => {
|
||||
if (e.textEditor === vscode.window.activeTextEditor) refresh(e.textEditor);
|
||||
}),
|
||||
vscode.workspace.onDidChangeTextDocument((e) => {
|
||||
const ed = vscode.window.activeTextEditor;
|
||||
if (ed && e.document === ed.document) refresh(ed);
|
||||
}),
|
||||
vscode.languages.registerCodeLensProvider({ scheme: '*' }, {
|
||||
onDidChangeCodeLenses: codeLensEmitter.event,
|
||||
provideCodeLenses(document) {
|
||||
if (!active?.selected || active.uri.toString() !== document.uri.toString()) {
|
||||
return [];
|
||||
}
|
||||
const range = new vscode.Range(active.line, 0, active.line, 0);
|
||||
|
||||
if (!menuOpen) {
|
||||
return [
|
||||
new vscode.CodeLens(range, {
|
||||
title: '$(sparkle) LexAI',
|
||||
tooltip: 'Show LexAI actions here',
|
||||
command: 'lexai.toggleSelectionMenu',
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
new vscode.CodeLens(range, {
|
||||
title: '$(sparkle) LexAI',
|
||||
tooltip: 'Collapse',
|
||||
command: 'lexai.toggleSelectionMenu',
|
||||
}),
|
||||
new vscode.CodeLens(range, {
|
||||
title: '$(code) Code Assist',
|
||||
tooltip: 'Describe what to do with the selection (workspace-aware)',
|
||||
command: 'lexai.runSelectionAction',
|
||||
arguments: ['codeAssist'],
|
||||
}),
|
||||
...ACTIONS.map(
|
||||
(id) =>
|
||||
new vscode.CodeLens(range, {
|
||||
title: ACTION_LABELS[id],
|
||||
tooltip: `Run ${ACTION_LABELS[id]}`,
|
||||
command: 'lexai.runSelectionAction',
|
||||
arguments: [id],
|
||||
}),
|
||||
),
|
||||
new vscode.CodeLens(range, {
|
||||
title: '$(close)',
|
||||
tooltip: 'Close menu',
|
||||
command: 'lexai.toggleSelectionMenu',
|
||||
}),
|
||||
];
|
||||
},
|
||||
}),
|
||||
vscode.commands.registerCommand('lexai.toggleSelectionMenu', () => {
|
||||
if (!active?.selected) return;
|
||||
menuOpen = !menuOpen;
|
||||
codeLensEmitter.fire();
|
||||
}),
|
||||
vscode.commands.registerCommand(
|
||||
'lexai.runSelectionAction',
|
||||
async (action: LexAIRunAction) => {
|
||||
collapseMenu();
|
||||
await onPickAction(action);
|
||||
},
|
||||
),
|
||||
vscode.commands.registerCommand('lexai.showSelectionActions', () => {
|
||||
if (!active?.selected) {
|
||||
void vscode.window.showInformationMessage('LexAI: select some text first.');
|
||||
return;
|
||||
}
|
||||
menuOpen = true;
|
||||
codeLensEmitter.fire();
|
||||
}),
|
||||
);
|
||||
|
||||
refresh(vscode.window.activeTextEditor);
|
||||
}
|
||||
72
packages/vscode/src/session.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { ACTION_LABELS, type ActionId } from '@lib/actions';
|
||||
import type { VsCodeLexAISettings } from './config';
|
||||
|
||||
export type LexAIRunAction = ActionId | 'codeAssist';
|
||||
|
||||
export type SuggestionOptions = Pick<
|
||||
VsCodeLexAISettings,
|
||||
| 'writingStyle'
|
||||
| 'promptPattern'
|
||||
| 'promptPersona'
|
||||
| 'customPersona'
|
||||
| 'promptFormat'
|
||||
| 'promptModel'
|
||||
>;
|
||||
|
||||
export interface SuggestionSession {
|
||||
action: LexAIRunAction;
|
||||
documentUri: vscode.Uri;
|
||||
range: vscode.Range;
|
||||
originalText: string;
|
||||
suggestion: string;
|
||||
options: SuggestionOptions;
|
||||
/** Freeform Code Assist instruction */
|
||||
instruction?: string;
|
||||
/** Short UI blurb about gathered workspace files */
|
||||
contextSummary?: string;
|
||||
/** Full context pack for regenerating Code Assist */
|
||||
contextText?: string;
|
||||
}
|
||||
|
||||
export function actionLabel(action: LexAIRunAction): string {
|
||||
if (action === 'codeAssist') return 'Code Assist';
|
||||
return ACTION_LABELS[action];
|
||||
}
|
||||
|
||||
export function resolveReplaceRange(
|
||||
doc: vscode.TextDocument,
|
||||
s: SuggestionSession,
|
||||
): vscode.Range | undefined {
|
||||
const atRange = doc.getText(s.range);
|
||||
if (atRange === s.originalText) return s.range;
|
||||
|
||||
const full = doc.getText();
|
||||
const idx = full.indexOf(s.originalText);
|
||||
if (idx < 0) return undefined;
|
||||
const start = doc.positionAt(idx);
|
||||
const end = doc.positionAt(idx + s.originalText.length);
|
||||
return new vscode.Range(start, end);
|
||||
}
|
||||
|
||||
export async function applySuggestionReplace(
|
||||
session: SuggestionSession,
|
||||
suggestion: string,
|
||||
): Promise<boolean> {
|
||||
const doc = await vscode.workspace.openTextDocument(session.documentUri);
|
||||
const editor = await vscode.window.showTextDocument(doc, {
|
||||
viewColumn: vscode.ViewColumn.One,
|
||||
preview: false,
|
||||
preserveFocus: false,
|
||||
});
|
||||
const range = resolveReplaceRange(doc, session);
|
||||
if (!range) {
|
||||
void vscode.window.showErrorMessage(
|
||||
'LexAI: could not find the original selection (it may have changed). Copy the suggestion manually.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return editor.edit((edit) => {
|
||||
edit.replace(range, suggestion);
|
||||
});
|
||||
}
|
||||
426
packages/vscode/src/settingsPanel.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { listModels } from '@lib/providers';
|
||||
import {
|
||||
SECRET_API_KEY,
|
||||
clearApiKey,
|
||||
hasApiKey,
|
||||
readSettings,
|
||||
settingsCatalog,
|
||||
storeApiKey,
|
||||
updateSettings,
|
||||
} from './config';
|
||||
import { refreshStatusBar } from './statusBar';
|
||||
|
||||
type HostToWeb =
|
||||
| {
|
||||
type: 'state';
|
||||
settings: ReturnType<typeof readSettings>;
|
||||
catalog: ReturnType<typeof settingsCatalog>;
|
||||
hasApiKey: boolean;
|
||||
models: string[];
|
||||
modelsError?: string;
|
||||
status?: string;
|
||||
}
|
||||
| { type: 'models'; models: string[]; modelsError?: string };
|
||||
|
||||
type WebToHost =
|
||||
| { type: 'ready' }
|
||||
| { type: 'saveSettings'; patch: Record<string, string> }
|
||||
| { type: 'saveApiKey'; provider: string; apiKey: string }
|
||||
| { type: 'clearApiKey' }
|
||||
| { type: 'refreshModels' };
|
||||
|
||||
let panel: vscode.WebviewPanel | undefined;
|
||||
|
||||
export function openSettingsPanel(context: vscode.ExtensionContext): void {
|
||||
if (panel) {
|
||||
panel.reveal(vscode.ViewColumn.One);
|
||||
void pushState(context, panel.webview);
|
||||
return;
|
||||
}
|
||||
|
||||
panel = vscode.window.createWebviewPanel(
|
||||
'lexaiSettings',
|
||||
'LexAI Settings',
|
||||
vscode.ViewColumn.One,
|
||||
{ enableScripts: true, retainContextWhenHidden: true },
|
||||
);
|
||||
|
||||
panel.iconPath = undefined;
|
||||
panel.webview.html = getHtml(panel.webview);
|
||||
panel.onDidDispose(() => {
|
||||
panel = undefined;
|
||||
});
|
||||
|
||||
panel.webview.onDidReceiveMessage(async (msg: WebToHost) => {
|
||||
if (!panel) return;
|
||||
try {
|
||||
switch (msg.type) {
|
||||
case 'ready':
|
||||
await pushState(context, panel.webview);
|
||||
break;
|
||||
case 'saveSettings':
|
||||
await updateSettings(msg.patch);
|
||||
await pushState(context, panel.webview, 'Saved.');
|
||||
break;
|
||||
case 'saveApiKey':
|
||||
if (!msg.apiKey.trim()) {
|
||||
await pushState(context, panel.webview, 'API key cannot be empty.');
|
||||
return;
|
||||
}
|
||||
await storeApiKey(context, msg.apiKey, msg.provider);
|
||||
await updateSettings({ provider: msg.provider });
|
||||
await refreshStatusBar();
|
||||
await pushState(context, panel.webview, 'API key saved.');
|
||||
break;
|
||||
case 'clearApiKey':
|
||||
await clearApiKey(context);
|
||||
await refreshStatusBar();
|
||||
await pushState(context, panel.webview, 'API key cleared.');
|
||||
break;
|
||||
case 'refreshModels':
|
||||
await pushModels(context, panel.webview);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
await pushState(context, panel.webview, `Error: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function pushState(
|
||||
context: vscode.ExtensionContext,
|
||||
webview: vscode.Webview,
|
||||
status?: string,
|
||||
): Promise<void> {
|
||||
const settings = readSettings();
|
||||
const keyed = await hasApiKey(context);
|
||||
const modelResult = keyed
|
||||
? await listModels(settings.provider, (await context.secrets.get(SECRET_API_KEY)) ?? undefined)
|
||||
: { models: [] as string[], error: 'Set an API key to load models.' };
|
||||
|
||||
const payload: HostToWeb = {
|
||||
type: 'state',
|
||||
settings,
|
||||
catalog: settingsCatalog(),
|
||||
hasApiKey: keyed,
|
||||
models: modelResult.models ?? [],
|
||||
modelsError: modelResult.error,
|
||||
status,
|
||||
};
|
||||
await webview.postMessage(payload);
|
||||
}
|
||||
|
||||
async function pushModels(
|
||||
context: vscode.ExtensionContext,
|
||||
webview: vscode.Webview,
|
||||
): Promise<void> {
|
||||
const settings = readSettings();
|
||||
const apiKey = await context.secrets.get(SECRET_API_KEY);
|
||||
const modelResult = await listModels(settings.provider, apiKey ?? undefined);
|
||||
const payload: HostToWeb = {
|
||||
type: 'models',
|
||||
models: modelResult.models ?? [],
|
||||
modelsError: modelResult.error,
|
||||
};
|
||||
await webview.postMessage(payload);
|
||||
}
|
||||
|
||||
function getHtml(webview: vscode.Webview): string {
|
||||
const csp = [
|
||||
`default-src 'none'`,
|
||||
`style-src ${webview.cspSource} 'unsafe-inline'`,
|
||||
`script-src ${webview.cspSource} 'unsafe-inline'`,
|
||||
].join('; ');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="${csp}" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LexAI Settings</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
body {
|
||||
font-family: var(--vscode-font-family);
|
||||
font-size: var(--vscode-font-size);
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-editor-background);
|
||||
margin: 0;
|
||||
padding: 20px 24px 40px;
|
||||
max-width: 560px;
|
||||
}
|
||||
h1 { font-size: 1.35rem; font-weight: 700; margin: 0 0 4px; }
|
||||
.sub { opacity: 0.75; margin-bottom: 20px; font-size: 0.9rem; }
|
||||
section {
|
||||
border: 1px solid var(--vscode-widget-border, rgba(128,128,128,0.35));
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 14px;
|
||||
background: var(--vscode-sideBar-background, transparent);
|
||||
}
|
||||
section h2 {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0 0 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
label { display: block; font-size: 0.85rem; margin-bottom: 4px; opacity: 0.9; }
|
||||
.field { margin-bottom: 12px; }
|
||||
.field:last-child { margin-bottom: 0; }
|
||||
select, input[type="text"], input[type="password"] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vscode-input-border, rgba(128,128,128,0.45));
|
||||
background: var(--vscode-input-background);
|
||||
color: var(--vscode-input-foreground);
|
||||
}
|
||||
.row { display: flex; gap: 8px; align-items: center; }
|
||||
.row select { flex: 1; }
|
||||
button {
|
||||
padding: 7px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vscode-button-border, transparent);
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
button.secondary {
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
}
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
.hint { font-size: 0.8rem; opacity: 0.7; margin-top: 6px; }
|
||||
.status {
|
||||
min-height: 1.2em;
|
||||
margin-bottom: 12px;
|
||||
color: var(--vscode-testing-iconPassed, #3fb950);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
background: var(--vscode-badge-background);
|
||||
color: var(--vscode-badge-foreground);
|
||||
}
|
||||
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
|
||||
#customPersonaWrap { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>LexAI Settings</h1>
|
||||
<p class="sub">Provider, writing style, and Prompt Builder parameters. API key stays in Secret Storage.</p>
|
||||
<div class="status" id="status"></div>
|
||||
|
||||
<section>
|
||||
<h2>Provider</h2>
|
||||
<div class="field">
|
||||
<label for="provider">Provider</label>
|
||||
<select id="provider"></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="model">Model</label>
|
||||
<div class="row">
|
||||
<select id="model"></select>
|
||||
<button type="button" class="secondary" id="refreshModels">Refresh</button>
|
||||
</div>
|
||||
<div class="hint" id="modelsHint"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>API key <span class="badge" id="keyBadge">…</span></label>
|
||||
<input id="apiKey" type="password" placeholder="Paste new key to replace" autocomplete="off" />
|
||||
<div class="actions">
|
||||
<button type="button" id="saveKey">Save API key</button>
|
||||
<button type="button" class="secondary" id="clearKey">Clear key</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Writing</h2>
|
||||
<div class="field">
|
||||
<label for="writingStyle">Writing style</label>
|
||||
<select id="writingStyle"></select>
|
||||
<div class="hint">Used by Fix, Rephrase, Shorten, Expand, Explain.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Prompt Builder</h2>
|
||||
<div class="field">
|
||||
<label for="promptPattern">Pattern</label>
|
||||
<select id="promptPattern"></select>
|
||||
<div class="hint" id="patternHint"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="promptPersona">Persona</label>
|
||||
<select id="promptPersona"></select>
|
||||
</div>
|
||||
<div class="field" id="customPersonaWrap">
|
||||
<label for="customPersona">Custom persona</label>
|
||||
<input id="customPersona" type="text" placeholder="e.g. Staff engineer who writes crisp RFCs" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="promptFormat">Output format</label>
|
||||
<select id="promptFormat"></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="promptModel">Prompt model override</label>
|
||||
<select id="promptModel"></select>
|
||||
<div class="hint">Optional. Empty = use the provider model above when running Make Prompt.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" id="saveAll">Save settings</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
let catalog = null;
|
||||
let models = [];
|
||||
|
||||
function fillSelect(el, options, selected, mapFn) {
|
||||
el.innerHTML = '';
|
||||
for (const opt of options) {
|
||||
const { value, label } = mapFn(opt);
|
||||
const o = document.createElement('option');
|
||||
o.value = value;
|
||||
o.textContent = label;
|
||||
if (value === selected) o.selected = true;
|
||||
el.appendChild(o);
|
||||
}
|
||||
}
|
||||
|
||||
function syncCustomPersona() {
|
||||
$('customPersonaWrap').style.display =
|
||||
$('promptPersona').value === 'Custom…' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function syncPatternHint() {
|
||||
const id = $('promptPattern').value;
|
||||
const p = (catalog?.promptPatterns || []).find((x) => x.id === id);
|
||||
$('patternHint').textContent = p?.hint || '';
|
||||
}
|
||||
|
||||
function fillModelSelects(settings) {
|
||||
const modelOpts = ['', ...models];
|
||||
fillSelect($('model'), modelOpts, settings.model || '', (m) => ({
|
||||
value: m,
|
||||
label: m || '(provider default)',
|
||||
}));
|
||||
// Keep a typed/custom model even if not in the live list.
|
||||
if (settings.model && !modelOpts.includes(settings.model)) {
|
||||
const o = document.createElement('option');
|
||||
o.value = settings.model;
|
||||
o.textContent = settings.model;
|
||||
o.selected = true;
|
||||
$('model').appendChild(o);
|
||||
}
|
||||
fillSelect($('promptModel'), modelOpts, settings.promptModel || '', (m) => ({
|
||||
value: m,
|
||||
label: m || '(same as provider model)',
|
||||
}));
|
||||
if (settings.promptModel && !modelOpts.includes(settings.promptModel)) {
|
||||
const o = document.createElement('option');
|
||||
o.value = settings.promptModel;
|
||||
o.textContent = settings.promptModel;
|
||||
o.selected = true;
|
||||
$('promptModel').appendChild(o);
|
||||
}
|
||||
}
|
||||
|
||||
function applyState(msg) {
|
||||
catalog = msg.catalog;
|
||||
models = msg.models || [];
|
||||
const s = msg.settings;
|
||||
fillSelect($('provider'), catalog.providers, s.provider, (p) => ({
|
||||
value: p.id,
|
||||
label: p.label,
|
||||
}));
|
||||
fillSelect($('writingStyle'), catalog.writingStyles, s.writingStyle, (v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}));
|
||||
fillSelect($('promptPattern'), catalog.promptPatterns, s.promptPattern, (p) => ({
|
||||
value: p.id,
|
||||
label: p.group ? p.group + ' — ' + p.label : p.label,
|
||||
}));
|
||||
fillSelect($('promptPersona'), catalog.promptPersonas, s.promptPersona, (v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}));
|
||||
fillSelect($('promptFormat'), catalog.promptFormats, s.promptFormat, (v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}));
|
||||
$('customPersona').value = s.customPersona || '';
|
||||
fillModelSelects(s);
|
||||
$('keyBadge').textContent = msg.hasApiKey ? 'set' : 'not set';
|
||||
$('modelsHint').textContent = msg.modelsError || (models.length ? models.length + ' models' : '');
|
||||
$('status').textContent = msg.status || '';
|
||||
syncCustomPersona();
|
||||
syncPatternHint();
|
||||
}
|
||||
|
||||
function collectPatch() {
|
||||
return {
|
||||
provider: $('provider').value,
|
||||
model: $('model').value,
|
||||
writingStyle: $('writingStyle').value,
|
||||
promptPattern: $('promptPattern').value,
|
||||
promptPersona: $('promptPersona').value,
|
||||
customPersona: $('customPersona').value,
|
||||
promptFormat: $('promptFormat').value,
|
||||
promptModel: $('promptModel').value,
|
||||
};
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const msg = event.data;
|
||||
if (msg.type === 'state') applyState(msg);
|
||||
if (msg.type === 'models') {
|
||||
models = msg.models || [];
|
||||
fillModelSelects(collectPatch());
|
||||
$('modelsHint').textContent = msg.modelsError || (models.length ? models.length + ' models' : '');
|
||||
}
|
||||
});
|
||||
|
||||
$('promptPersona').addEventListener('change', syncCustomPersona);
|
||||
$('promptPattern').addEventListener('change', syncPatternHint);
|
||||
$('provider').addEventListener('change', () => {
|
||||
vscode.postMessage({ type: 'saveSettings', patch: { provider: $('provider').value } });
|
||||
});
|
||||
$('saveAll').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'saveSettings', patch: collectPatch() });
|
||||
});
|
||||
$('saveKey').addEventListener('click', () => {
|
||||
vscode.postMessage({
|
||||
type: 'saveApiKey',
|
||||
provider: $('provider').value,
|
||||
apiKey: $('apiKey').value,
|
||||
});
|
||||
$('apiKey').value = '';
|
||||
});
|
||||
$('clearKey').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'clearApiKey' });
|
||||
});
|
||||
$('refreshModels').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'refreshModels' });
|
||||
});
|
||||
|
||||
vscode.postMessage({ type: 'ready' });
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
605
packages/vscode/src/sidebarView.ts
Normal file
@@ -0,0 +1,605 @@
|
||||
import * as vscode from 'vscode';
|
||||
import {
|
||||
ACTIONS,
|
||||
ACTION_LABELS,
|
||||
MIN_SELECTION_LENGTH,
|
||||
resolvePromptPersona,
|
||||
type ActionId,
|
||||
} from '@lib/actions';
|
||||
import { runCodeAssist } from './codeAssist';
|
||||
import { hasApiKey, readSettings, resolveConfig, settingsCatalog } from './config';
|
||||
import { generateSuggestion } from './llm';
|
||||
import { withLexAIProgress } from './statusBar';
|
||||
|
||||
const VIEW_ID = 'lexai.sidebar';
|
||||
|
||||
export function registerSidebarView(context: vscode.ExtensionContext): void {
|
||||
const provider = new LexAISidebarProvider(context);
|
||||
context.subscriptions.push(
|
||||
vscode.window.registerWebviewViewProvider(VIEW_ID, provider, {
|
||||
webviewOptions: { retainContextWhenHidden: true },
|
||||
}),
|
||||
vscode.commands.registerCommand('lexai.openSidebar', async () => {
|
||||
await vscode.commands.executeCommand(`${VIEW_ID}.focus`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
class LexAISidebarProvider implements vscode.WebviewViewProvider {
|
||||
private view?: vscode.WebviewView;
|
||||
|
||||
constructor(private readonly context: vscode.ExtensionContext) {}
|
||||
|
||||
resolveWebviewView(webviewView: vscode.WebviewView): void {
|
||||
this.view = webviewView;
|
||||
const logoUri = webviewView.webview.asWebviewUri(
|
||||
vscode.Uri.joinPath(this.context.extensionUri, 'media', 'icon.svg'),
|
||||
);
|
||||
webviewView.webview.options = {
|
||||
enableScripts: true,
|
||||
localResourceRoots: [vscode.Uri.joinPath(this.context.extensionUri, 'media')],
|
||||
};
|
||||
webviewView.webview.html = getHtml(webviewView.webview, logoUri.toString());
|
||||
|
||||
webviewView.webview.onDidReceiveMessage(async (msg) => {
|
||||
switch (msg.type) {
|
||||
case 'ready':
|
||||
await this.pushState();
|
||||
break;
|
||||
case 'run':
|
||||
if (msg.action === 'codeAssist') {
|
||||
await this.runCodeAssistFromSidebar(String(msg.text ?? ''));
|
||||
break;
|
||||
}
|
||||
await this.run(msg.action as ActionId, String(msg.text ?? ''), msg.options ?? {});
|
||||
break;
|
||||
case 'insertSelection':
|
||||
await this.insertSelection();
|
||||
break;
|
||||
case 'openSettings':
|
||||
await vscode.commands.executeCommand('lexai.openSettings');
|
||||
break;
|
||||
case 'copy':
|
||||
await vscode.env.clipboard.writeText(String(msg.text ?? ''));
|
||||
await webviewView.webview.postMessage({ type: 'copied' });
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async pushState(extra?: { status?: string; error?: string; result?: string }): Promise<void> {
|
||||
if (!this.view) return;
|
||||
const settings = readSettings();
|
||||
const keyed = await hasApiKey(this.context);
|
||||
await this.view.webview.postMessage({
|
||||
type: 'state',
|
||||
catalog: settingsCatalog(),
|
||||
settings: {
|
||||
writingStyle: settings.writingStyle,
|
||||
promptPattern: settings.promptPattern,
|
||||
promptPersona: settings.promptPersona,
|
||||
promptFormat: settings.promptFormat,
|
||||
},
|
||||
actions: [
|
||||
{ id: 'codeAssist', label: 'Code Assist' },
|
||||
...ACTIONS.map((id) => ({ id, label: ACTION_LABELS[id] })),
|
||||
],
|
||||
hasApiKey: keyed,
|
||||
minLength: MIN_SELECTION_LENGTH,
|
||||
status: extra?.status,
|
||||
error: extra?.error,
|
||||
result: extra?.result,
|
||||
});
|
||||
}
|
||||
|
||||
private async insertSelection(): Promise<void> {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
const text = editor && !editor.selection.isEmpty
|
||||
? editor.document.getText(editor.selection)
|
||||
: '';
|
||||
if (!this.view) return;
|
||||
if (!text.trim()) {
|
||||
await this.view.webview.postMessage({
|
||||
type: 'status',
|
||||
error: 'No editor selection to insert.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.view.webview.postMessage({ type: 'setInput', text });
|
||||
}
|
||||
|
||||
private async runCodeAssistFromSidebar(instruction: string): Promise<void> {
|
||||
if (!this.view) return;
|
||||
const goal = instruction.trim();
|
||||
if (goal.length < 3) {
|
||||
await this.pushState({
|
||||
error: 'Describe what you want LexAI to do with the editor selection.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor || editor.selection.isEmpty) {
|
||||
await this.pushState({
|
||||
error: 'Select code in the editor first (Code Assist uses that selection + related files).',
|
||||
});
|
||||
return;
|
||||
}
|
||||
await runCodeAssist(this.context, goal);
|
||||
await this.pushState({
|
||||
status: 'Code Assist result is in the editor suggestion zone.',
|
||||
});
|
||||
}
|
||||
|
||||
private async run(
|
||||
action: ActionId,
|
||||
text: string,
|
||||
options: {
|
||||
writingStyle?: string;
|
||||
promptPattern?: string;
|
||||
promptPersona?: string;
|
||||
promptFormat?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!this.view) return;
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length < MIN_SELECTION_LENGTH) {
|
||||
await this.pushState({
|
||||
error: `Enter at least ${MIN_SELECTION_LENGTH} characters.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = await resolveConfig(this.context);
|
||||
if (resolved.error || !resolved.config) {
|
||||
await this.pushState({ error: resolved.error ?? 'Not configured' });
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = readSettings();
|
||||
const writingStyle = (options.writingStyle || settings.writingStyle) as typeof settings.writingStyle;
|
||||
const promptPattern = options.promptPattern || settings.promptPattern;
|
||||
const promptPersona = options.promptPersona || settings.promptPersona;
|
||||
const promptFormat = options.promptFormat || settings.promptFormat;
|
||||
|
||||
const label = ACTION_LABELS[action];
|
||||
const response = await withLexAIProgress(`LexAI: ${label}…`, () =>
|
||||
generateSuggestion({
|
||||
action,
|
||||
text,
|
||||
config: resolved.config!,
|
||||
writingStyle,
|
||||
promptParams: {
|
||||
pattern: promptPattern,
|
||||
persona: resolvePromptPersona(promptPersona, settings.customPersona),
|
||||
format: promptFormat,
|
||||
},
|
||||
promptModel: settings.promptModel || undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.error || !response.result) {
|
||||
await this.pushState({ error: response.error ?? 'Empty response.' });
|
||||
return;
|
||||
}
|
||||
await this.pushState({ result: response.result, status: `${label} done.` });
|
||||
}
|
||||
}
|
||||
|
||||
function getHtml(webview: vscode.Webview, logoUri: string): string {
|
||||
const csp = [
|
||||
`default-src 'none'`,
|
||||
`img-src ${webview.cspSource} data:`,
|
||||
`style-src ${webview.cspSource} 'unsafe-inline'`,
|
||||
`script-src ${webview.cspSource} 'unsafe-inline'`,
|
||||
].join('; ');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="${csp}" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LexAI</title>
|
||||
<style>
|
||||
:root {
|
||||
--lexai-accent: #818cf8;
|
||||
--lexai-accent-strong: #6366f1;
|
||||
--lexai-radius: 10px;
|
||||
--lexai-gap: 10px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: var(--vscode-font-family);
|
||||
font-size: var(--vscode-font-size);
|
||||
color: var(--vscode-foreground);
|
||||
background: transparent;
|
||||
margin: 0;
|
||||
padding: 12px 12px 28px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--lexai-accent) 35%, var(--vscode-widget-border, transparent));
|
||||
background:
|
||||
linear-gradient(135deg, color-mix(in srgb, var(--lexai-accent-strong) 18%, transparent), transparent 60%),
|
||||
var(--vscode-sideBar-background, transparent);
|
||||
}
|
||||
.hero img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.35);
|
||||
}
|
||||
.hero-text { min-width: 0; }
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--lexai-accent);
|
||||
}
|
||||
.hero p {
|
||||
margin: 3px 0 0;
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.78;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 6px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--lexai-accent) 22%, transparent);
|
||||
color: var(--lexai-accent);
|
||||
border: 1px solid color-mix(in srgb, var(--lexai-accent) 40%, transparent);
|
||||
}
|
||||
.badge.bad {
|
||||
background: color-mix(in srgb, var(--vscode-errorForeground) 18%, transparent);
|
||||
color: var(--vscode-errorForeground);
|
||||
border-color: color-mix(in srgb, var(--vscode-errorForeground) 40%, transparent);
|
||||
}
|
||||
.badge .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--vscode-widget-border, rgba(128,128,128,0.28));
|
||||
border-radius: var(--lexai-radius);
|
||||
background: color-mix(in srgb, var(--vscode-editor-background) 88%, transparent);
|
||||
padding: 12px;
|
||||
margin-bottom: var(--lexai-gap);
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.72;
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
label.field {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
opacity: 0.8;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
label.field:first-child { margin-top: 0; }
|
||||
|
||||
textarea, select {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--vscode-input-border, rgba(128,128,128,0.4));
|
||||
background: var(--vscode-input-background);
|
||||
color: var(--vscode-input-foreground);
|
||||
font-family: var(--vscode-editor-font-family, ui-monospace, monospace);
|
||||
font-size: 0.84rem;
|
||||
padding: 9px 10px;
|
||||
outline: none;
|
||||
}
|
||||
textarea:focus, select:focus {
|
||||
border-color: var(--lexai-accent);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--lexai-accent) 55%, transparent);
|
||||
}
|
||||
textarea { min-height: 120px; resize: vertical; line-height: 1.4; }
|
||||
textarea#output { min-height: 150px; }
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.grid-2 .span-2 { grid-column: 1 / -1; }
|
||||
|
||||
button {
|
||||
padding: 7px 11px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--vscode-button-border, transparent);
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
button.secondary {
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
}
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
border-color: var(--vscode-widget-border, rgba(128,128,128,0.35));
|
||||
color: var(--vscode-foreground);
|
||||
font-weight: 500;
|
||||
}
|
||||
button.primary {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 0.86rem;
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
border: none;
|
||||
color: #fff;
|
||||
box-shadow: 0 6px 18px rgba(99, 102, 241, 0.28);
|
||||
}
|
||||
button.primary:hover { filter: brightness(1.05); }
|
||||
button:disabled { opacity: 0.45; cursor: default; filter: none; }
|
||||
|
||||
.out-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.out-actions button { flex: 1; }
|
||||
|
||||
.status {
|
||||
min-height: 1.15em;
|
||||
margin-top: 10px;
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.status.error {
|
||||
color: var(--vscode-errorForeground);
|
||||
opacity: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status.ok { color: var(--lexai-accent); opacity: 1; }
|
||||
|
||||
#promptOpts { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="hero">
|
||||
<img src="${logoUri}" alt="LexAI" />
|
||||
<div class="hero-text">
|
||||
<h1>LexAI</h1>
|
||||
<p>Writing help in the sidebar · Code Assist on editor selections with workspace context.</p>
|
||||
<span class="badge" id="keyBadge"><span class="dot"></span><span id="keyLabel">…</span></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="toolbar">
|
||||
<button type="button" class="ghost" id="insertSel">Insert selection</button>
|
||||
<button type="button" class="ghost" id="clearIn">Clear</button>
|
||||
<button type="button" class="ghost" id="settings">Settings</button>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2 class="card-title">Input</h2>
|
||||
<span class="hint" id="charHint"></span>
|
||||
</div>
|
||||
<textarea id="input" placeholder="Paste text from your terminal, notes, or draft…"></textarea>
|
||||
<p class="hint" id="inputHint" style="margin:6px 0 0"></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2 class="card-title">Action</h2>
|
||||
</div>
|
||||
<label class="field" for="action">What should LexAI do?</label>
|
||||
<select id="action"></select>
|
||||
|
||||
<div id="styleOpts">
|
||||
<label class="field" for="writingStyle">Writing style</label>
|
||||
<select id="writingStyle"></select>
|
||||
</div>
|
||||
<div id="promptOpts">
|
||||
<div class="grid-2">
|
||||
<div class="span-2">
|
||||
<label class="field" for="promptPattern">Pattern</label>
|
||||
<select id="promptPattern"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field" for="promptPersona">Persona</label>
|
||||
<select id="promptPersona"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field" for="promptFormat">Format</label>
|
||||
<select id="promptFormat"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="primary" id="run">Run LexAI</button>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2 class="card-title">Output</h2>
|
||||
<span class="hint">Read-only · copy when ready</span>
|
||||
</div>
|
||||
<textarea id="output" readonly placeholder="Result appears here…"></textarea>
|
||||
<div class="out-actions">
|
||||
<button type="button" id="copy" disabled>Copy</button>
|
||||
<button type="button" class="secondary" id="useAsInput" disabled>Use as input</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="status" id="status"></div>
|
||||
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let busy = false;
|
||||
let minLength = 10;
|
||||
|
||||
function fillSelect(el, options, selected, mapFn) {
|
||||
const prev = selected || el.value;
|
||||
el.innerHTML = '';
|
||||
for (const opt of options) {
|
||||
const { value, label } = mapFn(opt);
|
||||
const o = document.createElement('option');
|
||||
o.value = value;
|
||||
o.textContent = label;
|
||||
if (value === prev) o.selected = true;
|
||||
el.appendChild(o);
|
||||
}
|
||||
}
|
||||
|
||||
function syncActionUi() {
|
||||
const action = $('action').value;
|
||||
const isPrompt = action === 'prompt';
|
||||
const isCodeAssist = action === 'codeAssist';
|
||||
$('promptOpts').style.display = isPrompt ? 'block' : 'none';
|
||||
$('styleOpts').style.display = isPrompt || isCodeAssist ? 'none' : 'block';
|
||||
$('input').placeholder = isCodeAssist
|
||||
? 'e.g. Explain how this uses AuthService · Refactor to async/await'
|
||||
: 'Paste text from your terminal, notes, or draft…';
|
||||
$('inputHint').textContent = isCodeAssist
|
||||
? 'Uses the active editor selection + related files (imports/defs). Select code first.'
|
||||
: '';
|
||||
$('run').textContent = isCodeAssist ? 'Run Code Assist' : 'Run LexAI';
|
||||
}
|
||||
|
||||
function updateCharHint() {
|
||||
const n = $('input').value.trim().length;
|
||||
$('charHint').textContent = n ? n + ' chars' : '';
|
||||
}
|
||||
|
||||
function setBusy(next) {
|
||||
busy = next;
|
||||
$('run').disabled = next;
|
||||
$('run').textContent = next ? 'Working…' : 'Run LexAI';
|
||||
}
|
||||
|
||||
function setStatus(text, kind) {
|
||||
$('status').textContent = text || '';
|
||||
$('status').className = 'status' + (kind === 'error' ? ' error' : kind === 'ok' ? ' ok' : '');
|
||||
}
|
||||
|
||||
function applyState(msg) {
|
||||
minLength = msg.minLength || 10;
|
||||
const ready = !!msg.hasApiKey;
|
||||
$('keyLabel').textContent = ready ? 'Ready' : 'API key needed';
|
||||
$('keyBadge').className = 'badge' + (ready ? '' : ' bad');
|
||||
|
||||
fillSelect($('action'), msg.actions, $('action').value || 'fix', (a) => ({
|
||||
value: a.id, label: a.label,
|
||||
}));
|
||||
fillSelect($('writingStyle'), msg.catalog.writingStyles, msg.settings.writingStyle, (v) => ({ value: v, label: v }));
|
||||
fillSelect($('promptPattern'), msg.catalog.promptPatterns, msg.settings.promptPattern, (p) => ({
|
||||
value: p.id, label: p.label,
|
||||
}));
|
||||
fillSelect(
|
||||
$('promptPersona'),
|
||||
msg.catalog.promptPersonas.filter((p) => p !== 'Custom…'),
|
||||
msg.settings.promptPersona,
|
||||
(v) => ({ value: v, label: v }),
|
||||
);
|
||||
fillSelect($('promptFormat'), msg.catalog.promptFormats, msg.settings.promptFormat, (v) => ({ value: v, label: v }));
|
||||
syncActionUi();
|
||||
updateCharHint();
|
||||
|
||||
if (msg.result !== undefined) {
|
||||
$('output').value = msg.result || '';
|
||||
$('copy').disabled = !msg.result;
|
||||
$('useAsInput').disabled = !msg.result;
|
||||
}
|
||||
if (msg.error) setStatus(msg.error, 'error');
|
||||
else if (msg.status) setStatus(msg.status, 'ok');
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const msg = event.data;
|
||||
if (msg.type === 'state') { setBusy(false); applyState(msg); }
|
||||
if (msg.type === 'setInput') {
|
||||
$('input').value = msg.text || '';
|
||||
updateCharHint();
|
||||
setStatus('Inserted editor selection.', 'ok');
|
||||
}
|
||||
if (msg.type === 'status') {
|
||||
setBusy(false);
|
||||
setStatus(msg.error || msg.status || '', msg.error ? 'error' : 'ok');
|
||||
}
|
||||
if (msg.type === 'copied') setStatus('Copied to clipboard.', 'ok');
|
||||
});
|
||||
|
||||
$('action').addEventListener('change', syncActionUi);
|
||||
$('input').addEventListener('input', updateCharHint);
|
||||
$('run').addEventListener('click', () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setStatus('Working…', '');
|
||||
vscode.postMessage({
|
||||
type: 'run',
|
||||
action: $('action').value,
|
||||
text: $('input').value,
|
||||
options: {
|
||||
writingStyle: $('writingStyle').value,
|
||||
promptPattern: $('promptPattern').value,
|
||||
promptPersona: $('promptPersona').value,
|
||||
promptFormat: $('promptFormat').value,
|
||||
},
|
||||
});
|
||||
});
|
||||
$('copy').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'copy', text: $('output').value });
|
||||
});
|
||||
$('useAsInput').addEventListener('click', () => {
|
||||
$('input').value = $('output').value;
|
||||
updateCharHint();
|
||||
setStatus('Moved output into input.', 'ok');
|
||||
});
|
||||
$('insertSel').addEventListener('click', () => vscode.postMessage({ type: 'insertSelection' }));
|
||||
$('clearIn').addEventListener('click', () => { $('input').value = ''; updateCharHint(); });
|
||||
$('settings').addEventListener('click', () => vscode.postMessage({ type: 'openSettings' }));
|
||||
|
||||
vscode.postMessage({ type: 'ready' });
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
88
packages/vscode/src/statusBar.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { PROVIDER_SPECS, providerLabel } from '@lib/providers';
|
||||
import { hasApiKey, readSettings } from './config';
|
||||
|
||||
export type LexAIStatus = 'ready' | 'processing' | 'notReady';
|
||||
|
||||
let item: vscode.StatusBarItem | undefined;
|
||||
let contextRef: vscode.ExtensionContext | undefined;
|
||||
let busyDepth = 0;
|
||||
let lastStatus: LexAIStatus = 'notReady';
|
||||
|
||||
export function registerStatusBar(context: vscode.ExtensionContext): void {
|
||||
contextRef = context;
|
||||
item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
|
||||
item.name = 'LexAI';
|
||||
item.command = 'lexai.openSettings';
|
||||
item.show();
|
||||
context.subscriptions.push(item);
|
||||
|
||||
context.subscriptions.push(
|
||||
context.secrets.onDidChange((e) => {
|
||||
if (e.key === 'lexai.apiKey') void refreshStatusBar();
|
||||
}),
|
||||
vscode.workspace.onDidChangeConfiguration((e) => {
|
||||
if (e.affectsConfiguration('lexai')) void refreshStatusBar();
|
||||
}),
|
||||
);
|
||||
|
||||
void refreshStatusBar();
|
||||
}
|
||||
|
||||
export async function refreshStatusBar(): Promise<void> {
|
||||
if (!item || !contextRef) return;
|
||||
if (busyDepth > 0) {
|
||||
apply('processing');
|
||||
return;
|
||||
}
|
||||
const keyed = await hasApiKey(contextRef);
|
||||
apply(keyed ? 'ready' : 'notReady');
|
||||
}
|
||||
|
||||
/** Nestable busy indicator for generate / regenerate. */
|
||||
export async function withLexAIProgress<T>(title: string, task: () => Promise<T>): Promise<T> {
|
||||
busyDepth += 1;
|
||||
apply('processing', title);
|
||||
try {
|
||||
return await vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Window,
|
||||
title,
|
||||
},
|
||||
async () => task(),
|
||||
);
|
||||
} finally {
|
||||
busyDepth = Math.max(0, busyDepth - 1);
|
||||
await refreshStatusBar();
|
||||
}
|
||||
}
|
||||
|
||||
function apply(status: LexAIStatus, detail?: string): void {
|
||||
if (!item) return;
|
||||
lastStatus = status;
|
||||
const settings = readSettings();
|
||||
const provider = providerLabel(settings.provider);
|
||||
const model = settings.model || PROVIDER_SPECS[settings.provider]?.defaultModel || 'default';
|
||||
|
||||
switch (status) {
|
||||
case 'ready':
|
||||
item.text = '$(check) LexAI';
|
||||
item.backgroundColor = undefined;
|
||||
item.tooltip = `LexAI ready · ${provider} · ${model}\nClick to open settings`;
|
||||
break;
|
||||
case 'processing':
|
||||
item.text = '$(sync~spin) LexAI';
|
||||
item.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
|
||||
item.tooltip = detail ? `LexAI: ${detail}` : 'LexAI is working…';
|
||||
break;
|
||||
case 'notReady':
|
||||
item.text = '$(warning) LexAI';
|
||||
item.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground');
|
||||
item.tooltip = 'LexAI not ready — click to set your API key';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function getLexAIStatus(): LexAIStatus {
|
||||
return lastStatus;
|
||||
}
|
||||
464
packages/vscode/src/suggestionPanel.ts
Normal file
@@ -0,0 +1,464 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { resolvePromptPersona, type ActionId } from '@lib/actions';
|
||||
import { callProvider } from '@lib/providers';
|
||||
import { resolveConfig, settingsCatalog, updateSettings } from './config';
|
||||
import { generateSuggestion } from './llm';
|
||||
import {
|
||||
actionLabel,
|
||||
applySuggestionReplace,
|
||||
type SuggestionSession,
|
||||
} from './session';
|
||||
|
||||
export type { SuggestionSession } from './session';
|
||||
|
||||
const CODE_ASSIST_SYSTEM = [
|
||||
'You are LexAI Code Assist, a senior engineer working inside the user\'s IDE.',
|
||||
'Follow the user instruction using the provided workspace context.',
|
||||
'If changing code: return ONLY the replacement for the selection (no fences).',
|
||||
'If explaining: return a clear explanation.',
|
||||
].join(' ');
|
||||
|
||||
type HostToWeb =
|
||||
| {
|
||||
type: 'state';
|
||||
action: SuggestionSession['action'];
|
||||
actionLabel: string;
|
||||
originalText: string;
|
||||
suggestion: string;
|
||||
options: SuggestionSession['options'];
|
||||
catalog: ReturnType<typeof settingsCatalog>;
|
||||
busy: boolean;
|
||||
status?: string;
|
||||
error?: string;
|
||||
}
|
||||
| { type: 'busy'; busy: boolean; status?: string };
|
||||
|
||||
type WebToHost =
|
||||
| { type: 'ready' }
|
||||
| { type: 'accept'; suggestion: string; persistOptions: boolean; options: SuggestionSession['options'] }
|
||||
| { type: 'regenerate'; options: SuggestionSession['options']; persistOptions: boolean }
|
||||
| { type: 'discard' }
|
||||
| { type: 'openSettings' };
|
||||
|
||||
let panel: vscode.WebviewPanel | undefined;
|
||||
let session: SuggestionSession | undefined;
|
||||
let extContext: vscode.ExtensionContext | undefined;
|
||||
|
||||
export async function openSuggestionPanel(
|
||||
context: vscode.ExtensionContext,
|
||||
next: SuggestionSession,
|
||||
): Promise<void> {
|
||||
extContext = context;
|
||||
session = next;
|
||||
|
||||
if (panel) {
|
||||
panel.reveal(vscode.ViewColumn.Beside);
|
||||
} else {
|
||||
panel = vscode.window.createWebviewPanel(
|
||||
'lexaiSuggestion',
|
||||
'LexAI Suggestion',
|
||||
vscode.ViewColumn.Beside,
|
||||
{ enableScripts: true, retainContextWhenHidden: true },
|
||||
);
|
||||
panel.webview.html = getHtml();
|
||||
panel.onDidDispose(() => {
|
||||
panel = undefined;
|
||||
session = undefined;
|
||||
});
|
||||
panel.webview.onDidReceiveMessage((msg: WebToHost) => {
|
||||
void handleMessage(msg);
|
||||
});
|
||||
}
|
||||
|
||||
panel.title = `LexAI: ${actionLabel(next.action)}`;
|
||||
await postState();
|
||||
}
|
||||
|
||||
async function postState(extra?: { status?: string; error?: string; busy?: boolean }): Promise<void> {
|
||||
if (!panel || !session) return;
|
||||
const payload: HostToWeb = {
|
||||
type: 'state',
|
||||
action: session.action,
|
||||
actionLabel: actionLabel(session.action),
|
||||
originalText: session.originalText,
|
||||
suggestion: session.suggestion,
|
||||
options: session.options,
|
||||
catalog: settingsCatalog(),
|
||||
busy: extra?.busy ?? false,
|
||||
status: extra?.status,
|
||||
error: extra?.error,
|
||||
};
|
||||
await panel.webview.postMessage(payload);
|
||||
}
|
||||
|
||||
async function handleMessage(msg: WebToHost): Promise<void> {
|
||||
if (!panel || !session || !extContext) return;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'ready':
|
||||
await postState();
|
||||
break;
|
||||
case 'discard':
|
||||
panel.dispose();
|
||||
break;
|
||||
case 'openSettings':
|
||||
await vscode.commands.executeCommand('lexai.openSettings');
|
||||
break;
|
||||
case 'accept':
|
||||
await acceptSuggestion(msg.suggestion, msg.options, msg.persistOptions);
|
||||
break;
|
||||
case 'regenerate':
|
||||
await regenerate(msg.options, msg.persistOptions);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function maybePersistOptions(
|
||||
options: SuggestionSession['options'],
|
||||
persist: boolean,
|
||||
): Promise<void> {
|
||||
if (!persist) return;
|
||||
await updateSettings({
|
||||
writingStyle: options.writingStyle,
|
||||
promptPattern: options.promptPattern,
|
||||
promptPersona: options.promptPersona,
|
||||
customPersona: options.customPersona,
|
||||
promptFormat: options.promptFormat,
|
||||
promptModel: options.promptModel,
|
||||
});
|
||||
}
|
||||
|
||||
async function acceptSuggestion(
|
||||
suggestion: string,
|
||||
options: SuggestionSession['options'],
|
||||
persistOptions: boolean,
|
||||
): Promise<void> {
|
||||
if (!session) return;
|
||||
await maybePersistOptions(options, persistOptions);
|
||||
const ok = await applySuggestionReplace(session, suggestion);
|
||||
if (!ok) {
|
||||
void vscode.window.showErrorMessage('LexAI: replace failed (editor busy or selection moved).');
|
||||
return;
|
||||
}
|
||||
void vscode.window.showInformationMessage(`LexAI: ${actionLabel(session.action)} applied.`);
|
||||
panel?.dispose();
|
||||
}
|
||||
|
||||
async function regenerate(
|
||||
options: SuggestionSession['options'],
|
||||
persistOptions: boolean,
|
||||
): Promise<void> {
|
||||
if (!session || !extContext || !panel) return;
|
||||
session.options = options;
|
||||
await maybePersistOptions(options, persistOptions);
|
||||
|
||||
await panel.webview.postMessage({
|
||||
type: 'busy',
|
||||
busy: true,
|
||||
status: 'Generating another suggestion…',
|
||||
} satisfies HostToWeb);
|
||||
|
||||
const resolved = await resolveConfig(extContext);
|
||||
if (resolved.error || !resolved.config) {
|
||||
await postState({ busy: false, error: resolved.error ?? 'configuration error' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.action === 'codeAssist') {
|
||||
const goal = session.instruction || 'Improve this code.';
|
||||
const ctx = session.contextText || session.originalText;
|
||||
const userPayload = [`### User instruction`, goal, '', ctx].join('\n');
|
||||
const response = await callProvider(resolved.config, userPayload, CODE_ASSIST_SYSTEM, {
|
||||
maxTokens: Math.max(2048, Math.min(8192, Math.ceil(session.originalText.length + 1500))),
|
||||
});
|
||||
if (response.error || !response.result) {
|
||||
await postState({
|
||||
busy: false,
|
||||
error: response.error ?? 'Empty response from provider.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
session.suggestion = response.result.replace(/\r\n/g, '\n').trim()
|
||||
.replace(/^```(?:[\w.+-]+)?\n([\s\S]*?)\n```$/, '$1');
|
||||
await postState({ busy: false, status: 'New suggestion ready.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await generateSuggestion({
|
||||
action: session.action as ActionId,
|
||||
text: session.originalText,
|
||||
config: resolved.config,
|
||||
writingStyle: options.writingStyle,
|
||||
promptParams: {
|
||||
pattern: options.promptPattern,
|
||||
persona: resolvePromptPersona(options.promptPersona, options.customPersona),
|
||||
format: options.promptFormat,
|
||||
},
|
||||
promptModel: options.promptModel || undefined,
|
||||
});
|
||||
|
||||
if (response.error || !response.result) {
|
||||
await postState({
|
||||
busy: false,
|
||||
error: response.error ?? 'Empty response from provider.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
session.suggestion = response.result;
|
||||
await postState({ busy: false, status: 'New suggestion ready.' });
|
||||
}
|
||||
|
||||
function getHtml(): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline';" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LexAI Suggestion</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: var(--vscode-font-family);
|
||||
font-size: var(--vscode-font-size);
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-editor-background);
|
||||
margin: 0;
|
||||
padding: 16px 18px 28px;
|
||||
}
|
||||
h1 { font-size: 1.15rem; margin: 0 0 4px; }
|
||||
.sub { opacity: 0.75; margin-bottom: 12px; font-size: 0.85rem; }
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding: 8px 0;
|
||||
background: var(--vscode-editor-background);
|
||||
z-index: 2;
|
||||
}
|
||||
button {
|
||||
padding: 7px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vscode-button-border, transparent);
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
button.secondary {
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
}
|
||||
button.danger {
|
||||
background: transparent;
|
||||
color: var(--vscode-errorForeground);
|
||||
border-color: var(--vscode-errorForeground);
|
||||
}
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
.status { min-height: 1.2em; font-size: 0.85rem; margin-bottom: 10px; opacity: 0.85; }
|
||||
.status.error { color: var(--vscode-errorForeground); opacity: 1; }
|
||||
section {
|
||||
border: 1px solid var(--vscode-widget-border, rgba(128,128,128,0.35));
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
section h2 {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0 0 8px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
label { display: block; font-size: 0.8rem; margin-bottom: 4px; opacity: 0.9; }
|
||||
.field { margin-bottom: 10px; }
|
||||
.field:last-child { margin-bottom: 0; }
|
||||
select, textarea, input[type="text"] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vscode-input-border, rgba(128,128,128,0.45));
|
||||
background: var(--vscode-input-background);
|
||||
color: var(--vscode-input-foreground);
|
||||
font-family: var(--vscode-editor-font-family, monospace);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
textarea { min-height: 140px; resize: vertical; line-height: 1.4; }
|
||||
textarea.original { min-height: 90px; opacity: 0.9; }
|
||||
.hint { font-size: 0.78rem; opacity: 0.7; margin-top: 4px; }
|
||||
.check { display: flex; align-items: center; gap: 8px; font-size: 0.85rem; margin-top: 8px; }
|
||||
.check input { width: auto; }
|
||||
#promptOptions { display: none; }
|
||||
#customPersonaWrap { display: none; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
@media (max-width: 520px) { .grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 id="title">LexAI Suggestion</h1>
|
||||
<p class="sub">Review the suggestion. Accept to replace the selection, or regenerate with different options.</p>
|
||||
|
||||
<div class="toolbar">
|
||||
<button type="button" id="accept">Accept</button>
|
||||
<button type="button" class="secondary" id="regenerate">Regenerate</button>
|
||||
<button type="button" class="danger" id="discard">Discard</button>
|
||||
<button type="button" class="secondary" id="openSettings">Full settings…</button>
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
|
||||
<section>
|
||||
<h2>Options for this suggestion</h2>
|
||||
<div class="grid">
|
||||
<div class="field" id="styleField">
|
||||
<label for="writingStyle">Writing style</label>
|
||||
<select id="writingStyle"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="promptOptions">
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label for="promptPattern">Pattern</label>
|
||||
<select id="promptPattern"></select>
|
||||
<div class="hint" id="patternHint"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="promptPersona">Persona</label>
|
||||
<select id="promptPersona"></select>
|
||||
</div>
|
||||
<div class="field" id="customPersonaWrap">
|
||||
<label for="customPersona">Custom persona</label>
|
||||
<input id="customPersona" type="text" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="promptFormat">Format</label>
|
||||
<select id="promptFormat"></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="promptModel">Prompt model override</label>
|
||||
<input id="promptModel" type="text" placeholder="(optional model id)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="persistOptions" />
|
||||
Also save these options as my defaults
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Original selection</h2>
|
||||
<textarea class="original" id="original" readonly></textarea>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Suggestion <span class="hint">(editable before Accept)</span></h2>
|
||||
<textarea id="suggestion"></textarea>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let catalog = null;
|
||||
let action = 'fix';
|
||||
let busy = false;
|
||||
|
||||
function fillSelect(el, options, selected, mapFn) {
|
||||
el.innerHTML = '';
|
||||
for (const opt of options) {
|
||||
const { value, label } = mapFn(opt);
|
||||
const o = document.createElement('option');
|
||||
o.value = value;
|
||||
o.textContent = label;
|
||||
if (value === selected) o.selected = true;
|
||||
el.appendChild(o);
|
||||
}
|
||||
}
|
||||
|
||||
function syncPromptVisibility() {
|
||||
$('promptOptions').style.display = action === 'prompt' ? 'block' : 'none';
|
||||
$('styleField').style.display = action === 'prompt' ? 'none' : 'block';
|
||||
$('customPersonaWrap').style.display =
|
||||
$('promptPersona').value === 'Custom…' ? 'block' : 'none';
|
||||
const p = (catalog?.promptPatterns || []).find((x) => x.id === $('promptPattern').value);
|
||||
$('patternHint').textContent = p?.hint || '';
|
||||
}
|
||||
|
||||
function setBusy(next, status) {
|
||||
busy = next;
|
||||
$('accept').disabled = next;
|
||||
$('regenerate').disabled = next;
|
||||
if (status !== undefined) {
|
||||
$('status').textContent = status || '';
|
||||
$('status').className = 'status';
|
||||
}
|
||||
}
|
||||
|
||||
function collectOptions() {
|
||||
return {
|
||||
writingStyle: $('writingStyle').value,
|
||||
promptPattern: $('promptPattern').value,
|
||||
promptPersona: $('promptPersona').value,
|
||||
customPersona: $('customPersona').value,
|
||||
promptFormat: $('promptFormat').value,
|
||||
promptModel: $('promptModel').value.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function applyState(msg) {
|
||||
catalog = msg.catalog;
|
||||
action = msg.action;
|
||||
$('title').textContent = 'LexAI: ' + msg.actionLabel;
|
||||
$('original').value = msg.originalText;
|
||||
$('suggestion').value = msg.suggestion;
|
||||
fillSelect($('writingStyle'), catalog.writingStyles, msg.options.writingStyle, (v) => ({ value: v, label: v }));
|
||||
fillSelect($('promptPattern'), catalog.promptPatterns, msg.options.promptPattern, (p) => ({
|
||||
value: p.id,
|
||||
label: (p.group ? p.group + ' — ' : '') + p.label,
|
||||
}));
|
||||
fillSelect($('promptPersona'), catalog.promptPersonas, msg.options.promptPersona, (v) => ({ value: v, label: v }));
|
||||
fillSelect($('promptFormat'), catalog.promptFormats, msg.options.promptFormat, (v) => ({ value: v, label: v }));
|
||||
$('customPersona').value = msg.options.customPersona || '';
|
||||
$('promptModel').value = msg.options.promptModel || '';
|
||||
syncPromptVisibility();
|
||||
setBusy(!!msg.busy, msg.status || '');
|
||||
if (msg.error) {
|
||||
$('status').textContent = msg.error;
|
||||
$('status').className = 'status error';
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const msg = event.data;
|
||||
if (msg.type === 'state') applyState(msg);
|
||||
if (msg.type === 'busy') setBusy(msg.busy, msg.status);
|
||||
});
|
||||
|
||||
$('promptPersona').addEventListener('change', syncPromptVisibility);
|
||||
$('promptPattern').addEventListener('change', syncPromptVisibility);
|
||||
|
||||
$('accept').addEventListener('click', () => {
|
||||
vscode.postMessage({
|
||||
type: 'accept',
|
||||
suggestion: $('suggestion').value,
|
||||
options: collectOptions(),
|
||||
persistOptions: $('persistOptions').checked,
|
||||
});
|
||||
});
|
||||
$('regenerate').addEventListener('click', () => {
|
||||
vscode.postMessage({
|
||||
type: 'regenerate',
|
||||
options: collectOptions(),
|
||||
persistOptions: $('persistOptions').checked,
|
||||
});
|
||||
});
|
||||
$('discard').addEventListener('click', () => vscode.postMessage({ type: 'discard' }));
|
||||
$('openSettings').addEventListener('click', () => vscode.postMessage({ type: 'openSettings' }));
|
||||
|
||||
vscode.postMessage({ type: 'ready' });
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
377
packages/vscode/src/suggestionZone.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import * as vscode from 'vscode';
|
||||
import {
|
||||
PROMPT_FORMATS,
|
||||
PROMPT_PATTERNS,
|
||||
PROMPT_PERSONAS,
|
||||
WRITING_STYLES,
|
||||
resolvePromptPersona,
|
||||
type ActionId,
|
||||
} from '@lib/actions';
|
||||
import { callProvider } from '@lib/providers';
|
||||
import { readSettings, resolveConfig, updateSettings } from './config';
|
||||
import { generateSuggestion } from './llm';
|
||||
import {
|
||||
actionLabel,
|
||||
applySuggestionReplace,
|
||||
type SuggestionOptions,
|
||||
type SuggestionSession,
|
||||
} from './session';
|
||||
import { withLexAIProgress } from './statusBar';
|
||||
|
||||
const CODE_ASSIST_SYSTEM = [
|
||||
'You are LexAI Code Assist, a senior engineer working inside the user\'s IDE.',
|
||||
'The user highlighted code and described what they want. Follow their instruction precisely.',
|
||||
'You are given the selection plus workspace context (surrounding code, imports, and symbol definitions from other files when available).',
|
||||
'Use that context to resolve references — do not invent APIs that contradict the provided definitions.',
|
||||
'If the instruction asks to change code: return ONLY the replacement code for the selection (no markdown fences, no preamble).',
|
||||
'If the instruction asks to explain, review, or answer a question: return a clear explanation; use short code citations when helpful.',
|
||||
'If context is incomplete, say what is missing instead of guessing.',
|
||||
].join(' ');
|
||||
|
||||
let controller: vscode.CommentController | undefined;
|
||||
let thread: vscode.CommentThread | undefined;
|
||||
let session: SuggestionSession | undefined;
|
||||
let extContext: vscode.ExtensionContext | undefined;
|
||||
|
||||
type OptionKey = 'writingStyle' | 'promptPattern' | 'promptPersona' | 'promptFormat';
|
||||
|
||||
export function registerSuggestionZone(context: vscode.ExtensionContext): void {
|
||||
extContext = context;
|
||||
controller = vscode.comments.createCommentController('lexai', 'LexAI');
|
||||
controller.options = {
|
||||
placeHolder: 'LexAI suggestion',
|
||||
prompt: 'Review the suggestion, then Accept or Regenerate.',
|
||||
};
|
||||
controller.commentingRangeProvider = {
|
||||
provideCommentingRanges: () => [],
|
||||
};
|
||||
context.subscriptions.push(controller);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('lexai.zone.accept', () => void accept()),
|
||||
vscode.commands.registerCommand('lexai.zone.regenerate', () => void regenerate()),
|
||||
vscode.commands.registerCommand('lexai.zone.discard', () => discard()),
|
||||
vscode.commands.registerCommand(
|
||||
'lexai.zone.setOption',
|
||||
(key: OptionKey, value: string) => void setOptionAndRegenerate(key, value),
|
||||
),
|
||||
vscode.commands.registerCommand('lexai.zone.openPanel', async () => {
|
||||
if (!session || !extContext) return;
|
||||
const { openSuggestionPanel } = await import('./suggestionPanel');
|
||||
await openSuggestionPanel(extContext, session);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function openSuggestionZone(
|
||||
context: vscode.ExtensionContext,
|
||||
next: SuggestionSession,
|
||||
): Promise<void> {
|
||||
extContext = context;
|
||||
session = next;
|
||||
if (!controller) registerSuggestionZone(context);
|
||||
|
||||
disposeThread();
|
||||
|
||||
// Comment widgets render *below* their anchor line. Anchor on the previous
|
||||
// line so the zone appears before the first highlighted line.
|
||||
const doc =
|
||||
vscode.workspace.textDocuments.find((d) => d.uri.toString() === next.documentUri.toString()) ??
|
||||
(await vscode.workspace.openTextDocument(next.documentUri));
|
||||
const startLine = next.range.start.line;
|
||||
let anchor: vscode.Range;
|
||||
if (startLine > 0) {
|
||||
const prev = startLine - 1;
|
||||
const col = doc.lineAt(prev).text.length;
|
||||
anchor = new vscode.Range(prev, col, prev, col);
|
||||
} else {
|
||||
anchor = new vscode.Range(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
thread = controller!.createCommentThread(next.documentUri, anchor, [
|
||||
buildComment(next),
|
||||
]);
|
||||
thread.label = `LexAI: ${actionLabel(next.action)}`;
|
||||
thread.contextValue = 'lexaiSuggestion';
|
||||
thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded;
|
||||
thread.canReply = false;
|
||||
}
|
||||
|
||||
function disposeThread(): void {
|
||||
thread?.dispose();
|
||||
thread = undefined;
|
||||
}
|
||||
|
||||
function discard(): void {
|
||||
disposeThread();
|
||||
session = undefined;
|
||||
}
|
||||
|
||||
/** Soft-wrap long lines for readable comment display only (Accept still uses raw text). */
|
||||
function wrapForDisplay(text: string, width = 72): string {
|
||||
return text
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
if (line.length <= width) return line;
|
||||
const words = line.split(/(\s+)/);
|
||||
const rows: string[] = [];
|
||||
let row = '';
|
||||
for (const part of words) {
|
||||
if (row.length + part.length > width && row.length > 0) {
|
||||
rows.push(row);
|
||||
row = part.trimStart();
|
||||
} else {
|
||||
row += part;
|
||||
}
|
||||
}
|
||||
if (row) rows.push(row);
|
||||
return rows.join('\n');
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function cmdLink(title: string, command: string, args: unknown[]): string {
|
||||
const encoded = encodeURIComponent(JSON.stringify(args));
|
||||
return `[${title}](command:${command}?${encoded})`;
|
||||
}
|
||||
|
||||
function optionChip(label: string, key: OptionKey, value: string, selected: boolean): string {
|
||||
const title = selected ? `✓ ${label}` : label;
|
||||
return cmdLink(title, 'lexai.zone.setOption', [key, value]);
|
||||
}
|
||||
|
||||
function buildOptionsRow(s: SuggestionSession): string {
|
||||
if (s.action === 'codeAssist') {
|
||||
const instr = (s.instruction || '').replace(/\n/g, ' ').slice(0, 200);
|
||||
const ctx = s.contextSummary || 'Workspace context attached.';
|
||||
return (
|
||||
`**Instruction** ${instr}\n\n` +
|
||||
`**Context** ${ctx}\n\n` +
|
||||
`_Accept replaces the selection (use for code changes). For explanations, copy from the draft instead._\n\n`
|
||||
);
|
||||
}
|
||||
|
||||
if (s.action === 'prompt') {
|
||||
const patterns = PROMPT_PATTERNS.map((p) =>
|
||||
optionChip(p.label, 'promptPattern', p.id, s.options.promptPattern === p.id),
|
||||
).join(' · ');
|
||||
// Personas / formats: keep the common ones inline; Custom still via chip + input elsewhere
|
||||
const personas = PROMPT_PERSONAS.filter((p) => p !== 'Custom…')
|
||||
.map((p) => optionChip(p, 'promptPersona', p, s.options.promptPersona === p))
|
||||
.join(' · ');
|
||||
const formats = PROMPT_FORMATS.map((p) =>
|
||||
optionChip(p, 'promptFormat', p, s.options.promptFormat === p),
|
||||
).join(' · ');
|
||||
return (
|
||||
`**Pattern** ${patterns}\n\n` +
|
||||
`**Persona** ${personas}\n\n` +
|
||||
`**Format** ${formats}\n\n`
|
||||
);
|
||||
}
|
||||
|
||||
const styles = WRITING_STYLES.map((style) =>
|
||||
optionChip(style, 'writingStyle', style, s.options.writingStyle === style),
|
||||
).join(' · ');
|
||||
return `**Writing style** ${styles}\n\n`;
|
||||
}
|
||||
|
||||
function buildComment(s: SuggestionSession): vscode.Comment {
|
||||
const suggestion = s.suggestion.replace(/\r\n/g, '\n');
|
||||
const display = wrapForDisplay(suggestion);
|
||||
const lineCount = display.split('\n').length;
|
||||
|
||||
const md = new vscode.MarkdownString(undefined, true);
|
||||
md.isTrusted = true;
|
||||
md.supportHtml = true;
|
||||
|
||||
md.appendMarkdown(
|
||||
`[Accept](command:lexai.zone.accept) · [Regenerate](command:lexai.zone.regenerate) · [Discard](command:lexai.zone.discard)\n\n`,
|
||||
);
|
||||
md.appendMarkdown(buildOptionsRow(s));
|
||||
md.appendMarkdown(`_Click an option to apply it and regenerate · ${lineCount} line${lineCount === 1 ? '' : 's'}_\n\n`);
|
||||
md.appendMarkdown('---\n\n');
|
||||
const fence = '````';
|
||||
md.appendMarkdown(`${fence}text\n${display}\n${fence}\n`);
|
||||
|
||||
return {
|
||||
body: md,
|
||||
mode: vscode.CommentMode.Preview,
|
||||
author: { name: 'LexAI' },
|
||||
label: 'suggestion',
|
||||
};
|
||||
}
|
||||
|
||||
function refreshThread(): void {
|
||||
if (!thread || !session) return;
|
||||
thread.label = `LexAI: ${actionLabel(session.action)}`;
|
||||
thread.comments = [buildComment(session)];
|
||||
}
|
||||
|
||||
async function setOptionAndRegenerate(key: OptionKey, value: string): Promise<void> {
|
||||
if (!session) return;
|
||||
|
||||
if (key === 'writingStyle') {
|
||||
session.options = {
|
||||
...session.options,
|
||||
writingStyle: value as SuggestionOptions['writingStyle'],
|
||||
};
|
||||
} else if (key === 'promptPattern') {
|
||||
session.options = { ...session.options, promptPattern: value };
|
||||
} else if (key === 'promptPersona') {
|
||||
session.options = { ...session.options, promptPersona: value };
|
||||
} else if (key === 'promptFormat') {
|
||||
session.options = { ...session.options, promptFormat: value };
|
||||
}
|
||||
|
||||
// Persist as defaults so the next run starts from the last choice.
|
||||
await updateSettings({ [key]: value });
|
||||
refreshThread();
|
||||
await regenerate();
|
||||
}
|
||||
|
||||
async function accept(): Promise<void> {
|
||||
if (!session) return;
|
||||
const ok = await applySuggestionReplace(session, session.suggestion);
|
||||
if (ok) {
|
||||
void vscode.window.showInformationMessage(
|
||||
`LexAI: ${actionLabel(session.action)} applied.`,
|
||||
);
|
||||
discard();
|
||||
}
|
||||
}
|
||||
|
||||
async function regenerate(): Promise<void> {
|
||||
if (!session || !extContext) return;
|
||||
const resolved = await resolveConfig(extContext);
|
||||
if (resolved.error || !resolved.config) {
|
||||
void vscode.window.showErrorMessage(`LexAI: ${resolved.error ?? 'configuration error'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const label = actionLabel(session.action);
|
||||
|
||||
if (session.action === 'codeAssist') {
|
||||
const goal = session.instruction || 'Improve this code.';
|
||||
const ctx = session.contextText || session.originalText;
|
||||
const userPayload = [`### User instruction`, goal, '', ctx].join('\n');
|
||||
const response = await withLexAIProgress(`LexAI: regenerating ${label}…`, () =>
|
||||
callProvider(resolved.config!, userPayload, CODE_ASSIST_SYSTEM, {
|
||||
maxTokens: Math.max(2048, Math.min(8192, Math.ceil(session!.originalText.length + 1500))),
|
||||
}),
|
||||
);
|
||||
if (response.error || !response.result) {
|
||||
void vscode.window.showErrorMessage(
|
||||
`LexAI: ${response.error ?? 'Empty response from provider.'}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
session.suggestion = response.result.replace(/\r\n/g, '\n').trim()
|
||||
.replace(/^```(?:[\w.+-]+)?\n([\s\S]*?)\n```$/, '$1');
|
||||
refreshThread();
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await withLexAIProgress(`LexAI: regenerating ${label}…`, () =>
|
||||
generateSuggestion({
|
||||
action: session!.action as ActionId,
|
||||
text: session!.originalText,
|
||||
config: resolved.config!,
|
||||
writingStyle: session!.options.writingStyle,
|
||||
promptParams: {
|
||||
pattern: session!.options.promptPattern,
|
||||
persona: resolvePromptPersona(
|
||||
session!.options.promptPersona,
|
||||
session!.options.customPersona,
|
||||
),
|
||||
format: session!.options.promptFormat,
|
||||
},
|
||||
promptModel: session!.options.promptModel || undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.error || !response.result) {
|
||||
void vscode.window.showErrorMessage(
|
||||
`LexAI: ${response.error ?? 'Empty response from provider.'}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
session.suggestion = response.result;
|
||||
refreshThread();
|
||||
}
|
||||
|
||||
export async function pickOptionsForAction(
|
||||
action: ActionId,
|
||||
current: SuggestionOptions,
|
||||
): Promise<SuggestionOptions | undefined> {
|
||||
// Kept for askOptionsBeforeGenerate; primary UX is in-zone chips.
|
||||
if (action !== 'prompt') {
|
||||
const style = await vscode.window.showQuickPick([...WRITING_STYLES], {
|
||||
title: 'Writing style',
|
||||
placeHolder: current.writingStyle,
|
||||
});
|
||||
if (!style) return undefined;
|
||||
return { ...current, writingStyle: style as SuggestionOptions['writingStyle'] };
|
||||
}
|
||||
|
||||
const patternPick = await vscode.window.showQuickPick(
|
||||
PROMPT_PATTERNS.map((p) => ({
|
||||
label: p.label,
|
||||
description: p.group,
|
||||
detail: p.hint,
|
||||
id: p.id,
|
||||
})),
|
||||
{ title: 'Prompt pattern', placeHolder: current.promptPattern },
|
||||
);
|
||||
if (!patternPick) return undefined;
|
||||
|
||||
const persona = await vscode.window.showQuickPick([...PROMPT_PERSONAS], {
|
||||
title: 'Persona',
|
||||
placeHolder: current.promptPersona,
|
||||
});
|
||||
if (!persona) return undefined;
|
||||
|
||||
let customPersona = current.customPersona;
|
||||
if (persona === 'Custom…') {
|
||||
customPersona =
|
||||
(await vscode.window.showInputBox({
|
||||
title: 'Custom persona',
|
||||
value: current.customPersona,
|
||||
})) ?? current.customPersona;
|
||||
}
|
||||
|
||||
const format = await vscode.window.showQuickPick([...PROMPT_FORMATS], {
|
||||
title: 'Output format',
|
||||
placeHolder: current.promptFormat,
|
||||
});
|
||||
if (!format) return undefined;
|
||||
|
||||
return {
|
||||
...current,
|
||||
promptPattern: patternPick.id,
|
||||
promptPersona: persona,
|
||||
customPersona,
|
||||
promptFormat: format,
|
||||
};
|
||||
}
|
||||
|
||||
/** Optional: offer options QuickPick before the first generation. */
|
||||
export async function maybeTuneOptionsBeforeRun(
|
||||
action: ActionId,
|
||||
): Promise<SuggestionOptions | undefined> {
|
||||
const settings = readSettings();
|
||||
const base: SuggestionOptions = {
|
||||
writingStyle: settings.writingStyle,
|
||||
promptPattern: settings.promptPattern,
|
||||
promptPersona: settings.promptPersona,
|
||||
customPersona: settings.customPersona,
|
||||
promptFormat: settings.promptFormat,
|
||||
promptModel: settings.promptModel,
|
||||
};
|
||||
|
||||
const tune = vscode.workspace
|
||||
.getConfiguration('lexai')
|
||||
.get<boolean>('askOptionsBeforeGenerate', false);
|
||||
if (!tune) return base;
|
||||
return (await pickOptionsForAction(action, base)) ?? base;
|
||||
}
|
||||
26
packages/vscode/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"types": ["node", "vscode"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@lib/*": ["../../src/lib/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"../../src/lib/actions.ts",
|
||||
"../../src/lib/providers.ts",
|
||||
"../../src/lib/types.ts",
|
||||
"../../src/lib/theme.ts"
|
||||
],
|
||||
"exclude": ["node_modules", "out"]
|
||||
}
|
||||