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