diff --git a/CHANGELOG.md b/CHANGELOG.md index 541cd9f9..a4ec6dbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,26 +3,29 @@ -## Jan. 12, 2025 - Entering beta +## Jan. 13, 2025 - Entering beta +- Added quick edits! Void handles FIM-prompting, output parsing, and history management for inline UI. - Migrated away from VS Code extension API - Void now lives and interacts entirely within the VS Code codebase. -- Added quick edits! Void handles FIM-prompting and output parsing, inline UI, and history management. - - New settings page with model configuration, one-click switch, and user settings. - Added auto-detection (via polling) of local models by default. - LLM requests originate from `node/`, which fixes common CORS and CSP issues when running some models locally. -- Misc improvements like UI and history for Accept | Reject in the sidebar and editor, stream interruptions, and past chats history. +- Misc improvements like UI and history for Accept | Reject in the sidebar and editor, streaming interruptions, and past chat history. + +- Automatic file selection on tab switches. - Lots of new UI, misc bug fixes, and performance improvements. +- VS Code's default Ctrl+L is now Ctrl+M in Void (on Mac Cmd+L becomes Cmd+M). + - Switched from the MIT License to the Apache 2.0 License. Apache's attribution clause provides a small amount of protection to our source initiative. -Many thanks to our contributors __, __, __ +A huge shoutout to our many contributors. If you'd like to help build Void, ## Sept/Oct. 2024 - Early launch diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f911ae3d..be386721 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,7 +46,7 @@ First, run `npm install -g node-gyp`. Then: To build Void, open `void/` inside VSCode. Then open your terminal and run: 1. `npm install` to install all dependencies. -2. `npm run watchreact` to build Void's browser dependencies like React. +2. `npm run watchreact` to build Void's browser dependencies like React. (If this doesn't work, try `npm run buildreact`). 3. Build Void. - Press Cmd+Shift+B (Mac). - Press Ctrl+Shift+B (Windows/Linux). diff --git a/build/darwin/create-universal-app.js b/build/darwin/create-universal-app.js index a3daf187..e6a355d5 100644 --- a/build/darwin/create-universal-app.js +++ b/build/darwin/create-universal-app.js @@ -4,6 +4,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ Object.defineProperty(exports, "__esModule", { value: true }); +// Void explanation - product-build-darwin-universal.yml runs this (create-universal-app.ts), then sign.ts const path = require("path"); const fs = require("fs"); const minimatch = require("minimatch"); diff --git a/build/darwin/sign.js b/build/darwin/sign.js index feb5834f..90c2e825 100644 --- a/build/darwin/sign.js +++ b/build/darwin/sign.js @@ -78,24 +78,24 @@ async function main(buildDir) { // universal will get its copy from the x64 build. if (arch !== 'universal') { await (0, cross_spawn_promise_1.spawn)('plutil', [ - '-insert', + '-replace', // Void changed this to replace 'NSAppleEventsUsageDescription', '-string', - 'An application in Visual Studio Code wants to use AppleScript.', + 'An application in Void wants to use AppleScript.', `${infoPlistPath}` ]); await (0, cross_spawn_promise_1.spawn)('plutil', [ '-replace', 'NSMicrophoneUsageDescription', '-string', - 'An application in Visual Studio Code wants to use the Microphone.', + 'An application in Void wants to use the Microphone.', `${infoPlistPath}` ]); await (0, cross_spawn_promise_1.spawn)('plutil', [ '-replace', 'NSCameraUsageDescription', '-string', - 'An application in Visual Studio Code wants to use the Camera.', + 'An application in Void wants to use the Camera.', `${infoPlistPath}` ]); } diff --git a/build/darwin/sign.ts b/build/darwin/sign.ts index 9e605801..a41ca30d 100644 --- a/build/darwin/sign.ts +++ b/build/darwin/sign.ts @@ -89,7 +89,7 @@ async function main(buildDir?: string): Promise { // universal will get its copy from the x64 build. if (arch !== 'universal') { await spawn('plutil', [ - '-insert', + '-replace', // Void changed this to replace 'NSAppleEventsUsageDescription', '-string', 'An application in Void wants to use AppleScript.', diff --git a/extensions/void/LangaugeServerTest/createJsProgramGraph.ts b/extensions/void/LangaugeServerTest/createJsProgramGraph.ts deleted file mode 100644 index 4fb9bcaf..00000000 --- a/extensions/void/LangaugeServerTest/createJsProgramGraph.ts +++ /dev/null @@ -1,333 +0,0 @@ -import * as vscode from 'vscode'; -import Parser from 'tree-sitter'; -import JavaScript from 'tree-sitter-javascript'; - -interface Definition { - file: string; - node: Parser.SyntaxNode; -} - -interface DefnUse { - parent: Parser.SyntaxNode; - file: string; -} - -interface ImportInfo { - source: string; - imported: string; -} - -class ProjectAnalyzer { - private parser: Parser; - private graph: Map>; - private visited: Set; - private parsedFiles: Map; - private imports: Map>; - private definitions: Map; - private fileStack: Set; - - constructor() { - this.parser = new Parser(); - this.parser.setLanguage(JavaScript); - this.graph = new Map(); - this.visited = new Set(); - this.parsedFiles = new Map(); - this.imports = new Map(); - this.definitions = new Map(); - this.fileStack = new Set(); - } - - async parseFile(filePath: string): Promise { - if (this.parsedFiles.has(filePath)) { - return this.parsedFiles.get(filePath)!; - } - - if (this.fileStack.has(filePath)) { - return null; // Circular import - } - - this.fileStack.add(filePath); - - try { - const uri = vscode.Uri.file(filePath); - const document = await vscode.workspace.openTextDocument(uri); - const code = document.getText(); - const tree = this.parser.parse(code); - - this.parsedFiles.set(filePath, tree); - this.collectImports(filePath, tree); - this.collectDefinitions(filePath, tree); - - return tree; - } catch (error) { - console.error(`Error parsing ${filePath}:`, error); - return null; - } finally { - this.fileStack.delete(filePath); - } - } - - private collectImports(filePath: string, tree: Parser.Tree): void { - const fileImports = new Map(); - - const visit = (node: Parser.SyntaxNode): void => { - if (node.type === 'import_declaration') { - const source = node.childForFieldName('source')?.text.slice(1, -1) ?? ''; - const specifiers = node.childForFieldName('specifiers'); - - specifiers?.children.forEach(spec => { - if (spec.type === 'import_specifier') { - const local = spec.childForFieldName('local')?.text ?? ''; - const imported = spec.childForFieldName('imported')?.text ?? ''; - fileImports.set(local, { source, imported }); - } - }); - } - node.children.forEach(visit); - }; - - visit(tree.rootNode); - this.imports.set(filePath, fileImports); - } - - private collectDefinitions(filePath: string, tree: Parser.Tree): void { - const visit = (node: Parser.SyntaxNode): void => { - if (node.type === 'function_declaration') { - const name = node.childForFieldName('name')?.text ?? ''; - this.definitions.set(name, { file: filePath, node }); - } - else if (node.type === 'variable_declarator') { - const name = node.childForFieldName('name')?.text; - const value = node.childForFieldName('value'); - if (name && (value?.type === 'arrow_function' || value?.type === 'function')) { - this.definitions.set(name, { file: filePath, node: value }); - } - } - node.children.forEach(visit); - }; - - visit(tree.rootNode); - } - - private async getTypeFromPosition(uri: vscode.Uri, position: vscode.Position): Promise { - const hover = await vscode.commands.executeCommand( - 'vscode.executeHoverProvider', - uri, - position - ); - - if (hover?.[0]?.contents.length) { - for (const content of hover[0].contents) { - let hoverText = typeof content === 'string' ? - content : - ('value' in content ? content.value : ''); - - // Remove typescript backticks if present - hoverText = hoverText.replace(/```typescript\s*/, '').replace(/```\s*$/, ''); - console.log('Processing hover text:', hoverText); - - // Extract the type information - look for the type after the colon - const typeMatches = [ - /:\s*([\w<>]+)(?:\[\])?/, // matches "foo: Type" or "foo: Type[]" - /var\s+\w+:\s*([\w<>]+)/, // matches "var foo: Type" - /\(type\)\s+[\w<>]+:\s*([\w<>]+)/, // matches "(type) foo: Type" - /\(method\)\s*([\w<>]+)\./ // matches "(method) Type.method" - ]; - - for (const pattern of typeMatches) { - const match = pattern.exec(hoverText); - if (match) { - let type = match[1]; - // Handle array types - if (hoverText.includes('[]')) { - return 'Array'; - } - // Extract base type from generics - if (type.includes('<')) { - type = type.split('<')[0]; - } - return type; - } - } - } - } - return null; - } - - private async getCallsInDefn(defnNode: Parser.SyntaxNode, currentFile: string): Promise> { - const calls = new Set(); - const fileImports = this.imports.get(currentFile) ?? new Map(); - const uri = vscode.Uri.file(currentFile); - - const visit = async (node: Parser.SyntaxNode): Promise => { - if (node.type === 'call_expression') { - const callee = node.childForFieldName('function'); - if (callee?.type === 'identifier') { - const name = callee.text; - const importInfo = fileImports.get(name); - if (importInfo) { - calls.add(`${importInfo.source}:${importInfo.imported}`); - } else { - calls.add(name); - } - } - else if (callee?.type === 'member_expression') { - const method = callee.childForFieldName('property')?.text; - const object = callee.childForFieldName('object'); - - if (method && object) { - const position = new vscode.Position( - object.startPosition.row, - object.startPosition.column - ); - - const type = await this.getTypeFromPosition(uri, position); - if (type) { - calls.add(`${type}.${method}`); - } else { - calls.add(`method:${method}`); - } - } - } - } - - for (const child of node.children) { - await visit(child); - } - }; - - await visit(defnNode); - return calls; - } - - private gotoDefn(name: string): Definition | null { - if (name.includes(':')) { - const [file, funcName] = name.split(':'); - const def = this.definitions.get(funcName); - return def ?? null; - } - - return this.definitions.get(name) ?? null; - } - - private getUses(defnNode: Parser.SyntaxNode, currentFile: string): DefnUse[] { - const uses: DefnUse[] = []; - - let fnName: string | undefined; - if (defnNode.type === 'function_declaration') { - fnName = defnNode.childForFieldName('name')?.text; - } else if (defnNode.type === 'arrow_function' || defnNode.type === 'function') { - const parent = defnNode.parent; - if (parent?.type === 'variable_declarator') { - fnName = parent.childForFieldName('name')?.text; - } - } - - if (!fnName) return uses; - - for (const [file, tree] of this.parsedFiles) { - const visit = (node: Parser.SyntaxNode): void => { - if (node.type === 'call_expression') { - const callee = node.childForFieldName('function'); - if (callee?.type === 'identifier' && callee.text === fnName) { - let current: Parser.SyntaxNode | null = node; - while (current) { - if (current.type === 'function_declaration' || - current.type === 'arrow_function' || - current.type === 'function') { - uses.push({ parent: current, file }); - break; - } - current = current.parent; - } - } - } - node.children.forEach(visit); - }; - - visit(tree.rootNode); - } - - return uses; - } - - private async visitAllNodesInGraphFromDefinition(defn: Parser.SyntaxNode, currentFile: string): Promise { - let defnName: string | undefined; - if (defn.type === 'function_declaration') { - defnName = defn.childForFieldName('name')?.text; - } else if (defn.type === 'arrow_function' || defn.type === 'function') { - const parent = defn.parent; - if (parent?.type === 'variable_declarator') { - defnName = parent.childForFieldName('name')?.text; - } - } - - if (!defnName) return; - - const fullName = `${currentFile}:${defnName}`; - if (this.visited.has(fullName)) return; - - const calls = await this.getCallsInDefn(defn, currentFile); - this.graph.set(fullName, calls); - this.visited.add(fullName); - - const callDefns = Array.from(calls).map(call => this.gotoDefn(call)); - for (const callDefn of callDefns) { - if (callDefn) { - await this.visitAllNodesInGraphFromDefinition(callDefn.node, callDefn.file); - } - } - - const defnUses = this.getUses(defn, currentFile); - for (const defnUse of defnUses) { - await this.visitAllNodesInGraphFromDefinition(defnUse.parent, defnUse.file); - } - } - - async analyze(entryFile: string): Promise>> { - const tree = await this.parseFile(entryFile); - if (!tree) return new Map(); - - const visit = async (node: Parser.SyntaxNode): Promise => { - if (node.type === 'function_declaration') { - await this.visitAllNodesInGraphFromDefinition(node, entryFile); - } - else if (node.type === 'variable_declarator') { - const value = node.childForFieldName('value'); - if (value?.type === 'arrow_function' || value?.type === 'function') { - await this.visitAllNodesInGraphFromDefinition(value, entryFile); - } - } - for (const child of node.children) { - await visit(child); - } - }; - - await visit(tree.rootNode); - return this.graph; - } -} - -export async function runTreeSitter(filePath?: string): Promise> | null> { - const editor = vscode.window.activeTextEditor; - if (!editor && !filePath) { - vscode.window.showWarningMessage('No active editor found'); - return null; - } - - try { - const targetPath = filePath ?? editor!.document.uri.fsPath; - const analyzer = new ProjectAnalyzer(); - const graph = await analyzer.analyze(targetPath); - - for (const [defn, calls] of graph) { - console.log(`${defn} calls: ${[...calls].join(', ')}`); - } - - return graph; - } catch (error) { - console.error('Error analyzing file:', error); - vscode.window.showErrorMessage('Error analyzing file'); - return null; - } -} \ No newline at end of file diff --git a/extensions/void/LangaugeServerTest/findFunctions.ts b/extensions/void/LangaugeServerTest/findFunctions.ts deleted file mode 100644 index 570b4369..00000000 --- a/extensions/void/LangaugeServerTest/findFunctions.ts +++ /dev/null @@ -1,62 +0,0 @@ -import * as vscode from 'vscode'; - -const legend = new vscode.SemanticTokensLegend([], []); - -export async function findFunctions() { - - const editor = vscode.window.activeTextEditor; - if (!editor) return; - const document = editor.document; - - const tokens = await vscode.commands.executeCommand( - 'vscode.provideDocumentSemanticTokens', - document.uri - ); - - if (!tokens) { - console.error('No tokens found'); - return []; - } - - const allTokens = decodeTokens(tokens, document); - - - return allTokens; -} - -function decodeTokens(tokens: vscode.SemanticTokens, document: vscode.TextDocument) { - const data = tokens.data; - const decodedTokens = []; - let line = 0; - let character = 0; - - for (let i = 0; i < data.length; i += 5) { - const deltaLine = data[i]; - const deltaStartChar = data[i + 1]; - const length = data[i + 2]; - const tokenTypeIdx = data[i + 3]; - const tokenModifierIdx = data[i + 4]; - - line += deltaLine; - character = deltaLine === 0 ? character + deltaStartChar : deltaStartChar; - - const type = legend.tokenTypes[tokenTypeIdx] || `(${tokenTypeIdx})`; - const modifier = legend.tokenModifiers[tokenModifierIdx] || `(${tokenModifierIdx})`; - - const tokenRange = new vscode.Range(line, character, line, character + length); - const tokenText = document.getText(tokenRange); - - decodedTokens.push({ - line, - startCharacter: character, - length, - type, - modifier, - text: tokenText, - }); - - console.log(`Token: '${tokenText}' | Type: ${type} | Modifier: ${modifier} | Line: ${line}, Character: ${character}`); - } - - return decodedTokens; -} diff --git a/resources/win32/VisualElementsManifest.xml b/resources/win32/VisualElementsManifest.xml index 40efd0a3..71b18418 100644 --- a/resources/win32/VisualElementsManifest.xml +++ b/resources/win32/VisualElementsManifest.xml @@ -1,9 +1,9 @@ + ForegroundText="light" + ShortDisplayName="Void" /> diff --git a/resources/win32/logo_cube_noshadow.png b/resources/win32/logo_cube_noshadow.png new file mode 100644 index 00000000..225179f8 Binary files /dev/null and b/resources/win32/logo_cube_noshadow.png differ diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 1a027000..f9c50efa 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -124,7 +124,8 @@ import { ExtensionSignatureVerificationService, IExtensionSignatureVerificationS import { LLMMessageChannel } from '../../platform/void/electron-main/llmMessageChannel.js'; import { IMetricsService } from '../../platform/void/common/metricsService.js'; import { MetricsMainService } from '../../platform/void/electron-main/metricsMainService.js'; - +import { VoidMainUpdateService } from '../../platform/void/electron-main/voidUpdateMainService.js'; +import { IVoidUpdateService } from '../../platform/void/common/voidUpdateService.js'; /** * The main VS Code application. There will only ever be one instance, * even if the user starts many instances (e.g. from the command line). @@ -1107,6 +1108,7 @@ export class CodeApplication extends Disposable { // Void main process services (required for services with a channel for comm between browser and electron-main (node)) services.set(IMetricsService, new SyncDescriptor(MetricsMainService, undefined, false)); + services.set(IVoidUpdateService, new SyncDescriptor(VoidMainUpdateService, undefined, false)); // Default Extensions Profile Init services.set(IExtensionsProfileScannerService, new SyncDescriptor(ExtensionsProfileScannerService, undefined, true)); @@ -1245,6 +1247,10 @@ export class CodeApplication extends Disposable { // Void - use loggerChannel as reference const metricsChannel = ProxyChannel.fromService(accessor.get(IMetricsService), disposables); mainProcessElectronServer.registerChannel('void-channel-metrics', metricsChannel); + + const voidUpdatesChannel = ProxyChannel.fromService(accessor.get(IVoidUpdateService), disposables); + mainProcessElectronServer.registerChannel('void-channel-update', voidUpdatesChannel); + const llmMessageChannel = new LLMMessageChannel(accessor.get(IMetricsService)); mainProcessElectronServer.registerChannel('void-channel-llmMessageService', llmMessageChannel); diff --git a/src/vs/platform/extensions/common/extensionValidator.ts b/src/vs/platform/extensions/common/extensionValidator.ts index 94f3bcf8..e9736b22 100644 --- a/src/vs/platform/extensions/common/extensionValidator.ts +++ b/src/vs/platform/extensions/common/extensionValidator.ts @@ -404,8 +404,8 @@ function isVersionValid(currentVersion: string, date: ProductDate, requestedVers if (!isValidVersion(currentVersion, date, desiredVersion)) { // Void - ignore not compatible - // notices.push(nls.localize('versionMismatch', "Extension is not compatible with Code {0}. Extension requires: {1}.", currentVersion, requestedVersion)); - // return false; + notices.push(nls.localize('versionMismatch', "Extension is not compatible with Code {0}. Extension requires: {1}.", currentVersion, requestedVersion)); + return false; } return true; diff --git a/src/vs/platform/keybinding/common/keybindingsRegistry.ts b/src/vs/platform/keybinding/common/keybindingsRegistry.ts index eeabcc55..e83230ae 100644 --- a/src/vs/platform/keybinding/common/keybindingsRegistry.ts +++ b/src/vs/platform/keybinding/common/keybindingsRegistry.ts @@ -64,7 +64,8 @@ export const enum KeybindingWeight { EditorContrib = 100, WorkbenchContrib = 200, BuiltinExtension = 300, - ExternalExtension = 400 + ExternalExtension = 400, + VoidExtension = 605, // Void - must trump any external extension } export interface ICommandAndKeybindingRule extends IKeybindingRule { diff --git a/src/vs/platform/telemetry/common/telemetryService.ts b/src/vs/platform/telemetry/common/telemetryService.ts index c3e05eb2..eb04c7a1 100644 --- a/src/vs/platform/telemetry/common/telemetryService.ts +++ b/src/vs/platform/telemetry/common/telemetryService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { DisposableStore } from '../../../base/common/lifecycle.js'; +// import { mixin } from '../../../base/common/objects.js'; import { isWeb } from '../../../base/common/platform.js'; import { escapeRegExpCharacters } from '../../../base/common/strings.js'; import { localize } from '../../../nls.js'; @@ -15,6 +16,7 @@ import { Registry } from '../../registry/common/platform.js'; import { ClassifiedEvent, IGDPRProperty, OmitMetadata, StrictPropertyCheck } from './gdprTypings.js'; import { ITelemetryData, ITelemetryService, TelemetryConfiguration, TelemetryLevel, TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SECTION_ID, TELEMETRY_SETTING_ID, ICommonProperties } from './telemetry.js'; import { getTelemetryLevel, ITelemetryAppender } from './telemetryUtils.js'; +// import { cleanData } from './telemetryUtils.js'; export interface ITelemetryServiceConfig { appenders: ITelemetryAppender[]; diff --git a/src/vs/platform/update/electron-main/abstractUpdateService.ts b/src/vs/platform/update/electron-main/abstractUpdateService.ts index 2d3134d8..92707e34 100644 --- a/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -3,11 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { timeout } from '../../../base/common/async.js'; +// import { timeout } from '../../../base/common/async.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; +// import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { ILifecycleMainService, LifecycleMainPhase } from '../../lifecycle/electron-main/lifecycleMainService.js'; import { ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; @@ -60,7 +61,7 @@ export abstract class AbstractUpdateService implements IUpdateService { @IEnvironmentMainService private readonly environmentMainService: IEnvironmentMainService, @IRequestService protected requestService: IRequestService, @ILogService protected logService: ILogService, - @IProductService protected readonly productService: IProductService + @IProductService protected readonly productService: IProductService, ) { lifecycleMainService.when(LifecycleMainPhase.AfterWindowOpen) .finally(() => this.initialize()); @@ -79,72 +80,29 @@ export abstract class AbstractUpdateService implements IUpdateService { } console.log('is built, continuing with update service') - // Void commented this - // if (this.environmentMainService.disableUpdates) { - // this.setState(State.Disabled(DisablementReason.DisabledByEnvironment)); - // this.logService.info('update#ctor - updates are disabled by the environment'); - // return; - // } - - // if (!this.productService.updateUrl || !this.productService.commit) { - // this.setState(State.Disabled(DisablementReason.MissingConfiguration)); - // this.logService.info('update#ctor - updates are disabled as there is no update URL'); - // return; - // } - - // Void - for now, always update - - const updateMode = 'default' //this.configurationService.getValue<'none' | 'manual' | 'start' | 'default'>('update.mode'); - - const quality = this.getProductQuality(updateMode); - if (!quality) { - this.setState(State.Disabled(DisablementReason.ManuallyDisabled)); - this.logService.info('update#ctor - updates are disabled by user preference'); - return; - } - - // const quality = 'stable' - this.url = this.doBuildUpdateFeedUrl(quality); + this.url = this.doBuildUpdateFeedUrl('stable'); if (!this.url) { this.setState(State.Disabled(DisablementReason.InvalidConfiguration)); this.logService.info('update#ctor - updates are disabled as the update URL is badly formed'); return; } - // hidden setting - if (this.configurationService.getValue('_update.prss')) { - const url = new URL(this.url); - url.searchParams.set('prss', 'true'); - this.url = url.toString(); - } + this.setState(State.Disabled(DisablementReason.ManuallyDisabled)); - this.setState(State.Idle(this.getUpdateType())); - // if (updateMode === 'manual') { - // this.logService.info('update#ctor - manual checks only; automatic updates are disabled by user preference'); - // return; - // } + // Void - temporarily disabled while we figure out how to do this the right way - // if (updateMode === 'start') { - // this.logService.info('update#ctor - startup checks only; automatic updates are disabled by user preference'); + // this.setState(State.Idle(this.getUpdateType())); - // // Check for updates only once after 30 seconds - // setTimeout(() => this.checkForUpdates(false), 30 * 1000); - // } else { - // Start checking for updates after 30 seconds - this.scheduleCheckForUpdates(30 * 1000).then(undefined, err => this.logService.error(err)); - // } + // start checking for updates after 10 seconds + // this.scheduleCheckForUpdates(10 * 1000).then(undefined, err => this.logService.error(err)); } - private getProductQuality(updateMode: string): string | undefined { - return updateMode === 'none' ? undefined : this.productService.quality; - } - - private async scheduleCheckForUpdates(delay = 60 * 60 * 1000): Promise { - await timeout(delay); - await this.checkForUpdates(false); - return await this.scheduleCheckForUpdates(60 * 60 * 1000); - } + // private async scheduleCheckForUpdates(delay = 60 * 60 * 1000): Promise { + // await timeout(delay); + // await this.checkForUpdates(false); + // return await this.scheduleCheckForUpdates(60 * 60 * 1000); + // } async checkForUpdates(explicit: boolean): Promise { this.logService.trace('update#checkForUpdates, state = ', this.state.type); diff --git a/src/vs/platform/update/electron-main/updateService.darwin.ts b/src/vs/platform/update/electron-main/updateService.darwin.ts index c521b76f..5b00195a 100644 --- a/src/vs/platform/update/electron-main/updateService.darwin.ts +++ b/src/vs/platform/update/electron-main/updateService.darwin.ts @@ -34,7 +34,7 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau @IEnvironmentMainService environmentMainService: IEnvironmentMainService, @IRequestService requestService: IRequestService, @ILogService logService: ILogService, - @IProductService productService: IProductService + @IProductService productService: IProductService, ) { super(lifecycleMainService, configurationService, environmentMainService, requestService, logService, productService); diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index c987ecce..0e2396a6 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -67,7 +67,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun @ILogService logService: ILogService, @IFileService private readonly fileService: IFileService, @INativeHostMainService private readonly nativeHostMainService: INativeHostMainService, - @IProductService productService: IProductService + @IProductService productService: IProductService, ) { super(lifecycleMainService, configurationService, environmentMainService, requestService, logService, productService); diff --git a/src/vs/platform/void/browser/void.contribution.ts b/src/vs/platform/void/browser/void.contribution.ts index 1b3cddd2..276d6e72 100644 --- a/src/vs/platform/void/browser/void.contribution.ts +++ b/src/vs/platform/void/browser/void.contribution.ts @@ -16,3 +16,6 @@ import '../common/refreshModelService.js' // metrics import '../common/metricsService.js' + +// updates +import '../common/voidUpdateService.js' diff --git a/src/vs/platform/void/common/llmMessageService.ts b/src/vs/platform/void/common/llmMessageService.ts index 9fd039c6..caaeb0c8 100644 --- a/src/vs/platform/void/common/llmMessageService.ts +++ b/src/vs/platform/void/common/llmMessageService.ts @@ -65,18 +65,18 @@ export class LLMMessageService extends Disposable implements ILLMMessageService this._onRequestIdDone(e.requestId) })) this._register((this.channel.listen('onError_llm') satisfies Event)(e => { - console.log('Error in LLMMessageService:', JSON.stringify(e)) + console.error('Error in LLMMessageService:', JSON.stringify(e)) this.onErrorHooks_llm[e.requestId]?.(e) this._onRequestIdDone(e.requestId) })) - // ollama + // ollama .list() this._register((this.channel.listen('onSuccess_ollama') satisfies Event>)(e => { this.onSuccess_ollama[e.requestId]?.(e) })) this._register((this.channel.listen('onError_ollama') satisfies Event>)(e => { this.onError_ollama[e.requestId]?.(e) })) - // openaiCompatible + // openaiCompatible .list() this._register((this.channel.listen('onSuccess_openAICompatible') satisfies Event>)(e => { this.onSuccess_openAICompatible[e.requestId]?.(e) })) @@ -88,7 +88,7 @@ export class LLMMessageService extends Disposable implements ILLMMessageService sendLLMMessage(params: ServiceSendLLMMessageParams) { const { onText, onFinalMessage, onError, ...proxyParams } = params; - const { featureName } = proxyParams + const { useProviderFor: featureName } = proxyParams // end early if no provider const modelSelection = this.voidSettingsService.state.modelSelectionOfFeature[featureName] @@ -98,6 +98,10 @@ export class LLMMessageService extends Disposable implements ILLMMessageService } const { providerName, modelName } = modelSelection + const aiInstructions = this.voidSettingsService.state.globalSettings.aiInstructions + if (aiInstructions) + proxyParams.messages.unshift({ role: 'system', content: aiInstructions }) + // add state for request id const requestId_ = generateUuid(); this.onTextHooks_llm[requestId_] = onText diff --git a/src/vs/platform/void/common/llmMessageTypes.ts b/src/vs/platform/void/common/llmMessageTypes.ts index db560ab9..f14e82a6 100644 --- a/src/vs/platform/void/common/llmMessageTypes.ts +++ b/src/vs/platform/void/common/llmMessageTypes.ts @@ -31,12 +31,12 @@ export type LLMMessage = { } export type ServiceSendLLMFeatureParams = { - featureName: 'Ctrl+K'; + useProviderFor: 'Ctrl+K'; range: IRange; } | { - featureName: 'Ctrl+L'; + useProviderFor: 'Ctrl+L'; } | { - featureName: 'Autocomplete'; + useProviderFor: 'Autocomplete'; range: IRange; } diff --git a/src/vs/platform/void/common/metricsService.ts b/src/vs/platform/void/common/metricsService.ts index 3d185669..a3aeb6a8 100644 --- a/src/vs/platform/void/common/metricsService.ts +++ b/src/vs/platform/void/common/metricsService.ts @@ -7,10 +7,15 @@ import { createDecorator } from '../../instantiation/common/instantiation.js'; import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; +import { Action2, registerAction2 } from '../../actions/common/actions.js'; +import { localize2 } from '../../../nls.js'; +import { ServicesAccessor } from '../../../editor/browser/editorExtensions.js'; +import { INotificationService } from '../../notification/common/notification.js'; export interface IMetricsService { readonly _serviceBrand: undefined; capture(event: string, params: Record): void; + getDebuggingProperties(): Promise; } export const IMetricsService = createDecorator('metricsService'); @@ -25,6 +30,7 @@ export class MetricsService implements IMetricsService { constructor( @IMainProcessService mainProcessService: IMainProcessService // (only usable on client side) ) { + // creates an IPC proxy to use metricsMainService.ts this.metricsService = ProxyChannel.toService(mainProcessService.getChannel('void-channel-metrics')); } @@ -33,7 +39,30 @@ export class MetricsService implements IMetricsService { this.metricsService.capture(...params); } + // anything transmitted over a channel must be async even if it looks like it doesn't have to be + async getDebuggingProperties(): Promise { + return this.metricsService.getDebuggingProperties() + } } registerSingleton(IMetricsService, MetricsService, InstantiationType.Eager); + +// debugging action +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'voidDebugInfo', + f1: true, + title: localize2('voidMetricsDebug', 'Void: Log Debug Info'), + }); + } + async run(accessor: ServicesAccessor): Promise { + const metricsService = accessor.get(IMetricsService) + const notifService = accessor.get(INotificationService) + + const debugProperties = await metricsService.getDebuggingProperties() + console.log('Metrics:', debugProperties) + notifService.info(`Void Debug info:\n${JSON.stringify(debugProperties, null, 2)}`) + } +}) diff --git a/src/vs/platform/void/common/refreshModelService.ts b/src/vs/platform/void/common/refreshModelService.ts index 5a60fffd..811db0db 100644 --- a/src/vs/platform/void/common/refreshModelService.ts +++ b/src/vs/platform/void/common/refreshModelService.ts @@ -25,11 +25,20 @@ type RefreshableState = ({ state: 'finished', timeoutId: null, } | { - state: 'finished_invisible', + state: 'error', timeoutId: null, }) +/* + +user click -> error -> fire(error) + \> success -> fire(success) + finally: keep polling + +poll -> do not fire + +*/ export type RefreshModelStateOfProvider = Record @@ -41,6 +50,8 @@ const refreshBasedOn: { [k in RefreshableProviderName]: (keyof SettingsOfProvide const REFRESH_INTERVAL = 5_000 // const COOLDOWN_TIMEOUT = 300 +const autoOptions = { enableProviderOnSuccess: true, doNotFire: true } + // element-wise equals function eq(a: T[], b: T[]): boolean { if (a.length !== b.length) return false @@ -51,7 +62,7 @@ function eq(a: T[], b: T[]): boolean { } export interface IRefreshModelService { readonly _serviceBrand: undefined; - refreshModels: (providerName: RefreshableProviderName) => Promise; + startRefreshingModels: (providerName: RefreshableProviderName, options: { enableProviderOnSuccess: boolean, doNotFire: boolean }) => void; onDidChangeState: Event; state: RefreshModelStateOfProvider; } @@ -75,23 +86,23 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ const disposables: Set = new Set() - const initializePollingAndOnChange = () => { + const initializeAutoPollingAndOnChange = () => { this._clearAllTimeouts() disposables.forEach(d => d.dispose()) disposables.clear() - if (!voidSettingsService.state.featureFlagSettings.autoRefreshModels) return + if (!voidSettingsService.state.globalSettings.autoRefreshModels) return for (const providerName of refreshableProviderNames) { - const { _enabled: enabled } = this.voidSettingsService.state.settingsOfProvider[providerName] - this.refreshModels(providerName, !enabled, { isPolling: true, isInvisible: true }) + // const { _enabled: enabled } = this.voidSettingsService.state.settingsOfProvider[providerName] + this.startRefreshingModels(providerName, autoOptions) // every time providerName.enabled changes, refresh models too, like a useEffect - let relevantVals = () => refreshBasedOn[providerName].map(settingName => this.voidSettingsService.state.settingsOfProvider[providerName][settingName]) + let relevantVals = () => refreshBasedOn[providerName].map(settingName => voidSettingsService.state.settingsOfProvider[providerName][settingName]) let prevVals = relevantVals() // each iteration of a for loop has its own context and vars, so this is ok disposables.add( - this.voidSettingsService.onDidChangeState(() => { // we might want to debounce this + voidSettingsService.onDidChangeState(() => { // we might want to debounce this const newVals = relevantVals() if (!eq(prevVals, newVals)) { @@ -101,7 +112,7 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ // if it was just enabled, or there was a change and it wasn't to the enabled state, refresh if ((enabled && !prevEnabled) || (!enabled && !prevEnabled)) { // if user just clicked enable, refresh - this.refreshModels(providerName, !enabled, { isPolling: false, isInvisible: true }) + this.startRefreshingModels(providerName, autoOptions) } else { // else if user just clicked disable, don't refresh @@ -117,11 +128,11 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ } } - // on mount (when get init settings state), and if a relevant feature flag changes (detected natively right now by refreshing if any flag changes), start refreshing models + // on mount (when get init settings state), and if a relevant feature flag changes, start refreshing models voidSettingsService.waitForInitState.then(() => { - initializePollingAndOnChange() + initializeAutoPollingAndOnChange() this._register( - voidSettingsService.onDidChangeState((type) => { if (type === 'featureFlagSettings') initializePollingAndOnChange() }) + voidSettingsService.onDidChangeState((type) => { if (typeof type === 'object' && type[1] === 'autoRefreshModels') initializeAutoPollingAndOnChange() }) ) }) @@ -129,22 +140,23 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ state: RefreshModelStateOfProvider = { ollama: { state: 'init', timeoutId: null }, - // openAICompatible: { state: 'init', timeoutId: null }, } // start listening for models (and don't stop until success) - async refreshModels(providerName: RefreshableProviderName, enableProviderOnSuccess?: boolean, options?: { isPolling?: boolean, isInvisible?: boolean }) { - - const { isPolling, isInvisible } = options ?? {} - - console.log(`refreshModels, isInvisible ${isInvisible} isPolling ${isPolling}`) + startRefreshingModels: IRefreshModelService['startRefreshingModels'] = (providerName, options) => { this._clearProviderTimeout(providerName) - // start loading models - if (!isInvisible) this._setRefreshState(providerName, 'refreshing') + this._setRefreshState(providerName, 'refreshing', options) + const autoPoll = () => { + if (this.voidSettingsService.state.globalSettings.autoRefreshModels) { + // resume auto-polling + const timeoutId = setTimeout(() => this.startRefreshingModels(providerName, autoOptions), REFRESH_INTERVAL) + this._setTimeoutId(providerName, timeoutId) + } + } const listFn = providerName === 'ollama' ? this.llmMessageService.ollamaList : providerName === 'openAICompatible' ? this.llmMessageService.openAICompatibleList : () => { } @@ -160,34 +172,21 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ else if (providerName === 'openAICompatible') return (model as OpenaiCompatibleModelResponse).id; else throw new Error('refreshMode fn: unknown provider', providerName); }), - { enableProviderOnSuccess, isPolling, isInvisible } + { enableProviderOnSuccess: options.enableProviderOnSuccess, hideRefresh: options.doNotFire } ) - // update state - if (enableProviderOnSuccess) { - this.voidSettingsService.setSettingOfProvider(providerName, '_enabled', true) - } - - if (!isInvisible) { - this._setRefreshState(providerName, 'finished') - } else if (isInvisible) { - this._setRefreshState(providerName, 'finished_invisible') - } - + if (options.enableProviderOnSuccess) this.voidSettingsService.setSettingOfProvider(providerName, '_enabled', true) + this._setRefreshState(providerName, 'finished', options) + autoPoll() }, onError: ({ error }) => { - console.log('retrying list models:', providerName, error) + this._setRefreshState(providerName, 'error', options) + autoPoll() } }) - // check if we should poll - // if it was originally called as `isPolling` and if the `autoRefreshModels` flag is enabled - if (isPolling && this.voidSettingsService.state.featureFlagSettings.autoRefreshModels) { - const timeoutId = setTimeout(() => this.refreshModels(providerName, enableProviderOnSuccess, options), REFRESH_INTERVAL) - this._setTimeoutId(providerName, timeoutId) - } } _clearAllTimeouts() { @@ -208,7 +207,8 @@ export class RefreshModelService extends Disposable implements IRefreshModelServ this.state[providerName].timeoutId = timeoutId } - private _setRefreshState(providerName: RefreshableProviderName, state: RefreshableState['state']) { + private _setRefreshState(providerName: RefreshableProviderName, state: RefreshableState['state'], options?: { doNotFire: boolean }) { + if (options?.doNotFire) return this.state[providerName].state = state this._onDidChangeState.fire(providerName) } diff --git a/src/vs/platform/void/common/voidSettingsService.ts b/src/vs/platform/void/common/voidSettingsService.ts index f3d43e6b..ffaa5e72 100644 --- a/src/vs/platform/void/common/voidSettingsService.ts +++ b/src/vs/platform/void/common/voidSettingsService.ts @@ -11,10 +11,10 @@ import { registerSingleton, InstantiationType } from '../../instantiation/common import { createDecorator } from '../../instantiation/common/instantiation.js'; import { IStorageService, StorageScope, StorageTarget } from '../../storage/common/storage.js'; import { IMetricsService } from './metricsService.js'; -import { defaultSettingsOfProvider, FeatureName, ProviderName, ModelSelectionOfFeature, SettingsOfProvider, SettingName, providerNames, ModelSelection, modelSelectionsEqual, featureNames, modelInfoOfDefaultNames, VoidModelInfo, FeatureFlagSettings, FeatureFlagName, defaultFeatureFlagSettings } from './voidSettingsTypes.js'; +import { defaultSettingsOfProvider, FeatureName, ProviderName, ModelSelectionOfFeature, SettingsOfProvider, SettingName, providerNames, ModelSelection, modelSelectionsEqual, featureNames, modelInfoOfDefaultNames, VoidModelInfo, GlobalSettings, GlobalSettingName, defaultGlobalSettings } from './voidSettingsTypes.js'; -const STORAGE_KEY = 'void.voidSettingsStorage' +const STORAGE_KEY = 'void.settingsServiceStorage' type SetSettingOfProviderFn = ( providerName: ProviderName, @@ -28,7 +28,7 @@ type SetModelSelectionOfFeatureFn = ( options?: { doNotApplyEffects?: true } ) => Promise; -type SetFeatureFlagFn = (flagName: FeatureFlagName, newVal: boolean) => void; +type SetGlobalSettingFn = (settingName: T, newVal: GlobalSettings[T]) => void; export type ModelOption = { name: string, selection: ModelSelection } @@ -37,12 +37,13 @@ export type ModelOption = { name: string, selection: ModelSelection } export type VoidSettingsState = { readonly settingsOfProvider: SettingsOfProvider; // optionsOfProvider readonly modelSelectionOfFeature: ModelSelectionOfFeature; // stateOfFeature - readonly featureFlagSettings: FeatureFlagSettings; + readonly globalSettings: GlobalSettings; readonly _modelOptions: ModelOption[] // computed based on the two above items } -type EventProp = Exclude | 'all' +type RealVoidSettings = Exclude +type EventProp = T extends 'globalSettings' ? [T, keyof VoidSettingsState[T]] : T | 'all' export interface IVoidSettingsService { @@ -54,9 +55,9 @@ export interface IVoidSettingsService { setSettingOfProvider: SetSettingOfProviderFn; setModelSelectionOfFeature: SetModelSelectionOfFeatureFn; - setFeatureFlag: SetFeatureFlagFn; + setGlobalSetting: SetGlobalSettingFn; - setAutodetectedModels(providerName: ProviderName, modelNames: string[], logging: { enableProviderOnSuccess?: boolean, isPolling?: boolean, isInvisible?: boolean }): void; + setAutodetectedModels(providerName: ProviderName, modelNames: string[], logging: object): void; toggleModelHidden(providerName: ProviderName, modelName: string): void; addModel(providerName: ProviderName, modelName: string): void; deleteModel(providerName: ProviderName, modelName: string): boolean; @@ -81,7 +82,7 @@ const defaultState = () => { const d: VoidSettingsState = { settingsOfProvider: deepClone(defaultSettingsOfProvider), modelSelectionOfFeature: { 'Ctrl+L': null, 'Ctrl+K': null, 'Autocomplete': null }, - featureFlagSettings: deepClone(defaultFeatureFlagSettings), + globalSettings: deepClone(defaultGlobalSettings), _modelOptions: _computeModelOptions(defaultSettingsOfProvider), // computed } return d @@ -150,7 +151,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { } } - const newFeatureFlags = this.state.featureFlagSettings + const newGlobalSettings = this.state.globalSettings // if changed models or enabled a provider, recompute models list const modelsListChanged = settingName === 'models' || settingName === '_enabled' @@ -159,7 +160,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { const newState: VoidSettingsState = { modelSelectionOfFeature: newModelSelectionOfFeature, settingsOfProvider: newSettingsOfProvider, - featureFlagSettings: newFeatureFlags, + globalSettings: newGlobalSettings, _modelOptions: newModelsList, } @@ -187,17 +188,17 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { } - setFeatureFlag: SetFeatureFlagFn = async (flagName, newVal) => { - const newState = { + setGlobalSetting: SetGlobalSettingFn = async (settingName, newVal) => { + const newState: VoidSettingsState = { ...this.state, - featureFlagSettings: { - ...this.state.featureFlagSettings, - [flagName]: newVal + globalSettings: { + ...this.state.globalSettings, + [settingName]: newVal } } this.state = newState await this._storeState() - this._onDidChangeState.fire('featureFlagSettings') + this._onDidChangeState.fire(['globalSettings', settingName]) } @@ -222,7 +223,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { - setAutodetectedModels(providerName: ProviderName, newDefaultModelNames: string[], logging: { enableProviderOnSuccess?: boolean, isPolling?: boolean, isInvisible?: boolean }) { + setAutodetectedModels(providerName: ProviderName, newDefaultModelNames: string[], logging: object) { const { models } = this.state.settingsOfProvider[providerName] diff --git a/src/vs/platform/void/common/voidSettingsTypes.ts b/src/vs/platform/void/common/voidSettingsTypes.ts index acc81f02..222ce250 100644 --- a/src/vs/platform/void/common/voidSettingsTypes.ts +++ b/src/vs/platform/void/common/voidSettingsTypes.ts @@ -429,26 +429,16 @@ export type RefreshableProviderName = typeof refreshableProviderNames[number] -export type FeatureFlagSettings = { +export type GlobalSettings = { autoRefreshModels: boolean; + aiInstructions: string; } -export const defaultFeatureFlagSettings: FeatureFlagSettings = { +export const defaultGlobalSettings: GlobalSettings = { autoRefreshModels: true, + aiInstructions: '', } -export type FeatureFlagName = keyof FeatureFlagSettings -export const featureFlagNames = Object.keys(defaultFeatureFlagSettings) as FeatureFlagName[] - -type FeatureFlagDisplayInfo = { - description: string, -} -export const displayInfoOfFeatureFlag = (featureFlag: FeatureFlagName): FeatureFlagDisplayInfo => { - if (featureFlag === 'autoRefreshModels') { - return { - description: `Automatically detect local providers and models (${refreshableProviderNames.map(providerName => displayInfoOfProviderName(providerName).title).join(', ')}).`, - } - } - throw new Error(`featureFlagInfo: Unknown feature flag: "${featureFlag}"`) -} +export type GlobalSettingName = keyof GlobalSettings +export const globalSettingNames = Object.keys(defaultGlobalSettings) as GlobalSettingName[] diff --git a/src/vs/platform/void/common/voidUpdateService.ts b/src/vs/platform/void/common/voidUpdateService.ts new file mode 100644 index 00000000..0304073f --- /dev/null +++ b/src/vs/platform/void/common/voidUpdateService.ts @@ -0,0 +1,46 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; +import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; + + + +export interface IVoidUpdateService { + readonly _serviceBrand: undefined; + check: () => Promise<{ hasUpdate: true, message: string } | { hasUpdate: false } | null>; +} + + +export const IVoidUpdateService = createDecorator('VoidUpdateService'); + + +// implemented by calling channel +export class VoidUpdateService implements IVoidUpdateService { + + readonly _serviceBrand: undefined; + private readonly voidUpdateService: IVoidUpdateService; + + constructor( + @IMainProcessService mainProcessService: IMainProcessService, // (only usable on client side) + ) { + // creates an IPC proxy to use metricsMainService.ts + this.voidUpdateService = ProxyChannel.toService(mainProcessService.getChannel('void-channel-update')); + } + + + + // anything transmitted over a channel must be async even if it looks like it doesn't have to be + check: IVoidUpdateService['check'] = async () => { + const res = await this.voidUpdateService.check() + return res + } +} + +registerSingleton(IVoidUpdateService, VoidUpdateService, InstantiationType.Eager); + + diff --git a/src/vs/platform/void/electron-main/llmMessage/sendLLMMessage.ts b/src/vs/platform/void/electron-main/llmMessage/sendLLMMessage.ts index 95716e81..2696020b 100644 --- a/src/vs/platform/void/electron-main/llmMessage/sendLLMMessage.ts +++ b/src/vs/platform/void/electron-main/llmMessage/sendLLMMessage.ts @@ -38,7 +38,6 @@ export const sendLLMMessage = ({ modelName, numMessages: messages?.length, messagesShape: messages?.map(msg => ({ role: msg.role, length: msg.content.length })), - version: '2024-11-14', ...extras, }) } @@ -63,7 +62,6 @@ export const sendLLMMessage = ({ const onError: OnError = ({ message: error, fullError }) => { if (_didAbort) return - console.log("ERROR!!!!!", error) console.error('sendLLMMessage onError:', error) captureChatEvent(`${loggingName} - Error`, { error }) onError_({ message: error, fullError }) diff --git a/src/vs/platform/void/electron-main/metricsMainService.ts b/src/vs/platform/void/electron-main/metricsMainService.ts index 31ca1252..fdfb1d16 100644 --- a/src/vs/platform/void/electron-main/metricsMainService.ts +++ b/src/vs/platform/void/electron-main/metricsMainService.ts @@ -4,43 +4,97 @@ *--------------------------------------------------------------------------------------*/ import { Disposable } from '../../../base/common/lifecycle.js'; -import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import { isLinux, isMacintosh, isWindows } from '../../../base/common/platform.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; + +import { IProductService } from '../../product/common/productService.js'; +import { IStorageMainService } from '../../storage/electron-main/storageMainService.js'; import { IMetricsService } from '../common/metricsService.js'; import { PostHog } from 'posthog-node' -// posthog-js (old): -// posthog.init('phc_UanIdujHiLp55BkUTjB1AuBXcasVkdqRwgnwRlWESH2', { api_host: 'https://us.i.posthog.com', }) +const os = isWindows ? 'windows' : isMacintosh ? 'mac' : isLinux ? 'linux' : null -// const buildEnv = 'development'; -// const buildNumber = '1.0.0'; -// const isMac = process.platform === 'darwin'; +const VOID_MACHINE_STORAGE_KEY = 'void.machineId' export class MetricsMainService extends Disposable implements IMetricsService { _serviceBrand: undefined; - readonly _distinctId: string - readonly client: PostHog + private readonly client: PostHog + + private readonly _initProperties: object + + + // TODO we should eventually identify people based on email + private get machineId() { + const currVal = this._storageService.applicationStorage.get(VOID_MACHINE_STORAGE_KEY) + if (currVal !== undefined) return currVal + const newVal = generateUuid() + this._storageService.applicationStorage.set(VOID_MACHINE_STORAGE_KEY, newVal) + return newVal + } + constructor( - @ITelemetryService private readonly _telemetryService: ITelemetryService + @IProductService private readonly _productService: IProductService, + @IStorageMainService private readonly _storageService: IStorageMainService, + @IEnvironmentMainService private readonly _envMainService: IEnvironmentMainService, ) { super() - this.client = new PostHog('phc_UanIdujHiLp55BkUTjB1AuBXcasVkdqRwgnwRlWESH2', { host: 'https://us.i.posthog.com', }) + this.client = new PostHog('phc_UanIdujHiLp55BkUTjB1AuBXcasVkdqRwgnwRlWESH2', { + host: 'https://us.i.posthog.com', + }) - const { devDeviceId, firstSessionDate, machineId } = this._telemetryService - this._distinctId = devDeviceId - this.client.identify({ distinctId: devDeviceId, properties: { firstSessionDate, machineId } }) + // we'd like to use devDeviceId on telemetryService, but that gets sanitized by the time it gets here as 'someValue.devDeviceId' - console.log('Void posthog metrics info:', JSON.stringify({ devDeviceId, firstSessionDate, machineId })) + const { commit, version, quality } = this._productService + + const isDevMode = !this._envMainService.isBuilt // found in abstractUpdateService.ts + + + // custom properties we identify + this._initProperties = { + commit, + version, + os, + quality, + distinctId: this.machineId, + isDevMode, + ...this._getOSInfo(), + } + + const identifyMessage = { + distinctId: this.machineId, + properties: this._initProperties, + } + this.client.identify(identifyMessage) + + console.log('Void posthog metrics info:', JSON.stringify(identifyMessage, null, 2)) + + } + + _getOSInfo() { + try { + const { platform, arch } = process // see platform.ts + return { platform, arch } + } + catch (e) { + return { osInfo: { platform: '??', arch: '??' } } + } } capture: IMetricsService['capture'] = (event, params) => { - const capture = { distinctId: this._distinctId, event, properties: params } as const + const capture = { distinctId: this.machineId, event, properties: params } as const // console.log('full capture:', capture) this.client.capture(capture) } + + + async getDebuggingProperties() { + return this._initProperties + } } diff --git a/src/vs/platform/void/electron-main/voidUpdateMainService.ts b/src/vs/platform/void/electron-main/voidUpdateMainService.ts new file mode 100644 index 00000000..029db5f4 --- /dev/null +++ b/src/vs/platform/void/electron-main/voidUpdateMainService.ts @@ -0,0 +1,50 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../base/common/lifecycle.js'; +import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; + +import { IProductService } from '../../product/common/productService.js'; + +import { IVoidUpdateService } from '../common/voidUpdateService.js'; + + + +export class VoidMainUpdateService extends Disposable implements IVoidUpdateService { + _serviceBrand: undefined; + + constructor( + @IProductService private readonly _productService: IProductService, + @IEnvironmentMainService private readonly _envMainService: IEnvironmentMainService, + ) { + super() + } + + async check() { + const isDevMode = !this._envMainService.isBuilt // found in abstractUpdateService.ts + + if (isDevMode) { + return { hasUpdate: false } as const + } + + try { + const res = await fetch(`https://updates.voideditor.dev/api/v0/${this._productService.commit}`) + const resJSON = await res.json() + + if (!resJSON) return null + + const { hasUpdate, downloadMessage } = resJSON ?? {} + if (hasUpdate === undefined) + return null + + const after = (downloadMessage || '') + '' + return { hasUpdate: !!hasUpdate, message: after } + } + catch (e) { + return null + } + } +} + diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index e39d7ce6..770269f8 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -2534,7 +2534,7 @@ const LayoutStateKeys = { // Part Sizing GRID_SIZE: new InitializationStateKey('grid.size', StorageScope.PROFILE, StorageTarget.MACHINE, { width: 800, height: 600 }), SIDEBAR_SIZE: new InitializationStateKey('sideBar.size', StorageScope.PROFILE, StorageTarget.MACHINE, 200), - AUXILIARYBAR_SIZE: new InitializationStateKey('auxiliaryBar.size', StorageScope.PROFILE, StorageTarget.MACHINE, 200), + AUXILIARYBAR_SIZE: new InitializationStateKey('auxiliaryBar.size', StorageScope.PROFILE, StorageTarget.MACHINE, 800), // Void changed this from 200 to 800 PANEL_SIZE: new InitializationStateKey('panel.size', StorageScope.PROFILE, StorageTarget.MACHINE, 300), PANEL_LAST_NON_MAXIMIZED_HEIGHT: new RuntimeStateKey('panel.lastNonMaximizedHeight', StorageScope.PROFILE, StorageTarget.MACHINE, 300), diff --git a/src/vs/workbench/browser/media/code-icon.svg b/src/vs/workbench/browser/media/code-icon.svg deleted file mode 100644 index cc61f81e..00000000 --- a/src/vs/workbench/browser/media/code-icon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/vs/workbench/browser/media/void-icon-sm.png b/src/vs/workbench/browser/media/void-icon-sm.png new file mode 100644 index 00000000..225179f8 Binary files /dev/null and b/src/vs/workbench/browser/media/void-icon-sm.png differ diff --git a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarPart.ts b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarPart.ts index f3718162..f7225d40 100644 --- a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarPart.ts +++ b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarPart.ts @@ -45,7 +45,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { static readonly viewContainersWorkspaceStateKey = 'workbench.auxiliarybar.viewContainersWorkspaceState'; // Use the side bar dimensions - override readonly minimumWidth: number = 170; + override readonly minimumWidth: number = 230; // Void changed this (was 170) override readonly maximumWidth: number = Number.POSITIVE_INFINITY; override readonly minimumHeight: number = 0; override readonly maximumHeight: number = Number.POSITIVE_INFINITY; diff --git a/src/vs/workbench/browser/parts/banner/media/bannerpart.css b/src/vs/workbench/browser/parts/banner/media/bannerpart.css index a0de81f2..90e26053 100644 --- a/src/vs/workbench/browser/parts/banner/media/bannerpart.css +++ b/src/vs/workbench/browser/parts/banner/media/bannerpart.css @@ -30,7 +30,7 @@ background-repeat: no-repeat; background-position: center center; background-size: 16px; - background-image: url('../../../../browser/media/code-icon.svg'); + background-image: url('../../../../browser/media/void-icon-sm.png'); width: 16px; padding: 0; margin: 0 6px 0 10px; diff --git a/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css b/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css index ccc39382..221634bb 100644 --- a/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css +++ b/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css @@ -253,7 +253,7 @@ } .monaco-workbench .part.titlebar > .titlebar-container > .titlebar-left > .window-appicon:not(.codicon) { - background-image: url('../../../media/code-icon.svg'); + background-image: url('../../../media/void-icon-sm.png'); background-repeat: no-repeat; background-position: center center; background-size: 16px; @@ -275,7 +275,7 @@ height: 8px; z-index: 1; /* on top of home indicator */ - background-image: url('../../../media/code-icon.svg'); + background-image: url('../../../media/void-icon-sm.png'); background-repeat: no-repeat; background-position: center center; background-size: 8px; diff --git a/src/vs/workbench/contrib/update/browser/media/releasenoteseditor.css b/src/vs/workbench/contrib/update/browser/media/releasenoteseditor.css index 4210055b..b79e3905 100644 --- a/src/vs/workbench/contrib/update/browser/media/releasenoteseditor.css +++ b/src/vs/workbench/contrib/update/browser/media/releasenoteseditor.css @@ -5,5 +5,5 @@ .file-icons-enabled .show-file-icons .webview-vs_code_release_notes-name-file-icon.file-icon::before { content: ' '; - background-image: url('../../../../browser/media/code-icon.svg'); + background-image: url('../../../../browser/media/void-icon-sm.png'); } diff --git a/src/vs/workbench/contrib/void/browser/autocompleteService.ts b/src/vs/workbench/contrib/void/browser/autocompleteService.ts index 1400d429..9237c7ca 100644 --- a/src/vs/workbench/contrib/void/browser/autocompleteService.ts +++ b/src/vs/workbench/contrib/void/browser/autocompleteService.ts @@ -652,7 +652,8 @@ export class AutocompleteService extends Disposable implements IAutocompleteServ // newAutocompletion.abortRef = { current: () => { } } newAutocompletion.status = 'finished' // newAutocompletion.promise = undefined - newAutocompletion.insertText = postprocessResult(extractCodeFromRegular(fullText)) + const [text, _] = extractCodeFromRegular({ text: fullText, recentlyAddedTextLen: 0 }) + newAutocompletion.insertText = postprocessResult(text) resolve(newAutocompletion.insertText) @@ -662,7 +663,7 @@ export class AutocompleteService extends Disposable implements IAutocompleteServ newAutocompletion.status = 'error' reject(message) }, - featureName: 'Autocomplete', + useProviderFor: 'Autocomplete', range: { startLineNumber: position.lineNumber, startColumn: position.column, endLineNumber: position.lineNumber, endColumn: position.column }, }) newAutocompletion.requestId = requestId diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts new file mode 100644 index 00000000..7fede377 --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -0,0 +1,302 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; + +import { URI } from '../../../../base/common/uri.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { IRange } from '../../../../editor/common/core/range.js'; +import { ILLMMessageService } from '../../../../platform/void/common/llmMessageService.js'; +import { IModelService } from '../../../../editor/common/services/model.js'; +import { VSReadFile } from './helpers/readFile.js'; +import { chat_prompt, chat_systemMessage } from './prompt/prompts.js'; + +export type CodeSelection = { + fileURI: URI; + selectionStr: string | null; + content: string; // TODO remove this (replace `selectionStr` with `content`) + range: IRange; +} + +// if selectionStr is null, it means to use the entire file at send time +export type CodeStagingSelection = { + type: 'Selection', + fileURI: URI, + selectionStr: string, + range: IRange +} | { + type: 'File', + fileURI: URI, + selectionStr: null, + range: null +} + + +// WARNING: changing this format is a big deal!!!!!! need to migrate old format to new format on users' computers so people don't get errors. +export type ChatMessage = + | { + role: 'user'; + content: string | null; // content sent to the llm - allowed to be '', will be replaced with (empty) + displayContent: string | null; // content displayed to user - allowed to be '', will be ignored + selections: CodeSelection[] | null; // the user's selection + } + | { + role: 'assistant'; + content: string | null; // content received from LLM - allowed to be '', will be replaced with (empty) + displayContent: string | null; // content displayed to user (this is the same as content for now) - allowed to be '', will be ignored + } + | { + role: 'system'; + content: string; + displayContent?: undefined; + } + +// a 'thread' means a chat message history +export type ChatThreads = { + [id: string]: { + id: string; // store the id here too + createdAt: string; // ISO string + lastModified: string; // ISO string + messages: ChatMessage[]; + }; +} + +export type ThreadsState = { + allThreads: ChatThreads; + currentThreadId: string; // intended for internal use only + currentStagingSelections: CodeStagingSelection[] | null; +} + +export type ThreadStreamState = { + [threadId: string]: undefined | { + error?: { message: string, fullError: Error | null }; + messageSoFar?: string; + streamingToken?: string; + } +} + + +const newThreadObject = () => { + const now = new Date().toISOString() + return { + id: new Date().getTime().toString(), + createdAt: now, + lastModified: now, + messages: [], + } satisfies ChatThreads[string] +} + +const THREAD_STORAGE_KEY = 'void.chatThreadStorage' + +export interface IChatThreadService { + readonly _serviceBrand: undefined; + + readonly state: ThreadsState; + readonly streamState: ThreadStreamState; + + onDidChangeCurrentThread: Event; + onDidChangeStreamState: Event<{ threadId: string }> + + getCurrentThread(): ChatThreads[string]; + openNewThread(): void; + switchToThread(threadId: string): void; + + setStaging(stagingSelection: CodeStagingSelection[] | null): void; + + addUserMessageAndStreamResponse(userMessage: string): Promise; + cancelStreaming(threadId: string): void; + dismissStreamError(threadId: string): void; + +} + +export const IChatThreadService = createDecorator('voidChatThreadService'); +class ChatThreadService extends Disposable implements IChatThreadService { + _serviceBrand: undefined; + + // this fires when the current thread changes at all (a switch of currentThread, or a message added to it, etc) + private readonly _onDidChangeCurrentThread = new Emitter(); + readonly onDidChangeCurrentThread: Event = this._onDidChangeCurrentThread.event; + + readonly streamState: ThreadStreamState = {} + private readonly _onDidChangeStreamState = new Emitter<{ threadId: string }>(); + readonly onDidChangeStreamState: Event<{ threadId: string }> = this._onDidChangeStreamState.event; + + state: ThreadsState // allThreads is persisted, currentThread is not + + constructor( + @IStorageService private readonly _storageService: IStorageService, + @IModelService private readonly _modelService: IModelService, + @ILLMMessageService private readonly _llmMessageService: ILLMMessageService, + ) { + super() + + this.state = { + allThreads: this._readAllThreads(), + currentThreadId: null as unknown as string, // gets set in startNewThread() + currentStagingSelections: null, + } + + // always be in a thread + this.openNewThread() + } + + + private _readAllThreads(): ChatThreads { + // PUT ANY VERSION CHANGE FORMAT CONVERSION CODE HERE + const threads = this._storageService.get(THREAD_STORAGE_KEY, StorageScope.APPLICATION) + return threads ? JSON.parse(threads) : {} + } + + private _storeAllThreads(threads: ChatThreads) { + this._storageService.store(THREAD_STORAGE_KEY, JSON.stringify(threads), StorageScope.APPLICATION, StorageTarget.USER) + } + + // this should be the only place this.state = ... appears besides constructor + private _setState(state: Partial, affectsCurrent: boolean) { + this.state = { + ...this.state, + ...state + } + if (affectsCurrent) + this._onDidChangeCurrentThread.fire() + } + + private _setStreamState(threadId: string, state: Partial>) { + this.streamState[threadId] = { + ...this.streamState[threadId], + ...state + } + this._onDidChangeStreamState.fire({ threadId }) + } + + + // ---------- streaming ---------- + + finishStreaming = (threadId: string, content: string, error?: { message: string, fullError: Error | null }) => { + // add assistant's message to chat history, and clear selection + const assistantHistoryElt: ChatMessage = { role: 'assistant', content, displayContent: content || null } + this._addMessageToThread(threadId, assistantHistoryElt) + this._setStreamState(threadId, { messageSoFar: undefined, streamingToken: undefined, error }) + } + + async addUserMessageAndStreamResponse(userMessage: string) { + const threadId = this.getCurrentThread().id + + const currSelns = this.state.currentStagingSelections ?? [] + const selections = !currSelns ? null : await Promise.all( + currSelns.map(async (sel) => ({ ...sel, content: await VSReadFile(this._modelService, sel.fileURI) })) + ).then( + (files) => files.filter(file => file.content !== null) as CodeSelection[] + ) + + // add user's message to chat history + const instructions = userMessage + const userHistoryElt: ChatMessage = { role: 'user', content: chat_prompt(instructions, selections), displayContent: instructions, selections: selections } + this._addMessageToThread(threadId, userHistoryElt) + + + this._setStreamState(threadId, { error: undefined }) + + const llmCancelToken = this._llmMessageService.sendLLMMessage({ + logging: { loggingName: 'Chat' }, + messages: [ + { role: 'system', content: chat_systemMessage }, + ...this.getCurrentThread().messages.map(m => ({ role: m.role, content: m.content || '(null)' })), + ], + onText: ({ newText, fullText }) => { + this._setStreamState(threadId, { messageSoFar: fullText }) + }, + onFinalMessage: ({ fullText: content }) => { + this.finishStreaming(threadId, content) + }, + onError: (error) => { + this.finishStreaming(threadId, this.streamState[threadId]?.messageSoFar ?? '', error) + }, + useProviderFor: 'Ctrl+L', + + }) + if (llmCancelToken === null) return + this._setStreamState(threadId, { streamingToken: llmCancelToken }) + + } + + cancelStreaming(threadId: string) { + const llmCancelToken = this.streamState[threadId]?.streamingToken + if (llmCancelToken !== undefined) this._llmMessageService.abort(llmCancelToken) + this.finishStreaming(threadId, this.streamState[threadId]?.messageSoFar ?? '') + } + + dismissStreamError(threadId: string): void { + this._setStreamState(threadId, { error: undefined }) + } + + + + // ---------- the rest ---------- + + getCurrentThread(): ChatThreads[string] { + const state = this.state + return state.allThreads[state.currentThreadId]; + } + + switchToThread(threadId: string) { + // console.log('threadId', threadId) + // console.log('messages', this.state.allThreads[threadId].messages) + this._setState({ currentThreadId: threadId }, true) + } + + + openNewThread() { + // if a thread with 0 messages already exists, switch to it + const { allThreads: currentThreads } = this.state + for (const threadId in currentThreads) { + if (currentThreads[threadId].messages.length === 0) { + this.switchToThread(threadId) + return + } + } + // otherwise, start a new thread + const newThread = newThreadObject() + + // update state + const newThreads: ChatThreads = { + ...currentThreads, + [newThread.id]: newThread + } + this._storeAllThreads(newThreads) + this._setState({ allThreads: newThreads, currentThreadId: newThread.id }, true) + } + + + _addMessageToThread(threadId: string, message: ChatMessage) { + const { allThreads } = this.state + + const oldThread = allThreads[threadId] + + // update state and store it + const newThreads = { + ...allThreads, + [oldThread.id]: { + ...oldThread, + lastModified: new Date().toISOString(), + messages: [...oldThread.messages, message], + } + } + this._storeAllThreads(newThreads) + this._setState({ allThreads: newThreads }, true) // the current thread just changed (it had a message added to it) + } + + + setStaging(stagingSelection: CodeStagingSelection[] | null): void { + this._setState({ currentStagingSelections: stagingSelection }, true) // this is a hack for now + } + +} + +registerSingleton(IChatThreadService, ChatThreadService, InstantiationType.Eager); + diff --git a/src/vs/workbench/contrib/void/browser/helperServices/consistentItemService.ts b/src/vs/workbench/contrib/void/browser/helperServices/consistentItemService.ts index f6787e4c..6de0b53b 100644 --- a/src/vs/workbench/contrib/void/browser/helperServices/consistentItemService.ts +++ b/src/vs/workbench/contrib/void/browser/helperServices/consistentItemService.ts @@ -80,6 +80,7 @@ export class ConsistentItemService extends Disposable { } const initializeEditor = (editor: ICodeEditor) => { + // if (editor.getModel()?.uri.scheme !== 'file') return // THIS BREAKS THINGS addTabSwitchListeners(editor) addDisposeListener(editor) putItemsOnEditor(editor, editor.getModel()?.uri ?? null) @@ -126,6 +127,8 @@ export class ConsistentItemService extends Disposable { const editorId = editor.getId() this.itemIdsOfEditorId[editorId]?.delete(itemId) + if (this.itemIdsOfEditorId[editorId]?.size === 0) + delete this.itemIdsOfEditorId[editorId] this.disposeFnOfItemId[itemId]?.() delete this.disposeFnOfItemId[itemId] @@ -157,7 +160,6 @@ export class ConsistentItemService extends Disposable { removeConsistentItemFromURI(consistentItemId: string) { - if (!(consistentItemId in this.infoOfConsistentItemId)) return @@ -173,6 +175,9 @@ export class ConsistentItemService extends Disposable { // clear this.consistentItemIdsOfURI[uri.fsPath]?.delete(consistentItemId) + if (this.consistentItemIdsOfURI[uri.fsPath]?.size === 0) + delete this.consistentItemIdsOfURI[uri.fsPath] + delete this.infoOfConsistentItemId[consistentItemId] } diff --git a/src/vs/workbench/contrib/void/browser/helpers/extractCodeFromResult.ts b/src/vs/workbench/contrib/void/browser/helpers/extractCodeFromResult.ts index af883788..d7e109ae 100644 --- a/src/vs/workbench/contrib/void/browser/helpers/extractCodeFromResult.ts +++ b/src/vs/workbench/contrib/void/browser/helpers/extractCodeFromResult.ts @@ -22,7 +22,7 @@ class SurroundingsRemover { // returns whether it removed the whole prefix removePrefix = (prefix: string): boolean => { let offset = 0 - console.log('prefix', prefix, Math.min(this.j, prefix.length - 1)) + // console.log('prefix', prefix, Math.min(this.j, prefix.length - 1)) while (this.i <= this.j && offset <= prefix.length - 1) { if (this.originalS.charAt(this.i) !== prefix.charAt(offset)) break @@ -64,7 +64,7 @@ class SurroundingsRemover { this.i = this.j + 1 return false } - console.log('index', index, until.length) + // console.log('index', index, until.length) if (alsoRemoveUntilStr) this.i = index + until.length @@ -90,11 +90,21 @@ class SurroundingsRemover { } + actualRecentlyAdded = (recentlyAddedTextLen: number) => { + // aaaaaatextaaaaaa{recentlyAdded} + // i ^ j + // | + // recentyAddedIdx + const recentlyAddedIdx = this.j - recentlyAddedTextLen + 1 + return this.originalS.substring(Math.max(this.i, recentlyAddedIdx), this.j + 1) + } + + } -export const extractCodeFromRegular = (text: string): string => { +export const extractCodeFromRegular = ({ text, recentlyAddedTextLen }: { text: string, recentlyAddedTextLen: number }): [string, string] => { // Match either: // 1. ```language\n``` // 2. `````` @@ -104,7 +114,9 @@ export const extractCodeFromRegular = (text: string): string => { pm.removeCodeBlock() const s = pm.value() - return s + const actual = pm.actualRecentlyAdded(recentlyAddedTextLen) + + return [s, actual] } @@ -112,7 +124,7 @@ export const extractCodeFromRegular = (text: string): string => { // Ollama has its own FIM, we should not use this if we use that -export const extractCodeFromFIM = ({ text, midTag }: { text: string, midTag: string }): string => { +export const extractCodeFromFIM = ({ text, recentlyAddedTextLen, midTag, }: { text: string, recentlyAddedTextLen: number, midTag: string }): [string, string] => { /* ------------- summary of the regex ------------- [optional ` | `` | ```] @@ -126,23 +138,17 @@ export const extractCodeFromFIM = ({ text, midTag }: { text: string, midTag: str const pm = new SurroundingsRemover(text) - console.log('ORIGIINAL CODE', text) - pm.removeCodeBlock() - console.log('D', pm.i, pm.j) - - const foundMid = pm.removePrefix(`<${midTag}>`) - console.log('E', midTag, pm.i, pm.j) if (foundMid) { pm.removeSuffix(``) - console.log('F', pm.i, pm.j) - } const s = pm.value() - return s + const actual = pm.actualRecentlyAdded(recentlyAddedTextLen) + + return [s, actual] // // const regex = /[\s\S]*?(?:`{1,3}\s*([a-zA-Z_]+[\w]*)?[\s\S]*?)?([\s\S]*?)(?:<\/MID>|`{1,3}|$)/; diff --git a/src/vs/workbench/contrib/void/browser/helpers/readFile.ts b/src/vs/workbench/contrib/void/browser/helpers/readFile.ts new file mode 100644 index 00000000..60e5dc5c --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/helpers/readFile.ts @@ -0,0 +1,10 @@ +import { URI } from '../../../../../base/common/uri' +import { EndOfLinePreference } from '../../../../../editor/common/model' +import { IModelService } from '../../../../../editor/common/services/model.js' + +// read files from VSCode +export const VSReadFile = async (modelService: IModelService, uri: URI): Promise => { + const model = modelService.getModel(uri) + if (!model) return null + return model.getValue(EndOfLinePreference.LF) +} diff --git a/src/vs/workbench/contrib/void/browser/inlineDiffsService.ts b/src/vs/workbench/contrib/void/browser/inlineDiffsService.ts index 0422f5df..76bcbd8c 100644 --- a/src/vs/workbench/contrib/void/browser/inlineDiffsService.ts +++ b/src/vs/workbench/contrib/void/browser/inlineDiffsService.ts @@ -6,21 +6,19 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { ICodeEditor, IOverlayWidget, IViewZone } from '../../../../editor/browser/editorBrowser.js'; +import { ICodeEditor, IOverlayWidget, IViewZone, OverlayWidgetPositionPreference } from '../../../../editor/browser/editorBrowser.js'; // import { IUndoRedoService } from '../../../../platform/undoRedo/common/undoRedo.js'; import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; // import { throttle } from '../../../../base/common/decorators.js'; import { ComputedDiff, findDiffs } from './helpers/findDiffs.js'; import { EndOfLinePreference, IModelDecorationOptions, ITextModel } from '../../../../editor/common/model.js'; -import { IRange, Range } from '../../../../editor/common/core/range.js'; +import { IRange } from '../../../../editor/common/core/range.js'; import { registerColor } from '../../../../platform/theme/common/colorUtils.js'; import { Color, RGBA } from '../../../../base/common/color.js'; import { IModelService } from '../../../../editor/common/services/model.js'; import { IUndoRedoElement, IUndoRedoService, UndoRedoElementType } from '../../../../platform/undoRedo/common/undoRedo.js'; -import { LineSource, renderLines, RenderOptions } from '../../../../editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.js'; -import { LineTokens } from '../../../../editor/common/tokens/lineTokens.js'; -import { ILanguageService } from '../../../../editor/common/languages/language.js'; +import { RenderOptions } from '../../../../editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.js'; // import { IModelService } from '../../../../editor/common/services/model.js'; import * as dom from '../../../../base/browser/dom.js'; @@ -36,14 +34,13 @@ import { errorDetails, LLMMessage } from '../../../../platform/void/common/llmMe import { IModelContentChangedEvent } from '../../../../editor/common/textModelEvents.js'; import { extractCodeFromFIM, extractCodeFromRegular } from './helpers/extractCodeFromResult.js'; import { IMetricsService } from '../../../../platform/void/common/metricsService.js'; -import { InlineDecorationType } from '../../../../editor/common/viewModel.js'; import { filenameToVscodeLanguage } from './helpers/detectLanguage.js'; -import { BaseEditorSimpleWorker } from '../../../../editor/common/services/editorSimpleWorker.js'; -import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; -import { localize2 } from '../../../../nls.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { isMacintosh } from '../../../../base/common/platform.js'; +import { EditorOption } from '../../../../editor/common/config/editorOptions.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { VOID_OPEN_SETTINGS_ACTION_ID } from './voidSettingsPane.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; const configOfBG = (color: Color) => { return { dark: color, light: color, hcDark: color, hcLight: color, } @@ -66,6 +63,43 @@ registerColor('void.sweepIdxBG', configOfBG(sweepIdxBG), '', true); + + +const getLeadingWhitespacePx = (editor: ICodeEditor, startLine: number): number => { + + const model = editor.getModel(); + if (!model) { + return 0; + } + + // Get the line content, defaulting to empty string if line doesn't exist + const lineContent = model.getLineContent(startLine) || ''; + + // Find the first non-whitespace character + const firstNonWhitespaceIndex = lineContent.search(/\S/); + + // Extract leading whitespace, handling case where line is all whitespace + const leadingWhitespace = firstNonWhitespaceIndex === -1 + ? lineContent + : lineContent.slice(0, firstNonWhitespaceIndex); + + // Get font information from editor render options + const { tabSize: numSpacesInTab } = model.getFormattingOptions(); + const spaceWidth = editor.getOption(EditorOption.fontInfo).spaceWidth; + const tabWidth = numSpacesInTab * spaceWidth; + + let paddingLeft = 0; + for (const char of leadingWhitespace) { + if (char === '\t') { + paddingLeft += tabWidth + } else if (char === ' ') { + paddingLeft += spaceWidth; + } + } + + return paddingLeft; +}; + // similar to ServiceLLM export type StartApplyingOpts = { featureName: 'Ctrl+K'; @@ -130,6 +164,8 @@ type CtrlKZone = { refresh: () => void; } + _linkedStreamingDiffZone: number | null; // diffareaid of the diffZone currently streaming here + } & CommonZoneProps @@ -147,6 +183,7 @@ type DiffZone = { line?: undefined; }; editorId?: undefined; + linkedStreamingDiffZone?: undefined; } & CommonZoneProps @@ -161,6 +198,7 @@ const diffAreaSnapshotKeys = [ 'startLine', 'endLine', 'editorId', + ] as const satisfies (keyof DiffArea)[] type DiffAreaSnapshot = Pick @@ -180,8 +218,7 @@ export interface IInlineDiffsService { interruptStreaming(diffareaid: number): void; addCtrlKZone(opts: AddCtrlKOpts): number | undefined; removeCtrlKZone(opts: { diffareaid: number }): void; - - testDiffs(): void; + // testDiffs(): void; } export const IInlineDiffsService = createDecorator('inlineDiffAreasService'); @@ -197,18 +234,24 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { diffOfId: Record = {}; // redundant with diffArea._diffs + // only applies to diffZones + // streamingDiffZones: Set = new Set() + private readonly _onDidChangeStreaming = new Emitter<{ uri: URI; diffareaid: number }>(); + private readonly _onDidAddOrDeleteDiffZones = new Emitter<{ uri: URI }>(); + + constructor( // @IHistoryService private readonly _historyService: IHistoryService, // history service is the history of pressing alt left/right @ICodeEditorService private readonly _editorService: ICodeEditorService, @IModelService private readonly _modelService: IModelService, @IUndoRedoService private readonly _undoRedoService: IUndoRedoService, // undoRedo service is the history of pressing ctrl+z - @ILanguageService private readonly _langService: ILanguageService, @ILLMMessageService private readonly _llmMessageService: ILLMMessageService, @IConsistentItemService private readonly _consistentItemService: IConsistentItemService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IConsistentEditorItemService private readonly _consistentEditorItemService: IConsistentEditorItemService, @IMetricsService private readonly _metricsService: IMetricsService, @INotificationService private readonly _notificationService: INotificationService, + @ICommandService private readonly _commandService: ICommandService, ) { super(); @@ -227,6 +270,24 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { this._onUserChangeContent(uri, e) }) ) + + // when a stream starts or ends + let removeAcceptRejectAllUI: (() => void) | null = null + const onChangeUriState = () => { + const uri = model.uri + const diffZones = [...this.diffAreasOfURI[uri.fsPath].values()] + .map(diffareaid => this.diffAreaOfId[diffareaid]) + .filter(diffArea => !!diffArea && diffArea.type === 'DiffZone') + const isStreaming = diffZones.find(diffZone => !!diffZone._streamState.isStreaming) + if (diffZones.length !== 0 && !isStreaming && !removeAcceptRejectAllUI) { + removeAcceptRejectAllUI = this._addAcceptRejectUI(uri) ?? null + } else { + removeAcceptRejectAllUI?.() + removeAcceptRejectAllUI = null + } + } + this._register(this._onDidAddOrDeleteDiffZones.event(({ uri: uri_ }) => { if (uri_.fsPath === model.uri.fsPath) onChangeUriState() })) + this._register(this._onDidChangeStreaming.event(({ uri: uri_ }) => { if (uri_.fsPath === model.uri.fsPath) onChangeUriState() })) } // initialize all existing models + initialize when a new model mounts for (let model of this._modelService.getModels()) { initializeModel(model) } @@ -293,13 +354,15 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { // sweepLine ... sweepLine const fn1 = this._addLineDecoration(model, diffArea._streamState.line, diffArea._streamState.line, 'void-sweepIdxBG') // sweepLine+1 ... endLine - const fn2 = this._addLineDecoration(model, diffArea._streamState.line + 1, diffArea.endLine, 'void-sweepBG') + const fn2 = diffArea._streamState.line + 1 <= diffArea.endLine ? + this._addLineDecoration(model, diffArea._streamState.line + 1, diffArea.endLine, 'void-sweepBG') + : null diffArea._removeStylesFns.add(() => { fn1?.(); fn2?.(); }) } } - else if (diffArea.type === 'CtrlKZone') { + else if (diffArea.type === 'CtrlKZone' && diffArea._linkedStreamingDiffZone === null) { // highlight zone's text const fn = this._addLineDecoration(model, diffArea.startLine, diffArea.endLine, 'void-highlightBG') diffArea._removeStylesFns.add(() => fn?.()); @@ -331,6 +394,40 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { } } + private _addAcceptRejectUI(uri: URI) { + + // find all diffzones that aren't streaming + const diffZones: DiffZone[] = [] + for (let diffareaid of this.diffAreasOfURI[uri.fsPath]) { + const diffArea = this.diffAreaOfId[diffareaid] + if (diffArea.type !== 'DiffZone') continue + if (diffArea._streamState.isStreaming) continue + diffZones.push(diffArea) + } + if (diffZones.length === 0) return + + const consistentItemId = this._consistentItemService.addConsistentItemToURI({ + uri, + fn: (editor) => { + const buttonsWidget = new AcceptAllRejectAllWidget({ + editor, + onAcceptAll: () => { + this.removeDiffAreas({ uri, behavior: 'accept', removeCtrlKs: false }) + this._metricsService.capture('Accept All', {}) + }, + onRejectAll: () => { + this.removeDiffAreas({ uri, behavior: 'reject', removeCtrlKs: false }) + this._metricsService.capture('Reject All', {}) + }, + }) + return () => { buttonsWidget.dispose() } + } + }) + + + return () => { this._consistentItemService.removeConsistentItemFromURI(consistentItemId) } + } + mostRecentTextOfCtrlKZoneId: Record = {} private _addCtrlKZoneInput = (ctrlKZone: CtrlKZone) => { @@ -343,10 +440,14 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { let viewZone_: IViewZone | null = null const textAreaRef: { current: HTMLTextAreaElement | null } = { current: null } + + const paddingLeft = getLeadingWhitespacePx(editor, ctrlKZone.startLine) + const itemId = this._consistentEditorItemService.addToEditor(editor, () => { const domNode = document.createElement('div'); domNode.style.zIndex = '1' domNode.style.height = 'auto' + domNode.style.paddingLeft = `${paddingLeft}px` const viewZone: IViewZone = { afterLineNumber: ctrlKZone.startLine - 1, domNode: domNode, @@ -361,12 +462,12 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { zoneId = accessor.addZone(viewZone) }) - // mount react this._instantiationService.invokeFunction(accessor => { mountCtrlK(domNode, accessor, { diffareaid: ctrlKZone.diffareaid, + initStreamingDiffZoneId: ctrlKZone._linkedStreamingDiffZone, textAreaRef: (r) => { textAreaRef.current = r @@ -378,6 +479,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { } }, onChangeHeight(height) { + if (height === 0) return // the viewZone sets this height to the container if it's out of view, ignore it viewZone.heightInPx = height // re-render with this new height editor.changeViewZones(accessor => { @@ -455,31 +557,55 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { const domNode = document.createElement('div'); domNode.className = 'void-redBG' - const renderOptions = RenderOptions.fromEditor(editor); - // applyFontInfo(domNode, renderOptions.fontInfo) + const renderOptions = RenderOptions.fromEditor(editor) - // Compute view-lines based on redText - const redText = diff.originalCode - const lines = redText.split('\n'); - const lineTokens = lines.map(line => LineTokens.createFromTextAndMetadata([{ text: line, metadata: 0 }], this._langService.languageIdCodec)); - const source = new LineSource(lineTokens, lines.map(() => null), false, false) - const result = renderLines(source, renderOptions, [ - { // add dummy so it doesn't highlight in red - range: Range.lift({ startLineNumber: 1, startColumn: 1, endLineNumber: Number.MAX_SAFE_INTEGER, endColumn: Number.MAX_SAFE_INTEGER }), - inlineClassName: '', - type: InlineDecorationType.Regular - } - ], domNode); + const processedText = diff.originalCode.replace(/\t/g, ' '.repeat(renderOptions.tabSize)); + + const lines = processedText.split('\n'); + + const linesContainer = document.createElement('div'); + linesContainer.style.fontFamily = renderOptions.fontInfo.fontFamily + linesContainer.style.fontSize = `${renderOptions.fontInfo.fontSize}px` + linesContainer.style.lineHeight = `${renderOptions.fontInfo.lineHeight}px` + // linesContainer.style.tabSize = `${tabWidth}px` // \t + linesContainer.style.whiteSpace = 'pre' + linesContainer.style.position = 'relative' + linesContainer.style.width = '100%' + + lines.forEach(line => { + // div for current line + const lineDiv = document.createElement('div'); + lineDiv.className = 'view-line'; + lineDiv.style.whiteSpace = 'pre' + lineDiv.style.position = 'relative' + lineDiv.style.height = `${renderOptions.fontInfo.lineHeight}px` + + // span (this is just how vscode does it) + const span = document.createElement('span'); + span.textContent = line || '\u00a0'; + span.style.whiteSpace = 'pre' + span.style.display = 'inline-block' + + lineDiv.appendChild(span); + linesContainer.appendChild(lineDiv); + }); + + domNode.appendChild(linesContainer); + + // Calculate height based on number of lines and line height + const heightInLines = lines.length; + const minWidthInPx = Math.max(...lines.map(line => + Math.ceil(renderOptions.fontInfo.typicalFullwidthCharacterWidth * line.length) + )); const viewZone: IViewZone = { - // afterLineNumber: computedDiff.startLine - 1, - afterLineNumber: type === 'edit' ? diff.endLine : diff.startLine - 1, - heightInLines: result.heightInLines, - minWidthInPx: result.minWidthInPx, - domNode: domNode, - marginDomNode: document.createElement('div'), // displayed to left - suppressMouseDown: true, - showInHiddenAreas: true, + afterLineNumber: diff.startLine - 1, + heightInLines, + minWidthInPx, + domNode, + marginDomNode: document.createElement('div'), + suppressMouseDown: false, + showInHiddenAreas: false, }; let zoneId: string | null = null @@ -494,29 +620,51 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { - // Accept | Reject widget - const consistentWidgetId = this._consistentItemService.addConsistentItemToURI({ - uri, - fn: (editor) => { - const buttonsWidget = new AcceptRejectWidget({ - editor, - onAccept: () => { - this.acceptDiff({ diffid }) - this._metricsService.capture('Accept Diff', { batch: false }) - }, - onReject: () => { - this.rejectDiff({ diffid }) - this._metricsService.capture('Reject Diff', { batch: false }) - }, - diffid: diffid.toString(), - startLine: diff.startLine, - }) - return () => { buttonsWidget.dispose() } - } - }) - disposeInThisEditorFns.push(() => { this._consistentItemService.removeConsistentItemFromURI(consistentWidgetId) }) - + const diffZone = this.diffAreaOfId[diff.diffareaid] + if (diffZone.type === 'DiffZone' && !diffZone._streamState.isStreaming) { + // Accept | Reject widget + const consistentWidgetId = this._consistentItemService.addConsistentItemToURI({ + uri, + fn: (editor) => { + let startLine: number + let offsetLines: number + if (diff.type === 'insertion' || diff.type === 'edit') { + startLine = diff.startLine // green start + offsetLines = 0 + } + else if (diff.type === 'deletion') { + // if diff.startLine is out of bounds + if (diff.startLine === 1) { + const numRedLines = diff.originalEndLine - diff.originalStartLine + 1 + startLine = diff.startLine + offsetLines = -numRedLines + } + else { + startLine = diff.startLine - 1 + offsetLines = 1 + } + } + else { throw 1 } + const buttonsWidget = new AcceptRejectWidget({ + editor, + onAccept: () => { + this.acceptDiff({ diffid }) + this._metricsService.capture('Accept Diff', {}) + }, + onReject: () => { + this.rejectDiff({ diffid }) + this._metricsService.capture('Reject Diff', {}) + }, + diffid: diffid.toString(), + startLine, + offsetLines + }) + return () => { buttonsWidget.dispose() } + } + }) + disposeInThisEditorFns.push(() => { this._consistentItemService.removeConsistentItemFromURI(consistentWidgetId) }) + } const disposeInEditor = () => { disposeInThisEditorFns.forEach(f => f()) } return disposeInEditor; @@ -548,24 +696,30 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { } weAreWriting = false - worker = new BaseEditorSimpleWorker() private async _writeText(uri: URI, text: string, range: IRange, { shouldRealignDiffAreas }: { shouldRealignDiffAreas: boolean }) { const model = this._getModel(uri) if (!model) return const uriStr = this._readURI(uri, range) - if (!uriStr) return + if (uriStr === null) return - // minimal edits so not so flashy - // __TODO__ THIS IS NOT INSIDE A WORKER, SO IT MIGHT BE SLOW, we should instead just do an optimal write ourselves - const edits = this.worker.$Void_computeMoreMinimalEdits(uri.toString(), [{ range, text }], false) - if (edits) { - this.weAreWriting = true - model.applyEdits(edits) - this.weAreWriting = false + // heuristic check if don't need to make edits + const dontNeedToWrite = uriStr === text + if (dontNeedToWrite) { + // at the end of a write, we still expect to refresh all styles + // e.g. sometimes we expect to restore all the decorations even if no edits were made when _writeText is used + this._refreshStylesAndDiffsInURI(uri) + return } + // minimal edits so not so flashy + // const edits = this.worker.$Void_computeMoreMinimalEdits(uri.toString(), [{ range, text }], false) + this.weAreWriting = true + model.applyEdits([{ range, text }]) + this.weAreWriting = false + this._onInternalChangeContent(uri, { shouldRealign: shouldRealignDiffAreas && { newText: text, oldRange: range } }) + } @@ -617,7 +771,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { type: 'DiffZone', _diffOfId: {}, _URI: uri, - _streamState: { isStreaming: false }, + _streamState: { isStreaming: false }, // when restoring, we will never be streaming _removeStylesFns: new Set(), } } @@ -627,25 +781,22 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { _URI: uri, _removeStylesFns: new Set(), _mountInfo: null, + _linkedStreamingDiffZone: null, // when restoring, we will never be streaming } } this.diffAreasOfURI[uri.fsPath].add(diffareaid) } + this._onDidAddOrDeleteDiffZones.fire({ uri }) // restore file content const numLines = this._getNumLines(uri) if (numLines === null) return - const hasWriteChange = this._readURI(uri) !== entireModelCode // a heuristic check - if (hasWriteChange) - this._writeText(uri, entireModelCode, - { startColumn: 1, startLineNumber: 1, endLineNumber: numLines, endColumn: Number.MAX_SAFE_INTEGER }, - { shouldRealignDiffAreas: false } - ) - else { - // restore all the decorations - this._refreshStylesAndDiffsInURI(uri) - } + + this._writeText(uri, entireModelCode, + { startColumn: 1, startLineNumber: 1, endLineNumber: numLines, endColumn: Number.MAX_SAFE_INTEGER }, + { shouldRealignDiffAreas: false } + ) } const beforeSnapshot: HistorySnapshot = getCurrentSnapshot() @@ -691,7 +842,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { } - // clears all Diffs (and their styles) and all styles of DiffAreas + // clears all Diffs (and their styles) and all styles of DiffAreas, etc private _clearAllEffects(uri: URI) { for (let diffareaid of this.diffAreasOfURI[uri.fsPath]) { const diffArea = this.diffAreaOfId[diffareaid] @@ -705,6 +856,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { this._clearAllDiffAreaEffects(diffZone) delete this.diffAreaOfId[diffZone.diffareaid] this.diffAreasOfURI[diffZone._URI.fsPath].delete(diffZone.diffareaid.toString()) + this._onDidAddOrDeleteDiffZones.fire({ uri: diffZone._URI }) } private _deleteCtrlKZone(ctrlKZone: CtrlKZone) { @@ -844,7 +996,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { // @throttle(100) - private _writeDiffZoneLLMText(diffZone: DiffZone, llmText: string) { + private _writeStreamedDiffZoneLLMText(diffZone: DiffZone, llmText: string, deltaText: string, latest: { line: number, col: number, addedSplitYet: boolean, originalCodeStartLine: number }) { // ----------- 1. Write the new code to the document ----------- // figure out where to highlight based on where the AI is in the stream right now, use the last diff to figure that out @@ -881,17 +1033,41 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { } - // lines are 1-indexed - const newCodeTop = llmText.split('\n').slice(0, (newCodeEndLine - 1) + 1).join('\n') - const oldFileBottom = diffZone.originalCode.split('\n').slice((originalCodeStartLine - 1) + 1, Infinity).join('\n') - // oriignalCode[1 + line...Infinity]. Must make sure 1 + line < originalCode.length. This is another way to check: - const newCode = (newCodeTop && oldFileBottom) ? `${newCodeTop}\n${oldFileBottom}` : (oldFileBottom || newCodeTop) + // at the start, add a newline between the stream and originalCode to make reasoning easier + if (!latest.addedSplitYet) { + this._writeText(uri, '\n', + { startLineNumber: latest.line, startColumn: latest.col, endLineNumber: latest.line, endColumn: latest.col, }, + { shouldRealignDiffAreas: true } + ) + latest.addedSplitYet = true + } - this._writeText(uri, newCode, - { startLineNumber: diffZone.startLine, startColumn: 1, endLineNumber: diffZone.endLine, endColumn: Number.MAX_SAFE_INTEGER, }, // 1-indexed + // insert deltaText at latest line and col + this._writeText(uri, deltaText, + { startLineNumber: latest.line, startColumn: latest.col, endLineNumber: latest.line, endColumn: latest.col }, { shouldRealignDiffAreas: true } ) + latest.line += deltaText.split('\n').length - 1 + const lastNewlineIdx = deltaText.lastIndexOf('\n') + latest.col = lastNewlineIdx === -1 ? latest.col + deltaText.length : deltaText.length - lastNewlineIdx + + // delete or insert to get original up to speed + if (latest.originalCodeStartLine < originalCodeStartLine) { + // moved up, delete + const numLinesDeleted = originalCodeStartLine - latest.originalCodeStartLine + this._writeText(uri, '', + { startLineNumber: latest.line, startColumn: latest.col, endLineNumber: latest.line + numLinesDeleted, endColumn: Number.MAX_SAFE_INTEGER, }, + { shouldRealignDiffAreas: true } + ) + } + else if (latest.originalCodeStartLine > originalCodeStartLine) { + this._writeText(uri, '\n' + diffZone.originalCode.split('\n').slice((originalCodeStartLine - 1), (latest.originalCodeStartLine - 1) - 1 + 1).join('\n'), + { startLineNumber: latest.line, startColumn: latest.col, endLineNumber: latest.line, endColumn: latest.col }, + { shouldRealignDiffAreas: true } + ) + } + latest.originalCodeStartLine = originalCodeStartLine // add diffZone.startLine to convert to right coordinate system (line in file, not in diffarea) diffZone._streamState.line = (diffZone.startLine - 1) + newCodeEndLine @@ -962,7 +1138,8 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { // check if there's overlap with any other ctrlKZone and if so, focus it const overlappingCtrlKZone = this._findOverlappingDiffArea({ startLine, endLine, uri, filter: (diffArea) => diffArea.type === 'CtrlKZone' }) if (overlappingCtrlKZone) { - (overlappingCtrlKZone as CtrlKZone)._mountInfo?.textAreaRef.current?.focus() + editor.revealLine(overlappingCtrlKZone.startLine) // important + setTimeout(() => (overlappingCtrlKZone as CtrlKZone)._mountInfo?.textAreaRef.current?.focus(), 100) return } @@ -970,6 +1147,9 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { if (overlappingDiffZone) return + editor.revealLine(startLine) + editor.setSelection({ startLineNumber: startLine, endLineNumber: startLine, startColumn: 1, endColumn: 1 }) + const { onFinishEdit } = this._addToHistory(uri) const adding: Omit = { @@ -980,6 +1160,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { _URI: uri, _removeStylesFns: new Set(), _mountInfo: null, + _linkedStreamingDiffZone: null, } const ctrlKZone = this._addDiffArea(adding) this._refreshStylesAndDiffsInURI(uri) @@ -1041,8 +1222,8 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { if (!uri_) return uri = uri_ - // reject all diffareas on this URI, adding to history (there can't possibly be overlap after this) - this.removeDiffAreas({ uri, behavior: 'reject' }) + // reject all diffZones on this URI, adding to history (there can't possibly be overlap after this) + this.removeDiffAreas({ uri, behavior: 'reject', removeCtrlKs: true }) // in ctrl+L the start and end lines are the full document const numLines = this._getNumLines(uri) @@ -1054,7 +1235,6 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { } else if (featureName === 'Ctrl+K') { const { diffareaid } = opts - const ctrlKZone = this.diffAreaOfId[diffareaid] if (ctrlKZone.type !== 'CtrlKZone') return @@ -1081,9 +1261,8 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { // add to history const { onFinishEdit } = this._addToHistory(uri) - - // __TODO__ ctrl+K should use Ollama's FIM method. - const ollamaStyleFIM = false + // __TODO__ let users customize modelFimTags + const isOllamaFIM = false // this._voidSettingsService.state.modelSelectionOfFeature['Ctrl+K']?.providerName === 'ollama' const modelFimTags = defaultFimTags const adding: Omit = { @@ -1101,26 +1280,38 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { _removeStylesFns: new Set(), } const diffZone = this._addDiffArea(adding) + this._onDidChangeStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) + this._onDidAddOrDeleteDiffZones.fire({ uri }) + if (featureName === 'Ctrl+K') { + const { diffareaid } = opts + const ctrlKZone = this.diffAreaOfId[diffareaid] + if (ctrlKZone.type !== 'CtrlKZone') return + + ctrlKZone._linkedStreamingDiffZone = diffZone.diffareaid + } + + // now handle messages let messages: LLMMessage[] if (featureName === 'Ctrl+L') { const userContent = ctrlLStream_prompt({ originalCode, userMessage, uri }) messages = [ - // TODO include more context too { role: 'system', content: ctrlLStream_systemMessage, }, { role: 'user', content: userContent, } ] } else if (featureName === 'Ctrl+K') { const { prefix, suffix } = ctrlKStream_prefixAndSuffix({ fullFileStr: currentFileStr, startLine, endLine }) - const language = filenameToVscodeLanguage(uri.fsPath) ?? '' - const userContent = ctrlKStream_prompt({ selection: originalCode, userMessage, prefix, suffix, ollamaStyleFIM, fimTags: modelFimTags, language }) // console.log('PREFIX:\n', prefix) // console.log('SUFFIX:\n', suffix) // console.log('USER CONTENT:\n', userContent) + + // __TODO__ use Ollama's FIM api + // if (isOllamaFIM) {...} else: + const language = filenameToVscodeLanguage(uri.fsPath) ?? '' + const userContent = ctrlKStream_prompt({ selection: originalCode, userMessage, prefix, suffix, isOllamaFIM: false, fimTags: modelFimTags, language }) messages = [ - // TODO include more context too (LSP, file history, etc) { role: 'system', content: ctrlKStream_systemMessage, }, { role: 'user', content: userContent, } ] @@ -1130,8 +1321,12 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { const onDone = (hadError: boolean) => { diffZone._streamState = { isStreaming: false, } + this._onDidChangeStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) + if (featureName === 'Ctrl+K') { const ctrlKZone = this.diffAreaOfId[opts.diffareaid] as CtrlKZone + + ctrlKZone._linkedStreamingDiffZone = null this._deleteCtrlKZone(ctrlKZone) } this._refreshStylesAndDiffsInURI(uri) @@ -1147,40 +1342,53 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { this._refreshStylesAndDiffsInURI(uri) - const extractText = (fullText: string) => { + const extractText = (fullText: string, recentlyAddedTextLen: number) => { if (featureName === 'Ctrl+K') { - if (ollamaStyleFIM) return fullText - return extractCodeFromFIM({ text: fullText, midTag: modelFimTags.midTag }) + if (isOllamaFIM) return fullText + return extractCodeFromFIM({ text: fullText, recentlyAddedTextLen, midTag: modelFimTags.midTag }) } else if (featureName === 'Ctrl+L') { - return extractCodeFromRegular(fullText) + return extractCodeFromRegular({ text: fullText, recentlyAddedTextLen }) } throw 1 } + const latestStreamInfo = { line: diffZone.startLine, addedSplitYet: false, col: 1, originalCodeStartLine: 1 } streamRequestIdRef.current = this._llmMessageService.sendLLMMessage({ - featureName, + useProviderFor: featureName, logging: { loggingName: `startApplying - ${featureName}` }, messages, onText: ({ newText, fullText }) => { - this._writeDiffZoneLLMText(diffZone, extractText(fullText)) + const [text, deltaText] = extractText(fullText, newText.length) + + this._writeStreamedDiffZoneLLMText(diffZone, text, deltaText, latestStreamInfo) this._refreshStylesAndDiffsInURI(uri) }, onFinalMessage: ({ fullText }) => { // console.log('DONE! FULL TEXT\n', extractText(fullText), diffZone.startLine, diffZone.endLine) // at the end, re-write whole thing to make sure no sync errors - this._writeText(uri, extractText(fullText), + const [text, _] = extractText(fullText, 0) + this._writeText(uri, text, { startLineNumber: diffZone.startLine, startColumn: 1, endLineNumber: diffZone.endLine, endColumn: Number.MAX_SAFE_INTEGER }, // 1-indexed { shouldRealignDiffAreas: true } ) onDone(false) }, onError: (e) => { - console.error('Error rewriting file with diff', e); const details = errorDetails(e.fullError) this._notificationService.notify({ severity: Severity.Warning, message: `Void Error: ${e.message}`, + actions: { + secondary: [{ + id: 'void.onerror.opensettings', + enabled: true, + label: 'Open Void settings', + tooltip: '', + class: undefined, + run: () => { this._commandService.executeCommand(VOID_OPEN_SETTINGS_ACTION_ID) } + }] + }, source: details ? `(Hold ${isMacintosh ? 'Option' : 'Alt'} to hover) - ${details}` : undefined }) onDone(true) @@ -1194,57 +1402,18 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { } - testDiffs(): DiffZone | undefined { - const uri = this._getActiveEditorURI() - if (!uri) return - - const startLine = 1 - const endLine = 4 - - const currentFileStr = this._readURI(uri) - if (currentFileStr === null) return - const originalCode = currentFileStr.split('\n').slice((startLine - 1), (endLine - 1) + 1).join('\n') - - const { onFinishEdit } = this._addToHistory(uri) - const adding: Omit = { - type: 'DiffZone', - originalCode, - startLine, - endLine, - _URI: uri, - _streamState: { isStreaming: false, }, - _diffOfId: {}, // added later - _removeStylesFns: new Set(), - } - const diffZone = this._addDiffArea(adding) - const endResult = `\ -const x = 1; -if (x > 0) { - console.log('hi!') -}` - this._writeText(uri, endResult, - { startLineNumber: diffZone.startLine, startColumn: 1, endLineNumber: diffZone.endLine, endColumn: Number.MAX_SAFE_INTEGER }, // 1-indexed - { shouldRealignDiffAreas: true } - ) - diffZone._streamState = { isStreaming: false, } - this._refreshStylesAndDiffsInURI(uri) - onFinishEdit() - - return diffZone - } - private _stopIfStreaming(diffZone: DiffZone) { + const uri = diffZone._URI const streamRequestId = diffZone._streamState.streamRequestIdRef?.current - if (!streamRequestId) - return + if (!streamRequestId) return this._llmMessageService.abort(streamRequestId) diffZone._streamState = { isStreaming: false, } - + this._onDidChangeStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) } _undoHistory(uri: URI) { @@ -1292,7 +1461,7 @@ if (x > 0) { // remove a batch of diffareas all at once (and handle accept/reject of their diffs) - public removeDiffAreas({ uri, behavior }: { uri: URI, behavior: 'reject' | 'accept' }) { + public removeDiffAreas({ uri, removeCtrlKs, behavior }: { uri: URI, removeCtrlKs: boolean, behavior: 'reject' | 'accept' }) { const diffareaids = this.diffAreasOfURI[uri.fsPath] if (diffareaids.size === 0) return // do nothing @@ -1307,7 +1476,7 @@ if (x > 0) { if (behavior === 'reject') this._revertAndDeleteDiffZone(diffArea) else if (behavior === 'accept') this._deleteDiffZone(diffArea) } - else if (diffArea.type === 'CtrlKZone') { + else if (diffArea.type === 'CtrlKZone' && removeCtrlKs) { this._deleteCtrlKZone(diffArea) } } @@ -1474,12 +1643,60 @@ if (x > 0) { } + + + + // testDiffs(): DiffZone | undefined { + // const uri = this._getActiveEditorURI() + // if (!uri) return + + // const startLine = 1 + // const endLine = 4 + + // const currentFileStr = this._readURI(uri) + // if (currentFileStr === null) return + // const originalCode = currentFileStr.split('\n').slice((startLine - 1), (endLine - 1) + 1).join('\n') + + // const { onFinishEdit } = this._addToHistory(uri) + // const adding: Omit = { + // type: 'DiffZone', + // originalCode, + // startLine, + // endLine, + // _URI: uri, + // _streamState: { isStreaming: false, }, + // _diffOfId: {}, // added later + // _removeStylesFns: new Set(), + // } + // const diffZone = this._addDiffArea(adding) + // const endResult = `\ + // const x = 1; + // if (x > 0) { + // console.log('hi!') + // }` + // this._writeText(uri, endResult, + // { startLineNumber: diffZone.startLine, startColumn: 1, endLineNumber: diffZone.endLine, endColumn: Number.MAX_SAFE_INTEGER }, // 1-indexed + // { shouldRealignDiffAreas: true } + // ) + // diffZone._streamState = { isStreaming: false, } + // this._refreshStylesAndDiffsInURI(uri) + // onFinishEdit() + + // return diffZone + // } + } registerSingleton(IInlineDiffsService, InlineDiffsService, InstantiationType.Eager); - - +const acceptBg = '#1a7431' +const acceptAllBg = '#1e8538' +const acceptBorder = '1px solid #145626' +const rejectBg = '#b42331' +const rejectAllBg = '#cf2838' +const rejectBorder = '1px solid #8e1c27' +const buttonFontSize = '11px' +const buttonTextColor = 'white' class AcceptRejectWidget extends Widget implements IOverlayWidget { @@ -1492,13 +1709,16 @@ class AcceptRejectWidget extends Widget implements IOverlayWidget { private readonly ID private readonly startLine - constructor({ editor, onAccept, onReject, diffid, startLine }: { editor: ICodeEditor; onAccept: () => void; onReject: () => void; diffid: string, startLine: number }) { + constructor({ editor, onAccept, onReject, diffid, startLine, offsetLines }: { editor: ICodeEditor; onAccept: () => void; onReject: () => void; diffid: string, startLine: number, offsetLines: number }) { super() + this.ID = editor.getModel()?.uri.fsPath + diffid; this.editor = editor; this.startLine = startLine; + const lineHeight = editor.getOption(EditorOption.lineHeight); + // Create container div with buttons const { acceptButton, rejectButton, buttons } = dom.h('div@buttons', [ dom.h('button@acceptButton', []), @@ -1509,29 +1729,46 @@ class AcceptRejectWidget extends Widget implements IOverlayWidget { buttons.style.display = 'flex'; buttons.style.position = 'absolute'; buttons.style.gap = '4px'; - buttons.style.padding = '4px'; - buttons.style.zIndex = '1000'; + buttons.style.paddingRight = '4px'; + buttons.style.zIndex = '1'; + buttons.style.transform = `translateY(${offsetLines * lineHeight}px)`; // Style accept button acceptButton.onclick = onAccept; acceptButton.textContent = 'Accept'; - acceptButton.style.backgroundColor = '#28a745'; - acceptButton.style.color = 'white'; - acceptButton.style.border = 'none'; - acceptButton.style.padding = '4px 8px'; - acceptButton.style.borderRadius = '3px'; + acceptButton.style.backgroundColor = acceptBg; + acceptButton.style.border = acceptBorder; + acceptButton.style.color = buttonTextColor; + acceptButton.style.fontSize = buttonFontSize; + acceptButton.style.borderTop = 'none'; + acceptButton.style.padding = '1px 4px'; + acceptButton.style.borderBottomLeftRadius = '6px'; + acceptButton.style.borderBottomRightRadius = '6px'; + acceptButton.style.borderTopLeftRadius = '0'; + acceptButton.style.borderTopRightRadius = '0'; acceptButton.style.cursor = 'pointer'; + acceptButton.style.height = '100%'; + acceptButton.style.boxShadow = '0 2px 3px rgba(0,0,0,0.2)'; // Style reject button rejectButton.onclick = onReject; rejectButton.textContent = 'Reject'; - rejectButton.style.backgroundColor = '#dc3545'; - rejectButton.style.color = 'white'; - rejectButton.style.border = 'none'; - rejectButton.style.padding = '4px 8px'; - rejectButton.style.borderRadius = '3px'; + rejectButton.style.backgroundColor = rejectBg; + rejectButton.style.border = rejectBorder; + rejectButton.style.color = buttonTextColor; + rejectButton.style.fontSize = buttonFontSize; + rejectButton.style.borderTop = 'none'; + rejectButton.style.padding = '1px 4px'; + rejectButton.style.borderBottomLeftRadius = '6px'; + rejectButton.style.borderBottomRightRadius = '6px'; + rejectButton.style.borderTopLeftRadius = '0'; + rejectButton.style.borderTopRightRadius = '0'; rejectButton.style.cursor = 'pointer'; + rejectButton.style.height = '100%'; + rejectButton.style.boxShadow = '0 2px 3px rgba(0,0,0,0.2)'; + + this._domNode = buttons; @@ -1576,17 +1813,95 @@ class AcceptRejectWidget extends Widget implements IOverlayWidget { -registerAction2(class extends Action2 { - constructor() { - super({ - id: 'void.testDiff', - title: localize2('voidTestDiff', 'Void Test Diff'), - f1: true, - }); - } - async run(accessor: ServicesAccessor): Promise { - const inlineDiffsService = accessor.get(IInlineDiffsService) - inlineDiffsService.testDiffs() + +class AcceptAllRejectAllWidget extends Widget implements IOverlayWidget { + private readonly _domNode: HTMLElement; + private readonly editor: ICodeEditor; + private readonly ID: string; + + constructor({ editor, onAcceptAll, onRejectAll }: { editor: ICodeEditor, onAcceptAll: () => void, onRejectAll: () => void }) { + super(); + + this.ID = editor.getModel()?.uri.fsPath + ''; + this.editor = editor; + + // Create container div with buttons + const { acceptButton, rejectButton, buttons } = dom.h('div@buttons', [ + dom.h('button@acceptButton', []), + dom.h('button@rejectButton', []) + ]); + + // Style the container + buttons.style.zIndex = '2'; + buttons.style.padding = '4px'; + buttons.style.display = 'flex'; + buttons.style.gap = '4px'; + buttons.style.alignItems = 'center'; + + // Style accept button + acceptButton.addEventListener('click', onAcceptAll) + acceptButton.textContent = 'Accept All'; + acceptButton.style.backgroundColor = acceptAllBg; + acceptButton.style.border = acceptBorder; + acceptButton.style.color = buttonTextColor; + acceptButton.style.fontSize = buttonFontSize; + acceptButton.style.padding = '4px 8px'; + acceptButton.style.borderRadius = '6px'; + acceptButton.style.cursor = 'pointer'; + + // Style reject button + rejectButton.addEventListener('click', onRejectAll) + rejectButton.textContent = 'Reject All'; + rejectButton.style.backgroundColor = rejectAllBg; + rejectButton.style.border = rejectBorder; + rejectButton.style.color = buttonTextColor; + rejectButton.style.fontSize = buttonFontSize; + rejectButton.style.color = 'white'; + rejectButton.style.padding = '4px 8px'; + rejectButton.style.borderRadius = '6px'; + rejectButton.style.cursor = 'pointer'; + + this._domNode = buttons; + + // Mount the widget + editor.addOverlayWidget(this); } -}) + + + public getId(): string { + return this.ID; + } + + public getDomNode(): HTMLElement { + return this._domNode; + } + + public getPosition() { + return { + preference: OverlayWidgetPositionPreference.BOTTOM_RIGHT_CORNER, + } + } + + public override dispose(): void { + this.editor.removeOverlayWidget(this); + super.dispose(); + } +} + + + +// registerAction2(class extends Action2 { +// constructor() { +// super({ +// id: 'void.testDiff', +// title: localize2('voidTestDiff', 'Void Test Diff'), +// f1: true, +// }); +// } +// async run(accessor: ServicesAccessor): Promise { +// const inlineDiffsService = accessor.get(IInlineDiffsService) +// // inlineDiffsService.testDiffs() + +// } +// }) diff --git a/src/vs/workbench/contrib/void/browser/prompt/prompts.ts b/src/vs/workbench/contrib/void/browser/prompt/prompts.ts index ca692569..a45bdf1c 100644 --- a/src/vs/workbench/contrib/void/browser/prompt/prompts.ts +++ b/src/vs/workbench/contrib/void/browser/prompt/prompts.ts @@ -6,7 +6,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { filenameToVscodeLanguage } from '../helpers/detectLanguage.js'; -import { CodeSelection } from '../threadHistoryService.js'; +import { CodeSelection } from '../chatThreadService.js'; export const chat_systemMessage = `\ You are a coding assistant. You are given a list of relevant files \`files\`, a selection that the user is making \`selection\`, and instructions to follow \`instructions\`. @@ -341,32 +341,18 @@ export const defaultFimTags: FimTagsType = { midTag: 'SELECTION', } -export const ctrlKStream_prompt = ({ selection, prefix, suffix, userMessage, fimTags, ollamaStyleFIM, language }: - { selection: string, prefix: string, suffix: string, userMessage: string, ollamaStyleFIM: boolean, fimTags: FimTagsType, language: string }) => { +export const ctrlKStream_prompt = ({ selection, prefix, suffix, userMessage, fimTags, isOllamaFIM, language }: + { + selection: string, prefix: string, suffix: string, userMessage: string, fimTags: FimTagsType, language: string, + isOllamaFIM: false, // we require this be false for clarity + }) => { const { preTag, sufTag, midTag } = fimTags - - - if (ollamaStyleFIM) { - // const preTag = 'PRE' - // const sufTag = 'SUF' - // const midTag = 'MID' - return `\ -<${preTag}> -/* Original Selection: -${selection}*/ -/* Instructions: -${userMessage}*/ -${prefix} -<${sufTag}>${suffix} -<${midTag}>` - } // prompt the model artifically on how to do FIM - else { - // const preTag = 'BEFORE' - // const sufTag = 'AFTER' - // const midTag = 'SELECTION' - return `\ + // const preTag = 'BEFORE' + // const sufTag = 'AFTER' + // const midTag = 'SELECTION' + return `\ The user is selecting this code as their SELECTION: \`\`\` ${language} <${midTag}>${selection} @@ -377,12 +363,12 @@ ${userMessage} Please edit the SELECTION following the user's INSTRUCTIONS, and return the edited selection. -Note that the SELECTION has code that comes before it. This code is indicated with <${preTag}>...before<${preTag}/>. -Note also that the SELECTION has code that comes after it. This code is indicated with <${sufTag}>...after<${sufTag}/>. +Note that the SELECTION has code that comes before it. This code is indicated with <${preTag}>...before. +Note also that the SELECTION has code that comes after it. This code is indicated with <${sufTag}>...after. Instructions: -1. Your OUTPUT should be a SINGLE PIECE OF CODE of the form <${midTag}>...new_selection<${midTag}/>. Do not give any explanation before or after this. ONLY output this format, nothing more. -2. You may ONLY CHANGE the original SELECTION, and NOT the content in the <${preTag}>...<${preTag}/> or <${sufTag}>...<${sufTag}/> tags. +1. Your OUTPUT should be a SINGLE PIECE OF CODE of the form <${midTag}>...new_selection. Do NOT output any text or explanations before or after this. +2. You may ONLY CHANGE the original SELECTION, and NOT the content in the <${preTag}>... or <${sufTag}>... tags. 3. Make sure all brackets in the new selection are balanced the same as in the original selection. 4. Be careful not to duplicate or remove variables, comments, or other syntax by mistake. @@ -390,8 +376,7 @@ Given the code: <${preTag}>${prefix} <${sufTag}>${suffix} -Return only the completion block of code (of the form \`\`\` ${language}\n <${midTag}>...new_selection<${midTag}/>\`\`\`):` - } +Return only the completion block of code (of the form \`\`\` ${language}\n <${midTag}>...new_selection\`\`\`):` }; diff --git a/src/vs/workbench/contrib/void/browser/quickEditActions.ts b/src/vs/workbench/contrib/void/browser/quickEditActions.ts index a4b8a16c..1498c8f6 100644 --- a/src/vs/workbench/contrib/void/browser/quickEditActions.ts +++ b/src/vs/workbench/contrib/void/browser/quickEditActions.ts @@ -12,10 +12,12 @@ import { ICodeEditorService } from '../../../../editor/browser/services/codeEdit import { IInlineDiffsService } from './inlineDiffsService.js'; import { roundRangeToLines } from './sidebarActions.js'; import { VOID_CTRL_K_ACTION_ID } from './actionIDs.js'; +import { localize2 } from '../../../../nls.js'; export type QuickEditPropsType = { diffareaid: number, + initStreamingDiffZoneId: number | null, textAreaRef: (ref: HTMLTextAreaElement | null) => void; onChangeHeight: (height: number) => void; onChangeText: (text: string) => void; @@ -36,10 +38,11 @@ registerAction2(class extends Action2 { ) { super({ id: VOID_CTRL_K_ACTION_ID, - title: 'Void: Quick Edit', + f1: true, + title: localize2('voidQuickEditAction', 'Void: Quick Edit'), keybinding: { primary: KeyMod.CtrlCmd | KeyCode.KeyK, - weight: KeybindingWeight.BuiltinExtension, + weight: KeybindingWeight.VoidExtension, } }); } @@ -60,9 +63,6 @@ registerAction2(class extends Action2 { const { startLineNumber: startLine, endLineNumber: endLine } = selection - // deselect - clear selection - editor.setSelection({ startLineNumber: startLine, endLineNumber: startLine, startColumn: 1, endColumn: 1 }) - const inlineDiffsService = accessor.get(IInlineDiffsService) inlineDiffsService.addCtrlKZone({ startLine, endLine, editor }) } diff --git a/src/vs/workbench/contrib/void/browser/react/src/markdown/BlockCode.tsx b/src/vs/workbench/contrib/void/browser/react/src/markdown/BlockCode.tsx index fca06caa..43abd5b1 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/markdown/BlockCode.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/markdown/BlockCode.tsx @@ -9,23 +9,21 @@ import { VoidCodeEditor, VoidCodeEditorProps } from '../util/inputs.js'; export const BlockCode = ({ buttonsOnHover, ...codeEditorProps }: { buttonsOnHover?: React.ReactNode } & VoidCodeEditorProps) => { - const isSingleLine = !codeEditorProps.initValue.includes('\n') return ( <> -
- +
{buttonsOnHover === null ? null : ( -
-
+
+
{buttonsOnHover}
)} -
) diff --git a/src/vs/workbench/contrib/void/browser/react/src/quick-edit-tsx/QuickEditChat.tsx b/src/vs/workbench/contrib/void/browser/react/src/quick-edit-tsx/QuickEditChat.tsx index 755c3dec..80988038 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/quick-edit-tsx/QuickEditChat.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/quick-edit-tsx/QuickEditChat.tsx @@ -4,15 +4,18 @@ *--------------------------------------------------------------------------------------*/ import React, { FormEvent, useCallback, useEffect, useRef, useState } from 'react'; -import { useSettingsState, useSidebarState, useThreadsState, useQuickEditState, useAccessor } from '../util/services.js'; +import { useSettingsState, useSidebarState, useChatThreadsState, useQuickEditState, useAccessor } from '../util/services.js'; import { TextAreaFns, VoidInputBox2 } from '../util/inputs.js'; import { QuickEditPropsType } from '../../../quickEditActions.js'; import { ButtonStop, ButtonSubmit, IconX } from '../sidebar-tsx/SidebarChat.js'; import { ModelDropdown } from '../void-settings-tsx/ModelDropdown.js'; import { VOID_CTRL_K_ACTION_ID } from '../../../actionIDs.js'; +import { useRefState } from '../util/helpers.js'; +import { useScrollbarStyles } from '../util/useScrollbarStyles.js'; export const QuickEditChat = ({ diffareaid, + initStreamingDiffZoneId, onChangeHeight, onChangeText: onChangeText_, textAreaRef: textAreaRef_, @@ -43,39 +46,41 @@ export const QuickEditChat = ({ const [instructionsAreEmpty, setInstructionsAreEmpty] = useState(!(initText ?? '')) // the user's instructions const isDisabled = instructionsAreEmpty - const currentlyStreamingIdRef = useRef(undefined) - const [isStreaming, setIsStreaming] = useState(false) + const [currStreamingDiffZoneRef, setCurrentlyStreamingDiffZone] = useRefState(initStreamingDiffZoneId) + const isStreaming = currStreamingDiffZoneRef.current !== null const onSubmit = useCallback((e: FormEvent) => { if (isDisabled) return - if (currentlyStreamingIdRef.current !== undefined) return + if (currStreamingDiffZoneRef.current !== null) return textAreaFnsRef.current?.disable() const instructions = textAreaRef.current?.value ?? '' - currentlyStreamingIdRef.current = inlineDiffsService.startApplying({ + const id = inlineDiffsService.startApplying({ featureName: 'Ctrl+K', diffareaid: diffareaid, userMessage: instructions, }) - setIsStreaming(true) - }, [isDisabled, inlineDiffsService, diffareaid]) + setCurrentlyStreamingDiffZone(id ?? null) + }, [currStreamingDiffZoneRef, setCurrentlyStreamingDiffZone, isDisabled, inlineDiffsService, diffareaid]) const onInterrupt = useCallback(() => { - if (currentlyStreamingIdRef.current !== undefined) - inlineDiffsService.interruptStreaming(currentlyStreamingIdRef.current) + if (currStreamingDiffZoneRef.current === null) return + inlineDiffsService.interruptStreaming(currStreamingDiffZoneRef.current) + setCurrentlyStreamingDiffZone(null) textAreaFnsRef.current?.enable() - setIsStreaming(false) - }, [inlineDiffsService]) + }, [currStreamingDiffZoneRef, setCurrentlyStreamingDiffZone, inlineDiffsService]) const onX = useCallback(() => { + onInterrupt() inlineDiffsService.removeCtrlKZone({ diffareaid }) }, [inlineDiffsService, diffareaid]) + useScrollbarStyles(sizerRef) const keybindingString = accessor.get('IKeybindingService').lookupKeybinding(VOID_CTRL_K_ACTION_ID)?.getLabel() - return
+ return
{/* input */}
{/* text input */} { @@ -129,7 +124,8 @@ export const QuickEditChat = ({ fnsRef={textAreaFnsRef} - placeholder={`${keybindingString} to select`} + placeholder={`Enter instructions...`} + // ${keybindingString} to select. onChangeText={useCallback((newStr: string) => { setInstructionsAreEmpty(!newStr) diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/ErrorDisplay.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/ErrorDisplay.tsx index a87e466e..84fe410a 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/ErrorDisplay.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/ErrorDisplay.tsx @@ -9,7 +9,7 @@ import { errorDetails } from '../../../../../../../platform/void/common/llmMessa export const ErrorDisplay = ({ - message, + message:message_, fullError, onDismiss, showDismiss, @@ -23,6 +23,8 @@ export const ErrorDisplay = ({ const details = errorDetails(fullError) + const message = message_ === 'TypeError: fetch failed' ? 'TypeError: fetch failed. This likely means you specified the wrong endpoint in Void Settings.' : message_ + return (
diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/Sidebar.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/Sidebar.tsx index fe757a96..839a6679 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/Sidebar.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/Sidebar.tsx @@ -28,7 +28,7 @@ export const Sidebar = ({ className }: { className: string }) => {
) => { @@ -177,8 +178,8 @@ export const ButtonSubmit = ({ className, disabled, ...props }: ButtonProps & Re return