Merge pull request #214 from voideditor/model-selection

Latest UX/UI updates
This commit is contained in:
Andrew Pareles 2025-01-17 21:13:16 -08:00 committed by GitHub
commit aba7d7bf90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 506 additions and 638 deletions

View file

@ -21,6 +21,8 @@
- 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.
A huge shoutout to our many contributors. If you'd like to help build Void,

View file

@ -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");

View file

@ -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}`
]);
}

View file

@ -89,7 +89,7 @@ async function main(buildDir?: string): Promise<void> {
// 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.',

View file

@ -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<string, Set<string>>;
private visited: Set<string>;
private parsedFiles: Map<string, Parser.Tree>;
private imports: Map<string, Map<string, ImportInfo>>;
private definitions: Map<string, Definition>;
private fileStack: Set<string>;
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<Parser.Tree | null> {
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<string, ImportInfo>();
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<string | null> {
const hover = await vscode.commands.executeCommand<vscode.Hover[]>(
'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<Set<string>> {
const calls = new Set<string>();
const fileImports = this.imports.get(currentFile) ?? new Map();
const uri = vscode.Uri.file(currentFile);
const visit = async (node: Parser.SyntaxNode): Promise<void> => {
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<void> {
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<Map<string, Set<string>>> {
const tree = await this.parseFile(entryFile);
if (!tree) return new Map();
const visit = async (node: Parser.SyntaxNode): Promise<void> => {
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<Map<string, Set<string>> | 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;
}
}

View file

@ -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.SemanticTokens>(
'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;
}

View file

@ -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;

View file

@ -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 {

View file

@ -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[];

View file

@ -65,7 +65,7 @@ export class LLMMessageService extends Disposable implements ILLMMessageService
this._onRequestIdDone(e.requestId)
}))
this._register((this.channel.listen('onError_llm') satisfies Event<EventLLMMessageOnErrorParams>)(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)
}))

View file

@ -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<string, any>): void;
getDebuggingProperties(): Promise<object>;
}
export const IMetricsService = createDecorator<IMetricsService>('metricsService');
@ -34,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<object> {
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<void> {
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)}`)
}
})

View file

@ -5,50 +5,96 @@
import { Disposable } from '../../../base/common/lifecycle.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 { ITelemetryService } from '../../telemetry/common/telemetry.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
@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
// we'd like to use devDeviceId on telemetryService, but that gets sanitized by the time it gets here as 'someValue.devDeviceId'
this._distinctId = devDeviceId
const { commit, version, quality } = this._productService
const { commit, version } = this._productService
const os = isWindows ? 'windows' : isMacintosh ? 'mac' : isLinux ? 'linux' : null
const isDevMode = !this._envMainService.isBuilt // found in abstractUpdateService.ts
this.client.identify({ distinctId: this._distinctId, properties: { firstSessionDate, machineId, commit, version, os } })
console.log('Void posthog metrics info:', JSON.stringify({ devDeviceId, firstSessionDate, machineId }))
// 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
}
}

View file

@ -1 +0,0 @@
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><style>.st0{fill:#f6f6f6;fill-opacity:0}.st1{fill:#fff}.st2{fill:#167abf}</style><path class="st0" d="M1024 1024H0V0h1024v1024z"/><path class="st1" d="M1024 85.333v853.333H0V85.333h1024z"/><path class="st2" d="M0 85.333h298.667v853.333H0V85.333zm1024 0v853.333H384V85.333h640zm-554.667 160h341.333v-64H469.333v64zm341.334 533.334H469.333v64h341.333l.001-64zm128-149.334H597.333v64h341.333l.001-64zm0-149.333H597.333v64h341.333l.001-64zm0-149.333H597.333v64h341.333l.001-64z"/></svg>

Before

Width:  |  Height:  |  Size: 559 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View file

@ -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;

View file

@ -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;

View file

@ -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');
}

View file

@ -74,9 +74,9 @@ export type ThreadsState = {
export type ThreadStreamState = {
[threadId: string]: undefined | {
streamingToken?: string;
error?: { message: string, fullError: Error | null };
messageSoFar?: string;
streamingToken?: string;
}
}
@ -177,6 +177,13 @@ class ChatThreadService extends Disposable implements IChatThreadService {
// ---------- 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
@ -192,12 +199,6 @@ class ChatThreadService extends Disposable implements IChatThreadService {
const userHistoryElt: ChatMessage = { role: 'user', content: chat_prompt(instructions, selections), displayContent: instructions, selections: selections }
this._addMessageToThread(threadId, userHistoryElt)
const onDone = (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 })
}
this._setStreamState(threadId, { error: undefined })
@ -211,11 +212,10 @@ class ChatThreadService extends Disposable implements IChatThreadService {
this._setStreamState(threadId, { messageSoFar: fullText })
},
onFinalMessage: ({ fullText: content }) => {
onDone(content)
this.finishStreaming(threadId, content)
},
onError: (error) => {
console.log('Void Chat Error:', error)
onDone(this.streamState[threadId]?.messageSoFar ?? '', error)
this.finishStreaming(threadId, this.streamState[threadId]?.messageSoFar ?? '', error)
},
useProviderFor: 'Ctrl+L',
@ -227,8 +227,8 @@ class ChatThreadService extends Disposable implements IChatThreadService {
cancelStreaming(threadId: string) {
const llmCancelToken = this.streamState[threadId]?.streamingToken
if (llmCancelToken) this._llmMessageService.abort(llmCancelToken)
this._setStreamState(threadId, { streamingToken: undefined })
if (llmCancelToken !== undefined) this._llmMessageService.abort(llmCancelToken)
this.finishStreaming(threadId, this.streamState[threadId]?.messageSoFar ?? '')
}
dismissStreamError(threadId: string): void {

View file

@ -39,6 +39,8 @@ import { INotificationService, Severity } from '../../../../platform/notificatio
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, }
@ -249,6 +251,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService {
@IConsistentEditorItemService private readonly _consistentEditorItemService: IConsistentEditorItemService,
@IMetricsService private readonly _metricsService: IMetricsService,
@INotificationService private readonly _notificationService: INotificationService,
@ICommandService private readonly _commandService: ICommandService,
) {
super();
@ -596,7 +599,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService {
));
const viewZone: IViewZone = {
afterLineNumber: type === 'edit' ? diff.endLine : diff.startLine - 1,
afterLineNumber: diff.startLine - 1,
heightInLines,
minWidthInPx,
domNode,
@ -623,6 +626,26 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService {
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: () => {
@ -634,13 +657,8 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService {
this._metricsService.capture('Reject Diff', {})
},
diffid: diffid.toString(),
startLine: diff.startLine,
offsetLines: (
diff.type === 'insertion' ? 0
: diff.type === 'deletion' ? -(diff.originalEndLine - diff.originalStartLine + 1)
: diff.type === 'edit' ? (diff.endLine - diff.startLine + 1)
: 0 // not allowed
)
startLine,
offsetLines
})
return () => { buttonsWidget.dispose() }
}
@ -1357,11 +1375,20 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService {
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)
@ -1806,7 +1833,7 @@ class AcceptAllRejectAllWidget extends Widget implements IOverlayWidget {
]);
// Style the container
buttons.style.zIndex = '1';
buttons.style.zIndex = '2';
buttons.style.padding = '4px';
buttons.style.display = 'flex';
buttons.style.gap = '4px';

View file

@ -12,6 +12,7 @@ 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 = {
@ -37,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,
}
});
}

View file

@ -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 (
<>
<div className={`relative group w-full overflow-hidden`}>
<div className="relative group w-full overflow-hidden">
{buttonsOnHover === null ? null : (
<div className="z-[1] absolute top-0 right-0 opacity-0 group-hover:opacity-100 duration-200">
<div className={`flex space-x-2 ${isSingleLine ? '' : 'p-2'}`}>
<div className={`z-[1] absolute top-0 right-0 opacity-0 group-hover:opacity-100 duration-200 ${isSingleLine ? 'h-full flex items-center' : ''
}`}>
<div className={`flex space-x-1 ${isSingleLine ? 'pr-2' : 'p-2'}`}>
{buttonsOnHover}
</div>
</div>
)}
<VoidCodeEditor {...codeEditorProps} />
</div>
</>
)

View file

@ -28,7 +28,7 @@ export const Sidebar = ({ className }: { className: string }) => {
<div
// default background + text styles for sidebar
className={`
w-full h-full py-2
w-full h-full
bg-void-bg-2
text-void-fg-1
`}

View file

@ -6,7 +6,7 @@
import React, { ButtonHTMLAttributes, FormEvent, FormHTMLAttributes, Fragment, useCallback, useEffect, useRef, useState } from 'react';
import { useAccessor, useSidebarState, useChatThreadsState, useChatThreadsStreamState } from '../util/services.js';
import { useAccessor, useSidebarState, useChatThreadsState, useChatThreadsStreamState, useUriState } from '../util/services.js';
import { ChatMessage, CodeSelection, CodeStagingSelection } from '../../../chatThreadService.js';
import { BlockCode } from '../markdown/BlockCode.js';
@ -18,7 +18,7 @@ import { ErrorDisplay } from './ErrorDisplay.js';
import { OnError, ServiceSendLLMMessageParams } from '../../../../../../../platform/void/common/llmMessageTypes.js';
import { HistoryInputBox, InputBox } from '../../../../../../../base/browser/ui/inputbox/inputBox.js';
import { TextAreaFns, VoidCodeEditorProps, VoidInputBox2 } from '../util/inputs.js';
import { ModelDropdown } from '../void-settings-tsx/ModelDropdown.js';
import { ModelDropdown, WarningBox } from '../void-settings-tsx/ModelDropdown.js';
import { chat_systemMessage, chat_prompt } from '../../../prompt/prompts.js';
import { ISidebarStateService } from '../../../sidebarStateService.js';
import { ILLMMessageService } from '../../../../../../../platform/void/common/llmMessageService.js';
@ -29,6 +29,7 @@ import { VOID_CTRL_L_ACTION_ID } from '../../../actionIDs.js';
import { ArrowBigLeftDash, CopyX, Delete, FileX2, SquareX, X } from 'lucide-react';
import { filenameToVscodeLanguage } from '../../../helpers/detectLanguage.js';
import { Pencil } from 'lucide-react'
import { VOID_OPEN_SETTINGS_ACTION_ID } from '../../../voidSettingsPane.js';
export const IconX = ({ size, className = '', ...props }: { size: number, className?: string } & React.SVGProps<SVGSVGElement>) => {
@ -259,9 +260,9 @@ const getBasename = (pathStr: string) => {
}
export const SelectedFiles = (
{ type, selections, setStaging }:
| { type: 'past', selections: CodeSelection[] | null; setStaging?: undefined }
| { type: 'staging', selections: CodeStagingSelection[] | null; setStaging: ((files: CodeStagingSelection[]) => void) }
{ type, selections, setSelections, showProspectiveSelections }:
| { type: 'past', selections: CodeSelection[]; setSelections?: undefined, showProspectiveSelections?: undefined }
| { type: 'staging', selections: CodeStagingSelection[]; setSelections: ((newSelections: CodeStagingSelection[]) => void), showProspectiveSelections?: boolean }
) => {
// index -> isOpened
@ -273,85 +274,123 @@ export const SelectedFiles = (
const accessor = useAccessor()
const commandService = accessor.get('ICommandService')
// state for tracking prospective files
const { currentUri } = useUriState()
const [recentUris, setRecentUris] = useState<URI[]>([])
const maxRecentUris = 10
const maxProspectiveFiles = 3
useEffect(() => { // handle recent files
if (!currentUri) return
setRecentUris(prev => {
const withoutCurrent = prev.filter(uri => uri.fsPath !== currentUri.fsPath) // remove duplicates
const withCurrent = [currentUri, ...withoutCurrent]
return withCurrent.slice(0, maxRecentUris)
})
}, [currentUri])
let prospectiveSelections: CodeStagingSelection[] = []
if (type === 'staging' && showProspectiveSelections) { // handle prospective files
// add a prospective file if type === 'staging' and if the user is in a file, and if the file is not selected yet
prospectiveSelections = recentUris
.filter(uri => !selections.find(s => s.range === null && s.fileURI.fsPath === uri.fsPath))
.slice(0, maxProspectiveFiles)
.map(uri => ({
type: 'File',
fileURI: uri,
selectionStr: null,
range: null,
}))
}
const allSelections = [...selections, ...prospectiveSelections]
if (allSelections.length === 0) {
return null
}
return (
!!selections && selections.length !== 0 && (
<div
className='flex items-center flex-wrap gap-0.5 text-left relative'
>
{selections.map((selection, i) => {
<div className='flex items-center flex-wrap text-left relative'>
const isThisSelectionOpened = !!(selection.selectionStr && selectionIsOpened[i])
const isThisSelectionAFile = selection.selectionStr === null
{allSelections.map((selection, i) => {
return <div key={i} // container for `selectionSummary` and `selectionText`
className={`${isThisSelectionOpened ? 'w-full' : ''}`}
const isThisSelectionOpened = !!(selection.selectionStr && selectionIsOpened[i])
const isThisSelectionAFile = selection.selectionStr === null
const isThisSelectionProspective = i > selections.length - 1
const thisKey = `${isThisSelectionProspective}-${i}-${selections.length}`
const selectionHTML = (<div key={thisKey} // container for `selectionSummary` and `selectionText`
className={`
${isThisSelectionOpened ? 'w-full' : ''}
`}
>
{/* selection summary */}
<div // container for item and its delete button (if it's last)
className='flex items-center gap-1 mr-0.5 mb-0.5'
>
{/* selection summary */}
<div // container for delete button
className='flex items-center gap-0.5'
>
<div // styled summary box
className={`flex items-center gap-0.5 relative
rounded-md px-1
<div // styled summary box
className={`flex items-center gap-0.5 relative
px-1
w-fit h-fit
select-none
bg-void-bg-3 hover:brightness-95
text-void-fg-1 text-xs text-nowrap
border rounded-xs ${isClearHovered ? 'border-void-border-1' : 'border-void-border-2'} hover:border-void-border-1
${isThisSelectionProspective ? 'bg-void-1 text-void-fg-3 opacity-80' : 'bg-void-bg-3 hover:brightness-95 text-void-fg-1'}
text-xs text-nowrap
border rounded-sm ${isClearHovered && !isThisSelectionProspective ? 'border-void-border-1' : 'border-void-border-2'} hover:border-void-border-1
transition-all duration-150`}
onClick={() => {
// open the file if it is a file
if (isThisSelectionAFile) {
commandService.executeCommand('vscode.open', selection.fileURI, {
preview: true,
// preserveFocus: false,
});
} else {
// open the selection if it is a text-selection
setSelectionIsOpened(s => {
const newS = [...s]
newS[i] = !newS[i]
return newS
});
}
}}
>
<span>
{/* file name */}
{getBasename(selection.fileURI.fsPath)}
{/* selection range */}
{!isThisSelectionAFile ? ` (${selection.range.startLineNumber}-${selection.range.endLineNumber})` : ''}
</span>
onClick={() => {
if (isThisSelectionProspective) { // add prospective selection to selections
if (type !== 'staging') return; // (never)
setSelections([...selections, selection as CodeStagingSelection])
{/* X button */}
{type === 'staging' &&
<span
className='cursor-pointer hover:brightness-95 rounded-md z-1'
onClick={(e) => {
e.stopPropagation(); // don't open/close selection
if (type !== 'staging') return;
setStaging([...selections.slice(0, i), ...selections.slice(i + 1)])
setSelectionIsOpened(o => [...o.slice(0, i), ...o.slice(i + 1)])
}}
>
<IconX size={16} className="p-[2px] stroke-[3]" />
</span>}
} else if (isThisSelectionAFile) { // open files
commandService.executeCommand('vscode.open', selection.fileURI, {
preview: true,
// preserveFocus: false,
});
} else { // show text
setSelectionIsOpened(s => {
const newS = [...s]
newS[i] = !newS[i]
return newS
});
}
}}
>
<span>
{/* file name */}
{getBasename(selection.fileURI.fsPath)}
{/* selection range */}
{!isThisSelectionAFile ? ` (${selection.range.startLineNumber}-${selection.range.endLineNumber})` : ''}
</span>
{/* X button */}
{type === 'staging' && !isThisSelectionProspective &&
<span
className='cursor-pointer z-1'
onClick={(e) => {
e.stopPropagation(); // don't open/close selection
if (type !== 'staging') return;
setSelections([...selections.slice(0, i), ...selections.slice(i + 1)])
setSelectionIsOpened(o => [...o.slice(0, i), ...o.slice(i + 1)])
}}
>
<IconX size={10} className="stroke-[2]" />
</span>}
</div>
</div>
{/* clear all selections button */}
{type !== 'staging' || selections.length === 0 || i !== selections.length - 1
? null
: <div key={i} className={`flex items-center gap-0.5 ${isThisSelectionOpened ? 'w-full' : ''}`}>
<div
className='rounded-md'
onMouseEnter={() => setIsClearHovered(true)}
onMouseLeave={() => setIsClearHovered(false)}
>
<Delete
size={16}
className={`stroke-[1]
{/* clear all selections button */}
{type !== 'staging' || selections.length === 0 || i !== selections.length - 1
? null
: <div className={`flex items-center ${isThisSelectionOpened ? 'w-full' : ''}`}>
<div
className='rounded-md'
onMouseEnter={() => setIsClearHovered(true)}
onMouseLeave={() => setIsClearHovered(false)}
>
<Delete
size={16}
className={`stroke-[1]
stroke-void-fg-1
fill-void-bg-3
opacity-40
@ -359,55 +398,104 @@ export const SelectedFiles = (
transition-all duration-150
cursor-pointer
`}
onClick={() => { setStaging([]) }}
/>
</div>
onClick={() => { setSelections([]) }}
/>
</div>
}
</div>
{/* selection text */}
{isThisSelectionOpened &&
<div
className='w-full px-1 rounded-sm border-vscode-editor-border'
onClick={(e) => {
e.stopPropagation(); // don't focus input box
}}
>
<BlockCode
initValue={selection.selectionStr!}
language={filenameToVscodeLanguage(selection.fileURI.path)}
maxHeight={200}
showScrollbars={true}
/>
</div>
}
</div>
{/* selection text */}
{isThisSelectionOpened &&
<div
className='w-full px-1 rounded-sm border-vscode-editor-border'
onClick={(e) => {
e.stopPropagation(); // don't focus input box
}}
>
<BlockCode
initValue={selection.selectionStr!}
language={filenameToVscodeLanguage(selection.fileURI.path)}
maxHeight={200}
showScrollbars={true}
/>
</div>
}
</div>)
})}
return <Fragment key={thisKey}>
{selections.length > 0 && i === selections.length &&
<div className='w-full'></div> // divider between `selections` and `prospectiveSelections`
}
{selectionHTML}
</Fragment>
})}
</div>
)
</div>
)
}
const ChatBubble = ({ chatMessage, isLoading }: {
chatMessage: ChatMessage,
isLoading?: boolean,
}) => {
const ChatBubble_ = ({ isEditMode, isLoading, children, role }: { role: ChatMessage['role'], children: React.ReactNode, isLoading: boolean, isEditMode: boolean }) => {
return <div
// align chatbubble accoridng to role
className={`
relative
${isEditMode ? 'px-2 w-full max-w-full'
: role === 'user' ? `px-2 self-end w-fit max-w-full`
: role === 'assistant' ? `px-2 self-start w-full max-w-full` : ''
}
`}
>
<div
// style chatbubble according to role
className={`
text-left space-y-2 rounded-lg
overflow-x-auto max-w-full
${role === 'user' ? 'p-2 bg-void-bg-1 text-void-fg-1' : 'px-2'}
`}
>
{children}
{isLoading && <IconLoading className='opacity-50 text-sm' />}
</div>
{/* edit button */}
{/* {role === 'user' &&
<Pencil
size={16}
className={`
absolute top-0 right-2
translate-x-0 -translate-y-0
cursor-pointer z-1
`}
onClick={() => { setIsEditMode(v => !v); }}
/>
} */}
</div>
}
const ChatBubble = ({ chatMessage, isLoading }: { chatMessage: ChatMessage, isLoading?: boolean, }) => {
const role = chatMessage.role
// edit mode state
const [isEditMode, setIsEditMode] = useState(false)
if (!chatMessage.content) { // don't show if empty
return null
}
let chatbubbleContents: React.ReactNode
if (role === 'user') {
chatbubbleContents = <>
<SelectedFiles type='past' selections={chatMessage.selections} />
<SelectedFiles type='past' selections={chatMessage.selections || []} />
{chatMessage.displayContent}
{/* {!isEditMode ? chatMessage.displayContent : <></>} */}
@ -430,41 +518,9 @@ const ChatBubble = ({ chatMessage, isLoading }: {
chatbubbleContents = <ChatMarkdownRender string={chatMessage.displayContent ?? ''} />
}
return <div
// align chatbubble accoridng to role
className={`
relative
${isEditMode ? 'px-2 w-full max-w-full'
: role === 'user' ? `px-2 self-end w-fit max-w-full`
: role === 'assistant' ? `px-2 self-start w-full max-w-full` : ''
}
`}
>
<div
// style chatbubble according to role
className={`
p-2 text-left space-y-2 rounded-lg
overflow-x-auto max-w-full
${role === 'user' ? 'bg-void-bg-1 text-void-fg-1' : ''}
`}
>
{chatbubbleContents}
{isLoading && <IconLoading className='opacity-50 text-sm' />}
</div>
{/* edit button */}
{/* {role === 'user' &&
<Pencil
size={16}
className={`
absolute top-0 right-2
translate-x-0 -translate-y-0
cursor-pointer z-1
`}
onClick={() => { setIsEditMode(v => !v); }}
/>
} */}
</div>
return <ChatBubble_ role={role} isEditMode={isEditMode} isLoading={!!isLoading}>
{chatbubbleContents}
</ChatBubble_>
}
@ -474,7 +530,8 @@ export const SidebarChat = () => {
const textAreaFnsRef = useRef<TextAreaFns | null>(null)
const accessor = useAccessor()
const modelService = accessor.get('IModelService')
// const modelService = accessor.get('IModelService')
const commandService = accessor.get('ICommandService')
// ----- HIGHER STATE -----
// sidebar state
@ -499,10 +556,10 @@ export const SidebarChat = () => {
const selections = chatThreadsState.currentStagingSelections
// stream state
const chatThreadsStreamState = useChatThreadsStreamState(chatThreadsState.currentThreadId)
const isCurrThreadStreaming = !!chatThreadsStreamState?.streamingToken
const latestError = chatThreadsStreamState?.error
const messageSoFar = chatThreadsStreamState?.messageSoFar
const currThreadStreamState = useChatThreadsStreamState(chatThreadsState.currentThreadId)
const isStreaming = !!currThreadStreamState?.streamingToken
const latestError = currThreadStreamState?.error
const messageSoFar = currThreadStreamState?.messageSoFar
// ----- SIDEBAR CHAT state (local) -----
@ -521,7 +578,7 @@ export const SidebarChat = () => {
const onSubmit = async () => {
if (isDisabled) return
if (isCurrThreadStreaming) return
if (isStreaming) return
// send message to LLM
const userMessage = textAreaRef.current?.value ?? ''
@ -534,9 +591,8 @@ export const SidebarChat = () => {
}
const onAbort = () => {
const token = chatThreadsStreamState?.streamingToken
if (!token) return
chatThreadsService.cancelStreaming(token)
const threadId = currentThread.id
chatThreadsService.cancelStreaming(threadId)
}
// const [_test_messages, _set_test_messages] = useState<string[]>([])
@ -566,11 +622,11 @@ export const SidebarChat = () => {
scrollContainerRef={scrollContainerRef}
className={`
w-full h-auto
flex flex-col gap-0
flex flex-col gap-1
overflow-x-hidden
overflow-y-auto
`}
style={{ maxHeight: sidebarDimensions.height - historyDimensions.height - formDimensions.height - 30 }} // the height of the previousMessages is determined by all other heights
style={{ maxHeight: sidebarDimensions.height - historyDimensions.height - formDimensions.height - 36 }} // the height of the previousMessages is determined by all other heights
>
{/* previous messages */}
{previousMessages.map((message, i) =>
@ -578,7 +634,22 @@ export const SidebarChat = () => {
)}
{/* message stream */}
<ChatBubble chatMessage={{ role: 'assistant', content: messageSoFar ?? '', displayContent: messageSoFar || null }} isLoading={isCurrThreadStreaming} />
<ChatBubble chatMessage={{ role: 'assistant', content: messageSoFar ?? '', displayContent: messageSoFar || null }} isLoading={isStreaming} />
{/* error message */}
{latestError === undefined ? null :
<div className='px-2'>
<ErrorDisplay
message={latestError.message}
fullError={latestError.fullError}
onDismiss={() => { chatThreadsService.dismissStreamError(currentThread.id) }}
showDismiss={true}
/>
<WarningBox className='text-sm my-2 pl-4' onClick={() => { commandService.executeCommand(VOID_OPEN_SETTINGS_ACTION_ID) }} text='Open settings' />
</div>
}
</ScrollToBottomContainer>
@ -590,7 +661,7 @@ export const SidebarChat = () => {
<div
ref={formRef}
className={`
flex flex-col gap-2 p-2 relative input text-left shrink-0
flex flex-col gap-1 p-2 relative input text-left shrink-0
transition-all duration-200
rounded-md
bg-vscode-input-bg
@ -604,19 +675,7 @@ export const SidebarChat = () => {
{/* top row */}
<>
{/* selections */}
{(selections && selections.length !== 0) &&
<SelectedFiles type='staging' selections={selections} setStaging={chatThreadsService.setStaging.bind(chatThreadsService)} />
}
{/* error message */}
{latestError === undefined ? null :
<ErrorDisplay
message={latestError.message}
fullError={latestError.fullError}
onDismiss={() => { chatThreadsService.dismissStreamError(currentThread.id) }}
showDismiss={true}
/>
}
<SelectedFiles type='staging' selections={selections || []} setSelections={chatThreadsService.setStaging.bind(chatThreadsService)} showProspectiveSelections={previousMessages.length === 0}/>
</>
{/* middle row */}
@ -625,7 +684,7 @@ export const SidebarChat = () => {
{/* text input */}
<VoidInputBox2
className='min-h-[81px] p-1'
placeholder={`${keybindingString} to select. Enter instructions...`}
placeholder={`${keybindingString ? `${keybindingString} to select. ` : ''}Enter instructions...`}
onChangeText={useCallback((newStr: string) => { setInstructionsAreEmpty(!newStr) }, [setInstructionsAreEmpty])}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
@ -653,7 +712,7 @@ export const SidebarChat = () => {
</div>
{/* submit / stop button */}
{isCurrThreadStreaming ?
{isStreaming ?
// stop button
<ButtonStop
onClick={onAbort}

View file

@ -28,10 +28,12 @@ export const SidebarThreadSelector = () => {
const { allThreads } = threadsState
// sorted by most recent to least recent
const sortedThreadIds = Object.keys(allThreads ?? {}).sort((threadId1, threadId2) => allThreads![threadId1].lastModified > allThreads![threadId2].lastModified ? -1 : 1)
const sortedThreadIds = Object.keys(allThreads ?? {})
.sort((threadId1, threadId2) => allThreads![threadId1].lastModified > allThreads![threadId2].lastModified ? -1 : 1)
.filter(threadId => allThreads![threadId].messages.length !== 0)
return (
<div className="flex p-2 flex-col mb-2 gap-y-1 max-h-[400px] overflow-y-auto">
<div className="flex p-2 flex-col gap-y-1 max-h-[400px] overflow-y-auto">
<div className="w-full relative flex justify-center items-center">
{/* title */}

View file

@ -10,6 +10,7 @@ import { IDisposable } from '../../../../../../../base/common/lifecycle.js'
import { VoidSidebarState } from '../../../sidebarStateService.js'
import { VoidSettingsState } from '../../../../../../../platform/void/common/voidSettingsService.js'
import { ColorScheme } from '../../../../../../../platform/theme/common/theme.js'
import { VoidUriState } from '../../../voidUriStateService.js';
import { VoidQuickEditState } from '../../../quickEditStateService.js'
import { RefreshModelStateOfProvider } from '../../../../../../../platform/void/common/refreshModelService.js'
@ -28,6 +29,7 @@ import { ILLMMessageService } from '../../../../../../../platform/void/common/ll
import { IRefreshModelService } from '../../../../../../../platform/void/common/refreshModelService.js';
import { IVoidSettingsService } from '../../../../../../../platform/void/common/voidSettingsService.js';
import { IInlineDiffsService } from '../../../inlineDiffsService.js';
import { IVoidUriStateService } from '../../../voidUriStateService.js';
import { IQuickEditStateService } from '../../../quickEditStateService.js';
import { ISidebarStateService } from '../../../sidebarStateService.js';
import { IChatThreadService } from '../../../chatThreadService.js';
@ -47,10 +49,14 @@ import { IPathService } from '../../../../../../../workbench/services/path/commo
import { IMetricsService } from '../../../../../../../platform/void/common/metricsService.js'
// normally to do this you'd use a useEffect that calls .onDidChangeState(), but useEffect mounts too late and misses initial state changes
// even if React hasn't mounted yet, the variables are always updated to the latest state.
// React listens by adding a setState function to these listeners.
let uriState: VoidUriState
const uriStateListeners: Set<(s: VoidUriState) => void> = new Set()
let quickEditState: VoidQuickEditState
const quickEditStateListeners: Set<(s: VoidQuickEditState) => void> = new Set()
@ -90,6 +96,7 @@ export const _registerServices = (accessor: ServicesAccessor) => {
_registerAccessor(accessor)
const stateServices = {
uriStateService: accessor.get(IVoidUriStateService),
quickEditStateService: accessor.get(IQuickEditStateService),
sidebarStateService: accessor.get(ISidebarStateService),
chatThreadsStateService: accessor.get(IChatThreadService),
@ -99,7 +106,15 @@ export const _registerServices = (accessor: ServicesAccessor) => {
inlineDiffsService: accessor.get(IInlineDiffsService),
}
const { sidebarStateService, quickEditStateService, settingsStateService, chatThreadsStateService, refreshModelService, themeService, inlineDiffsService } = stateServices
const { uriStateService, sidebarStateService, quickEditStateService, settingsStateService, chatThreadsStateService, refreshModelService, themeService, inlineDiffsService } = stateServices
uriState = uriStateService.state
disposables.push(
uriStateService.onDidChangeState(() => {
uriState = uriStateService.state
uriStateListeners.forEach(l => l(uriState))
})
)
quickEditState = quickEditStateService.state
disposables.push(
@ -178,6 +193,7 @@ const getReactAccessor = (accessor: ServicesAccessor) => {
IRefreshModelService: accessor.get(IRefreshModelService),
IVoidSettingsService: accessor.get(IVoidSettingsService),
IInlineDiffsService: accessor.get(IInlineDiffsService),
IVoidUriStateService: accessor.get(IVoidUriStateService),
IQuickEditStateService: accessor.get(IQuickEditStateService),
ISidebarStateService: accessor.get(ISidebarStateService),
IChatThreadService: accessor.get(IChatThreadService),
@ -224,6 +240,16 @@ export const useAccessor = () => {
// -- state of services --
export const useUriState = () => {
const [s, ss] = useState(uriState)
useEffect(() => {
ss(uriState)
uriStateListeners.add(ss)
return () => { uriStateListeners.delete(ss) }
}, [ss])
return s
}
export const useQuickEditState = () => {
const [s, ss] = useState(quickEditState)
useEffect(() => {

View file

@ -26,10 +26,9 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase
import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { URI } from '../../../../base/common/uri.js';
import { localize2 } from '../../../../nls.js';
import { IViewsService } from '../../../services/views/common/viewsService.js';
import { IVoidUriStateService } from './voidUriStateService.js';
// ---------- Register commands and keybindings ----------
@ -153,7 +152,15 @@ registerAction2(class extends Action2 {
registerAction2(class extends Action2 {
constructor() {
super({ id: VOID_CTRL_L_ACTION_ID, title: 'Void: Press Ctrl+L', keybinding: { primary: KeyMod.CtrlCmd | KeyCode.KeyL, weight: KeybindingWeight.BuiltinExtension } });
super({
id: VOID_CTRL_L_ACTION_ID,
f1: true,
title: localize2('voidCtrlL', 'Void: Add Select to Chat'),
keybinding: {
primary: KeyMod.CtrlCmd | KeyCode.KeyL,
weight: KeybindingWeight.VoidExtension
}
});
}
async run(accessor: ServicesAccessor): Promise<void> {
const commandService = accessor.get(ICommandService)
@ -233,7 +240,7 @@ registerAction2(class extends Action2 {
export class TabSwitchListener extends Disposable {
constructor(
onSwitchTab: (uri: URI) => void,
onSwitchTab: () => void,
@ICodeEditorService private readonly _editorService: ICodeEditorService,
) {
super()
@ -242,7 +249,7 @@ export class TabSwitchListener extends Disposable {
const addTabSwitchListeners = (editor: ICodeEditor) => {
this._register(editor.onDidChangeModel(e => {
if (e.newModelUrl?.scheme !== 'file') return
onSwitchTab(e.newModelUrl)
onSwitchTab()
}))
}
@ -262,8 +269,10 @@ class TabSwitchContribution extends Disposable implements IWorkbenchContribution
constructor(
@IInstantiationService private readonly instantiationService: IInstantiationService,
@ICommandService private readonly commandService: ICommandService,
@IViewsService private readonly viewsService: IViewsService,
@IVoidUriStateService private readonly uriStateService: IVoidUriStateService,
@ICodeEditorService private readonly codeEditorService: ICodeEditorService,
// @ICommandService private readonly commandService: ICommandService,
) {
super()
@ -273,18 +282,22 @@ class TabSwitchContribution extends Disposable implements IWorkbenchContribution
sidebarIsVisible = e.visible
}))
const addCurrentFileIfVisible = () => {
if (sidebarIsVisible)
this.commandService.executeCommand(VOID_ADD_SELECTION_TO_SIDEBAR_ACTION_ID)
const onSwitchTab = () => { // update state
if (sidebarIsVisible) {
const currentUri = this.codeEditorService.getActiveCodeEditor()?.getModel()?.uri
if (!currentUri) return;
this.uriStateService.setState({ currentUri })
// this.commandService.executeCommand(VOID_ADD_SELECTION_TO_SIDEBAR_ACTION_ID)
}
}
// when sidebar becomes visible, add current file
this._register(this.viewsService.onDidChangeViewVisibility(e => { sidebarIsVisible = e.visible }))
// run on current tab if it exists, and listen for tab switches and visibility changes
addCurrentFileIfVisible()
this._register(this.viewsService.onDidChangeViewVisibility(() => { addCurrentFileIfVisible() }))
this._register(this.instantiationService.createInstance(TabSwitchListener, () => { addCurrentFileIfVisible() }))
onSwitchTab()
this._register(this.viewsService.onDidChangeViewVisibility(() => { onSwitchTab() }))
this._register(this.instantiationService.createInstance(TabSwitchListener, () => { onSwitchTab() }))
}
}

View file

@ -0,0 +1,57 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { URI } from '../../../../base/common/uri.js';
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
// service that manages state
export type VoidUriState = {
currentUri?: URI
}
export interface IVoidUriStateService {
readonly _serviceBrand: undefined;
readonly state: VoidUriState; // readonly to the user
setState(newState: Partial<VoidUriState>): void;
onDidChangeState: Event<void>;
}
export const IVoidUriStateService = createDecorator<IVoidUriStateService>('voidUriStateService');
class VoidUriStateService extends Disposable implements IVoidUriStateService {
_serviceBrand: undefined;
static readonly ID = 'voidUriStateService';
private readonly _onDidChangeState = new Emitter<void>();
readonly onDidChangeState: Event<void> = this._onDidChangeState.event;
// state
state: VoidUriState
constructor(
) {
super()
// initial state
this.state = { currentUri: undefined }
}
setState(newState: Partial<VoidUriState>) {
this.state = { ...this.state, ...newState }
this._onDidChangeState.fire()
}
}
registerSingleton(IVoidUriStateService, VoidUriStateService, InstantiationType.Eager);

View file

@ -5,7 +5,7 @@
.file-icons-enabled .show-file-icons .vscode_getting_started_page-name-file-icon.file-icon::before {
content: ' ';
background-image: url('../../../../browser/media/code-icon.svg');
background-image: url('../../../../browser/media/void-icon-sm.png');
}
.monaco-workbench .part.editor > .content .gettingStartedContainer {

View file

@ -113,7 +113,7 @@
.file-icons-enabled .show-file-icons .vs_code_editor_walkthrough\.md-name-file-icon.md-ext-file-icon.ext-file-icon.markdown-lang-file-icon.file-icon::before {
content: ' ';
background-image: url('../../../../browser/media/code-icon.svg');
background-image: url('../../../../browser/media/void-icon-sm.png');
}
.monaco-workbench .part.editor > .content .walkThroughContent .mac-only,