Extension 0.6.0: next-edit, token healing, TS errors context, cursor-move, prewarm
ci / build (push) Canceled after 0s
ci / build (push) Canceled after 0s
- shared/nextedit.ts: next-edit prompt format (region rewrite or NO_EDIT, Qwen spare special tokens) - shared/diagnostics.ts: TS errors near the cursor as context; symbols format 2 adds namespace imports - shared/prompt.ts: token healing (cut the half-typed word, force it via grammar), errors header section - daemon: model profiles carry contextFormat/errors/members/nextEdit; /v1/next-edit with edit-burst tracking; /v1/prewarm; /v1/debug/last; token healing on by default - extension: next-edit decorations (Tab apply / Esc dismiss), suggest on cursor move, prewarm on focus, Show Last Prompt, update check against releases, diagnostics in the edit recorder - models/README: training-data attribution; release script profiles for stage-d/stage-e - .gitea/workflows/ci.yml: build + test, publish release on vscode-v* tags (needs a runner + RELEASE_TOKEN) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J39imRDT8L7xYbWzsVWd8A
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
"displayName": "Local Autocomplete",
|
||||
"description": "Inline code completion from a local fine-tuned model via the acd daemon",
|
||||
"publisher": "harvmaster",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"vscode": "^1.90.0"
|
||||
@@ -37,6 +37,26 @@
|
||||
{
|
||||
"command": "localAutocomplete.showLog",
|
||||
"title": "Local Autocomplete: Show Log"
|
||||
},
|
||||
{
|
||||
"command": "localAutocomplete.applyNextEdit",
|
||||
"title": "Local Autocomplete: Apply Suggested Edit"
|
||||
},
|
||||
{
|
||||
"command": "localAutocomplete.dismissNextEdit",
|
||||
"title": "Local Autocomplete: Dismiss Suggested Edit"
|
||||
},
|
||||
{
|
||||
"command": "localAutocomplete.requestNextEdit",
|
||||
"title": "Local Autocomplete: Suggest Next Edit Here"
|
||||
},
|
||||
{
|
||||
"command": "localAutocomplete.showLastPrompt",
|
||||
"title": "Local Autocomplete: Show Last Prompt"
|
||||
},
|
||||
{
|
||||
"command": "localAutocomplete.checkForUpdates",
|
||||
"title": "Local Autocomplete: Check for Updates"
|
||||
}
|
||||
],
|
||||
"configuration": {
|
||||
@@ -187,9 +207,51 @@
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "If set (e.g. ~/Projects/AutoComplete/daemon/recordings), edit recordings are written here directly and no daemon is needed. Otherwise they are sent to the daemon."
|
||||
},
|
||||
"localAutocomplete.nextEdit.enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Suggest edits beyond the cursor (e.g. remove a leftover \")\", update call sites) after you pause typing. Only with models trained for next-edit."
|
||||
},
|
||||
"localAutocomplete.nextEdit.debounceMs": {
|
||||
"type": "number",
|
||||
"default": 700,
|
||||
"description": "Pause after typing before asking for a next-edit suggestion."
|
||||
},
|
||||
"localAutocomplete.triggerOnCursorMove": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Also suggest when you move the cursor (arrow keys, mouse) and pause. With next-edit models it predicts what you came to change; otherwise only on empty lines, line ends or lines with errors."
|
||||
},
|
||||
"localAutocomplete.cursorMoveDelayMs": {
|
||||
"type": "number",
|
||||
"default": 400,
|
||||
"description": "Pause after moving the cursor before triggerOnCursorMove fires."
|
||||
},
|
||||
"localAutocomplete.prewarmOnFocus": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Prefill the model cache when you switch to a file, so the first keystroke is fast (uses some CPU/GPU when switching files)."
|
||||
},
|
||||
"localAutocomplete.checkForUpdates": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Check the Gitea releases page for a newer version once a day."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"keybindings": [
|
||||
{
|
||||
"command": "localAutocomplete.applyNextEdit",
|
||||
"key": "tab",
|
||||
"when": "editorTextFocus && localAutocomplete.nextEditVisible && !inlineSuggestionVisible && !suggestWidgetVisible"
|
||||
},
|
||||
{
|
||||
"command": "localAutocomplete.dismissNextEdit",
|
||||
"key": "escape",
|
||||
"when": "editorTextFocus && localAutocomplete.nextEditVisible"
|
||||
}
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"compile": "tsc -p . && node build-daemon.mjs",
|
||||
|
||||
@@ -4,7 +4,8 @@ import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as nodePath from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { MODE_DESCRIPTIONS, applySelectedSuggestion, repoAndPath, sliceDocument, statusText, type ContextMode } from './logic'
|
||||
import { MODE_DESCRIPTIONS, applySelectedSuggestion, insertionMakesSense, newerVersion, repoAndPath, sliceDocument, statusText, type ContextMode } from './logic'
|
||||
import { NextEditController } from './nextedit-ui'
|
||||
import * as managed from './managed-core'
|
||||
|
||||
interface DaemonConfig {
|
||||
@@ -12,7 +13,7 @@ interface DaemonConfig {
|
||||
model: string
|
||||
mode: ContextMode
|
||||
effectiveMode: ContextMode
|
||||
models: Record<string, { order: string; modes: ContextMode[]; defaultMode: ContextMode }>
|
||||
models: Record<string, { order: string; modes: ContextMode[]; defaultMode: ContextMode; nextEdit?: boolean }>
|
||||
llama: { running: boolean; loading: boolean; error: string | null }
|
||||
}
|
||||
|
||||
@@ -190,7 +191,35 @@ class EditRecorder {
|
||||
this.timer ??= setTimeout(() => this.flush(), 2000)
|
||||
}
|
||||
|
||||
forget(doc: vscode.TextDocument) { this.counts.delete(doc.uri.toString()); this.lastSelection.delete(doc.uri.toString()) }
|
||||
/** VS Code's own diagnostics (TS, Vue, ESLint…) for recorded files: real "error appeared → fixed" labels. */
|
||||
private lastDiagnostics = new Map<string, string>()
|
||||
onDiagnostics(e: vscode.DiagnosticChangeEvent) {
|
||||
for (const uri of e.uris) {
|
||||
const doc = vscode.workspace.textDocuments.find(d => d.uri.toString() === uri.toString())
|
||||
if (!doc || !this.eligible(doc)) continue
|
||||
const diagnostics = vscode.languages.getDiagnostics(uri)
|
||||
.filter(d => d.severity <= vscode.DiagnosticSeverity.Warning)
|
||||
.map(d => ({
|
||||
line: d.range.start.line, char: d.range.start.character, endLine: d.range.end.line, endChar: d.range.end.character,
|
||||
severity: d.severity === vscode.DiagnosticSeverity.Error ? 'error' : 'warning',
|
||||
code: typeof d.code === 'object' ? d.code.value : d.code, source: d.source, message: d.message,
|
||||
}))
|
||||
const key = uri.toString()
|
||||
const fingerprint = JSON.stringify(diagnostics)
|
||||
if (this.lastDiagnostics.get(key) === fingerprint) continue
|
||||
this.lastDiagnostics.set(key, fingerprint)
|
||||
const folder = vscode.workspace.getWorkspaceFolder(uri)
|
||||
const { repo, path } = repoAndPath(folder?.name, vscode.workspace.asRelativePath(uri, false), doc.fileName)
|
||||
this.queue.push({ kind: 'diagnostics', app: this.app, t: Date.now(), repo, path, version: doc.version, diagnostics })
|
||||
this.timer ??= setTimeout(() => this.flush(), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
forget(doc: vscode.TextDocument) {
|
||||
this.lastDiagnostics.delete(doc.uri.toString())
|
||||
this.counts.delete(doc.uri.toString())
|
||||
this.lastSelection.delete(doc.uri.toString())
|
||||
}
|
||||
|
||||
flush() {
|
||||
if (this.timer) { clearTimeout(this.timer); this.timer = null }
|
||||
@@ -211,6 +240,136 @@ class EditRecorder {
|
||||
}
|
||||
const recorder = new EditRecorder()
|
||||
|
||||
/** Fields every daemon request carries for a document. */
|
||||
function documentBase(document: vscode.TextDocument) {
|
||||
const folder = vscode.workspace.getWorkspaceFolder(document.uri)
|
||||
const { repo, path } = repoAndPath(folder?.name, vscode.workspace.asRelativePath(document.uri, false), document.fileName)
|
||||
return { repo, path, workspaceRoot: folder?.uri.scheme === 'file' ? folder.uri.fsPath : undefined, languageId: document.languageId }
|
||||
}
|
||||
|
||||
function documentAllowed(document: vscode.TextDocument) {
|
||||
const cfg = settings()
|
||||
return cfg.get<boolean>('enableCompletions', true) && !(daemonConfig && !daemonConfig.enabled)
|
||||
&& cfg.get<string[]>('languages', []).includes(document.languageId)
|
||||
&& !cfg.get<string[]>('excludeGlobs', []).some(pattern => vscode.languages.match({ pattern }, document) > 0)
|
||||
}
|
||||
|
||||
const modelSupportsNextEdit = () => !!(daemonConfig && daemonConfig.models[daemonConfig.model]?.nextEdit)
|
||||
|
||||
// ---------- next-edit prediction (models trained for it, e.g. Stage E)
|
||||
const nextEdit = new NextEditController(s => {
|
||||
api('POST', '/v1/feedback', { event: 'next-edit-accepted', lines: s.newText.split('\n').length }).catch(() => {})
|
||||
})
|
||||
let nextEditTimer: NodeJS.Timeout | null = null
|
||||
let nextEditAbort: AbortController | null = null
|
||||
|
||||
async function requestNextEdit(editor: vscode.TextEditor, reason: string) {
|
||||
const doc = editor.document
|
||||
if (!modelSupportsNextEdit() || !settings().get<boolean>('nextEdit.enabled', true) || !documentAllowed(doc)) return
|
||||
nextEditAbort?.abort()
|
||||
const abort = new AbortController()
|
||||
nextEditAbort = abort
|
||||
const version = doc.version
|
||||
try {
|
||||
const res = await api<{ noEdit: boolean; startLine: number; endLine: number; oldText: string; newText: string | null }>('POST', '/v1/next-edit', {
|
||||
...documentBase(doc), text: doc.getText(), cursor: doc.offsetAt(editor.selection.active), reason,
|
||||
}, abort.signal)
|
||||
if (abort.signal.aborted || res.noEdit || !res.newText || editor.document.version !== version) return
|
||||
nextEdit.show(editor, { uri: doc.uri, version, startLine: res.startLine, endLine: res.endLine, oldText: res.oldText, newText: res.newText })
|
||||
} catch (e) {
|
||||
if (!abort.signal.aborted) log.appendLine(`next-edit failed: ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNextEdit(editor: vscode.TextEditor, reason: string, delay: number) {
|
||||
if (nextEditTimer) clearTimeout(nextEditTimer)
|
||||
nextEditTimer = setTimeout(() => void requestNextEdit(editor, reason), delay)
|
||||
}
|
||||
|
||||
// ---------- cursor-move trigger ("why am I here?")
|
||||
const lastVersions = new Map<string, number>()
|
||||
let moveTimer: NodeJS.Timeout | null = null
|
||||
function onCursorMoved(e: vscode.TextEditorSelectionChangeEvent) {
|
||||
nextEdit.onSelectionChanged(e)
|
||||
const doc = e.textEditor.document
|
||||
const key = doc.uri.toString()
|
||||
const editedNow = lastVersions.get(key) !== doc.version
|
||||
lastVersions.set(key, doc.version)
|
||||
// Only plain navigation (keyboard/mouse), not the selection change that comes with typing.
|
||||
if (editedNow || e.kind === vscode.TextEditorSelectionChangeKind.Command || !settings().get<boolean>('triggerOnCursorMove', false)) return
|
||||
if (!documentAllowed(doc)) return
|
||||
if (moveTimer) clearTimeout(moveTimer)
|
||||
moveTimer = setTimeout(() => {
|
||||
if (vscode.window.activeTextEditor !== e.textEditor) return
|
||||
if (modelSupportsNextEdit()) return void requestNextEdit(e.textEditor, 'cursor-move')
|
||||
// Plain FIM can only insert at the cursor: only where an insertion makes sense.
|
||||
const pos = e.textEditor.selection.active
|
||||
const lineText = doc.lineAt(pos.line).text
|
||||
const lineHasError = vscode.languages.getDiagnostics(doc.uri).some(d => d.severity === vscode.DiagnosticSeverity.Error && d.range.start.line <= pos.line && d.range.end.line >= pos.line)
|
||||
if (insertionMakesSense(lineText, pos.character, lineHasError)) void vscode.commands.executeCommand('editor.action.inlineSuggest.trigger')
|
||||
}, settings().get<number>('cursorMoveDelayMs', 400))
|
||||
}
|
||||
|
||||
// ---------- prewarm on focus
|
||||
function prewarm(editor: vscode.TextEditor | undefined) {
|
||||
if (!editor || !settings().get<boolean>('prewarmOnFocus', false) || !documentAllowed(editor.document)) return
|
||||
const doc = editor.document
|
||||
const cfg = settings()
|
||||
const slice = sliceDocument(doc.getText(), doc.offsetAt(editor.selection.active), cfg.get<number>('maxPrefixChars', 200000), cfg.get<number>('maxSuffixChars', 20000))
|
||||
api('POST', '/v1/prewarm', { ...documentBase(doc), ...slice }).catch(() => {})
|
||||
}
|
||||
|
||||
// ---------- show last prompt
|
||||
async function showLastPrompt() {
|
||||
const entries = await api<Record<string, unknown>[]>('GET', '/v1/debug/last?n=1').catch(e => { void vscode.window.showErrorMessage(`Local Autocomplete: ${(e as Error).message}`); return null })
|
||||
if (!entries) return
|
||||
if (!entries.length) return void vscode.window.showInformationMessage('Local Autocomplete: no requests yet')
|
||||
const e = entries[0]
|
||||
const content = [
|
||||
`# Last ${e.kind} request (${e.t})`, '', `model \`${e.model}\`${e.mode ? `, mode \`${e.mode}\`` : ''}, file \`${e.path}\``, '',
|
||||
'## Timings', '```json', JSON.stringify(e.timings, null, 2), '```', '',
|
||||
'## Prompt (decoded, special tokens shown)', '```', String(e.prompt), '```', '',
|
||||
'## Raw model output', '```', String(e.raw), '```', '',
|
||||
...(e.text !== undefined ? ['## Shown to you (after post-processing)', '```', String(e.text), '```'] : []),
|
||||
].join('\n')
|
||||
const doc = await vscode.workspace.openTextDocument({ language: 'markdown', content })
|
||||
await vscode.window.showTextDocument(doc, { preview: true })
|
||||
}
|
||||
|
||||
// ---------- update check (Gitea releases)
|
||||
async function checkForUpdates(manual = false) {
|
||||
const current = extensionCtx.extension.packageJSON.version as string
|
||||
const last = extensionCtx.globalState.get<number>('lastUpdateCheck', 0)
|
||||
if (!manual && (!settings().get<boolean>('checkForUpdates', true) || Date.now() - last < 24 * 3600_000)) return
|
||||
await extensionCtx.globalState.update('lastUpdateCheck', Date.now())
|
||||
try {
|
||||
const res = await fetch('https://git.harvmaster.com/api/v1/repos/Harvmaster/autocomplete-model/releases?limit=20', { signal: AbortSignal.timeout(10000) })
|
||||
const releases = await res.json() as { tag_name: string; html_url: string; assets: { name: string; browser_download_url: string }[] }[]
|
||||
const latest = releases
|
||||
.map(r => ({ r, v: /^vscode-v(\d+\.\d+\.\d+)$/.exec(r.tag_name)?.[1] }))
|
||||
.filter((x): x is { r: typeof releases[number]; v: string } => !!x.v)
|
||||
.sort((a, b) => (newerVersion(a.v, b.v) ? -1 : 1))[0]
|
||||
if (!latest || !newerVersion(latest.v, current)) {
|
||||
if (manual) void vscode.window.showInformationMessage(`Local Autocomplete ${current} is up to date`)
|
||||
return
|
||||
}
|
||||
const asset = latest.r.assets.find(a => a.name.endsWith('.vsix'))
|
||||
const pick = await vscode.window.showInformationMessage(`Local Autocomplete ${latest.v} is available (you have ${current}).`, ...(asset ? ['Install'] : []), 'Release notes')
|
||||
if (pick === 'Release notes') void vscode.env.openExternal(vscode.Uri.parse(latest.r.html_url))
|
||||
if (pick === 'Install' && asset) {
|
||||
const file = nodePath.join(extensionCtx.globalStorageUri.fsPath, asset.name)
|
||||
fs.mkdirSync(nodePath.dirname(file), { recursive: true })
|
||||
fs.writeFileSync(file, Buffer.from(await (await fetch(asset.browser_download_url)).arrayBuffer()))
|
||||
await vscode.commands.executeCommand('workbench.extensions.installExtension', vscode.Uri.file(file))
|
||||
const reload = await vscode.window.showInformationMessage(`Local Autocomplete ${latest.v} installed.`, 'Reload Window')
|
||||
if (reload) void vscode.commands.executeCommand('workbench.action.reloadWindow')
|
||||
}
|
||||
} catch (e) {
|
||||
log.appendLine(`update check failed: ${(e as Error).message}`)
|
||||
if (manual) void vscode.window.showErrorMessage(`Local Autocomplete: update check failed (${(e as Error).message})`)
|
||||
}
|
||||
}
|
||||
|
||||
class Provider implements vscode.InlineCompletionItemProvider {
|
||||
async provideInlineCompletionItems(document: vscode.TextDocument, position: vscode.Position, context: vscode.InlineCompletionContext, token: vscode.CancellationToken) {
|
||||
const cfg = settings()
|
||||
@@ -235,14 +394,12 @@ class Provider implements vscode.InlineCompletionItemProvider {
|
||||
if (debounce > 0) await new Promise(r => setTimeout(r, debounce))
|
||||
if (token.isCancellationRequested) return undefined
|
||||
|
||||
const folder = vscode.workspace.getWorkspaceFolder(document.uri)
|
||||
const { repo, path } = repoAndPath(folder?.name, vscode.workspace.asRelativePath(document.uri, false), document.fileName)
|
||||
const { repo, path, workspaceRoot } = documentBase(document)
|
||||
const slice = sliceDocument(text, cursor, cfg.get<number>('maxPrefixChars', 200000), cfg.get<number>('maxSuffixChars', 20000))
|
||||
const requestId = randomUUID()
|
||||
const abort = new AbortController()
|
||||
const sub = token.onCancellationRequested(() => abort.abort())
|
||||
try {
|
||||
const workspaceRoot = folder?.uri.scheme === 'file' ? folder.uri.fsPath : undefined
|
||||
const res = await api<{ text: string; cancelled?: boolean; replaceBefore?: number; filterText?: string; timings?: { totalMs: number } }>('POST', '/v1/fim', {
|
||||
requestId, repo, path, workspaceRoot, languageId: document.languageId, ...slice,
|
||||
}, abort.signal)
|
||||
@@ -290,12 +447,16 @@ async function showMenu() {
|
||||
{ label: '$(file) Open daemon log', action: 'daemonLog' },
|
||||
] : []),
|
||||
{ label: '$(output) Show log', action: 'log' },
|
||||
{ label: '$(debug-console) Show last prompt', action: 'lastPrompt' },
|
||||
{ label: '$(cloud-download) Check for updates', action: 'updates' },
|
||||
]
|
||||
const pick = await vscode.window.showQuickPick(items, { title: 'Local Autocomplete' })
|
||||
if (pick?.action === 'mode') await selectMode()
|
||||
if (pick?.action === 'model') await selectModel()
|
||||
if (pick?.action === 'toggle') await toggleEnabled()
|
||||
if (pick?.action === 'log') log.show()
|
||||
if (pick?.action === 'lastPrompt') await showLastPrompt()
|
||||
if (pick?.action === 'updates') await checkForUpdates(true)
|
||||
if (pick?.action === 'restart') {
|
||||
await api('POST', '/v1/shutdown').catch(() => {})
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
@@ -381,9 +542,24 @@ export async function activate(ctx: vscode.ExtensionContext) {
|
||||
if (isManaged() && managedKeys.some(k => e.affectsConfiguration(`localAutocomplete.${k}`))) void startManaged()
|
||||
else void refreshConfig()
|
||||
}),
|
||||
vscode.workspace.onDidChangeTextDocument(e => recorder.onChange(e)),
|
||||
vscode.window.onDidChangeTextEditorSelection(e => recorder.onSelection(e)),
|
||||
vscode.window.onDidChangeActiveTextEditor(e => recorder.onActiveEditor(e)),
|
||||
vscode.workspace.onDidChangeTextDocument(e => {
|
||||
recorder.onChange(e)
|
||||
nextEdit.onDocumentChanged(e)
|
||||
// After a pause in typing, ask whether the edit implies a follow-up elsewhere in the region.
|
||||
const editor = vscode.window.activeTextEditor
|
||||
if (editor && editor.document === e.document && e.contentChanges.length && modelSupportsNextEdit()) {
|
||||
scheduleNextEdit(editor, 'edit', settings().get<number>('nextEdit.debounceMs', 700))
|
||||
}
|
||||
}),
|
||||
vscode.window.onDidChangeTextEditorSelection(e => { recorder.onSelection(e); onCursorMoved(e) }),
|
||||
vscode.window.onDidChangeActiveTextEditor(e => { recorder.onActiveEditor(e); prewarm(e) }),
|
||||
vscode.languages.onDidChangeDiagnostics(e => recorder.onDiagnostics(e)),
|
||||
nextEdit,
|
||||
vscode.commands.registerCommand('localAutocomplete.applyNextEdit', () => nextEdit.apply()),
|
||||
vscode.commands.registerCommand('localAutocomplete.dismissNextEdit', () => nextEdit.clear()),
|
||||
vscode.commands.registerCommand('localAutocomplete.requestNextEdit', () => { const ed = vscode.window.activeTextEditor; if (ed) void requestNextEdit(ed, 'manual') }),
|
||||
vscode.commands.registerCommand('localAutocomplete.showLastPrompt', showLastPrompt),
|
||||
vscode.commands.registerCommand('localAutocomplete.checkForUpdates', () => checkForUpdates(true)),
|
||||
vscode.workspace.onDidCloseTextDocument(doc => recorder.forget(doc)),
|
||||
{ dispose: () => recorder.flush() },
|
||||
vscode.window.onDidChangeWindowState(s => { if (s.focused) void refreshConfig() }),
|
||||
@@ -403,8 +579,10 @@ export async function activate(ctx: vscode.ExtensionContext) {
|
||||
status.text = cfg.get<boolean>('recordEdits', false) ? '$(record) AC: recording only' : '$(circle-slash) AC: completions off'
|
||||
if (cfg.get<string>('recordingDir', '')) return
|
||||
}
|
||||
void checkForUpdates()
|
||||
await ensureDaemon()
|
||||
await refreshConfig()
|
||||
prewarm(vscode.window.activeTextEditor)
|
||||
}
|
||||
|
||||
export function deactivate() {}
|
||||
|
||||
@@ -63,3 +63,39 @@ export function statusText(cfg: { enabled: boolean; mode: ContextMode; effective
|
||||
const mode = cfg.effectiveMode && cfg.effectiveMode !== cfg.mode ? `${cfg.mode}→${cfg.effectiveMode}` : cfg.mode
|
||||
return `$(sparkle) AC: ${mode}${lastMs !== null ? ` · ${Math.round(lastMs)} ms` : ''}`
|
||||
}
|
||||
|
||||
/** Line-block diff of a region: the first/last changed lines (old and new) after trimming common lines. */
|
||||
export function lineBlockDiff(oldText: string, newText: string): { oldStart: number; oldEnd: number; newLines: string[]; oldLines: string[] } | null {
|
||||
const a = oldText.split('\n'), b = newText.split('\n')
|
||||
let p = 0
|
||||
while (p < a.length && p < b.length && a[p] === b[p]) p++
|
||||
if (p === a.length && p === b.length) return null
|
||||
let s = 0
|
||||
while (s < a.length - p && s < b.length - p && a[a.length - 1 - s] === b[b.length - 1 - s]) s++
|
||||
return { oldStart: p, oldEnd: a.length - s, oldLines: a.slice(p, a.length - s), newLines: b.slice(p, b.length - s) }
|
||||
}
|
||||
|
||||
/** Character diff of one line: deleted [start, end) in the old line and the text inserted there. */
|
||||
export function charDiff(oldLine: string, newLine: string): { start: number; deleteEnd: number; insert: string } {
|
||||
let p = 0
|
||||
while (p < oldLine.length && p < newLine.length && oldLine[p] === newLine[p]) p++
|
||||
let s = 0
|
||||
while (s < oldLine.length - p && s < newLine.length - p && oldLine[oldLine.length - 1 - s] === newLine[newLine.length - 1 - s]) s++
|
||||
return { start: p, deleteEnd: oldLine.length - s, insert: newLine.slice(p, newLine.length - s) }
|
||||
}
|
||||
|
||||
/** Where cursor-move triggers make sense for plain FIM: empty line, end of line, or on a line with an error. */
|
||||
export function insertionMakesSense(lineText: string, column: number, lineHasError: boolean): boolean {
|
||||
if (!lineText.trim()) return true
|
||||
if (!lineText.slice(column).trim()) return true
|
||||
return lineHasError
|
||||
}
|
||||
|
||||
/** Semver-ish comparison of "a.b.c" strings. */
|
||||
export function newerVersion(candidate: string, current: string): boolean {
|
||||
const pa = candidate.split('.').map(Number), pb = current.split('.').map(Number)
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -26,8 +26,19 @@ export interface ModelEntry {
|
||||
modes: ('symbols' | 'simple' | 'raw')[]
|
||||
defaultMode: 'symbols' | 'simple' | 'raw'
|
||||
description?: string
|
||||
/** Prompt-format flags the model was trained with (passed through to the daemon's model profile). */
|
||||
contextFormat?: 1 | 2
|
||||
errors?: boolean
|
||||
members?: boolean
|
||||
nextEdit?: boolean
|
||||
}
|
||||
|
||||
const profileOf = (m: ModelEntry) => ({
|
||||
order: m.order, modes: m.modes, defaultMode: m.defaultMode,
|
||||
...(m.contextFormat ? { contextFormat: m.contextFormat } : {}), ...(m.errors ? { errors: true } : {}),
|
||||
...(m.members !== undefined ? { members: m.members } : {}), ...(m.nextEdit ? { nextEdit: true } : {}),
|
||||
})
|
||||
|
||||
export interface Manifest {
|
||||
schema: 1
|
||||
default: string
|
||||
@@ -197,7 +208,7 @@ export interface Prepared { configPath: string; instanceTag: string }
|
||||
|
||||
/** Resolves model + llama-server (downloading as needed) and writes the daemon config. */
|
||||
export async function prepare(storage: string, daemonDir: string, extensionVersion: string, s: ManagedSettings, progress: Progress, signal?: AbortSignal): Promise<Prepared> {
|
||||
const models: Record<string, { path: string } & Pick<ModelEntry, 'order' | 'modes' | 'defaultMode'>> = {}
|
||||
const models: Record<string, { path: string } & Partial<ModelEntry>> = {}
|
||||
let defaultModel: string
|
||||
let llamaCpp = DEFAULT_LLAMA_CPP
|
||||
|
||||
@@ -218,11 +229,11 @@ export async function prepare(storage: string, daemonDir: string, extensionVersi
|
||||
if (!entry) throw new Error(`model "${s.model || manifest.default}" is not in the manifest (available: ${manifest.models.map(m => m.name).join(', ')})`)
|
||||
const file = await ensureFile(entry.url, path.join(storage, 'models', entry.file), entry.sha256, `model ${entry.name}`, progress, signal)
|
||||
defaultModel = entry.name
|
||||
models[entry.name] = { path: file, order: entry.order, modes: entry.modes, defaultMode: entry.defaultMode }
|
||||
models[entry.name] = { path: file, ...profileOf(entry) }
|
||||
// Other models already on disk stay switchable from the menu.
|
||||
for (const m of manifest.models) {
|
||||
const f = path.join(storage, 'models', m.file)
|
||||
if (m.name !== entry.name && fs.existsSync(`${f}.verified`)) models[m.name] = { path: f, order: m.order, modes: m.modes, defaultMode: m.defaultMode }
|
||||
if (m.name !== entry.name && fs.existsSync(`${f}.verified`)) models[m.name] = { path: f, ...profileOf(m) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import * as vscode from 'vscode'
|
||||
import { charDiff, lineBlockDiff } from './logic'
|
||||
|
||||
export interface NextEditSuggestion {
|
||||
uri: vscode.Uri
|
||||
version: number
|
||||
startLine: number
|
||||
endLine: number
|
||||
oldText: string
|
||||
newText: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a predicted region rewrite as decorations (VS Code's stable inline-completion API can't show deletions):
|
||||
* - one changed line: deleted characters struck through in place, inserted text as ghost text at its position
|
||||
* - several lines: changed lines highlighted, "Tab to apply" hint, full diff on hover
|
||||
* Tab applies (only while visible and no other inline suggestion / suggest widget is showing), Escape dismisses.
|
||||
*/
|
||||
export class NextEditController implements vscode.Disposable {
|
||||
private current: NextEditSuggestion | null = null
|
||||
private readonly deleted = vscode.window.createTextEditorDecorationType({
|
||||
textDecoration: 'line-through',
|
||||
backgroundColor: new vscode.ThemeColor('diffEditor.removedTextBackground'),
|
||||
})
|
||||
private readonly inserted = vscode.window.createTextEditorDecorationType({})
|
||||
private readonly block = vscode.window.createTextEditorDecorationType({
|
||||
isWholeLine: true,
|
||||
backgroundColor: new vscode.ThemeColor('diffEditor.removedLineBackground'),
|
||||
})
|
||||
private readonly onAccepted: (s: NextEditSuggestion) => void
|
||||
|
||||
constructor(onAccepted: (s: NextEditSuggestion) => void) {
|
||||
this.onAccepted = onAccepted
|
||||
}
|
||||
|
||||
get visible() { return this.current !== null }
|
||||
|
||||
show(editor: vscode.TextEditor, s: NextEditSuggestion) {
|
||||
this.clear()
|
||||
const doc = editor.document
|
||||
// Only show if the region still holds exactly what the model saw.
|
||||
const range = new vscode.Range(s.startLine, 0, Math.min(s.endLine, doc.lineCount), 0)
|
||||
if (doc.version !== s.version || doc.getText(range) !== s.oldText) return
|
||||
const diff = lineBlockDiff(s.oldText, s.newText)
|
||||
if (!diff) return
|
||||
this.current = s
|
||||
const first = s.startLine + diff.oldStart
|
||||
|
||||
if (diff.oldLines.length === 1 && diff.newLines.length === 1) {
|
||||
const d = charDiff(diff.oldLines[0], diff.newLines[0])
|
||||
if (d.deleteEnd > d.start) editor.setDecorations(this.deleted, [new vscode.Range(first, d.start, first, d.deleteEnd)])
|
||||
if (d.insert) {
|
||||
editor.setDecorations(this.inserted, [{
|
||||
range: new vscode.Range(first, d.deleteEnd, first, d.deleteEnd),
|
||||
renderOptions: { before: { contentText: d.insert, color: new vscode.ThemeColor('editorGhostText.foreground'), fontStyle: 'italic', backgroundColor: new vscode.ThemeColor('diffEditor.insertedTextBackground') } },
|
||||
}])
|
||||
}
|
||||
} else {
|
||||
const hover = new vscode.MarkdownString(['**Suggested edit** (Tab to apply, Esc to dismiss)', '```diff', ...diff.oldLines.map(l => `-${l}`), ...diff.newLines.map(l => `+${l}`), '```'].join('\n'))
|
||||
const lines = Math.max(1, diff.oldLines.length)
|
||||
editor.setDecorations(this.block, Array.from({ length: lines }, (_, i) => ({ range: new vscode.Range(first + i, 0, first + i, 0), hoverMessage: hover })))
|
||||
editor.setDecorations(this.inserted, [{
|
||||
range: new vscode.Range(first, Number.MAX_SAFE_INTEGER, first, Number.MAX_SAFE_INTEGER),
|
||||
hoverMessage: hover,
|
||||
renderOptions: { after: { contentText: ` ⇥ Tab: suggested edit (−${diff.oldLines.length} +${diff.newLines.length} lines)`, color: new vscode.ThemeColor('editorGhostText.foreground'), fontStyle: 'italic' } },
|
||||
}])
|
||||
}
|
||||
void vscode.commands.executeCommand('setContext', 'localAutocomplete.nextEditVisible', true)
|
||||
}
|
||||
|
||||
async apply(): Promise<boolean> {
|
||||
const s = this.current
|
||||
const editor = vscode.window.activeTextEditor
|
||||
this.clear()
|
||||
if (!s || !editor || editor.document.uri.toString() !== s.uri.toString()) return false
|
||||
const doc = editor.document
|
||||
const range = new vscode.Range(s.startLine, 0, Math.min(s.endLine, doc.lineCount), 0)
|
||||
if (doc.getText(range) !== s.oldText) return false
|
||||
const ok = await editor.edit(b => b.replace(range, s.newText))
|
||||
if (ok) {
|
||||
const diff = lineBlockDiff(s.oldText, s.newText)
|
||||
if (diff) {
|
||||
const line = s.startLine + diff.oldStart + Math.max(0, diff.newLines.length - 1)
|
||||
const pos = new vscode.Position(line, doc.lineAt(Math.min(line, doc.lineCount - 1)).text.length)
|
||||
editor.selection = new vscode.Selection(pos, pos)
|
||||
}
|
||||
this.onAccepted(s)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Dismiss when the document changes or the cursor leaves the region. */
|
||||
onSelectionChanged(e: vscode.TextEditorSelectionChangeEvent) {
|
||||
const s = this.current
|
||||
if (!s || e.textEditor.document.uri.toString() !== s.uri.toString()) return
|
||||
const line = e.selections[0].active.line
|
||||
if (line < s.startLine - 2 || line > s.endLine + 2) this.clear()
|
||||
}
|
||||
|
||||
onDocumentChanged(e: vscode.TextDocumentChangeEvent) {
|
||||
if (this.current && e.document.uri.toString() === this.current.uri.toString() && e.contentChanges.length) this.clear()
|
||||
}
|
||||
|
||||
clear() {
|
||||
if (!this.current) return
|
||||
this.current = null
|
||||
for (const editor of vscode.window.visibleTextEditors) {
|
||||
editor.setDecorations(this.deleted, [])
|
||||
editor.setDecorations(this.inserted, [])
|
||||
editor.setDecorations(this.block, [])
|
||||
}
|
||||
void vscode.commands.executeCommand('setContext', 'localAutocomplete.nextEditVisible', false)
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.clear()
|
||||
this.deleted.dispose()
|
||||
this.inserted.dispose()
|
||||
this.block.dispose()
|
||||
}
|
||||
}
|
||||
@@ -36,3 +36,28 @@ test('status text', () => {
|
||||
assert.match(statusText({ enabled: true, mode: 'symbols', effectiveMode: 'simple', model: 'stage-c' }, 41.6), /symbols→simple · 42 ms/)
|
||||
assert.match(statusText(null, null), /no daemon/)
|
||||
})
|
||||
|
||||
import { lineBlockDiff, charDiff, insertionMakesSense, newerVersion } from '../dist/logic.js'
|
||||
|
||||
test('leftover paren is a one-character deletion', () => {
|
||||
const d = charDiff('const v = fromString(someString))', 'const v = fromString(someString)')
|
||||
assert.deepEqual(d, { start: 32, deleteEnd: 33, insert: '' })
|
||||
})
|
||||
|
||||
test('line block diff finds the changed lines', () => {
|
||||
const d = lineBlockDiff('a\nb\nc\nd\n', 'a\nB\nB2\nd\n')
|
||||
assert.deepEqual(d, { oldStart: 1, oldEnd: 3, oldLines: ['b', 'c'], newLines: ['B', 'B2'] })
|
||||
assert.equal(lineBlockDiff('x\n', 'x\n'), null)
|
||||
})
|
||||
|
||||
test('cursor-move gate', () => {
|
||||
assert.equal(insertionMakesSense(' ', 4, false), true)
|
||||
assert.equal(insertionMakesSense(' foo()', 7, false), true)
|
||||
assert.equal(insertionMakesSense(' foo()', 3, false), false)
|
||||
assert.equal(insertionMakesSense(' foo()', 3, true), true)
|
||||
})
|
||||
|
||||
test('version comparison', () => {
|
||||
assert.equal(newerVersion('0.10.0', '0.9.3'), true)
|
||||
assert.equal(newerVersion('0.5.0', '0.5.0'), false)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user