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.
This commit is contained in:
john kevin asprec
2026-08-13 18:06:45 +08:00
parent fc19ead0a7
commit 8bc529ef2d
84 changed files with 10025 additions and 662 deletions

4
packages/vscode/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
out/
*.vsix
*.tsbuildinfo

13
packages/vscode/.vscode/launch.json vendored Normal file
View 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
View File

@@ -0,0 +1,15 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "compile",
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": ["$tsc"],
"label": "npm: compile"
}
]
}

View 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
View 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
View 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
View 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 editors 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 |

View 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');
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View 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

View 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

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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);
}
}

View 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;
}

View 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));
}

View 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],
};
}

View 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 });
}

View 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);
}

View 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);
}

View 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);
});
}

View 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>`;
}

View 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>`;
}

View 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;
}

View 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>`;
}

View 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;
}

View 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"]
}