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

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