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.
606 lines
19 KiB
TypeScript
606 lines
19 KiB
TypeScript
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>`;
|
|
}
|