✨ feat(vscode): 新增「了解一块代码」:AI 找功能代码占一栏,对话里接着改

主栏标题栏的放大镜问要了解哪一块,占一个栏位建一栏(和收藏共用 30 个栏位,视图改名 slot1..30,按 slotKindN 分收藏 / 功能挂标题栏按钮),同时开对话页让 Claude 只读地在全部工作区文件夹里找,按 --json-schema 交回环节 → 文件 + 符号 + 一句话。
对话复用预览顶部的聊天(CodeQa.askTop:会话续接、进行中的一轮、记录存 workspaceState);结构不合格算这一轮没答成、留着能再画出来。
条目每次画、点时按眼下的代码找:有语言插件的解析成代码块按「类名.方法名」找,没有的问 VS Code 文档符号;找不到画成灰的。
This commit is contained in:
cruldra committed 2026-10-12 15:48:54 +11:00
1 parent 16d27004d8
commit 880bf7fc57
38 files changed
+3386 -378

No files matched your search

+21 -27
View File
@@ -1,13 +1,13 @@
import type { CustomTextEditorProvider, Disposable, DocumentSymbol, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode'
import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode'
import type { ClaudeImage } from '../../cc/spawnClaude'
import type { IndexFind } from '../callTree/endpoint'
import type { CommentEdit, CommentJob, WriteResult } from '../commentRewrite'
import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, SourceRange } from '../contract'
import type { BlockKind, LocalSymbol, RuleLocation, RuleReference } from '../contract'
import type { DiffBaseSource } from '../diff/diffBase'
import type { OldDocument, SentDocument } from '../diff/previewDiff'
import type { WorkspaceEntries } from '../entries/workspaceEntries'
import type { DefinitionStart, IndexedName, NameSources } from '../nameResolution'
import type { CodeQa } from '../qa/codeQa'
import type { CodeQa, TopRequest } from '../qa/codeQa'
import type { CodeReaderRegistry } from '../registry'
import type { LookupSources } from '../ruleLookups'
import type { TreeSitterRuntime } from '../runtime'
@@ -16,6 +16,7 @@ import type { BlockFilter } from './blockFilter'
import type { FoundLocation } from './definitions'
import type { DefinitionPeek, ExtensionToWebview, Occurrence, Reveal, WebviewToExtension } from './messages'
import type { TargetSpot } from './openTarget'
import type { TopSubject } from './pathPreview'
import { realpath } from 'node:fs/promises'
import { commands, EndOfLine, Position, Range, Uri, window, workspace, WorkspaceEdit } from 'vscode'
import { logger } from '../../logging/logger'
@@ -38,10 +39,11 @@ import { definitionSiteNames, namesOnCard, peekBlock } from './definitionPeek'
import { definitionKind, findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions'
import { DefinitionSources } from './definitionSources'
import { documentMessage } from './documentMessage'
import { documentSymbolNodes } from './documentSymbols'
import { NavigationContext, OCCURRENCES_CONTEXT } from './occurrenceNavigation'
import { occurrencesOf } from './occurrences'
import { languageOf, openingFor } from './openTarget'
import { pathPreviewOf } from './pathPreview'
import { PATH_PREVIEW_SCHEME, topSubjectOf } from './pathPreview'
import { findRepoSymbol, isRepoImagePath, repoRootOf, resolveRepoEntry } from './repoPath'
export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview'
@@ -81,6 +83,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
readonly entries: Pick<WorkspaceEntries, 'namesOf' | 'onDidChange'>,
/** 在代码阅读侧栏的树上定位一个目录(仓库链接指到目录时);树上没有它时返回 false */
readonly revealInTree: (uri: Uri) => Promise<boolean>,
/** 「了解一块代码」的对话页里接着说(subject 是那个功能,见 featureSubjectUri);说不了时返回给人看的原因 */
readonly askFeature: (subject: Uri, request: TopRequest) => Promise<string | undefined>,
) {}
/** 侧栏「问 AI」打开预览,并把光标放进顶部输入框。已经开着的就揭到前面再聚焦。 */
@@ -103,7 +107,7 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void {
// 问答带的图片(工作区文件夹的 .spx/code-qa/)也要能读,历史里才画得出缩略图
const imageRoot = this.qa.imageRoot(pathPreviewOf(document.uri)?.uri ?? document.uri)
const imageRoot = this.qa.imageRoot(topSubjectOf(document.uri).uri)
panel.webview.options = { enableScripts: true, localResourceRoots: [webviewUiRoot(this.extensionUri), ...(imageRoot ? [imageRoot] : [])] }
panel.webview.html = webviewHtml(panel.webview, this.extensionUri, 'code-reader.html')
const key = document.uri.toString()
@@ -339,7 +343,8 @@ class Preview {
return
const request = ++this.latest
this.callTrees.cancel()
if (pathPreviewOf(this.document.uri)) {
// 路径页、功能对话页:文档是空的,只在顶部聊天
if (this.document.uri.scheme === PATH_PREVIEW_SCHEME) {
this.sent = undefined
const reveal = this.pendingReveal
this.pendingReveal = undefined
@@ -497,9 +502,9 @@ class Preview {
this.post(this.owner.qa.view(subject.uri, file => this.panel.webview.asWebviewUri(file).toString(), subject.kind))
}
/** 这一页问的是哪个文件或文件夹。路径页还原成真实路径,源码预览就是文档自己。 */
private subject(): { uri: Uri, kind: 'file' | 'folder' } {
return pathPreviewOf(this.document.uri) ?? { uri: this.document.uri, kind: 'file' }
/** 这一页顶部问的是什么(见 topSubjectOf)。 */
private subject(): { uri: Uri, kind: TopSubject } {
return topSubjectOf(this.document.uri)
}
/** 把光标放进顶部输入框;页面还没准备好就等 ready。 */
@@ -526,7 +531,10 @@ class Preview {
private async ask(askId: string, anchor: string, question: string, images: ClaudeImage[]): Promise<void> {
const subject = this.subject()
if (anchor === PATH_ASK_ANCHOR) {
const refused = await this.owner.qa.askPath(subject.uri, subject.kind, { askId, question, images })
const request = { askId, question, images }
const refused = subject.kind === 'feature'
? await this.owner.askFeature(subject.uri, request)
: await this.owner.qa.askPath(subject.uri, subject.kind, request)
if (refused !== undefined)
this.post({ type: 'answerEnd', askId, status: 'failed', error: refused })
else
@@ -808,7 +816,7 @@ class Preview {
const root = await repoRootOf(this.document.uri.fsPath)
const asked = new Map<string, Promise<SymbolNode[]>>()
const symbolsOf = (uri: string): Promise<SymbolNode[]> => {
const known = asked.get(uri) ?? this.symbolNodes(uri)
const known = asked.get(uri) ?? documentSymbolNodes(uri)
asked.set(uri, known)
return known
}
@@ -919,29 +927,15 @@ class Preview {
/** 用文档符号按名字找(`Agent`、`Agent.owner_id`);没有这个名字就没有,不打开到文件顶上。 */
private async locateRepoSymbol(uri: Uri, symbol: string): Promise<LinkTarget | undefined> {
const found = findRepoSymbol(await this.symbolNodes(uri.toString()), symbol)
const found = findRepoSymbol(await documentSymbolNodes(uri.toString()), symbol)
return found && { uri: uri.toString(), selection: found.selection }
}
/** 一个文件的文档符号(vscode.executeDocumentSymbolProvider):先把文件读进来(不显示),范围换成那个文件里的下标。 */
private async symbolNodes(uri: string): Promise<SymbolNode[]> {
const doc = await workspace.openTextDocument(Uri.parse(uri))
const symbols = await commands.executeCommand<DocumentSymbol[] | undefined>('vscode.executeDocumentSymbolProvider', doc.uri)
const span = (range: Range): SourceRange => ({ startIndex: doc.offsetAt(range.start), endIndex: doc.offsetAt(range.end) })
const node = (symbol: DocumentSymbol): SymbolNode => ({
name: symbol.name,
range: span(symbol.range),
selection: span(symbol.selectionRange),
children: (symbol.children ?? []).map(node),
})
return Array.isArray(symbols) ? symbols.map(node) : []
}
/**
* 注释里不带路径的引用(`[Name]`、`[Class.member]`)指到哪:本文件定义的优先,再是导入进来的(见 symbolLinks.ts)。
* 文档符号同仓库链接找符号那一套(symbols,一批引用一起问时共用一份),导入的名字问语言扩展它的定义(同跳到定义),定义几处取第一处。
*/
private async resolveSymbolLink(symbol: string, symbols: (uri: string) => Promise<SymbolNode[]> = uri => this.symbolNodes(uri)): Promise<LinkTarget | undefined> {
private async resolveSymbolLink(symbol: string, symbols: (uri: string) => Promise<SymbolNode[]> = documentSymbolNodes): Promise<LinkTarget | undefined> {
const { document } = this
try {
return await resolveReference(symbol, this.imports, {