feat(plugin): VSCode extension (#717)
Co-authored-by: Neko <neko@ayaka.moe>
This commit is contained in:
Vendored
+12
@@ -25,6 +25,18 @@
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Debug VSCode Extension",
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}/plugins/airi-plugin-vscode"
|
||||
],
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/plugins/airi-plugin-vscode/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "npm: build - plugins/airi-plugin-vscode"
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface WebSocketEvents<C = undefined> {
|
||||
'input:voice': {
|
||||
audio: ArrayBuffer
|
||||
} & Partial<WithInputSource<'browser' | 'discord'>>
|
||||
'vscode:context': C
|
||||
}
|
||||
|
||||
export type WebSocketEvent<C = undefined> = {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"publisher": "airi",
|
||||
"name": "@proj-airi/airi-plugin-vscode",
|
||||
"displayName": "Airi",
|
||||
"version": "0.1.0",
|
||||
"description": "VSCode extension that shares your coding context with Airi",
|
||||
"categories": [
|
||||
"Other"
|
||||
],
|
||||
"main": "./dist/extension.js",
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
},
|
||||
"activationEvents": [
|
||||
"onStartupFinished"
|
||||
],
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "airi.enable",
|
||||
"title": "AIRI: Enable"
|
||||
},
|
||||
{
|
||||
"command": "airi.disable",
|
||||
"title": "AIRI: Disable"
|
||||
},
|
||||
{
|
||||
"command": "airi.status",
|
||||
"title": "AIRI: Show Status"
|
||||
}
|
||||
],
|
||||
"configuration": {
|
||||
"title": "AIRI",
|
||||
"properties": {
|
||||
"airi.enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable Airi companion"
|
||||
},
|
||||
"airi.contextLines": {
|
||||
"type": "number",
|
||||
"default": 5,
|
||||
"description": "Number of context lines to send (before and after current line)"
|
||||
},
|
||||
"airi.sendInterval": {
|
||||
"type": "number",
|
||||
"default": 3000,
|
||||
"description": "Interval in milliseconds to send updates (0 for real-time)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"vscode:prepublish": "pnpm run build",
|
||||
"build": "tsdown",
|
||||
"dev": "tsdown --watch",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@guiiai/logg": "catalog:",
|
||||
"@proj-airi/server-sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/vscode": "^1.85.0",
|
||||
"tsdown": "^0.15.2",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { AiriEvent } from './types'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { Client } from '@proj-airi/server-sdk'
|
||||
|
||||
/**
|
||||
* Airi Channel Server Client
|
||||
*/
|
||||
export class AiriClient {
|
||||
private client: Client<AiriEvent> | null = null
|
||||
|
||||
/**
|
||||
* Connect to Channel Server
|
||||
*/
|
||||
async connect(): Promise<boolean> {
|
||||
try {
|
||||
this.client = new Client({ name: 'proj-airi:plugin-vscode' })
|
||||
|
||||
useLogger().log('Airi companion connected to Channel Server')
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
useLogger().errorWithError('Failed to connect to Airi Channel Server:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from Channel Server
|
||||
*/
|
||||
disconnect(): void {
|
||||
if (this.client) {
|
||||
this.client.close()
|
||||
this.client = null
|
||||
useLogger().log('Airi companion disconnected')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send event to Airi
|
||||
*/
|
||||
sendEvent(event: AiriEvent): void {
|
||||
if (!this.client) {
|
||||
useLogger().warn('Cannot send event: not connected to Airi Channel Server')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Send event to Airi
|
||||
this.client.send({
|
||||
type: 'vscode:context',
|
||||
data: event,
|
||||
})
|
||||
|
||||
useLogger().log(`Sent event to Airi: ${event.type}`, event)
|
||||
}
|
||||
catch (error) {
|
||||
useLogger().errorWithError('Failed to send event to Airi:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is connected to Channel Server
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return !!this.client
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { CodingContext } from './types'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
|
||||
import * as vscode from 'vscode'
|
||||
|
||||
/**
|
||||
* Collector for coding context in VSCode
|
||||
*/
|
||||
export class ContextCollector {
|
||||
constructor(
|
||||
private readonly contextLines: number = 5,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Collect context from the current active editor
|
||||
*/
|
||||
async collect(editor: vscode.TextEditor): Promise<CodingContext | null> {
|
||||
try {
|
||||
const document = editor.document
|
||||
const position = editor.selection.active
|
||||
|
||||
// File information
|
||||
const file = {
|
||||
path: document.uri.fsPath,
|
||||
languageId: document.languageId,
|
||||
fileName: document.fileName,
|
||||
workspaceFolder: this.getWorkspaceFolder(document.uri),
|
||||
}
|
||||
|
||||
// Cursor position
|
||||
const cursor = {
|
||||
line: position.line,
|
||||
character: position.character,
|
||||
}
|
||||
|
||||
// Selected text
|
||||
const selection = editor.selection.isEmpty
|
||||
? undefined
|
||||
: {
|
||||
text: document.getText(editor.selection),
|
||||
start: {
|
||||
line: editor.selection.start.line,
|
||||
character: editor.selection.start.character,
|
||||
},
|
||||
end: {
|
||||
line: editor.selection.end.line,
|
||||
character: editor.selection.end.character,
|
||||
},
|
||||
}
|
||||
|
||||
// Current line
|
||||
const currentLine = {
|
||||
lineNumber: position.line,
|
||||
text: document.lineAt(position.line).text,
|
||||
}
|
||||
|
||||
// Context (N lines before and after)
|
||||
const context = this.getContext(document, position.line)
|
||||
|
||||
// Git information (simplified, can be extended later)
|
||||
const git = await this.getGitInfo(document.uri)
|
||||
|
||||
return {
|
||||
file,
|
||||
cursor,
|
||||
selection,
|
||||
currentLine,
|
||||
context,
|
||||
git,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
useLogger().errorWithError('Failed to collect context:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get context before and after the current line
|
||||
*/
|
||||
private getContext(document: vscode.TextDocument, currentLine: number) {
|
||||
const before: string[] = []
|
||||
const after: string[] = []
|
||||
|
||||
// Get preceding lines
|
||||
const startLine = Math.max(0, currentLine - this.contextLines)
|
||||
for (let i = startLine; i < currentLine; i++) {
|
||||
before.push(document.lineAt(i).text)
|
||||
}
|
||||
|
||||
// Get following lines
|
||||
const endLine = Math.min(document.lineCount - 1, currentLine + this.contextLines)
|
||||
for (let i = currentLine + 1; i <= endLine; i++) {
|
||||
after.push(document.lineAt(i).text)
|
||||
}
|
||||
|
||||
return { before, after }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workspace folder path
|
||||
*/
|
||||
private getWorkspaceFolder(uri: vscode.Uri): string | undefined {
|
||||
const folder = vscode.workspace.getWorkspaceFolder(uri)
|
||||
return folder?.uri.fsPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Git information (simplified)
|
||||
*/
|
||||
private async getGitInfo(uri: vscode.Uri): Promise<{ branch: string, isDirty: boolean } | undefined> {
|
||||
try {
|
||||
const gitExtension = vscode.extensions.getExtension('vscode.git')?.exports
|
||||
if (!gitExtension)
|
||||
return undefined
|
||||
|
||||
const git = gitExtension.getAPI(1)
|
||||
const repo = git.getRepository(uri)
|
||||
if (!repo)
|
||||
return undefined
|
||||
|
||||
return {
|
||||
branch: repo.state.HEAD?.name ?? 'unknown',
|
||||
isDirty: repo.state.workingTreeChanges.length > 0,
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import type * as vscode from 'vscode'
|
||||
|
||||
import { initLogger, LoggerFormat, LoggerLevel, useLogger } from '@guiiai/logg'
|
||||
|
||||
import { AiriClient } from './airi-client'
|
||||
import { ContextCollector } from './context-collector'
|
||||
|
||||
let airiClient: AiriClient
|
||||
let contextCollector: ContextCollector
|
||||
let updateTimer: NodeJS.Timeout | null = null
|
||||
let isEnabled = true
|
||||
let eventListeners: vscode.Disposable[] = []
|
||||
|
||||
/**
|
||||
* Activate the plugin
|
||||
*/
|
||||
export async function activate(context: vscode.ExtensionContext) {
|
||||
initLogger(LoggerLevel.Debug, LoggerFormat.Pretty)
|
||||
|
||||
const { window, workspace, commands } = await import('vscode')
|
||||
|
||||
useLogger().log('Airi Companion is activating...')
|
||||
|
||||
// Get the configuration
|
||||
const config = workspace.getConfiguration('airi.companion')
|
||||
isEnabled = config.get<boolean>('enabled', true)
|
||||
const contextLines = config.get<number>('contextLines', 5)
|
||||
const sendInterval = config.get<number>('sendInterval', 3000)
|
||||
|
||||
// Initialize
|
||||
airiClient = new AiriClient()
|
||||
contextCollector = new ContextCollector(contextLines)
|
||||
|
||||
// Connect to Airi Channel Server
|
||||
if (isEnabled) {
|
||||
const connected = await airiClient.connect()
|
||||
if (connected) {
|
||||
window.showInformationMessage('Airi Companion connected!')
|
||||
}
|
||||
else {
|
||||
window.showWarningMessage('Airi Companion failed to connect to server')
|
||||
}
|
||||
}
|
||||
|
||||
// Register commands
|
||||
context.subscriptions.push(
|
||||
commands.registerCommand('airi.companion.enable', async () => {
|
||||
isEnabled = true
|
||||
await airiClient.connect()
|
||||
await registerListeners(sendInterval)
|
||||
window.showInformationMessage('Airi Companion enabled')
|
||||
}),
|
||||
|
||||
commands.registerCommand('airi.companion.disable', () => {
|
||||
isEnabled = false
|
||||
unregisterListeners()
|
||||
airiClient.disconnect()
|
||||
window.showInformationMessage('Airi Companion disabled')
|
||||
}),
|
||||
|
||||
commands.registerCommand('airi.companion.status', () => {
|
||||
const status = isEnabled && airiClient ? 'Connected' : 'Disconnected'
|
||||
window.showInformationMessage(`Airi Companion Status: ${status}`)
|
||||
}),
|
||||
)
|
||||
|
||||
// Register event listeners if enabled
|
||||
if (isEnabled) {
|
||||
await registerListeners(sendInterval)
|
||||
}
|
||||
|
||||
useLogger().log('Airi Companion activated successfully')
|
||||
}
|
||||
|
||||
/**
|
||||
* Register event listeners for file save and editor switch
|
||||
*/
|
||||
async function registerListeners(sendInterval: number) {
|
||||
unregisterListeners()
|
||||
|
||||
const { window, workspace } = await import('vscode')
|
||||
|
||||
// File save event
|
||||
eventListeners.push(
|
||||
workspace.onDidSaveTextDocument(async (document) => {
|
||||
const editor = window.activeTextEditor
|
||||
if (editor && editor.document === document) {
|
||||
const ctx = await contextCollector.collect(editor)
|
||||
if (ctx) {
|
||||
airiClient.sendEvent({
|
||||
type: 'coding:save',
|
||||
data: ctx,
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// Switch file event
|
||||
eventListeners.push(
|
||||
window.onDidChangeActiveTextEditor(async (editor) => {
|
||||
if (editor) {
|
||||
const ctx = await contextCollector.collect(editor)
|
||||
if (ctx) {
|
||||
airiClient.sendEvent({
|
||||
type: 'coding:switch-file',
|
||||
data: ctx,
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// Start periodic monitoring if interval is set
|
||||
if (sendInterval > 0) {
|
||||
startMonitoring(sendInterval)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister all event listeners
|
||||
*/
|
||||
function unregisterListeners() {
|
||||
eventListeners.forEach(listener => listener.dispose())
|
||||
eventListeners = []
|
||||
stopMonitoring()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start monitoring the coding context
|
||||
*/
|
||||
function startMonitoring(interval: number) {
|
||||
stopMonitoring()
|
||||
|
||||
updateTimer = setInterval(async () => {
|
||||
if (!isEnabled)
|
||||
return
|
||||
|
||||
const { window } = await import('vscode')
|
||||
const editor = window.activeTextEditor
|
||||
if (!editor)
|
||||
return
|
||||
|
||||
const ctx = await contextCollector.collect(editor)
|
||||
if (ctx) {
|
||||
airiClient.sendEvent({
|
||||
type: 'coding:context',
|
||||
data: ctx,
|
||||
})
|
||||
}
|
||||
}, interval)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop monitoring
|
||||
*/
|
||||
function stopMonitoring() {
|
||||
if (updateTimer) {
|
||||
clearInterval(updateTimer)
|
||||
updateTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate the plugin
|
||||
*/
|
||||
export function deactivate() {
|
||||
unregisterListeners()
|
||||
airiClient?.disconnect()
|
||||
useLogger().log('Airi Companion deactivated')
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Coding context information
|
||||
*/
|
||||
export interface CodingContext {
|
||||
/** File information */
|
||||
file: {
|
||||
path: string
|
||||
languageId: string
|
||||
fileName: string
|
||||
workspaceFolder?: string
|
||||
}
|
||||
/** Cursor position */
|
||||
cursor: {
|
||||
line: number
|
||||
character: number
|
||||
}
|
||||
/** Selected text */
|
||||
selection?: {
|
||||
text: string
|
||||
start: { line: number, character: number }
|
||||
end: { line: number, character: number }
|
||||
}
|
||||
/** Current line */
|
||||
currentLine: {
|
||||
lineNumber: number
|
||||
text: string
|
||||
}
|
||||
/** Context (previous and next N lines) */
|
||||
context: {
|
||||
before: string[]
|
||||
after: string[]
|
||||
}
|
||||
/** Git information */
|
||||
git?: {
|
||||
branch: string
|
||||
isDirty: boolean
|
||||
}
|
||||
/** Timestamp */
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Event types sent to Airi
|
||||
*/
|
||||
export interface AiriEvent {
|
||||
type: 'coding:context' | 'coding:save' | 'coding:switch-file'
|
||||
data: CodingContext
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ESNEXT",
|
||||
"lib": ["ESNEXT"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['./src/extension.ts'],
|
||||
format: 'cjs',
|
||||
platform: 'node',
|
||||
external: ['vscode'],
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
dts: false,
|
||||
},
|
||||
])
|
||||
Generated
+397
-438
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user