Extension 0.6.0: next-edit, token healing, TS errors context, cursor-move, prewarm
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:
2026-10-03 17:21:38 +00:00
co-authored by Claude Opus 5.5
parent af302df26a
commit 87d3c3c3bf
24 changed files with 1090 additions and 53 deletions
+64 -2
View File
@@ -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",
+187 -9
View File
@@ -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() {}
+36
View File
@@ -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
}
+14 -3
View File
@@ -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) }
}
}
+121
View File
@@ -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()
}
}
+25
View File
@@ -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)
})