From 1b4a90c7718f98d2e1f3038334249cba8e3fff93 Mon Sep 17 00:00:00 2001 From: rjmacarthy Date: Fri, 6 Sep 2024 20:16:48 +0100 Subject: [PATCH] mention file context and large clean up --- scripts/install_onyx_model.sh | 18 - src/common/constants.ts | 3 +- src/common/types.ts | 66 +-- src/extension.global.d.ts | 15 + src/extension/cache.ts | 32 +- src/extension/chat-service.ts | 45 +- src/extension/context.ts | 4 +- src/extension/conversation-history.ts | 24 +- src/extension/diff.ts | 90 +-- src/extension/embeddings.ts | 91 +-- .../{ollama-service.ts => ollama.ts} | 3 - src/extension/{parser-utils.ts => parser.ts} | 0 src/extension/provider-manager.ts | 20 +- src/extension/providers/base.ts | 474 ++++++++++++++++ src/extension/providers/completion.ts | 3 +- src/extension/providers/panel.ts | 96 ++++ src/extension/providers/sidebar.ts | 523 ++---------------- src/extension/session-manager.ts | 10 +- src/extension/symmetry-service.ts | 44 +- src/extension/symmetry-ws.ts | 21 +- src/extension/template-provider.ts | 8 +- src/extension/tree.ts | 134 +---- src/extension/utils.ts | 134 ++++- src/index.ts | 60 +- .../conversation-history.module.css | 0 src/{webview => styles}/index.module.css | 2 +- src/{webview => styles}/providers.module.css | 0 src/{webview => styles}/symmetry.module.css | 0 src/webview/chat.tsx | 42 +- src/webview/code-block.tsx | 3 +- src/webview/conversation-history.tsx | 3 +- src/webview/embedding-options.tsx | 6 +- src/webview/global.d.ts | 15 - src/webview/hooks.ts | 24 +- src/webview/{chat-loader.tsx => loader.tsx} | 1 + src/webview/main.tsx | 1 + src/webview/mention-list.tsx | 134 +++-- src/webview/message.tsx | 5 +- src/webview/provider-select.tsx | 2 +- src/webview/providers.tsx | 4 +- src/webview/settings.tsx | 4 +- src/webview/suggestion.tsx | 16 +- src/webview/suggestions.tsx | 6 +- src/webview/symmetry.tsx | 4 +- src/webview/utils.ts | 30 + 45 files changed, 1143 insertions(+), 1077 deletions(-) delete mode 100644 scripts/install_onyx_model.sh rename src/extension/{ollama-service.ts => ollama.ts} (88%) rename src/extension/{parser-utils.ts => parser.ts} (100%) create mode 100644 src/extension/providers/base.ts create mode 100644 src/extension/providers/panel.ts rename src/{webview => styles}/conversation-history.module.css (100%) rename src/{webview => styles}/index.module.css (98%) rename src/{webview => styles}/providers.module.css (100%) rename src/{webview => styles}/symmetry.module.css (100%) delete mode 100644 src/webview/global.d.ts rename src/webview/{chat-loader.tsx => loader.tsx} (99%) diff --git a/scripts/install_onyx_model.sh b/scripts/install_onyx_model.sh deleted file mode 100644 index c6373fd3..00000000 --- a/scripts/install_onyx_model.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash - -model_name=$1 - -export EVENTLET_NO_GREENDNS="yes" - -if [ -z "$model_name" ]; then - echo "Usage: bash model-onyx.sh " - exit 1 -fi - -output_name=${model_name////_}; output_name=${output_name//\-/_}; - -echo "Converting $model_name to onyx $output_name"; - -mkdir models && cd models || exit - -optimum-cli export onnx --model "$model_name" "$output_name"; diff --git a/src/common/constants.ts b/src/common/constants.ts index 31225837..0783ddb6 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -78,7 +78,7 @@ export const EVENT_NAME = { twinnySetWorkspaceContext: 'twinny-set-workspace-context', twinnyStopGeneration: 'twinny-stop-generation', twinnyTextSelection: 'twinny-text-selection', - twinnyWorkspaceContext: 'twinny-workspace-context' + twinnyGetWorkspaceContext: 'twinny-workspace-context' } export const TWINNY_COMMAND_NAME = { @@ -95,6 +95,7 @@ export const TWINNY_COMMAND_NAME = { manageProviders: 'twinny.manageProviders', manageTemplates: 'twinny.manageTemplates', newConversation: 'twinny.newConversation', + openPanelChat: 'twinny.openPanelChat', openChat: 'twinny.openChat', refactor: 'twinny.refactor', sendTerminalText: 'twinny.sendTerminalText', diff --git a/src/common/types.ts b/src/common/types.ts index 09ac377d..af6249f7 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -43,7 +43,7 @@ export interface StreamResponse { prompt_eval_duration: number eval_count: number eval_duration: number - type? : string + type?: string choices: [ { text: string @@ -60,8 +60,8 @@ export interface LanguageType { } export interface ClientMessage { - data?: T, - meta?: Y, + data?: T + meta?: Y type?: string key?: string } @@ -150,7 +150,7 @@ export interface StreamRequest { onEnd?: () => void onStart?: (controller: AbortController) => void onError?: (error: Error) => void - onData: (streamResponse: T) => void + onData: (streamResponse: T) => void } export interface UiTabs { @@ -216,47 +216,47 @@ export interface InferenceProvider { } export interface Peer { - publicKey: Buffer; - write: (value: string) => boolean; - on: (key: string, cb: (data: Buffer) => void) => void; - once: (key: string, cb: (data: Buffer) => void) => void; - writable: boolean; - key: string; - discovery_key: string; + publicKey: Buffer + write: (value: string) => boolean + on: (key: string, cb: (data: Buffer) => void) => void + once: (key: string, cb: (data: Buffer) => void) => void + writable: boolean + key: string + discovery_key: string } export interface SymmetryMessage { - key: string; - data: T; + key: string + data: T } -export type ServerMessageKey = keyof typeof SYMMETRY_DATA_MESSAGE; +export type ServerMessageKey = keyof typeof SYMMETRY_DATA_MESSAGE export interface SymmetryConnection { sessionToken?: string discoveryKey?: string modelName?: string - name: string; - provider: string; - id: string; + name: string + provider: string + id: string } export interface SymmetryModelProvider { - connections: number | null; - data_collection_enabled: number; - id: number; - last_seen: string; - max_connections: number; - model_name: string; - name: string; - online: number; - provider: string; - public: number; + connections: number | null + data_collection_enabled: number + id: number + last_seen: string + max_connections: number + model_name: string + name: string + online: number + provider: string + public: number } export interface InferenceRequest { - key: string; - messages: Message[]; + key: string + messages: Message[] } export interface ChunkOptions { @@ -276,11 +276,11 @@ export type EmbeddedDocument = { } export interface FileItem { - name: string; - path: string; + name: string + path: string } export interface MentionType { - name: string; - path: string; + name: string + path: string } diff --git a/src/extension.global.d.ts b/src/extension.global.d.ts index 31000c24..552f5b79 100644 --- a/src/extension.global.d.ts +++ b/src/extension.global.d.ts @@ -12,3 +12,18 @@ declare module 'hypercore-crypto' { export = hyperCoreCrypto } + +declare module '*.css' + +declare module '*.css' { + const content: Record + export default content +} + +interface Window { + acquireVsCodeApi: () => { + getState: () => T + setState: (data: T) => void + postMessage: (msg: unknown) => void + } +} diff --git a/src/extension/cache.ts b/src/extension/cache.ts index 0ef01f33..36830aec 100644 --- a/src/extension/cache.ts +++ b/src/extension/cache.ts @@ -1,41 +1,41 @@ import { PrefixSuffix } from '../common/types' export class LRUCache { - private capacity: number - private cache: Map + private _capacity: number + private _cache: Map constructor(capacity: number) { - this.capacity = capacity - this.cache = new Map() + this._capacity = capacity + this._cache = new Map() } getAll(): Map { - return this.cache + return this._cache } get(key: string): T | null | undefined { - if (!this.cache.has(key)) return undefined + if (!this._cache.has(key)) return undefined - const value = this.cache.get(key) - this.cache.delete(key) + const value = this._cache.get(key) + this._cache.delete(key) if (value !== undefined) { - this.cache.set(key, value) + this._cache.set(key, value) } return value } delete(key: string): void { - this.cache.delete(key) + this._cache.delete(key) } set(key: string, value: T | null): void { - if (this.cache.has(key)) { - this.cache.delete(key) - } else if (this.cache.size === this.capacity) { - const firstKey = this.cache.keys().next().value - this.cache.delete(firstKey) + if (this._cache.has(key)) { + this._cache.delete(key) + } else if (this._cache.size === this._capacity) { + const firstKey = this._cache.keys().next().value + this._cache.delete(firstKey) } - this.cache.set(key, value) + this._cache.set(key, value) } normalize(src: string): string { diff --git a/src/extension/chat-service.ts b/src/extension/chat-service.ts index 4cc6fb7a..80841b91 100644 --- a/src/extension/chat-service.ts +++ b/src/extension/chat-service.ts @@ -1,12 +1,12 @@ import { StatusBarItem, - WebviewView, commands, window, workspace, ExtensionContext, languages, - DiagnosticSeverity + DiagnosticSeverity, + Webview } from 'vscode' import * as path from 'path' import * as fs from 'fs/promises' @@ -67,19 +67,19 @@ export class ChatService { private _symmetryService?: SymmetryService private _temperature = this._config.get('temperature') as number private _templateProvider?: TemplateProvider - private _view?: WebviewView - private _sessionManager: SessionManager + private _webView?: Webview + private _sessionManager: SessionManager | undefined constructor( statusBar: StatusBarItem, - templateDir: string, + templateDir: string | undefined, extensionContext: ExtensionContext, - view: WebviewView, + webView: Webview, db: EmbeddingDatabase | undefined, - sessionManager: SessionManager, + sessionManager: SessionManager | undefined, symmetryService: SymmetryService ) { - this._view = view + this._webView = webView this._statusBar = statusBar this._templateProvider = new TemplateProvider(templateDir) this._reranker = new Reranker() @@ -101,7 +101,7 @@ export class ChatService { this._symmetryService?.on( SYMMETRY_EMITTER_KEY.inference, (completion: string) => { - this._view?.webview.postMessage({ + this._webView?.postMessage({ type: EVENT_NAME.twinnyOnCompletion, value: { completion: completion.trimStart(), @@ -345,7 +345,7 @@ export class ChatService { const data = getChatDataFromProvider(provider.provider, streamResponse) this._completion = this._completion + data if (onEnd) return - this._view?.webview.postMessage({ + this._webView?.postMessage({ type: EVENT_NAME.twinnyOnCompletion, value: { completion: this._completion.trimStart(), @@ -368,12 +368,12 @@ export class ChatService { ) if (onEnd) { onEnd(this._completion) - this._view?.webview.postMessage({ + this._webView?.postMessage({ type: EVENT_NAME.twinnyOnEnd } as ServerMessage) return } - this._view?.webview.postMessage({ + this._webView?.postMessage({ type: EVENT_NAME.twinnyOnEnd, value: { completion: this._completion.trimStart(), @@ -384,7 +384,7 @@ export class ChatService { } private onStreamError = (error: Error) => { - this._view?.webview.postMessage({ + this._webView?.postMessage({ type: EVENT_NAME.twinnyOnEnd, value: { error: true, @@ -400,7 +400,7 @@ export class ChatService { EXTENSION_CONTEXT_NAME.twinnyGeneratingText, true ) - this._view?.webview.onDidReceiveMessage((data: { type: string }) => { + this._webView?.onDidReceiveMessage((data: { type: string }) => { if (data.type === EVENT_NAME.twinnyStopGeneration) { this._controller?.abort() } @@ -415,7 +415,7 @@ export class ChatService { EXTENSION_CONTEXT_NAME.twinnyGeneratingText, true ) - this._view?.webview.postMessage({ + this._webView?.postMessage({ type: EVENT_NAME.twinnyOnEnd, value: { completion: this._completion.trimStart(), @@ -466,7 +466,7 @@ export class ChatService { } private sendEditorLanguage = () => { - this._view?.webview.postMessage({ + this._webView?.postMessage({ type: EVENT_NAME.twinnySendLanguage, value: { data: getLanguage() @@ -475,7 +475,7 @@ export class ChatService { } private focusChatTab = () => { - this._view?.webview.postMessage({ + this._webView?.postMessage({ type: EVENT_NAME.twinnySetTab, value: { data: WEBUI_TABS.chat @@ -514,7 +514,7 @@ export class ChatService { const problemsMentioned = text?.includes('@problems') - const ragContextKey = `${EVENT_NAME.twinnyWorkspaceContext}-${EXTENSION_CONTEXT_NAME.twinnyEnableRag}` + const ragContextKey = `${EVENT_NAME.twinnyGetWorkspaceContext}-${EXTENSION_CONTEXT_NAME.twinnyEnableRag}` const isRagEnabled = this._context?.workspaceState.get(ragContextKey) if (symmetryConnected) return null @@ -532,7 +532,7 @@ export class ChatService { let relevantCode: string | null = '' if (workspaceMentioned || isRagEnabled) { - updateLoadingMessage(this._view, 'Exploring knowledge base') + updateLoadingMessage(this._webView, 'Exploring knowledge base') relevantFiles = await this.getRelevantFiles(prompt) relevantCode = await this.getRelevantCode(prompt, relevantFiles) } @@ -559,6 +559,7 @@ export class ChatService { } private async loadFileContents(files: FileItem[]): Promise { + if (!files?.length) return '' let fileContents = ''; for (const file of files) { @@ -622,7 +623,7 @@ export class ChatService { content: cleanedText }) } - updateLoadingMessage(this._view, 'Thinking') + updateLoadingMessage(this._webView, 'Thinking') const request = this.buildStreamRequest(updatedMessages) if (!request) return const { requestBody, requestOptions } = request @@ -648,10 +649,10 @@ export class ChatService { if (!skipMessage) { this.focusChatTab() - this._view?.webview.postMessage({ + this._webView?.postMessage({ type: EVENT_NAME.twinnyOnLoading }) - this._view?.webview.postMessage({ + this._webView?.postMessage({ type: EVENT_NAME.twinngAddMessage, value: { completion: kebabToSentence(template) + '\n\n' + '```\n' + selection, diff --git a/src/extension/context.ts b/src/extension/context.ts index 8a031889..ef8868d9 100644 --- a/src/extension/context.ts +++ b/src/extension/context.ts @@ -2,8 +2,8 @@ import { ExtensionContext } from 'vscode' let context: ExtensionContext | null = null -export function setContext(newContext: ExtensionContext) { - context = newContext +export function setContext(extensionContext: ExtensionContext) { + context = extensionContext } export function getContext() { diff --git a/src/extension/conversation-history.ts b/src/extension/conversation-history.ts index d07f2878..094c6ca5 100644 --- a/src/extension/conversation-history.ts +++ b/src/extension/conversation-history.ts @@ -1,4 +1,4 @@ -import { ExtensionContext, WebviewView, workspace } from 'vscode' +import { ExtensionContext, Webview, workspace } from 'vscode' import { ClientMessage, Conversation, @@ -30,29 +30,29 @@ type Conversations = Record | undefined export class ConversationHistory { private _context: ExtensionContext - private _webviewView: WebviewView private _config = workspace.getConfiguration('twinny') private _keepAlive = this._config.get('keepAlive') as string | number + private _sessionManager: SessionManager | undefined + private _symmetryService: SymmetryService private _temperature = this._config.get('temperature') as number private _title = '' - private _sessionManager: SessionManager - private _symmetryService: SymmetryService + private _webView: Webview constructor( context: ExtensionContext, - webviewView: WebviewView, - sessionManager: SessionManager, + webView: Webview, + sessionManager: SessionManager | undefined, symmetryService: SymmetryService ) { this._context = context - this._webviewView = webviewView + this._webView = webView this._sessionManager = sessionManager this._symmetryService = symmetryService this.setUpEventListeners() } setUpEventListeners() { - this._webviewView.webview.onDidReceiveMessage( + this._webView?.onDidReceiveMessage( (message: ClientMessage) => { this.handleMessage(message) } @@ -62,7 +62,7 @@ export class ConversationHistory { SYMMETRY_EMITTER_KEY.conversationTitle, (completion: string) => { const activeConversation = this.getActiveConversation() - this._webviewView?.webview.postMessage({ + this._webView?.postMessage({ type: CONVERSATION_EVENT_NAME.getActiveConversation, value: { data: { @@ -178,7 +178,7 @@ export class ConversationHistory { getAllConversations() { const conversations = this.getConversations() || {} - this._webviewView.webview.postMessage({ + this._webView?.postMessage({ type: CONVERSATION_EVENT_NAME.getConversations, value: { data: conversations @@ -213,7 +213,7 @@ export class ConversationHistory { ACTIVE_CONVERSATION_STORAGE_KEY, conversation ) - this._webviewView?.webview.postMessage({ + this._webView?.postMessage({ type: CONVERSATION_EVENT_NAME.getActiveConversation, value: { data: conversation @@ -257,7 +257,7 @@ export class ConversationHistory { return if ( - this._sessionManager.get(EXTENSION_SESSION_NAME.twinnySymmetryConnection) + this._sessionManager?.get(EXTENSION_SESSION_NAME.twinnySymmetryConnection) ) { this._symmetryService?.write( createSymmetryMessage(SYMMETRY_DATA_MESSAGE.inference, { diff --git a/src/extension/diff.ts b/src/extension/diff.ts index 6d256606..56d606ad 100644 --- a/src/extension/diff.ts +++ b/src/extension/diff.ts @@ -1,12 +1,12 @@ import * as vscode from 'vscode' import * as path from 'path' - import { ClientMessage } from '../common/types' export class DiffManager { - private originalUri: vscode.Uri | undefined - private modifiedUri: vscode.Uri | undefined - private originalEditor: vscode.TextEditor | undefined + private _originalUri: vscode.Uri | undefined + private _modifiedUri: vscode.Uri | undefined + private _originalEditor: vscode.TextEditor | undefined + private _tempDir: string | undefined private getFileExtension(document: vscode.TextDocument): string { const fileName = document.fileName @@ -14,6 +14,18 @@ export class DiffManager { return extension ? extension.slice(1) : 'txt' } + private async cleanupTempFiles() { + if (this._originalUri && this._modifiedUri && this._tempDir) { + try { + await vscode.workspace.fs.delete(this._originalUri) + await vscode.workspace.fs.delete(this._modifiedUri) + await vscode.workspace.fs.delete(vscode.Uri.file(this._tempDir), { recursive: true }) + } catch (error) { + console.error('Error cleaning up temporary files:', error) + } + } + } + public async openDiff(message: ClientMessage) { const editor = vscode.window.activeTextEditor if (!editor) return @@ -25,26 +37,27 @@ export class DiffManager { const fileExtension = this.getFileExtension(editor.document) - const tempDir = path.join( + this._tempDir = path.join( vscode.workspace.workspaceFolders?.[0].uri.fsPath || '', - 'tmp' + 'tmp', + Date.now().toString() // Add timestamp to ensure uniqueness ) - this.originalUri = vscode.Uri.file( - path.join(tempDir, `original.${fileExtension}`) + this._originalUri = vscode.Uri.file( + path.join(this._tempDir, `original.${fileExtension}`) ) - this.modifiedUri = vscode.Uri.file( - path.join(tempDir, `proposed.${fileExtension}`) + this._modifiedUri = vscode.Uri.file( + path.join(this._tempDir, `proposed.${fileExtension}`) ) - this.originalEditor = editor + this._originalEditor = editor - await vscode.workspace.fs.createDirectory(vscode.Uri.file(tempDir)) + await vscode.workspace.fs.createDirectory(vscode.Uri.file(this._tempDir)) await vscode.workspace.fs.writeFile( - this.originalUri, + this._originalUri, Buffer.from(text, 'utf8') ) await vscode.workspace.fs.writeFile( - this.modifiedUri, + this._modifiedUri, Buffer.from(message.data as string, 'utf8') ) @@ -56,30 +69,43 @@ export class DiffManager { await vscode.commands.executeCommand( 'vscode.diff', - this.originalUri, - this.modifiedUri, + this._originalUri, + this._modifiedUri, title, options ) - } - public async acceptSolution(message: ClientMessage) { - if (!this.originalEditor) return - - const diffEditor = vscode.window.activeTextEditor - if (diffEditor && diffEditor.document.uri.scheme === 'diff') { - await vscode.commands.executeCommand('workbench.action.closeActiveEditor') - } - - await this.originalEditor.edit((editBuilder: vscode.TextEditorEdit) => { - const selection = this.originalEditor?.selection - if (!selection) return - editBuilder.replace(selection, message.data as string) + // Set up a listener to clean up temp files when the diff editor is closed + const disposable = vscode.window.onDidChangeVisibleTextEditors(async (editors) => { + const diffEditorOpen = editors.some(e => e.document.uri.scheme === 'diff') + if (!diffEditorOpen) { + await this.cleanupTempFiles() + disposable.dispose() + } }) + } - if (this.originalUri && this.modifiedUri) { - await vscode.workspace.fs.delete(this.originalUri) - await vscode.workspace.fs.delete(this.modifiedUri) + public async acceptSolution(message: ClientMessage) { + if (this._originalEditor) { + const diffEditor = vscode.window.activeTextEditor + if (diffEditor && diffEditor.document.uri.scheme === 'diff') { + await vscode.commands.executeCommand( + 'workbench.action.closeActiveEditor' + ) + } + await this._originalEditor.edit((editBuilder: vscode.TextEditorEdit) => { + const selection = this._originalEditor?.selection + if (!selection) return + editBuilder.replace(selection, message.data as string) + }) + await this.cleanupTempFiles() + } else { + const editor = vscode.window.activeTextEditor + await editor?.edit((editBuilder: vscode.TextEditorEdit) => { + const selection = editor?.selection + if (!selection) return + editBuilder.replace(selection, message.data as string) + }) } } } diff --git a/src/extension/embeddings.ts b/src/extension/embeddings.ts index dee278e1..5b3374da 100644 --- a/src/extension/embeddings.ts +++ b/src/extension/embeddings.ts @@ -11,21 +11,20 @@ import { StreamRequestOptions as RequestOptions, Embedding } from '../common/types' -import { - ACTIVE_EMBEDDINGS_PROVIDER_STORAGE_KEY, - EMBEDDING_IGNORE_LIST -} from '../common/constants' +import { ACTIVE_EMBEDDINGS_PROVIDER_STORAGE_KEY } from '../common/constants' import { TwinnyProvider } from './provider-manager' -import { getDocumentSplitChunks } from './utils' +import { + getDocumentSplitChunks, + getIgnoreDirectory, + readGitIgnoreFile, + readGitSubmodulesFile +} from './utils' import { IntoVector } from '@lancedb/lancedb/dist/arrow' import { Logger } from '../common/logger' const logger = new Logger() export class EmbeddingDatabase { - private _config = vscode.workspace.getConfiguration('twinny') - private _bearerToken = this._config.get('apiBearerToken') as string - private _embeddingModel = this._config.get('embeddingModel') as string private _documents: EmbeddedDocument[] = [] private _filePaths: EmbeddedDocument[] = [] private _db: lancedb.Connection | null = null @@ -59,7 +58,7 @@ export class EmbeddingDatabase { if (!provider) return const requestBody: RequestOptionsOllama = { - model: this._embeddingModel, + model: provider.modelName, input: content, stream: false, options: {} @@ -73,7 +72,7 @@ export class EmbeddingDatabase { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${this._bearerToken}` + Authorization: `Bearer ${provider.apiKey}` } } @@ -91,8 +90,8 @@ export class EmbeddingDatabase { private getAllFilePaths = async (dirPath: string): Promise => { let filePaths: string[] = [] const dirents = await fs.promises.readdir(dirPath, { withFileTypes: true }) - const gitIgnoredFiles = this.readGitIgnoreFile() || [] - const submodules = this.readGitSubmodulesFile() + const gitIgnoredFiles = readGitIgnoreFile() || [] + const submodules = readGitSubmodulesFile() const rootPath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '' @@ -100,7 +99,7 @@ export class EmbeddingDatabase { const fullPath = path.join(dirPath, dirent.name) const relativePath = path.relative(rootPath, fullPath) - if (this.getIgnoreDirectory(dirent.name)) continue + if (getIgnoreDirectory(dirent.name)) continue if (submodules?.some((submodule) => fullPath.includes(submodule))) { continue @@ -242,70 +241,4 @@ export class EmbeddingDatabase { private getIsDuplicateItem(item: string, collection: string[]): boolean { return collection.includes(item.trim().toLowerCase()) } - - private readGitIgnoreFile(): string[] | undefined { - try { - const folders = vscode.workspace.workspaceFolders - if (!folders || folders.length === 0) { - console.log('No workspace folders found') - return undefined - } - - const rootPath = folders[0].uri.fsPath - if (!rootPath) { - console.log('Root path is undefined') - return undefined - } - - const gitIgnoreFilePath = path.join(rootPath, '.gitignore') - if (!fs.existsSync(gitIgnoreFilePath)) { - console.log('.gitignore file not found at', gitIgnoreFilePath) - return undefined - } - - const ignoreFileContent = fs.readFileSync(gitIgnoreFilePath, 'utf8') - return ignoreFileContent - .split('\n') - .map((line) => line.trim()) - .filter((line) => line !== '' && !line.startsWith('#')) - .map((pattern) => { - if (pattern.endsWith('/')) { - return pattern + '**' - } - return pattern - }) - } catch (e) { - console.error('Error reading .gitignore file:', e) - return undefined - } - } - - private readGitSubmodulesFile(): string[] | undefined { - try { - const folders = vscode.workspace.workspaceFolders - if (!folders || folders.length === 0) return undefined - const rootPath = folders[0].uri.fsPath - if (!rootPath) return undefined - const gitSubmodulesFilePath = path.join(rootPath, '.gitmodules') - if (!fs.existsSync(gitSubmodulesFilePath)) return undefined - const submodulesFileContent = fs - .readFileSync(gitSubmodulesFilePath) - .toString() - const submodulePaths: string[] = [] - submodulesFileContent.split('\n').forEach((line: string) => { - if (line.startsWith('\tpath = ')) { - submodulePaths.push(line.slice(8)) - } - }) - return submodulePaths - } catch (e) { - return undefined - } - } - - private getIgnoreDirectory(fileName: string): boolean { - return EMBEDDING_IGNORE_LIST.some((ignoreItem: string) => - fileName.includes(ignoreItem) - ) - } } diff --git a/src/extension/ollama-service.ts b/src/extension/ollama.ts similarity index 88% rename from src/extension/ollama-service.ts rename to src/extension/ollama.ts index f009679d..3bb866e1 100644 --- a/src/extension/ollama-service.ts +++ b/src/extension/ollama.ts @@ -1,13 +1,10 @@ import { workspace } from 'vscode' -import { Logger } from '../common/logger' export class OllamaService { - private logger: Logger private _config = workspace.getConfiguration('twinny') private _baseUrl: string constructor() { - this.logger = new Logger() const protocol = (this._config.get('ollamaUseTls') as boolean) ? 'https' : 'http' diff --git a/src/extension/parser-utils.ts b/src/extension/parser.ts similarity index 100% rename from src/extension/parser-utils.ts rename to src/extension/parser.ts diff --git a/src/extension/provider-manager.ts b/src/extension/provider-manager.ts index f1b79bce..450a40c0 100644 --- a/src/extension/provider-manager.ts +++ b/src/extension/provider-manager.ts @@ -1,4 +1,4 @@ -import { ExtensionContext, WebviewView } from 'vscode' +import { ExtensionContext, Webview } from 'vscode' import { apiProviders, ClientMessage, ServerMessage } from '../common/types' import { ACTIVE_CHAT_PROVIDER_STORAGE_KEY, @@ -29,17 +29,17 @@ type Providers = Record | undefined export class ProviderManager { _context: ExtensionContext - _webviewView: WebviewView + _webView: Webview - constructor(context: ExtensionContext, webviewView: WebviewView) { + constructor(context: ExtensionContext, webviewView: Webview) { this._context = context - this._webviewView = webviewView + this._webView = webviewView this.setUpEventListeners() this.addDefaultProviders() } setUpEventListeners() { - this._webviewView.webview.onDidReceiveMessage( + this._webView?.onDidReceiveMessage( (message: ClientMessage) => { this.handleMessage(message) } @@ -75,7 +75,7 @@ export class ProviderManager { } public focusProviderTab = () => { - this._webviewView?.webview.postMessage({ + this._webView.postMessage({ type: PROVIDER_EVENT_NAME.focusProviderTab, value: { data: WEBUI_TABS.providers @@ -201,7 +201,7 @@ export class ProviderManager { getAllProviders() { const providers = this.getProviders() || {} - this._webviewView.webview.postMessage({ + this._webView?.postMessage({ type: PROVIDER_EVENT_NAME.getAllProviders, value: { data: providers @@ -213,7 +213,7 @@ export class ProviderManager { const provider = this._context.globalState.get( ACTIVE_CHAT_PROVIDER_STORAGE_KEY ) - this._webviewView.webview.postMessage({ + this._webView?.postMessage({ type: PROVIDER_EVENT_NAME.getActiveChatProvider, value: { data: provider @@ -226,7 +226,7 @@ export class ProviderManager { const provider = this._context.globalState.get( ACTIVE_FIM_PROVIDER_STORAGE_KEY ) - this._webviewView.webview.postMessage({ + this._webView?.postMessage({ type: PROVIDER_EVENT_NAME.getActiveFimProvider, value: { data: provider @@ -239,7 +239,7 @@ export class ProviderManager { const provider = this._context.globalState.get( ACTIVE_EMBEDDINGS_PROVIDER_STORAGE_KEY ) - this._webviewView.webview.postMessage({ + this._webView?.postMessage({ type: PROVIDER_EVENT_NAME.getActiveEmbeddingsProvider, value: { data: provider diff --git a/src/extension/providers/base.ts b/src/extension/providers/base.ts new file mode 100644 index 00000000..8bbe2a89 --- /dev/null +++ b/src/extension/providers/base.ts @@ -0,0 +1,474 @@ +import * as vscode from 'vscode' + +import { + createSymmetryMessage, + getGitChanges, + getLanguage, + getTextSelection, + getTheme, + updateLoadingMessage +} from '../utils' +import { + WORKSPACE_STORAGE_KEY, + EXTENSION_SESSION_NAME, + EVENT_NAME, + TWINNY_COMMAND_NAME, + SYMMETRY_DATA_MESSAGE, + SYMMETRY_EMITTER_KEY, + SYSTEM +} from '../../common/constants' +import { ChatService } from '../chat-service' +import { + ClientMessage, + Message, + ApiModel, + ServerMessage, + InferenceRequest, + SymmetryModelProvider, + FileItem +} from '../../common/types' +import { TemplateProvider } from '../template-provider' +import { OllamaService } from '../ollama' +import { ConversationHistory } from '../conversation-history' +import { EmbeddingDatabase } from '../embeddings' +import { SymmetryService } from '../symmetry-service' +import { SessionManager } from '../session-manager' +import { Logger } from '../../common/logger' +import { DiffManager } from '../diff' +import { ProviderManager } from '../provider-manager' +import { FileTreeProvider } from '../tree' + +const logger = new Logger() + +export class BaseProvider { + public context: vscode.ExtensionContext + public webView?: vscode.Webview + public conversationHistory: ConversationHistory | undefined = undefined + private _config = vscode.workspace.getConfiguration('twinny') + private _chatService: ChatService | undefined = undefined + private _diffManager = new DiffManager() + private _embeddingDatabase: EmbeddingDatabase | undefined + private _fileTreeProvider: FileTreeProvider + private _ollamaService: OllamaService | undefined = undefined + private _sessionManager: SessionManager | undefined = undefined + private _statusBarItem: vscode.StatusBarItem + private _symmetryService?: SymmetryService | undefined + private _templateDir: string | undefined + private _templateProvider: TemplateProvider + + constructor( + context: vscode.ExtensionContext, + templateDir: string, + statusBar: vscode.StatusBarItem, + db?: EmbeddingDatabase | undefined, + sessionManager?: SessionManager + ) { + this._fileTreeProvider = new FileTreeProvider() + this.context = context + this._embeddingDatabase = db + this._ollamaService = new OllamaService() + this._sessionManager = sessionManager + this._statusBarItem = statusBar + this._templateDir = templateDir + this._templateProvider = new TemplateProvider(templateDir) + } + + public registerWebView(webView: vscode.Webview) { + this.webView = webView + + this._symmetryService = new SymmetryService( + webView, + this._sessionManager, + this.context + ) + + this._chatService = new ChatService( + this._statusBarItem, + this._templateDir, + this.context, + this.webView, + this._embeddingDatabase, + this._sessionManager, + this._symmetryService + ) + + this.conversationHistory = new ConversationHistory( + this.context, + this.webView, + this._sessionManager, + this._symmetryService + ) + + new ProviderManager(this.context, this.webView) + + vscode.window.onDidChangeActiveColorTheme(() => { + this.webView?.postMessage({ + type: EVENT_NAME.twinnySendTheme, + value: { + data: getTheme() + } + }) + }) + + vscode.window.onDidChangeTextEditorSelection( + (event: vscode.TextEditorSelectionChangeEvent) => { + const text = event.textEditor.document.getText(event.selections[0]) + this.webView?.postMessage({ + type: EVENT_NAME.twinnyTextSelection, + value: { + type: WORKSPACE_STORAGE_KEY.selection, + completion: text + } + }) + } + ) + + vscode.window.onDidChangeActiveColorTheme(() => { + this.webView?.postMessage({ + type: EVENT_NAME.twinnySendTheme, + value: { + data: getTheme() + } + }) + }) + + this.webView?.onDidReceiveMessage((message) => { + const eventHandlers = { + [EVENT_NAME.twinnyAcceptSolution]: this.acceptSolution, + [EVENT_NAME.twinnyChatMessage]: this.streamChatCompletion, + [EVENT_NAME.twinnyClickSuggestion]: this.clickSuggestion, + [EVENT_NAME.twinnyConnectSymmetry]: this.connectToSymmetry, + [EVENT_NAME.twinnyDisconnectSymmetry]: this.disconnectSymmetry, + [EVENT_NAME.twinnyEmbedDocuments]: this.embedDocuments, + [EVENT_NAME.twinnyFetchOllamaModels]: this.fetchOllamaModels, + [EVENT_NAME.twinnyGetConfigValue]: this.getConfigurationValue, + [EVENT_NAME.twinnyGetGitChanges]: this.getGitCommitMessage, + [EVENT_NAME.twinnyGetWorkspaceContext]: this.getTwinnyWorkspaceContext, + [EVENT_NAME.twinnyGlobalContext]: this.getGlobalContext, + [EVENT_NAME.twinnyHideBackButton]: this.twinnyHideBackButton, + [EVENT_NAME.twinnyListTemplates]: this.listTemplates, + [EVENT_NAME.twinnyNewDocument]: this.createNewUntitledDocument, + [EVENT_NAME.twinnyNotification]: this.sendNotification, + [EVENT_NAME.twinnyOpenDiff]: this.openDiff, + [EVENT_NAME.twinnySendLanguage]: this.getCurrentLanguage, + [EVENT_NAME.twinnySendTheme]: this.getTheme, + [EVENT_NAME.twinnySessionContext]: this.getSessionContext, + [EVENT_NAME.twinnySetConfigValue]: this.setConfigurationValue, + [EVENT_NAME.twinnySetGlobalContext]: this.setGlobalContext, + [EVENT_NAME.twinnySetTab]: this.setTab, + [EVENT_NAME.twinnySetWorkspaceContext]: this.setWorkspaceContext, + [EVENT_NAME.twinnyStartSymmetryProvider]: this.createSymmetryProvider, + [EVENT_NAME.twinnyStopSymmetryProvider]: this.stopSymmetryProvider, + [EVENT_NAME.twinnyTextSelection]: this.getSelectedText, + [EVENT_NAME.twinnyFileListRequest]: this.fileListRequest, + [TWINNY_COMMAND_NAME.settings]: this.openSettings + } + eventHandlers[message.type as string]?.(message) + }) + } + + public newConversation() { + this._symmetryService?.write( + createSymmetryMessage(SYMMETRY_DATA_MESSAGE.newConversation) + ) + } + + public destroyStream = () => { + this._chatService?.destroyStream() + this.webView?.postMessage({ + type: EVENT_NAME.twinnyStopGeneration + }) + } + + private openSettings() { + vscode.commands.executeCommand(TWINNY_COMMAND_NAME.settings) + } + + private setTab(tab: ClientMessage) { + this.webView?.postMessage({ + type: EVENT_NAME.twinnySetTab, + value: { + data: tab as string + } + } as ServerMessage) + } + + private embedDocuments = async () => { + const dirs = vscode.workspace.workspaceFolders + if (!dirs?.length) { + vscode.window.showErrorMessage('No workspace loaded.') + return + } + if (!this._embeddingDatabase) return + for (const dir of dirs) { + (await this._embeddingDatabase.injestDocuments(dir.uri.fsPath)).populateDatabase() + } + } + + private getConfigurationValue = (message: ClientMessage) => { + if (!message.key) return + const config = vscode.workspace.getConfiguration('twinny') + this.webView?.postMessage({ + type: EVENT_NAME.twinnyGetConfigValue, + value: { + data: config.get(message.key as string), + type: message.key + } + } as ServerMessage) + } + + private fileListRequest = async (message: ClientMessage) => { + if (message.type === EVENT_NAME.twinnyFileListRequest) { + const files = await this._fileTreeProvider?.getAllFiles() + this.webView?.postMessage({ + type: EVENT_NAME.twinnyFileListResponse, + value: { + data: files + } + }) + } + } + + private setConfigurationValue = (message: ClientMessage) => { + if (!message.key) return + const config = vscode.workspace.getConfiguration('twinny') + config.update(message.key, message.data, vscode.ConfigurationTarget.Global) + } + + private fetchOllamaModels = async () => { + try { + const models = await this._ollamaService?.fetchModels() + if (!models?.length) { + return + } + this.webView?.postMessage({ + type: EVENT_NAME.twinnyFetchOllamaModels, + value: { + data: models + } + } as ServerMessage) + } catch (e) { + return + } + } + + private listTemplates = () => { + const templates = this._templateProvider.listTemplates() + this.webView?.postMessage({ + type: EVENT_NAME.twinnyListTemplates, + value: { + data: templates + } + } as ServerMessage) + } + + private sendNotification = (message: ClientMessage) => { + vscode.window.showInformationMessage(message.data as string) + } + + private clickSuggestion = (message: ClientMessage) => { + vscode.commands.executeCommand( + 'twinny.templateCompletion', + message.data as string + ) + } + + private streamChatCompletion = async (data: ClientMessage) => { + const symmetryConnected = this._sessionManager?.get( + EXTENSION_SESSION_NAME.twinnySymmetryConnection + ) + if (symmetryConnected) { + const systemMessage = { + role: SYSTEM, + content: await this._templateProvider?.readSystemMessageTemplate() + } + + const messages = [systemMessage, ...(data.data as Message[])] + + updateLoadingMessage(this.webView, 'Using symmetry for inference') + + logger.log(` + Using symmetry for inference + Messages: ${JSON.stringify(messages)} + `) + + return this._symmetryService?.write( + createSymmetryMessage( + SYMMETRY_DATA_MESSAGE.inference, + { + messages, + key: SYMMETRY_EMITTER_KEY.inference + } + ) + ) + } + + this._chatService?.streamChatCompletion( + data.data || [], + data.meta as FileItem[] + ) + } + + public async streamTemplateCompletion(template: string) { + const symmetryConnected = this._sessionManager?.get( + EXTENSION_SESSION_NAME.twinnySymmetryConnection + ) + if (symmetryConnected && this._chatService) { + const messages = await this._chatService.getTemplateMessages(template) + + logger.log(` + Using symmetry for inference + Messages: ${JSON.stringify(messages)} + `) + return this._symmetryService?.write( + createSymmetryMessage( + SYMMETRY_DATA_MESSAGE.inference, + { + messages, + key: SYMMETRY_EMITTER_KEY.inference + } + ) + ) + } + this._chatService?.streamTemplateCompletion(template) + } + + private getSelectedText = () => { + this.webView?.postMessage({ + type: EVENT_NAME.twinnyTextSelection, + value: { + type: WORKSPACE_STORAGE_KEY.selection, + completion: getTextSelection() + } + }) + } + + private openDiff = async (message: ClientMessage) => { + await this._diffManager.openDiff(message) + } + + private acceptSolution = async (message: ClientMessage) => { + await this._diffManager.acceptSolution(message) + } + + private createNewUntitledDocument = async (message: ClientMessage) => { + const lang = getLanguage() + const document = await vscode.workspace.openTextDocument({ + content: message.data as string, + language: lang.languageId + }) + await vscode.window.showTextDocument(document) + } + + private getGlobalContext = (message: ClientMessage) => { + const storedData = this.context?.globalState.get( + `${EVENT_NAME.twinnyGlobalContext}-${message.key}` + ) + this.webView?.postMessage({ + type: `${EVENT_NAME.twinnyGlobalContext}-${message.key}`, + value: storedData + }) + } + + private getTheme = () => { + this.webView?.postMessage({ + type: EVENT_NAME.twinnySendTheme, + value: { + data: getTheme() + } + }) + } + + private getCurrentLanguage = () => { + this.webView?.postMessage({ + type: EVENT_NAME.twinnySendLanguage, + value: { + data: getLanguage() + } + } as ServerMessage) + } + + public getGitCommitMessage = async () => { + const diff = await getGitChanges() + if (!diff.length) { + vscode.window.showInformationMessage( + 'No changes found in the current workspace.' + ) + return + } + this.conversationHistory?.resetConversation() + this._chatService?.streamTemplateCompletion( + 'commit-message', + diff, + (completion: string) => { + vscode.commands.executeCommand('twinny.sendTerminalText', completion) + }, + true + ) + } + + private getSessionContext = (data: ClientMessage) => { + if (!data.key) return undefined + return this.webView?.postMessage({ + type: `${EVENT_NAME.twinnySessionContext}-${data.key}`, + value: this._sessionManager?.get(data.key) + }) + } + + private setGlobalContext = (message: ClientMessage) => { + this.context?.globalState.update( + `${EVENT_NAME.twinnyGlobalContext}-${message.key}`, + message.data + ) + } + + private getTwinnyWorkspaceContext = (message: ClientMessage) => { + const storedData = this.context?.workspaceState.get( + `${EVENT_NAME.twinnyGetWorkspaceContext}-${message.key}` + ) + this.webView?.postMessage({ + type: `${EVENT_NAME.twinnyGetWorkspaceContext}-${message.key}`, + value: storedData + } as ServerMessage) + } + + private setWorkspaceContext = (message: ClientMessage) => { + const value = message.data + this.context.workspaceState.update( + `${EVENT_NAME.twinnyGetWorkspaceContext}-${message.key}`, + value + ) + this.webView?.postMessage({ + type: `${EVENT_NAME.twinnyGetWorkspaceContext}-${message.key}`, + value + }) + } + + private connectToSymmetry = (data: ClientMessage) => { + if (this._config.symmetryServerKey) { + this._symmetryService?.connect( + this._config.symmetryServerKey, + data.data?.model_name, + data.data?.provider + ) + } + } + + private disconnectSymmetry = async () => { + if (this._config.symmetryServerKey) { + await this._symmetryService?.disconnect() + } + } + + private createSymmetryProvider = () => { + this._symmetryService?.startSymmetryProvider() + } + + private stopSymmetryProvider = () => { + this._symmetryService?.stopSymmetryProvider() + } + + private twinnyHideBackButton() { + vscode.commands.executeCommand(TWINNY_COMMAND_NAME.hideBackButton) + } +} diff --git a/src/extension/providers/completion.ts b/src/extension/providers/completion.ts index 64972e86..d0fb17cd 100644 --- a/src/extension/providers/completion.ts +++ b/src/extension/providers/completion.ts @@ -53,7 +53,7 @@ import { FileInteractionCache } from '../file-interaction' import { getLineBreakCount } from '../../webview/utils' import { TemplateProvider } from '../template-provider' import { TwinnyProvider } from '../provider-manager' -import { getNodeAtPosition, getParser } from '../parser-utils' +import { getNodeAtPosition, getParser } from '../parser' export class CompletionProvider implements InlineCompletionItemProvider { private _config = workspace.getConfiguration('twinny') @@ -560,6 +560,5 @@ export class CompletionProvider implements InlineCompletionItemProvider { this._multilineCompletionsEnabled = this._config.get( 'multilineCompletionsEnabled' ) as boolean - this._logger.updateConfig() } } diff --git a/src/extension/providers/panel.ts b/src/extension/providers/panel.ts new file mode 100644 index 00000000..b3033176 --- /dev/null +++ b/src/extension/providers/panel.ts @@ -0,0 +1,96 @@ +import * as vscode from 'vscode' +import { BaseProvider } from './base' +import { getNonce } from '../utils' + +// TODO +export class FullScreenProvider extends BaseProvider { + private _panel?: vscode.WebviewPanel + + constructor( + context: vscode.ExtensionContext, + templateDir: string, + statusBarItem: vscode.StatusBarItem + ) { + super(context, templateDir, statusBarItem) + this.context = context + } + + public createOrShowPanel() { + const columnToShowIn = vscode.window.activeTextEditor + ? vscode.window.activeTextEditor.viewColumn + : undefined + + if (this._panel) { + this._panel.reveal(columnToShowIn) + } else { + this._panel = vscode.window.createWebviewPanel( + 'twinnyFullScreenPanel', + 'twinny', + columnToShowIn || vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [this.context.extensionUri], + retainContextWhenHidden: true + } + ) + + this._panel.webview.html = this.getHtmlForWebview(this._panel.webview) + + this.registerWebView(this._panel.webview) + + this._panel.onDidDispose( + () => { + this._panel = undefined + }, + null, + this.context.subscriptions + ) + } + } + + private getHtmlForWebview(webview: vscode.Webview) { + const scriptUri = webview.asWebviewUri( + vscode.Uri.joinPath(this.context.extensionUri, 'out', 'sidebar.js') + ) + + const codiconCssUri = vscode.Uri.joinPath( + this.context.extensionUri, + 'assets', + 'codicon.css' + ) + + const css = webview.asWebviewUri( + vscode.Uri.joinPath(this.context.extensionUri, 'out', 'sidebar.css') + ) + + const codiconCssWebviewUri = webview.asWebviewUri(codiconCssUri) + + const nonce = getNonce() + + return ` + + + twinny + + + + + + twinny + + + +
+ + + ` + } +} diff --git a/src/extension/providers/sidebar.ts b/src/extension/providers/sidebar.ts index 596373c0..d18e8982 100644 --- a/src/extension/providers/sidebar.ts +++ b/src/extension/providers/sidebar.ts @@ -1,491 +1,50 @@ import * as vscode from 'vscode' -import { - createSymmetryMessage, - getGitChanges, - getLanguage, - getTextSelection, - getTheme, - updateLoadingMessage -} from '../utils' -import { - WORKSPACE_STORAGE_KEY, - EXTENSION_SESSION_NAME, - EVENT_NAME, - TWINNY_COMMAND_NAME, - SYMMETRY_DATA_MESSAGE, - SYMMETRY_EMITTER_KEY, - SYSTEM -} from '../../common/constants' -import { ChatService } from '../chat-service' -import { - ClientMessage, - Message, - ApiModel, - ServerMessage, - InferenceRequest, - SymmetryModelProvider, - FileItem -} from '../../common/types' -import { TemplateProvider } from '../template-provider' -import { OllamaService } from '../ollama-service' -import { ProviderManager } from '../provider-manager' -import { ConversationHistory } from '../conversation-history' import { EmbeddingDatabase } from '../embeddings' -import { SymmetryService } from '../symmetry-service' import { SessionManager } from '../session-manager' -import { Logger } from '../../common/logger' -import { DiffManager } from '../diff' -import { FileTreeProvider } from '../tree' +import { BaseProvider } from './base' +import { getNonce } from '../utils' -const logger = new Logger() - -export class SidebarProvider implements vscode.WebviewViewProvider { - private _config = vscode.workspace.getConfiguration('twinny') - private _context: vscode.ExtensionContext - private _db: EmbeddingDatabase | undefined - private _diffManager = new DiffManager() - private _ollamaService: OllamaService | undefined = undefined - private _sessionManager: SessionManager - private _statusBar: vscode.StatusBarItem - private _templateDir: string - private _templateProvider: TemplateProvider - public chatService: ChatService | undefined = undefined - public conversationHistory: ConversationHistory | undefined = undefined - public symmetryService?: SymmetryService | undefined - public view?: vscode.WebviewView - private _fileTreeProvider: FileTreeProvider +export class SidebarProvider extends BaseProvider { + public context: vscode.ExtensionContext constructor( - statusBar: vscode.StatusBarItem, + statusBarItem: vscode.StatusBarItem, context: vscode.ExtensionContext, templateDir: string, db: EmbeddingDatabase | undefined, sessionManager: SessionManager ) { - this._statusBar = statusBar - this._context = context - this._templateDir = templateDir - this._sessionManager = sessionManager - this._templateProvider = new TemplateProvider(templateDir) - this._ollamaService = new OllamaService() - this._fileTreeProvider = new FileTreeProvider() - if (db) { - this._db = db - } - return this + super(context, templateDir, statusBarItem, db, sessionManager) + this.context = context } public resolveWebviewView(webviewView: vscode.WebviewView) { - this.view = webviewView - - this.symmetryService = new SymmetryService( - this.view, - this._sessionManager, - this._context - ) - - this.chatService = new ChatService( - this._statusBar, - this._templateDir, - this._context, - webviewView, - this._db, - this._sessionManager, - this.symmetryService - ) - - this.conversationHistory = new ConversationHistory( - this._context, - this.view, - this._sessionManager, - this.symmetryService - ) - - new ProviderManager(this._context, this.view) + if (!this.context) return webviewView.webview.options = { enableScripts: true, - localResourceRoots: [this._context?.extensionUri] - } - - webviewView.webview.html = this._getHtmlForWebview(webviewView.webview) - - vscode.window.onDidChangeTextEditorSelection( - (event: vscode.TextEditorSelectionChangeEvent) => { - const text = event.textEditor.document.getText(event.selections[0]) - webviewView.webview.postMessage({ - type: EVENT_NAME.twinnyTextSelection, - value: { - type: WORKSPACE_STORAGE_KEY.selection, - completion: text - } - }) - } - ) - - vscode.window.onDidChangeActiveColorTheme(() => { - webviewView.webview.postMessage({ - type: EVENT_NAME.twinnySendTheme, - value: { - data: getTheme() - } - }) - }) - - webviewView.webview.onDidReceiveMessage((message) => { - const eventHandlers = { - [EVENT_NAME.twinnyAcceptSolution]: this.acceptSolution, - [EVENT_NAME.twinnyChatMessage]: this.streamChatCompletion, - [EVENT_NAME.twinnyClickSuggestion]: this.clickSuggestion, - [EVENT_NAME.twinnyFetchOllamaModels]: this.fetchOllamaModels, - [EVENT_NAME.twinnyGlobalContext]: this.getGlobalContext, - [EVENT_NAME.twinnyOpenDiff]: this.openDiff, - [EVENT_NAME.twinnyListTemplates]: this.listTemplates, - [EVENT_NAME.twinnySetTab]: this.setTab, - [TWINNY_COMMAND_NAME.settings]: this.openSettings, - [EVENT_NAME.twinnyNewDocument]: this.createNewUntitledDocument, - [EVENT_NAME.twinnyNotification]: this.sendNotification, - [EVENT_NAME.twinnySendLanguage]: this.getCurrentLanguage, - [EVENT_NAME.twinnySendTheme]: this.getTheme, - [EVENT_NAME.twinnySetGlobalContext]: this.setGlobalContext, - [EVENT_NAME.twinnySetWorkspaceContext]: this.setWorkspaceContext, - [EVENT_NAME.twinnyTextSelection]: this.getSelectedText, - [EVENT_NAME.twinnyWorkspaceContext]: this.getTwinnyWorkspaceContext, - [EVENT_NAME.twinnySetConfigValue]: this.setConfigurationValue, - [EVENT_NAME.twinnyGetConfigValue]: this.getConfigurationValue, - [EVENT_NAME.twinnyGetGitChanges]: this.getGitCommitMessage, - [EVENT_NAME.twinnyHideBackButton]: this.twinnyHideBackButton, - [EVENT_NAME.twinnyEmbedDocuments]: this.embedDocuments, - [EVENT_NAME.twinnyConnectSymmetry]: this.connectToSymmetry, - [EVENT_NAME.twinnyDisconnectSymmetry]: this.disconnectSymmetry, - [EVENT_NAME.twinnySessionContext]: this.getSessionContext, - [EVENT_NAME.twinnyStartSymmetryProvider]: this.createSymmetryProvider, - [EVENT_NAME.twinnyStopSymmetryProvider]: this.stopSymmetryProvider, - [EVENT_NAME.twinnyFileListRequest]: this.requestFileList - } - eventHandlers[message.type as string]?.(message) - }) - } - - public openSettings() { - vscode.commands.executeCommand(TWINNY_COMMAND_NAME.settings) - } - - public setTab(tab: ClientMessage) { - this.view?.webview.postMessage({ - type: EVENT_NAME.twinnySetTab, - value: { - data: tab as string - } - } as ServerMessage) - } - - public requestFileList = async (message: ClientMessage) => { - if (message.type === EVENT_NAME.twinnyFileListRequest) { - const files = await this._fileTreeProvider?.getAllFiles() - this.view?.webview.postMessage({ - type: EVENT_NAME.twinnyFileListResponse, - value: { - data: files - } - }) - } - } - - public embedDocuments = async () => { - const dirs = vscode.workspace.workspaceFolders - if (!dirs?.length) { - vscode.window.showErrorMessage('No workspace loaded.') - return - } - if (!this._db) return - for (const dir of dirs) { - (await this._db.injestDocuments(dir.uri.fsPath)).populateDatabase() - } - } - - public getConfigurationValue = (message: ClientMessage) => { - if (!message.key) return - const config = vscode.workspace.getConfiguration('twinny') - this.view?.webview.postMessage({ - type: EVENT_NAME.twinnyGetConfigValue, - value: { - data: config.get(message.key as string), - type: message.key - } - } as ServerMessage) - } - - public setConfigurationValue = (message: ClientMessage) => { - if (!message.key) return - const config = vscode.workspace.getConfiguration('twinny') - config.update(message.key, message.data, vscode.ConfigurationTarget.Global) - } - - public fetchOllamaModels = async () => { - try { - const models = await this._ollamaService?.fetchModels() - if (!models?.length) { - return - } - this.view?.webview.postMessage({ - type: EVENT_NAME.twinnyFetchOllamaModels, - value: { - data: models - } - } as ServerMessage) - } catch (e) { - return - } - } - - public listTemplates = () => { - const templates = this._templateProvider.listTemplates() - this.view?.webview.postMessage({ - type: EVENT_NAME.twinnyListTemplates, - value: { - data: templates - } - } as ServerMessage) - } - - public sendNotification = (message: ClientMessage) => { - vscode.window.showInformationMessage(message.data as string) - } - - public clickSuggestion = (message: ClientMessage) => { - vscode.commands.executeCommand( - 'twinny.templateCompletion', - message.data as string - ) - } - - public streamChatCompletion = async (data: ClientMessage) => { - const symmetryConnected = this._sessionManager?.get( - EXTENSION_SESSION_NAME.twinnySymmetryConnection - ) - if (symmetryConnected) { - const systemMessage = { - role: SYSTEM, - content: await this._templateProvider?.readSystemMessageTemplate() - } - - const messages = [systemMessage, ...(data.data as Message[])] - - updateLoadingMessage(this.view, 'Using symmetry for inference') - - logger.log(` - Using symmetry for inference - Messages: ${JSON.stringify(messages)} - `) - - return this.symmetryService?.write( - createSymmetryMessage( - SYMMETRY_DATA_MESSAGE.inference, - { - messages, - key: SYMMETRY_EMITTER_KEY.inference - } - ) - ) - } - - this.chatService?.streamChatCompletion( - data.data || [], - data.meta as FileItem[] - ) - } - - public async streamTemplateCompletion(template: string) { - const symmetryConnected = this._sessionManager?.get( - EXTENSION_SESSION_NAME.twinnySymmetryConnection - ) - if (symmetryConnected && this.chatService) { - const messages = await this.chatService.getTemplateMessages(template) - - logger.log(` - Using symmetry for inference - Messages: ${JSON.stringify(messages)} - `) - return this.symmetryService?.write( - createSymmetryMessage( - SYMMETRY_DATA_MESSAGE.inference, - { - messages, - key: SYMMETRY_EMITTER_KEY.inference - } - ) - ) - } - this.chatService?.streamTemplateCompletion(template) - } - - public getSelectedText = () => { - this.view?.webview.postMessage({ - type: EVENT_NAME.twinnyTextSelection, - value: { - type: WORKSPACE_STORAGE_KEY.selection, - completion: getTextSelection() - } - }) - } - - public openDiff = async (message: ClientMessage) => { - await this._diffManager.openDiff(message) - } - - public acceptSolution = async (message: ClientMessage) => { - await this._diffManager.acceptSolution(message) - } - - public createNewUntitledDocument = async (message: ClientMessage) => { - const lang = getLanguage() - const document = await vscode.workspace.openTextDocument({ - content: message.data as string, - language: lang.languageId - }) - await vscode.window.showTextDocument(document) - } - - public getGlobalContext = (message: ClientMessage) => { - const storedData = this._context?.globalState.get( - `${EVENT_NAME.twinnyGlobalContext}-${message.key}` - ) - this.view?.webview.postMessage({ - type: `${EVENT_NAME.twinnyGlobalContext}-${message.key}`, - value: storedData - }) - } - - public getTheme = () => { - this.view?.webview.postMessage({ - type: EVENT_NAME.twinnySendTheme, - value: { - data: getTheme() - } - }) - } - - public getCurrentLanguage = () => { - this.view?.webview.postMessage({ - type: EVENT_NAME.twinnySendLanguage, - value: { - data: getLanguage() - } - } as ServerMessage) - } - - public getGitCommitMessage = async () => { - const diff = await getGitChanges() - if (!diff.length) { - vscode.window.showInformationMessage( - 'No changes found in the current workspace.' - ) - return + localResourceRoots: [this.context?.extensionUri] } - this.conversationHistory?.resetConversation() - this.chatService?.streamTemplateCompletion( - 'commit-message', - diff, - (completion: string) => { - vscode.commands.executeCommand('twinny.sendTerminalText', completion) - }, - true - ) - } - public getSessionContext = (data: ClientMessage) => { - if (!data.key) return undefined - this.view?.webview.postMessage({ - type: `${EVENT_NAME.twinnySessionContext}-${data.key}`, - value: this._sessionManager.get(data.key) - }) - } + webviewView.webview.html = this.getHtmlForWebview(webviewView.webview) - public setGlobalContext = (message: ClientMessage) => { - this._context?.globalState.update( - `${EVENT_NAME.twinnyGlobalContext}-${message.key}`, - message.data - ) + this.registerWebView(webviewView.webview) } - public getTwinnyWorkspaceContext = (message: ClientMessage) => { - const storedData = this._context?.workspaceState.get( - `${EVENT_NAME.twinnyWorkspaceContext}-${message.key}` - ) - this.view?.webview.postMessage({ - type: `${EVENT_NAME.twinnyWorkspaceContext}-${message.key}`, - value: storedData - } as ServerMessage) - } - - public setWorkspaceContext = (message: ClientMessage) => { - const value = message.data - this._context.workspaceState.update( - `${EVENT_NAME.twinnyWorkspaceContext}-${message.key}`, - value - ) - this.view?.webview.postMessage({ - type: `${EVENT_NAME.twinnyWorkspaceContext}-${message.key}`, - value - }) - } - - public newConversation() { - this.symmetryService?.write( - createSymmetryMessage(SYMMETRY_DATA_MESSAGE.newConversation) - ) - } - - public destroyStream = () => { - this.chatService?.destroyStream() - this.view?.webview.postMessage({ - type: EVENT_NAME.twinnyStopGeneration - }) - } - - private connectToSymmetry = (data: ClientMessage) => { - if (this._config.symmetryServerKey) { - this.symmetryService?.connect( - this._config.symmetryServerKey, - data.data?.model_name, - data.data?.provider - ) - } - } - - private disconnectSymmetry = async () => { - if (this._config.symmetryServerKey) { - await this.symmetryService?.disconnect() - } - } - - public createSymmetryProvider = () => { - this.symmetryService?.startSymmetryProvider() - } - - public stopSymmetryProvider = () => { - this.symmetryService?.stopSymmetryProvider() - } - - private twinnyHideBackButton() { - vscode.commands.executeCommand(TWINNY_COMMAND_NAME.hideBackButton) - } - - private _getHtmlForWebview(webview: vscode.Webview) { + private getHtmlForWebview(webview: vscode.Webview) { const scriptUri = webview.asWebviewUri( - vscode.Uri.joinPath(this._context.extensionUri, 'out', 'sidebar.js') + vscode.Uri.joinPath(this.context.extensionUri, 'out', 'sidebar.js') ) const codiconCssUri = vscode.Uri.joinPath( - this._context.extensionUri, + this.context.extensionUri, 'assets', 'codicon.css' ) const css = webview.asWebviewUri( - vscode.Uri.joinPath(this._context.extensionUri, 'out', 'sidebar.css') + vscode.Uri.joinPath(this.context.extensionUri, 'out', 'sidebar.css') ) const codiconCssWebviewUri = webview.asWebviewUri(codiconCssUri) @@ -494,37 +53,27 @@ export class SidebarProvider implements vscode.WebviewViewProvider { return ` - - - - - - - Sidebar - - - -
- - + + + + + + + twinny + + + +
+ + ` } } - -function getNonce() { - let text = '' - const possible = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' - for (let i = 0; i < 32; i++) { - text += possible.charAt(Math.floor(Math.random() * possible.length)) - } - return text -} diff --git a/src/extension/session-manager.ts b/src/extension/session-manager.ts index f7126f8d..cd0f0db0 100644 --- a/src/extension/session-manager.ts +++ b/src/extension/session-manager.ts @@ -1,20 +1,20 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ export class SessionManager { - private sessionData: Map + private _sessionData: Map constructor() { - this.sessionData = new Map() + this._sessionData = new Map() } set(key: string, value: any): void { - this.sessionData.set(key, value) + this._sessionData.set(key, value) } get(key: string): any { - return this.sessionData.get(key) + return this._sessionData.get(key) } clear(): void { - this.sessionData.clear() + this._sessionData.clear() } } diff --git a/src/extension/symmetry-service.ts b/src/extension/symmetry-service.ts index bc339d94..91631cd2 100644 --- a/src/extension/symmetry-service.ts +++ b/src/extension/symmetry-service.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { workspace, commands, WebviewView, ExtensionContext } from 'vscode' +import { workspace, commands, ExtensionContext, Webview } from 'vscode' import Hyperswarm from 'hyperswarm' import crypto from 'hypercore-crypto' import { SymmetryProvider } from 'symmetry-core' @@ -40,28 +40,28 @@ import { SymmetryWs } from './symmetry-ws' export class SymmetryService extends EventEmitter { private _config = workspace.getConfiguration('twinny') - private view: WebviewView | undefined private _completion = '' - private _providerSwarm: undefined | typeof Hyperswarm - private _serverSwarm: undefined | typeof Hyperswarm - private _sessionManager: SessionManager - private _providerPeer: undefined | Peer - private _serverPeer: undefined | Peer private _context: ExtensionContext - private _providerTopic: Buffer | undefined private _emitterKey = '' private _provider: SymmetryProvider | undefined + private _providerPeer: undefined | Peer + private _providerSwarm: undefined | typeof Hyperswarm + private _providerTopic: Buffer | undefined + private _serverPeer: undefined | Peer + private _serverSwarm: undefined | typeof Hyperswarm + private _sessionManager: SessionManager | undefined private _symmetryProvider: string | undefined private _symmetryServerKey = this._config.symmetryServerKey - private ws: SymmetryWs | undefined + private _webView: Webview | undefined + private _ws: SymmetryWs | undefined constructor( - view: WebviewView | undefined, - sessionManager: SessionManager, + webView: Webview | undefined, + sessionManager: SessionManager | undefined, context: ExtensionContext ) { super() - this.view = view + this._webView = webView this._sessionManager = sessionManager this._providerSwarm this._providerPeer @@ -77,8 +77,8 @@ export class SymmetryService extends EventEmitter { this.updateConfig() }) - this.ws = new SymmetryWs(view) - this.ws.connectSymmetryWs() + this._ws = new SymmetryWs(this._webView) + this._ws.connectSymmetryWs() } public connect = async ( @@ -129,13 +129,13 @@ export class SymmetryService extends EventEmitter { } public disconnect = async () => { - this._sessionManager.set( + this._sessionManager?.set( EXTENSION_SESSION_NAME.twinnySymmetryConnection, undefined ) this._serverSwarm?.destroy() this._providerSwarm?.destroy() - this.view?.webview.postMessage({ + this._webView?.postMessage({ type: EVENT_NAME.twinnyDisconnectedFromSymmetry } as ServerMessage) } @@ -150,7 +150,7 @@ export class SymmetryService extends EventEmitter { this._providerSwarm.on('connection', (peer: any) => { this._providerPeer = peer this.providerListeners(peer) - this.view?.webview.postMessage({ + this._webView?.postMessage({ type: EVENT_NAME.twinnyConnectedToSymmetry, value: { data: { @@ -160,7 +160,7 @@ export class SymmetryService extends EventEmitter { } } }) - this.view?.webview.postMessage({ + this._webView?.postMessage({ type: EVENT_NAME.twinnySetTab, value: { data: WEBUI_TABS.chat @@ -209,7 +209,7 @@ export class SymmetryService extends EventEmitter { if (!this._completion) return if (this._emitterKey === SYMMETRY_EMITTER_KEY.inference) { - this.view?.webview.postMessage({ + this._webView?.postMessage({ type: EVENT_NAME.twinnyOnEnd, value: { completion: this._completion.trimStart() @@ -297,7 +297,7 @@ export class SymmetryService extends EventEmitter { const sessionTypeName = `${EVENT_NAME.twinnySessionContext}-${sessionKey}` - this.view?.webview.postMessage({ + this._webView?.postMessage({ type: sessionTypeName, value: 'connecting' }) @@ -306,7 +306,7 @@ export class SymmetryService extends EventEmitter { this._sessionManager?.set(sessionKey, 'connected') - this.view?.webview.postMessage({ + this._webView?.postMessage({ type: sessionTypeName, value: 'connected' }) @@ -314,7 +314,7 @@ export class SymmetryService extends EventEmitter { public async stopSymmetryProvider() { await this._provider?.destroySwarms() - updateSymmetryStatus(this.view, 'disconnected') + updateSymmetryStatus(this._webView, 'disconnected') const sessionKey = EXTENSION_SESSION_NAME.twinnySymmetryConnectionProvider this._sessionManager?.set(sessionKey, 'disconnected') } diff --git a/src/extension/symmetry-ws.ts b/src/extension/symmetry-ws.ts index 46ce09bd..2a4275f5 100644 --- a/src/extension/symmetry-ws.ts +++ b/src/extension/symmetry-ws.ts @@ -1,22 +1,23 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { WebSocket } from 'ws' import * as vscode from 'vscode' import { EVENT_NAME, URL_SYMMETRY_WS } from '../common/constants' export class SymmetryWs { - private ws: WebSocket | null = null - private view: vscode.WebviewView | undefined + private _ws: WebSocket | null = null + private _webView: vscode.Webview | undefined - constructor(view: vscode.WebviewView | undefined) { - this.view = view + constructor(view: vscode.Webview | undefined) { + this._webView = view } public connectSymmetryWs = () => { - this.ws = new WebSocket(URL_SYMMETRY_WS) + this._ws = new WebSocket(URL_SYMMETRY_WS) - this.ws.on('message', (data) => { + this._ws.on('message', (data: any) => { try { const parsedData = JSON.parse(data.toString()) - this.view?.webview.postMessage({ + this._webView?.postMessage({ type: EVENT_NAME.twinnySymmetryModeles, value: { data: parsedData?.allPeers @@ -27,14 +28,14 @@ export class SymmetryWs { } }) - this.ws.on('error', (error) => { + this._ws.on('error', (error: any) => { console.error('WebSocket error:', error) }) } public dispose() { - if (this.ws) { - this.ws.close() + if (this._ws) { + this._ws.close() } } } diff --git a/src/extension/template-provider.ts b/src/extension/template-provider.ts index 4c257eee..5b26511b 100644 --- a/src/extension/template-provider.ts +++ b/src/extension/template-provider.ts @@ -6,9 +6,9 @@ import { defaultTemplates } from './templates' import { DEFAULT_TEMPLATE_NAMES, SYSTEM } from '../common/constants' export class TemplateProvider { - private _basePath: string + private _basePath: string | undefined - constructor(basePath: string) { + constructor(basePath: string | undefined) { this._basePath = basePath } @@ -23,6 +23,7 @@ export class TemplateProvider { public createTemplateDir() { try { + if (!this._basePath) return const exists = fs.existsSync(this._basePath) if (!exists) { fs.mkdirSync(this._basePath, { recursive: true }) @@ -37,7 +38,7 @@ export class TemplateProvider { public copyDefaultTemplates() { try { defaultTemplates.forEach(({ name, template }) => { - const destFile = path.join(this._basePath, name) + const destFile = path.join(this._basePath || '', name) if (!fs.existsSync(`${destFile}.hbs`)) { fs.writeFileSync(`${destFile}.hbs`, template, 'utf8') } @@ -107,6 +108,7 @@ export class TemplateProvider { } public listTemplates(): string[] { + if (!this._basePath) return [] const files = fs.readdirSync(this._basePath, 'utf8') const templates = files.filter((fileName) => fileName.endsWith('.hbs')) const templateNames = templates diff --git a/src/extension/tree.ts b/src/extension/tree.ts index 9ff3dcb3..12551a37 100644 --- a/src/extension/tree.ts +++ b/src/extension/tree.ts @@ -1,40 +1,38 @@ import * as vscode from 'vscode' import * as fs from 'fs' import * as path from 'path' -import { minimatch } from 'minimatch' import ignore, { Ignore } from 'ignore' -import { EMBEDDING_IGNORE_LIST } from '../common/constants' -import { Logger } from '../common/logger' - -const logger = new Logger() +import { + getAllFilePaths, +} from './utils' export class FileTreeProvider { - private ignoreRules: Ignore - private workspaceRoot = '' + private _ignoreRules: Ignore + private _workspaceRoot = '' constructor() { - this.ignoreRules = this.setupIgnoreRules() + this._ignoreRules = this.setupIgnoreRules() const workspaceFolders = vscode.workspace.workspaceFolders if (!workspaceFolders) return - this.workspaceRoot = workspaceFolders[0].uri.fsPath + this._workspaceRoot = workspaceFolders[0].uri.fsPath } provideTextDocumentContent(): string { - return this.generateFileTree(this.workspaceRoot) + return this.generateFileTree(this._workspaceRoot) } getAllFiles = async (): Promise => { - return this.getAllFilePaths(this.workspaceRoot) + return getAllFilePaths(this._workspaceRoot) } private setupIgnoreRules(): Ignore { const ig = ignore() ig.add(['.git', '.git/**']) - const gitIgnorePath = path.join(this.workspaceRoot, '.gitignore') + const gitIgnorePath = path.join(this._workspaceRoot, '.gitignore') if (fs.existsSync(gitIgnorePath)) { const ignoreContent = fs.readFileSync(gitIgnorePath, 'utf8') const rules = ignoreContent @@ -53,10 +51,10 @@ export class FileTreeProvider { const filteredEntries = entries.filter((entry) => { const relativePath = path.relative( - this.workspaceRoot, + this._workspaceRoot, path.join(dir, entry.name) ) - return !this.ignoreRules.ignores(relativePath) + return !this._ignoreRules.ignores(relativePath) }) filteredEntries.forEach((entry, index) => { @@ -72,112 +70,4 @@ export class FileTreeProvider { return output } - - private readGitIgnoreFile(): string[] | undefined { - try { - const folders = vscode.workspace.workspaceFolders - if (!folders || folders.length === 0) { - console.log('No workspace folders found') - return undefined - } - - const rootPath = folders[0].uri.fsPath - if (!rootPath) { - console.log('Root path is undefined') - return undefined - } - - const gitIgnoreFilePath = path.join(rootPath, '.gitignore') - if (!fs.existsSync(gitIgnoreFilePath)) { - console.log('.gitignore file not found at', gitIgnoreFilePath) - return undefined - } - - const ignoreFileContent = fs.readFileSync(gitIgnoreFilePath, 'utf8') - return ignoreFileContent - .split('\n') - .map((line) => line.trim()) - .filter((line) => line !== '' && !line.startsWith('#')) - .map((pattern) => { - if (pattern.endsWith('/')) { - return pattern + '**' - } - return pattern - }) - } catch (e) { - console.error('Error reading .gitignore file:', e) - return undefined - } - } - - private readGitSubmodulesFile(): string[] | undefined { - try { - const folders = vscode.workspace.workspaceFolders - if (!folders || folders.length === 0) return undefined - const rootPath = folders[0].uri.fsPath - if (!rootPath) return undefined - const gitSubmodulesFilePath = path.join(rootPath, '.gitmodules') - if (!fs.existsSync(gitSubmodulesFilePath)) return undefined - const submodulesFileContent = fs - .readFileSync(gitSubmodulesFilePath) - .toString() - const submodulePaths: string[] = [] - submodulesFileContent.split('\n').forEach((line: string) => { - if (line.startsWith('\tpath = ')) { - submodulePaths.push(line.slice(8)) - } - }) - return submodulePaths - } catch (e) { - return undefined - } - } - - private getAllFilePaths = async (dirPath: string): Promise => { - if (!dirPath) return [] - let filePaths: string[] = [] - const dirents = await fs.promises.readdir(dirPath, { withFileTypes: true }) - const gitIgnoredFiles = this.readGitIgnoreFile() || [] - const submodules = this.readGitSubmodulesFile() - - const rootPath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '' - - for (const dirent of dirents) { - const fullPath = path.join(dirPath, dirent.name) - const relativePath = path.relative(rootPath, fullPath) - - if (this.getIgnoreDirectory(dirent.name)) continue - - if (submodules?.some((submodule) => fullPath.includes(submodule))) { - continue - } - - if ( - gitIgnoredFiles.some((pattern) => { - const isIgnored = - minimatch(relativePath, pattern, { dot: true, matchBase: true }) && - !pattern.startsWith('!') - if (isIgnored) { - logger.log(`Ignoring ${relativePath} due to pattern: ${pattern}`) - } - return isIgnored - }) - ) { - continue - } - - if (dirent.isDirectory()) { - filePaths = filePaths.concat(await this.getAllFilePaths(fullPath)) - } else if (dirent.isFile()) { - filePaths.push(fullPath) - } - } - return filePaths - } - - private getIgnoreDirectory(fileName: string): boolean { - return EMBEDDING_IGNORE_LIST.some((ignoreItem: string) => - fileName.includes(ignoreItem) - ) - } } diff --git a/src/extension/utils.ts b/src/extension/utils.ts index d3ac64f6..8321cfcf 100644 --- a/src/extension/utils.ts +++ b/src/extension/utils.ts @@ -7,12 +7,15 @@ import { Range, Terminal, TextDocument, - WebviewView, + Webview, window, workspace } from 'vscode' +import fs from 'fs' +import path from 'path' import * as util from 'util' import { exec } from 'child_process' +import { minimatch } from 'minimatch' const execAsync = util.promisify(exec) @@ -33,6 +36,7 @@ import { ALL_BRACKETS, CLOSING_BRACKETS, defaultChunkOptions, + EMBEDDING_IGNORE_LIST, EVENT_NAME, EXTENSION_CONTEXT_NAME, LINE_BREAK_REGEX, @@ -46,7 +50,7 @@ import { } from '../common/constants' import { Logger } from '../common/logger' import { SyntaxNode } from 'web-tree-sitter' -import { getParser } from './parser-utils' +import { getParser } from './parser' const logger = new Logger() @@ -591,10 +595,10 @@ function simpleChunk(content: string, options: ChunkOptions): string[] { } export const updateLoadingMessage = ( - view: WebviewView | undefined, + webView: Webview | undefined, message: string ) => { - view?.webview.postMessage({ + webView?.postMessage({ type: EVENT_NAME.twinnySendLoader, value: { data: message @@ -603,10 +607,10 @@ export const updateLoadingMessage = ( } export const updateSymmetryStatus = ( - view: WebviewView | undefined, + webView: Webview | undefined, message: string ) => { - view?.webview.postMessage({ + webView?.postMessage({ type: EVENT_NAME.twinnySendSymmetryMessage, value: { data: message @@ -614,6 +618,124 @@ export const updateSymmetryStatus = ( } as ServerMessage) } +export function getNonce() { + let text = '' + const possible = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + for (let i = 0; i < 32; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)) + } + return text +} + +export function readGitSubmodulesFile(): string[] | undefined { + try { + const folders = workspace.workspaceFolders + if (!folders || folders.length === 0) return undefined + const rootPath = folders[0].uri.fsPath + if (!rootPath) return undefined + const gitSubmodulesFilePath = path.join(rootPath, '.gitmodules') + if (!fs.existsSync(gitSubmodulesFilePath)) return undefined + const submodulesFileContent = fs + .readFileSync(gitSubmodulesFilePath) + .toString() + const submodulePaths: string[] = [] + submodulesFileContent.split('\n').forEach((line: string) => { + if (line.startsWith('\tpath = ')) { + submodulePaths.push(line.slice(8)) + } + }) + return submodulePaths + } catch (e) { + return undefined + } +} + +export async function getAllFilePaths(dirPath: string): Promise { + if (!dirPath) return [] + let filePaths: string[] = [] + const dirents = await fs.promises.readdir(dirPath, { withFileTypes: true }) + const gitIgnoredFiles = readGitIgnoreFile() || [] + const submodules = readGitSubmodulesFile() + + const rootPath = workspace.workspaceFolders?.[0]?.uri.fsPath || '' + + for (const dirent of dirents) { + const fullPath = path.join(dirPath, dirent.name) + const relativePath = path.relative(rootPath, fullPath) + + if (getIgnoreDirectory(dirent.name)) continue + + if (submodules?.some((submodule) => fullPath.includes(submodule))) { + continue + } + + if ( + gitIgnoredFiles.some((pattern) => { + const isIgnored = + minimatch(relativePath, pattern, { dot: true, matchBase: true }) && + !pattern.startsWith('!') + if (isIgnored) { + logger.log(`Ignoring ${relativePath} due to pattern: ${pattern}`) + } + return isIgnored + }) + ) { + continue + } + + if (dirent.isDirectory()) { + filePaths = filePaths.concat(await getAllFilePaths(fullPath)) + } else if (dirent.isFile()) { + filePaths.push(fullPath) + } + } + return filePaths +} + +export function getIgnoreDirectory(fileName: string): boolean { + return EMBEDDING_IGNORE_LIST.some((ignoreItem: string) => + fileName.includes(ignoreItem) + ) +} + +export function readGitIgnoreFile(): string[] | undefined { + try { + const folders = workspace.workspaceFolders + if (!folders || folders.length === 0) { + console.log('No workspace folders found') + return undefined + } + + const rootPath = folders[0].uri.fsPath + if (!rootPath) { + console.log('Root path is undefined') + return undefined + } + + const gitIgnoreFilePath = path.join(rootPath, '.gitignore') + if (!fs.existsSync(gitIgnoreFilePath)) { + console.log('.gitignore file not found at', gitIgnoreFilePath) + return undefined + } + + const ignoreFileContent = fs.readFileSync(gitIgnoreFilePath, 'utf8') + return ignoreFileContent + .split('\n') + .map((line) => line.trim()) + .filter((line) => line !== '' && !line.startsWith('#')) + .map((pattern) => { + if (pattern.endsWith('/')) { + return pattern + '**' + } + return pattern + }) + } catch (e) { + console.error('Error reading .gitignore file:', e) + return undefined + } +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any export const logStreamOptions = (opts: any) => { logger.log( diff --git a/src/index.ts b/src/index.ts index 8f5ffabd..eb1a0337 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,16 +9,16 @@ import { import * as path from 'path' import * as os from 'os' import * as fs from 'fs' -import { EmbeddingDatabase } from './extension/embeddings' import * as vscode from 'vscode' import { CompletionProvider } from './extension/providers/completion' import { SidebarProvider } from './extension/providers/sidebar' import { SessionManager } from './extension/session-manager' +import { EmbeddingDatabase } from './extension/embeddings' import { delayExecution, getTerminal, - getSanitizedCommitMessage, + getSanitizedCommitMessage } from './extension/utils' import { setContext } from './extension/context' import { @@ -26,19 +26,17 @@ import { EXTENSION_NAME, EVENT_NAME, WEBUI_TABS, - TWINNY_COMMAND_NAME, + TWINNY_COMMAND_NAME } from './common/constants' import { TemplateProvider } from './extension/template-provider' -import { - ServerMessage -} from './common/types' +import { ServerMessage } from './common/types' import { FileInteractionCache } from './extension/file-interaction' import { getLineBreakCount } from './webview/utils' export async function activate(context: ExtensionContext) { setContext(context) const config = workspace.getConfiguration('twinny') - const statusBar = window.createStatusBarItem(StatusBarAlignment.Right) + const statusBarItem = window.createStatusBarItem(StatusBarAlignment.Right) const templateDir = path.join(os.homedir(), '.twinny/templates') as string const templateProvider = new TemplateProvider(templateDir) const fileInteractionCache = new FileInteractionCache() @@ -57,18 +55,18 @@ export async function activate(context: ExtensionContext) { } const sidebarProvider = new SidebarProvider( - statusBar, + statusBarItem, context, templateDir, db, - sessionManager, + sessionManager ) const completionProvider = new CompletionProvider( - statusBar, + statusBarItem, fileInteractionCache, templateProvider, - context, + context ) templateProvider.init() @@ -79,10 +77,10 @@ export async function activate(context: ExtensionContext) { completionProvider ), commands.registerCommand(TWINNY_COMMAND_NAME.enable, () => { - statusBar.show() + statusBarItem.show() }), commands.registerCommand(TWINNY_COMMAND_NAME.disable, () => { - statusBar.hide() + statusBarItem.hide() }), commands.registerCommand(TWINNY_COMMAND_NAME.explain, () => { commands.executeCommand(TWINNY_COMMAND_NAME.focusSidebar) @@ -138,7 +136,7 @@ export async function activate(context: ExtensionContext) { EXTENSION_CONTEXT_NAME.twinnyManageProviders, true ) - sidebarProvider.view?.webview.postMessage({ + sidebarProvider.webView?.postMessage({ type: EVENT_NAME.twinnySetTab, value: { data: WEBUI_TABS.providers @@ -153,7 +151,7 @@ export async function activate(context: ExtensionContext) { EXTENSION_CONTEXT_NAME.twinnySymmetryTab, true ) - sidebarProvider.view?.webview.postMessage({ + sidebarProvider.webView?.postMessage({ type: EVENT_NAME.twinnySetTab, value: { data: WEBUI_TABS.symmetry @@ -169,7 +167,7 @@ export async function activate(context: ExtensionContext) { EXTENSION_CONTEXT_NAME.twinnyConversationHistory, true ) - sidebarProvider.view?.webview.postMessage({ + sidebarProvider.webView?.postMessage({ type: EVENT_NAME.twinnySetTab, value: { data: WEBUI_TABS.history @@ -183,7 +181,7 @@ export async function activate(context: ExtensionContext) { EXTENSION_CONTEXT_NAME.twinnyManageTemplates, true ) - sidebarProvider.view?.webview.postMessage({ + sidebarProvider.webView?.postMessage({ type: EVENT_NAME.twinnySetTab, value: { data: WEBUI_TABS.settings @@ -214,7 +212,7 @@ export async function activate(context: ExtensionContext) { }), commands.registerCommand(TWINNY_COMMAND_NAME.openChat, () => { commands.executeCommand(TWINNY_COMMAND_NAME.hideBackButton) - sidebarProvider.view?.webview.postMessage({ + sidebarProvider.webView?.postMessage({ type: EVENT_NAME.twinnySetTab, value: { data: WEBUI_TABS.chat @@ -242,15 +240,10 @@ export async function activate(context: ExtensionContext) { commands.registerCommand(TWINNY_COMMAND_NAME.newConversation, () => { sidebarProvider.conversationHistory?.resetConversation() sidebarProvider.newConversation() - sidebarProvider.view?.webview.postMessage({ + sidebarProvider.webView?.postMessage({ type: EVENT_NAME.twinnyStopGeneration } as ServerMessage) }), - window.registerWebviewViewProvider('twinny.sidebar', sidebarProvider), - statusBar - ) - - context.subscriptions.push( workspace.onDidCloseTextDocument((document) => { const filePath = document.uri.fsPath fileInteractionCache.endSession() @@ -277,7 +270,13 @@ export async function activate(context: ExtensionContext) { const currentLine = changes.range.start.line const currentCharacter = changes.range.start.character fileInteractionCache.incrementStrokes(currentLine, currentCharacter) - }) + }), + workspace.onDidChangeConfiguration((event) => { + if (!event.affectsConfiguration('twinny')) return + completionProvider.updateConfig() + }), + window.registerWebviewViewProvider('twinny.sidebar', sidebarProvider), + statusBarItem ) window.onDidChangeTextEditorSelection(() => { @@ -287,14 +286,7 @@ export async function activate(context: ExtensionContext) { }, 200) }) - context.subscriptions.push( - workspace.onDidChangeConfiguration((event) => { - if (!event.affectsConfiguration('twinny')) return - completionProvider.updateConfig() - }) - ) - - if (config.get('enabled')) statusBar.show() + if (config.get('enabled')) statusBarItem.show() - statusBar.text = '$(code)' + statusBarItem.text = '$(code)' } diff --git a/src/webview/conversation-history.module.css b/src/styles/conversation-history.module.css similarity index 100% rename from src/webview/conversation-history.module.css rename to src/styles/conversation-history.module.css diff --git a/src/webview/index.module.css b/src/styles/index.module.css similarity index 98% rename from src/webview/index.module.css rename to src/styles/index.module.css index f37798f5..816ab2f1 100644 --- a/src/webview/index.module.css +++ b/src/styles/index.module.css @@ -90,10 +90,10 @@ pre code { overflow-x: hidden; padding-top: 20px; font-size: 14px; + margin-top: 20px; } .title { - background-color: var(--vscode-diffEditor-unchangedRegionBackground); color: var(--vscode-textPreformat-foreground); display: block; font-weight: 600; diff --git a/src/webview/providers.module.css b/src/styles/providers.module.css similarity index 100% rename from src/webview/providers.module.css rename to src/styles/providers.module.css diff --git a/src/webview/symmetry.module.css b/src/styles/symmetry.module.css similarity index 100% rename from src/webview/symmetry.module.css rename to src/styles/symmetry.module.css diff --git a/src/webview/chat.tsx b/src/webview/chat.tsx index 108eb8a7..7a1e17f7 100644 --- a/src/webview/chat.tsx +++ b/src/webview/chat.tsx @@ -6,10 +6,10 @@ import { VSCodeBadge, VSCodeDivider } from '@vscode/webview-ui-toolkit/react' -import { useEditor, EditorContent, Extension, Editor, JSONContent } from '@tiptap/react' +import { useEditor, EditorContent, Editor, JSONContent } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Placeholder from '@tiptap/extension-placeholder' -import Mention, { MentionPluginKey } from '@tiptap/extension-mention' +import Mention from '@tiptap/extension-mention' import { ASSISTANT, @@ -43,37 +43,11 @@ import { ServerMessage } from '../common/types' import { Message } from './message' -import { getCompletionContent } from './utils' +import { CustomKeyMap, getCompletionContent } from './utils' import { ProviderSelect } from './provider-select' import { EmbeddingOptions } from './embedding-options' -import ChatLoader from './chat-loader' -import styles from './index.module.css' - -const CustomKeyMap = Extension.create({ - name: 'chatKeyMap', - - addKeyboardShortcuts() { - return { - Enter: ({ editor }) => { - const mentionState = MentionPluginKey.getState(editor.state) - if (mentionState && mentionState.active) { - return false - } - this.options.handleSubmitForm() - this.options.clearEditor() - return true - }, - 'Mod-Enter': ({ editor }) => { - editor.commands.insertContent('\n') - return true - }, - 'Shift-Enter': ({ editor }) => { - editor.commands.insertContent('\n') - return true - } - } - } -}) +import ChatLoader from './loader' +import styles from '../styles/index.module.css' // eslint-disable-next-line @typescript-eslint/no-explicit-any const global = globalThis as any @@ -222,7 +196,6 @@ export const Chat = () => { } as ClientMessage) setCompletion(null) setIsLoading(false) - setMessages([]) generatingRef.current = false setTimeout(() => { chatRef.current?.focus() @@ -441,7 +414,10 @@ export const Chat = () => { }, suggestion: memoizedSuggestion, renderText({ node }) { - return `${node.attrs.name ?? node.attrs.id}` + if (node.attrs.name) { + return `${node.attrs.name ?? node.attrs.id}` + } + return node.attrs.id ?? '' } }), CustomKeyMap.configure({ diff --git a/src/webview/code-block.tsx b/src/webview/code-block.tsx index a815cf3f..ed398371 100644 --- a/src/webview/code-block.tsx +++ b/src/webview/code-block.tsx @@ -4,10 +4,9 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism' import { ASSISTANT, EVENT_NAME } from '../common/constants' - -import styles from './index.module.css' import { LanguageType, Theme, ThemeType } from '../common/types' import { getLanguageMatch } from './utils' +import styles from '../styles/index.module.css' interface CodeBlockProps { className?: string diff --git a/src/webview/conversation-history.tsx b/src/webview/conversation-history.tsx index b90f45c4..029b2985 100644 --- a/src/webview/conversation-history.tsx +++ b/src/webview/conversation-history.tsx @@ -1,6 +1,7 @@ import { VSCodeButton } from '@vscode/webview-ui-toolkit/react' + import { Conversation } from '../common/types' -import styles from './conversation-history.module.css' +import styles from '../styles/conversation-history.module.css' import { useConversationHistory } from './hooks' import { EVENT_NAME } from '../common/constants' diff --git a/src/webview/embedding-options.tsx b/src/webview/embedding-options.tsx index 2c8edbca..95e0d3b0 100644 --- a/src/webview/embedding-options.tsx +++ b/src/webview/embedding-options.tsx @@ -5,6 +5,8 @@ import { VSCodeOption, VSCodeTextField } from '@vscode/webview-ui-toolkit/react' +import { FormEvent } from 'react' +import { TextFieldType } from '@vscode/webview-ui-toolkit' import { EMBEDDING_METRICS, @@ -13,9 +15,7 @@ import { } from '../common/constants' import { ClientMessage } from '../common/types' import { useGlobalContext, useProviders } from './hooks' -import styles from './index.module.css' -import { FormEvent } from 'react' -import { TextFieldType } from '@vscode/webview-ui-toolkit' +import styles from '../styles/index.module.css' // eslint-disable-next-line @typescript-eslint/no-explicit-any const global = globalThis as any diff --git a/src/webview/global.d.ts b/src/webview/global.d.ts deleted file mode 100644 index 6c46fed3..00000000 --- a/src/webview/global.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -declare module '*.css' - -declare module '*.css' { - const content: Record - export default content -} - -interface Window { - acquireVsCodeApi: () => { - getState: () => T - setState: (data: T) => void - postMessage: (msg: unknown) => void - } -} diff --git a/src/webview/hooks.ts b/src/webview/hooks.ts index 7ec293f7..31e24a06 100644 --- a/src/webview/hooks.ts +++ b/src/webview/hooks.ts @@ -1,6 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { RefAttributes, useEffect, useRef, useState } from 'react' import tippy, { Instance as TippyInstance } from 'tippy.js' +import { SuggestionKeyDownProps, SuggestionProps } from '@tiptap/suggestion' +import { MentionNodeAttrs } from '@tiptap/extension-mention' +import { ReactRenderer } from '@tiptap/react' import { CONVERSATION_EVENT_NAME, @@ -22,10 +25,7 @@ import { ThemeType } from '../common/types' import { TwinnyProvider } from '../extension/provider-manager' -import { ReactRenderer } from '@tiptap/react' -import { AtList, AtListProps, AtListRef } from './mention-list' -import { SuggestionKeyDownProps, SuggestionProps } from '@tiptap/suggestion' -import { MentionNodeAttrs } from '@tiptap/extension-mention' +import { MentionList, MentionListProps, MentionListRef } from './mention-list' // eslint-disable-next-line @typescript-eslint/no-explicit-any const global = globalThis as any @@ -111,7 +111,7 @@ export const useWorkSpaceContext = (key: string) => { const handler = (event: MessageEvent) => { const message: ServerMessage = event.data - if (message?.type === `${EVENT_NAME.twinnyWorkspaceContext}-${key}`) { + if (message?.type === `${EVENT_NAME.twinnyGetWorkspaceContext}-${key}`) { setContext(event.data.value) } } @@ -119,7 +119,7 @@ export const useWorkSpaceContext = (key: string) => { useEffect(() => { window.addEventListener('message', handler) global.vscode.postMessage({ - type: EVENT_NAME.twinnyWorkspaceContext, + type: EVENT_NAME.twinnyGetWorkspaceContext, key }) @@ -536,14 +536,14 @@ export const useSuggestion = () => { }, render: () => { let reactRenderer: ReactRenderer< - AtListRef, - AtListProps & RefAttributes + MentionListRef, + MentionListProps & RefAttributes > let popup: TippyInstance[] return { onStart: (props: SuggestionProps) => { - reactRenderer = new ReactRenderer(AtList, { + reactRenderer = new ReactRenderer(MentionList, { props, editor: props.editor }) @@ -564,7 +564,7 @@ export const useSuggestion = () => { onUpdate(props: SuggestionProps) { reactRenderer.updateProps(props) - if (popup && popup.length) { + if (popup) { popup[0].setProps({ getReferenceClientRect: props.clientRect as () => DOMRect }) @@ -572,7 +572,7 @@ export const useSuggestion = () => { }, onKeyDown(props: SuggestionKeyDownProps) { - if (props.event.key === 'Escape' && popup && popup.length) { + if (props.event.key === 'Escape') { popup[0].hide() return true } @@ -583,7 +583,7 @@ export const useSuggestion = () => { }, onExit() { - if (popup && popup.length) { + if (popup) { popup[0].destroy() reactRenderer.destroy() } diff --git a/src/webview/chat-loader.tsx b/src/webview/loader.tsx similarity index 99% rename from src/webview/chat-loader.tsx rename to src/webview/loader.tsx index 13523716..27e50ede 100644 --- a/src/webview/chat-loader.tsx +++ b/src/webview/loader.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' + import { ASSISTANT } from '../common/constants' import { useLoading, useTheme } from './hooks' import { Message } from './message' diff --git a/src/webview/main.tsx b/src/webview/main.tsx index 5d654d3a..b994603d 100644 --- a/src/webview/main.tsx +++ b/src/webview/main.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' + import { Chat } from './chat' import { Settings } from './settings' import { ServerMessage } from '../common/types' diff --git a/src/webview/mention-list.tsx b/src/webview/mention-list.tsx index d0b3c107..c12e92da 100644 --- a/src/webview/mention-list.tsx +++ b/src/webview/mention-list.tsx @@ -1,96 +1,90 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import styles from './index.module.css' import cx from 'classnames' -import { - forwardRef, - useEffect, - useImperativeHandle, - useState -} from 'react' +import { forwardRef, useEffect, useImperativeHandle, useState } from 'react' import { Editor } from '@tiptap/core' -import { FileItem } from '../common/types' +import { MentionNodeAttrs } from '@tiptap/extension-mention' -export interface MentionNodeAttrs { - id: string - label: string -} +import { FileItem } from '../common/types' +import styles from '../styles/index.module.css' -export interface AtListProps { +export interface MentionListProps { items: FileItem[] command: (attrs: MentionNodeAttrs) => void editor: Editor range: Range } -export interface AtListRef { +export interface MentionListRef { onKeyDown: (props: { event: KeyboardEvent }) => boolean } -export const AtList = forwardRef((props, ref) => { - const [selectedIndex, setSelectedIndex] = useState(0) +export const MentionList = forwardRef( + (props, ref) => { + const [selectedIndex, setSelectedIndex] = useState(0) - const selectItem = (index: number) => { - const item = props.items[index] + const selectItem = (index: number) => { + const item = props.items[index] - if (item) { - props.command({ id: item.path, label: item.name }) + if (item) { + props.command({ id: item.path, label: item.name }) + } } - } - const upHandler = () => { - setSelectedIndex( - (selectedIndex + props.items.length - 1) % props.items.length - ) - } + const upHandler = () => { + setSelectedIndex( + (selectedIndex + props.items.length - 1) % props.items.length + ) + } - const downHandler = () => { - setSelectedIndex((selectedIndex + 1) % props.items.length) - } + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % props.items.length) + } - const enterHandler = () => { - selectItem(selectedIndex) - } + const enterHandler = () => { + selectItem(selectedIndex) + } - useEffect(() => setSelectedIndex(0), [props.items]) + useEffect(() => setSelectedIndex(0), [props.items]) - useImperativeHandle(ref, () => ({ - onKeyDown: ({ event }: { event: KeyboardEvent }) => { - if (event.key === 'ArrowUp') { - upHandler() - return true - } + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }: { event: KeyboardEvent }) => { + if (event.key === 'ArrowUp') { + upHandler() + return true + } - if (event.key === 'ArrowDown') { - downHandler() - return true - } + if (event.key === 'ArrowDown') { + downHandler() + return true + } - if (event.key === 'Enter') { - enterHandler() - return true - } + if (event.key === 'Enter') { + enterHandler() + return true + } - return false - } - })) + return false + } + })) - return ( -
- {props.items.length ? ( - props.items.map((item: FileItem, index: number) => ( - - )) - ) : ( -
No result
- )} -
- ) -}) + return ( +
+ {props.items.length ? ( + props.items.map((item: FileItem, index: number) => ( + + )) + ) : ( +
No result
+ )} +
+ ) + } +) diff --git a/src/webview/message.tsx b/src/webview/message.tsx index cddf4ac5..7c18c9fb 100644 --- a/src/webview/message.tsx +++ b/src/webview/message.tsx @@ -7,9 +7,9 @@ import StarterKit from '@tiptap/starter-kit' import { Markdown as TiptapMarkdown } from 'tiptap-markdown' import CodeBlock from './code-block' -import styles from './index.module.css' import { Message as MessageType, ThemeType } from '../common/types' import { ASSISTANT, TWINNY, YOU } from '../common/constants' +import styles from '../styles/index.module.css' interface MessageProps { conversationLength?: number @@ -111,7 +111,6 @@ export const Message: React.FC = React.memo( return ( @@ -119,7 +118,7 @@ export const Message: React.FC = React.memo( } return
{children}
}, - [message?.role, message?.language, theme] + [message?.role, theme] ) const renderCode = useCallback( diff --git a/src/webview/provider-select.tsx b/src/webview/provider-select.tsx index 6e7cc4fa..ade5d31a 100644 --- a/src/webview/provider-select.tsx +++ b/src/webview/provider-select.tsx @@ -3,8 +3,8 @@ import { VSCodeOption, } from '@vscode/webview-ui-toolkit/react' -import styles from './providers.module.css' import { useProviders } from './hooks' +import styles from '../styles/providers.module.css' export const ProviderSelect = () => { const { diff --git a/src/webview/providers.tsx b/src/webview/providers.tsx index 56a72720..e9991c23 100644 --- a/src/webview/providers.tsx +++ b/src/webview/providers.tsx @@ -8,15 +8,15 @@ import { VSCodePanelView, VSCodeTextField } from '@vscode/webview-ui-toolkit/react' -import { apiProviders } from '../common/types' -import styles from './providers.module.css' +import { apiProviders } from '../common/types' import { TwinnyProvider } from '../extension/provider-manager' import { DEFAULT_PROVIDER_FORM_VALUES, FIM_TEMPLATE_FORMAT } from '../common/constants' import { ModelSelect } from './model-select' +import styles from '../styles/providers.module.css' export const Providers = () => { const [showForm, setShowForm] = React.useState(false) diff --git a/src/webview/settings.tsx b/src/webview/settings.tsx index 8052a0eb..ba1107de 100644 --- a/src/webview/settings.tsx +++ b/src/webview/settings.tsx @@ -1,12 +1,12 @@ import { VSCodeButton, VSCodeCheckbox } from '@vscode/webview-ui-toolkit/react' + import { useTemplates, useWorkSpaceContext } from './hooks' import { DEFAULT_ACTION_TEMPLATES, WORKSPACE_STORAGE_KEY } from '../common/constants' import { kebabToSentence } from './utils' - -import styles from './index.module.css' +import styles from '../styles/index.module.css' export const Settings = () => { const { templates, saveTemplates } = useTemplates() diff --git a/src/webview/suggestion.tsx b/src/webview/suggestion.tsx index 3805d364..452c7a04 100644 --- a/src/webview/suggestion.tsx +++ b/src/webview/suggestion.tsx @@ -1,15 +1,15 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { RefAttributes } from 'react' import { ReactRenderer } from '@tiptap/react' import tippy, { Instance as TippyInstance } from 'tippy.js' import { SuggestionProps, SuggestionKeyDownProps } from '@tiptap/suggestion' +import { MentionNodeAttrs } from '@tiptap/extension-mention' import { - AtList, - AtListProps, - AtListRef, - MentionNodeAttrs + MentionList, + MentionListProps, + MentionListRef, } from './mention-list' -import { RefAttributes } from 'react' export const getSuggestions = (fileList: string[]) => ({ items: ({ query }: { query: string }): string[] => { @@ -20,14 +20,14 @@ export const getSuggestions = (fileList: string[]) => ({ render: () => { let component: ReactRenderer< - AtListRef, - AtListProps & RefAttributes + MentionListRef, + MentionListProps & RefAttributes > let popup: TippyInstance[] return { onStart: (props: SuggestionProps) => { - component = new ReactRenderer(AtList, { + component = new ReactRenderer(MentionList, { props, editor: props.editor }) diff --git a/src/webview/suggestions.tsx b/src/webview/suggestions.tsx index 3871a2c6..bceeeb83 100644 --- a/src/webview/suggestions.tsx +++ b/src/webview/suggestions.tsx @@ -1,10 +1,10 @@ -import { WORKSPACE_STORAGE_KEY, EVENT_NAME } from '../common/constants' +import { useEffect, useState } from 'react' import cn from 'classnames' -import styles from './index.module.css' import { useTemplates, useWorkSpaceContext } from './hooks' import { kebabToSentence } from './utils' -import { useEffect, useState } from 'react' +import { WORKSPACE_STORAGE_KEY, EVENT_NAME } from '../common/constants' +import styles from '../styles/index.module.css' // eslint-disable-next-line @typescript-eslint/no-explicit-any const global = globalThis as any diff --git a/src/webview/symmetry.tsx b/src/webview/symmetry.tsx index 2252053d..f3aa9490 100644 --- a/src/webview/symmetry.tsx +++ b/src/webview/symmetry.tsx @@ -1,5 +1,4 @@ import React, { useEffect } from 'react' -import { useSymmetryConnection } from './hooks' import { VSCodeButton, VSCodePanelView, @@ -9,7 +8,8 @@ import { VSCodeOption } from '@vscode/webview-ui-toolkit/react' -import styles from './symmetry.module.css' +import { useSymmetryConnection } from './hooks' +import styles from '../styles/symmetry.module.css' export const Symmetry = () => { const { diff --git a/src/webview/utils.ts b/src/webview/utils.ts index faa057c2..09ffdcbc 100644 --- a/src/webview/utils.ts +++ b/src/webview/utils.ts @@ -1,3 +1,6 @@ +import { Extension } from '@tiptap/react' +import { MentionPluginKey } from '@tiptap/extension-mention' + import { EMPTY_MESAGE } from '../common/constants' import { CodeLanguage, supportedLanguages } from '../common/languages' import { LanguageType, ServerMessage } from '../common/types' @@ -60,3 +63,30 @@ export const getModelShortName = (name: string) => { } return name } + + +export const CustomKeyMap = Extension.create({ + name: 'chatKeyMap', + + addKeyboardShortcuts() { + return { + Enter: ({ editor }) => { + const mentionState = MentionPluginKey.getState(editor.state) + if (mentionState && mentionState.active) { + return false + } + this.options.handleSubmitForm() + this.options.clearEditor() + return true + }, + 'Mod-Enter': ({ editor }) => { + editor.commands.insertContent('\n') + return true + }, + 'Shift-Enter': ({ editor }) => { + editor.commands.insertContent('\n') + return true + } + } + } +})