From 077a6f2bc79735d7508b92e22587836338146e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A4=9C?= <97166212+xiaoye6688@users.noreply.github.com> Date: Fri, 2 Jan 2026 00:17:54 +0800 Subject: [PATCH] feat: add real-time code selection indicator - Display selected code info (filename, line range) above chat input - Listen to editor selection changes and sync to WebView - Fix alien-signals reactivity: replace Vue watch with alien effect - Use absolute positioning to avoid layout shifts --- src/extension.ts | 37 ++++++++++++++++ src/webview/src/composables/useRuntime.ts | 20 ++++----- src/webview/src/core/Session.ts | 11 ++++- src/webview/src/pages/ChatPage.vue | 54 +++++++++++++++++++++++ 4 files changed, 109 insertions(+), 13 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index fcf04a9..678a17a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -59,11 +59,48 @@ export function activate(context: vscode.ExtensionContext) { // Set transport on Claude Agent Service claudeAgentService.setTransport(transport); + // 监听选区变化并通知 WebView + const sendSelectionUpdate = (editor?: vscode.TextEditor) => { + const selection = editor?.selection; + if (!editor || !selection || selection.isEmpty || editor.document.uri.scheme !== 'file') { + transport.send({ + type: 'request', + requestId: Math.random().toString(36).slice(2), + request: { type: 'selection_changed', selection: null } + }); + return; + } + const document = editor.document; + transport.send({ + type: 'request', + requestId: Math.random().toString(36).slice(2), + request: { + type: 'selection_changed', + selection: { + filePath: document.uri.fsPath, + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + startColumn: selection.start.character, + endColumn: selection.end.character + } + } + }); + }; + + const selectionDisposable = vscode.window.onDidChangeTextEditorSelection((event) => { + sendSelectionUpdate(event.textEditor); + }); + const activeEditorDisposable = vscode.window.onDidChangeActiveTextEditor((editor) => { + sendSelectionUpdate(editor); + }); + // Start message loop claudeAgentService.start(); // Register disposables context.subscriptions.push(webviewProvider); + context.subscriptions.push(selectionDisposable); + context.subscriptions.push(activeEditorDisposable); context.subscriptions.push( vscode.commands.registerCommand('claudix.openSettings', async () => { await instantiationService.invokeFunction(accessorInner => { diff --git a/src/webview/src/composables/useRuntime.ts b/src/webview/src/composables/useRuntime.ts index 17738d6..cf6ba54 100644 --- a/src/webview/src/composables/useRuntime.ts +++ b/src/webview/src/composables/useRuntime.ts @@ -1,4 +1,4 @@ -import { onMounted, onUnmounted, watch } from 'vue'; +import { onMounted, onUnmounted } from 'vue'; import { signal, effect } from 'alien-signals'; import { EventEmitter } from '../utils/events'; import { ConnectionManager } from '../core/ConnectionManager'; @@ -23,18 +23,13 @@ export function useRuntime(): RuntimeInstance { const appContext = new AppContext(connectionManager); // 创建 alien-signal 用于 SessionContext - // AppContext.currentSelection 是 Vue Ref,但 SessionContext 需要 alien-signal + // AppContext.currentSelection 是 alien-signal,SessionContext 也需要 alien-signal const currentSelectionSignal = signal(undefined); - // 双向同步 Vue Ref ↔ Alien Signal - // Vue Ref → Alien Signal - watch( - () => appContext.currentSelection(), - (newValue) => { - currentSelectionSignal(newValue); - }, - { immediate: true } - ); + // 使用 alien-signals 的 effect 同步信号(Vue watch 无法追踪 alien-signal) + const cleanupSelectionSync = effect(() => { + currentSelectionSignal(appContext.currentSelection()); + }); const sessionStore = new SessionStore(connectionManager, { commandRegistry: appContext.commandRegistry, @@ -47,7 +42,7 @@ export function useRuntime(): RuntimeInstance { }); selectionEvents.add((selection) => { - appContext.currentSelection(selection); + appContext.currentSelection(selection ?? undefined); }); // SessionStore 内部的 effect 会自动监听 connection 建立并拉取会话列表 @@ -122,6 +117,7 @@ export function useRuntime(): RuntimeInstance { // 清理命令注册 slashCommandDisposers.forEach(dispose => dispose()); cleanupSlashCommands(); + cleanupSelectionSync(); connectionManager.close(); }); diff --git a/src/webview/src/core/Session.ts b/src/webview/src/core/Session.ts index af48ec7..1bad08b 100644 --- a/src/webview/src/core/Session.ts +++ b/src/webview/src/core/Session.ts @@ -196,7 +196,7 @@ export class Session { async send( input: string, attachments: AttachmentPayload[] = [], - includeSelection = false + includeSelection = true ): Promise { const connection = await this.getConnection(); @@ -207,6 +207,15 @@ export class Session { // 启动 channel(确保已带上当前 thinkingLevel) await this.launchClaude(); + if (includeSelection && !isSlash) { + try { + const selection = await connection.getCurrentSelection(); + this.selection(selection?.selection ?? undefined); + } catch (error) { + console.warn('[Session] Failed to fetch current selection', error); + } + } + const shouldIncludeSelection = includeSelection && !isSlash; let selectionPayload: SelectionRange | undefined; diff --git a/src/webview/src/pages/ChatPage.vue b/src/webview/src/pages/ChatPage.vue index bc7fbb3..6244dc9 100644 --- a/src/webview/src/pages/ChatPage.vue +++ b/src/webview/src/pages/ChatPage.vue @@ -59,6 +59,10 @@ :on-resolve="handleResolvePermission" data-permission-panel="1" /> +
+ + {{ selectionInfo }} +
permissionRequests.value.length); const pendingPermission = computed(() => permissionRequests.value[0] as any); const platform = computed(() => runtime.appContext.platform); + const selectionInfo = computed(() => { + const sel = session.value?.selection.value; + if (!sel?.filePath) return ''; + const fileName = sel.filePath.split(/[\\/]/).pop() || sel.filePath; + const start = sel.startLine; + const end = sel.endLine ?? sel.startLine; + if (start && end) { + const lineCount = Math.max(1, end - start + 1); + return `${fileName} #${start}-${end} (${lineCount} 行)`; + } + return fileName; + }); // 注册命令:permissionMode.toggle(在下方定义函数后再注册) @@ -515,9 +531,47 @@ /* 输入区域容器 */ .inputContainer { + position: relative; padding: 8px 12px 12px; } + .selection-indicator { + position: absolute; + bottom: 100%; + left: 12px; + display: inline-flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; + padding: 4px 8px; + border: 1px solid var(--vscode-panel-border); + border-radius: 4px; + background: var(--vscode-editor-background); + color: var(--vscode-textLink-foreground); + font-size: 11px; + line-height: 1.4; + box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1); + z-index: 10; + } + + .selection-indicator-icon { + font-size: 12px; + } + + .selection-indicator-text { + font-family: var( + --app-monospace-font-family, + ui-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + 'Liberation Mono', + 'Courier New', + monospace + ); + } + /* 底部对话框区域钉在底部 */ .main > :last-child { flex-shrink: 0;