feat(plugin): VSCode extension (#717)

Co-authored-by: Neko <neko@ayaka.moe>
This commit is contained in:
RainbowBird
2025-11-04 15:13:59 +08:00
committed by GitHub
co-authored by Neko
parent c7cdb478de
commit 8c43127c22
10 changed files with 931 additions and 438 deletions
+12
View File
@@ -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> = {
+69
View File
@@ -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
}
}
}
+171
View File
@@ -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')
}
+48
View File
@@ -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
}
+19
View File
@@ -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,
},
])
+397 -438
View File
File diff suppressed because it is too large Load Diff