✨ 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

@@ -0,0 +1,476 @@
/**
* 「了解一块代码」的侧栏:主栏标题栏的放大镜问要了解哪一块,占一个栏位(和收藏共用)建一栏,同时让 AI 在整个工作区里找;
* 对话放在这个功能的对话页上(预览顶部的聊天,记录、进行中的那一轮都归 CodeQa 管),接着说就按上次的会话改、整栏换成新结果。
* 栏里是 AI 交回的各个环节和条目;条目每次画、每次点时按眼下的代码找位置,找不到的画成灰的。
*/
import type { Disposable, FileDecorationProvider, Memento, TreeDataProvider, TreeView, WorkspaceFolder } from 'vscode'
import type { LanguageAdapter, SourceRange } from '../contract'
import type { TargetSpot } from '../editor/openTarget'
import type { FileNode } from '../explorer/fileTree'
import type { Answer } from '../qa/claudeAsk'
import type { AskEvent, CodeQa, TopRequest } from '../qa/codeQa'
import type { TreeSitterRuntime } from '../runtime'
import type { SlotPool } from '../slots/slotPool'
import type { FeatureGroup, FeatureItem, FeatureResult } from './featureResult'
import type { Feature, FeatureRun } from './features'
import { randomUUID } from 'node:crypto'
import { EventEmitter, FileDecoration, MarkdownString, Range, TabInputCustom, ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri, window, workspace } from 'vscode'
import { documentSymbolNodes } from '../editor/documentSymbols'
import { featureIdOf, featurePageUri, featureSubjectUri } from '../editor/pathPreview'
import { findRepoSymbol } from '../editor/repoPath'
import { entryContext, GROUP_CONTEXT } from '../explorer/fileEntries'
import { PATH_ASK_ANCHOR } from '../qa/pathQa'
import { askSlotName, focusSlot } from '../slots/slotPool'
import { SLOTS, slotTitle, slotViewId } from '../slots/slots'
import { locateInSource } from './featureLocate'
import { featureAnswer, featureFollowUp, featurePrompt, featureSchema, parseFeatureResult } from './featureResult'
import { FEATURE_MARKER, featureMessage, itemRow, MISSING_CONTEXT, parseFeatures, removeFeature, renameFeature, withResult } from './features'
/** workspaceState:「了解一块代码」找过的功能(Feature 的数组),按工作区记。 */
const FEATURES_KEY = 'superpowers.codeReader.features'
/** 点栏里一条的命令:参数是这一行。 */
export const OPEN_FEATURE_ITEM = 'superpowers.codeReader.openFeatureItem'
/** 找不到的条目行挂的 Uri 的协议:MissingRows 把它的字画成灰的,真文件在别处的颜色不受影响。 */
const MISSING_SCHEME = 'code-reader-feature-missing'
/** 文件改了、增删了,合并成一次重找。 */
const REFRESH_DELAY_MS = 300
/** 栏位满了时说的话 */
const FULL_MESSAGE = `栏位满了(收藏和「了解一块代码」共用,最多 ${SLOTS.length} 个),先取消一个收藏或删掉一个功能再找`
/** 符号行的图标:按语言插件认出的块的种类;问 VS Code 文档符号找到的不知道种类,用方法的图标。 */
const SYMBOL_ICONS: Record<string, string> = {
class: 'symbol-class',
exception: 'symbol-class',
impl: 'symbol-class',
trait: 'symbol-interface',
enum: 'symbol-enum',
module: 'symbol-module',
const: 'symbol-constant',
variable: 'symbol-variable',
function: 'symbol-method',
}
/** 一条按眼下的代码找到的地方:文件,有符号时再加符号名字的位置(spot)和块的种类(kind,语言插件认出来的才有)。 */
export interface FoundItem {
uri: Uri
folder: WorkspaceFolder
spot?: TargetSpot
kind?: string
}
/** 找条目用的语言插件:这种语言的插件(没有时 undefined)和解析器。 */
export interface ItemLanguages {
adapterFor: (languageId: string) => LanguageAdapter | undefined
runtime: Pick<TreeSitterRuntime, 'parse'>
}
/** 分组行(一个环节) */
interface GroupRow {
slot: number
group: FeatureGroup
}
/** 找得到的条目行:同侧栏文件行一样带着文件(FileNode),行菜单(问 AI、复制、在系统文件管理器中显示)照常用。 */
type FoundRow = FileNode & { slot: number, item: FeatureItem, kind?: string }
/** 找不到的条目行 */
interface MissingRow {
slot: number
item: FeatureItem
missing: true
}
export type FeatureRow = GroupRow | FoundRow | MissingRow
/** 条目在磁盘上是哪个文件:工作区文件夹按名字找;那个文件夹不在了时没有。 */
function itemUri(item: FeatureItem): { uri: Uri, folder: WorkspaceFolder } | undefined {
const folder = workspace.workspaceFolders?.find(candidate => candidate.name === item.folder)
return folder && { uri: Uri.joinPath(folder.uri, ...item.path.split('/')), folder }
}
/**
* 按眼下的代码找一条在哪:只有文件的看文件还在不在;有符号的,这种语言有语言插件就解析成代码块按 `类名.方法名` 找(见 locateInSource),
* 没有插件就问 VS Code 的文档符号。文件、符号没有了时没有。
*/
export async function locateItem(item: FeatureItem, languages: ItemLanguages): Promise<FoundItem | undefined> {
const at = itemUri(item)
if (!at || !await workspace.fs.stat(at.uri).then(() => true, () => false))
return undefined
if (item.symbol === undefined)
return at
const { symbol } = item
try {
const doc = await workspace.openTextDocument(at.uri)
const adapter = languages.adapterFor(doc.languageId)
let found: { range: SourceRange, kind?: string } | undefined
if (adapter) {
const block = await locateInSource(languages.runtime, adapter, doc.languageId, doc.getText(), symbol)
found = block && { range: block.range, kind: block.kind }
}
else {
const node = findRepoSymbol(await documentSymbolNodes(at.uri.toString()), symbol)
found = node && { range: node.selection }
}
if (!found)
return undefined
const range = new Range(doc.positionAt(found.range.startIndex), doc.positionAt(found.range.endIndex))
return { ...at, spot: { range, offset: found.range.startIndex }, ...(found.kind !== undefined && { kind: found.kind }) }
}
catch {
// 打不开成文本(二进制文件)
return undefined
}
}
/** 找不到的条目行画成灰字:只认 MISSING_SCHEME 的 Uri。 */
class MissingRows implements FileDecorationProvider {
provideFileDecoration(uri: Uri): FileDecoration | undefined {
return uri.scheme === MISSING_SCHEME ? new FileDecoration(undefined, '找不到了', new ThemeColor('disabledForeground')) : undefined
}
}
/** 一个功能栏里的树:环节 → 条目。条目每次画时按眼下的代码找位置。 */
class FeatureTree implements TreeDataProvider<FeatureRow> {
private readonly changed = new EventEmitter<void>()
readonly onDidChangeTreeData = this.changed.event
constructor(private readonly slot: number, private result: FeatureResult | undefined, private readonly locate: (item: FeatureItem) => Promise<FoundItem | undefined>) {}
/** 换成这次的结果,整棵树重画。 */
show(result: FeatureResult): void {
this.result = result
this.changed.fire()
}
/** 按眼下的代码重找各条,整棵树重画。 */
refresh(): void {
this.changed.fire()
}
/** 有一条是不是这个文件 */
covers(uri: Uri): boolean {
const key = uri.toString()
return this.result?.groups.some(group => group.items.some(item => itemUri(item)?.uri.toString() === key)) ?? false
}
async getChildren(row?: FeatureRow): Promise<FeatureRow[]> {
if (!row)
return this.result?.groups.map(group => ({ slot: this.slot, group })) ?? []
if (!('group' in row))
return []
return Promise.all(row.group.items.map(async (item): Promise<FeatureRow> => {
const found = await this.locate(item)
if (!found)
return { slot: this.slot, item, missing: true }
return { slot: this.slot, item, uri: found.uri, folder: found.folder, isDirectory: false, isRoot: false, ...(found.kind !== undefined && { kind: found.kind }) }
}))
}
getTreeItem(row: FeatureRow): TreeItem {
if ('group' in row) {
const item = new TreeItem(row.group.name, TreeItemCollapsibleState.Expanded)
item.contextValue = GROUP_CONTEXT
return item
}
const found = !('missing' in row)
const { label, description, tooltip } = itemRow(row.item, found)
const item = new TreeItem(label, TreeItemCollapsibleState.None)
item.description = description
item.tooltip = new MarkdownString(tooltip)
item.command = { command: OPEN_FEATURE_ITEM, title: '打开', arguments: [row] }
if (!found) {
item.contextValue = MISSING_CONTEXT
item.resourceUri = Uri.from({ scheme: MISSING_SCHEME, path: `/${row.item.path}` })
item.iconPath = new ThemeIcon('circle-slash', new ThemeColor('disabledForeground'))
return item
}
item.contextValue = entryContext(row, FEATURE_MARKER)
if (row.item.symbol === undefined) {
// 只有文件的条目:文件图标、git 改动的颜色由 VS Code 按文件加
item.resourceUri = row.uri
item.iconPath = ThemeIcon.File
}
else {
item.iconPath = new ThemeIcon(SYMBOL_ICONS[row.kind ?? 'function'] ?? 'symbol-method')
}
return item
}
dispose(): void {
this.changed.dispose()
}
}
/** 一个功能栏:标题是放大镜加名字,栏上方一句话说正在找、没找成、没有结果(见 featureMessage)。占上栏位时建、删掉时销毁。 */
class FeaturePane {
private readonly tree: FeatureTree
private readonly view: TreeView<FeatureRow>
private result: FeatureResult | undefined
private run: FeatureRun | undefined
constructor(feature: Feature, locate: (item: FeatureItem) => Promise<FoundItem | undefined>) {
this.result = feature.result
this.tree = new FeatureTree(feature.slot, feature.result, locate)
this.view = window.createTreeView(slotViewId(feature.slot), { treeDataProvider: this.tree, showCollapseAll: true })
this.view.title = slotTitle('feature', feature.name)
this.view.message = featureMessage(this.result, this.run)
}
rename(name: string): void {
this.view.title = slotTitle('feature', name)
}
/** AI 交回了新结果:整栏换成它。 */
show(result: FeatureResult): void {
this.result = result
this.tree.show(result)
this.view.message = featureMessage(this.result, this.run)
}
/** 正在找、没找成、停下了、没在找(undefined)。 */
setRun(run: FeatureRun | undefined): void {
this.run = run
this.view.message = featureMessage(this.result, this.run)
}
covers(uri: Uri): boolean {
return this.tree.covers(uri)
}
refresh(): void {
this.tree.refresh()
}
dispose(): void {
this.view.dispose()
this.tree.dispose()
}
}
/**
* 「了解一块代码」:找过的功能各占一栏(和收藏共用栏位,见 SlotPool),记在 workspaceState 里,重开窗口栏还在。
* 找、接着说都交给 CodeQa 的顶部聊天(askTop):会话、进行中的那一轮、对话记录都在那里,关掉对话页也不中断;
* 这里拼发给 AI 的话、校验交回的结果、换栏里的树,跟着 CodeQa 报的进展改栏上方那句话。
* 打开对话页、打开文件不在这里做(要用预览),只给出要打开什么(chatPage、target)。
*/
export class FeatureViews implements Disposable {
private features: Feature[]
/** 栏位号 → 那一栏 */
private readonly panes = new Map<number, FeaturePane>()
private readonly disposables: Disposable[]
private timer: NodeJS.Timeout | undefined
/** 等着重找的文件 */
private readonly touched = new Map<string, Uri>()
/**
* @param state workspaceState
* @param pool 和收藏共用的栏位
* @param qa 对话:顶部聊天的一轮怎么问、记录怎么存
* @param locate 按眼下的代码找一条在哪(见 locateItem)
*/
constructor(
private readonly state: Memento,
private readonly pool: SlotPool,
private readonly qa: CodeQa,
private readonly locate: (item: FeatureItem) => Promise<FoundItem | undefined>,
) {
this.features = parseFeatures(state.get(FEATURES_KEY))
for (const feature of this.features) {
this.open(feature)
void pool.occupy(feature.slot, 'feature')
}
const watcher = workspace.createFileSystemWatcher('**/*')
this.disposables = [
watcher,
watcher.onDidChange(uri => this.touch(uri)),
watcher.onDidCreate(uri => this.touch(uri)),
watcher.onDidDelete(uri => this.touch(uri)),
workspace.onDidChangeWorkspaceFolders(() => this.refreshAll()),
window.registerFileDecorationProvider(new MissingRows()),
qa.onDidAnswer(event => this.answered(event)),
]
}
/**
* 主栏标题栏的「了解一块代码」:问要了解哪一块,占最小的空栏位建一栏(栏名就是问的话)、亮出来,让 AI 开始找。
* 返回这个功能的对话页(要打开它看找的过程);没问、栏位满了、没打开工作区文件夹时没有。
*/
async find(): Promise<Uri | undefined> {
if (!workspace.workspaceFolders?.length) {
void window.showWarningMessage('先打开工作区文件夹,再了解里面的代码')
return undefined
}
if (this.pool.free() === undefined) {
void window.showWarningMessage(FULL_MESSAGE)
return undefined
}
const question = (await window.showInputBox({
title: '了解一块代码',
prompt: '你想要了解哪一块的代码',
placeHolder: '例如:用户登录、会员自动续费',
validateInput: text => text.trim() ? undefined : '说说想了解哪一块',
}))?.trim()
if (!question)
return undefined
const slot = this.pool.free()
if (slot === undefined) {
void window.showWarningMessage(FULL_MESSAGE)
return undefined
}
const feature: Feature = { id: randomUUID(), question, name: question, slot }
this.open(feature)
await Promise.all([this.save([...this.features, feature]), this.pool.occupy(slot, 'feature')])
await focusSlot(slot)
await this.start(feature, { askId: randomUUID(), question, images: [] })
return featurePageUri(feature.id, feature.question)
}
/** 功能对话页里接着说(subject 见 featureSubjectUri):AI 接着上次的会话改,交回完整的新结果。说不了时返回给人看的原因。 */
async ask(subject: Uri, request: TopRequest): Promise<string | undefined> {
const feature = this.features.find(item => item.id === featureIdOf(subject))
if (!feature)
return '这个功能已经删掉了。'
return this.start(feature, request, request.question)
}
/** 第 slot 栏的对话页;这一栏不是功能时没有。 */
chatPage(slot: number): Uri | undefined {
const feature = this.features.find(item => item.slot === slot)
return feature && featurePageUri(feature.id, feature.question)
}
/** 点栏里一条:按眼下的代码找它在哪;找不到了说一声、这一栏重画(画成灰的)。 */
async target(row: FeatureRow | undefined): Promise<FoundItem | undefined> {
if (!row || 'group' in row)
return undefined
const found = await this.locate(row.item)
if (!found) {
void window.showInformationMessage(`找不到了:${row.item.path}${row.item.symbol === undefined ? '' : ` 里的 ${row.item.symbol}`}`)
this.panes.get(row.slot)?.refresh()
}
return found
}
/** 功能栏标题栏的「重命名」:改第 slot 栏的标题。 */
async rename(slot: number): Promise<void> {
const feature = this.features.find(item => item.slot === slot)
if (!feature)
return
const name = await askSlotName('重命名', feature.name)
if (name === undefined)
return
this.panes.get(slot)?.rename(name)
await this.save(renameFeature(this.features, slot, name))
}
/** 功能栏标题栏的「删除」:删栏(空出栏位)、删结果、删对话记录,正在找的叫停,开着的对话页关掉。 */
async remove(slot: number): Promise<void> {
const feature = this.features.find(item => item.slot === slot)
if (!feature)
return
const pane = this.panes.get(slot)
this.panes.delete(slot)
const pages = window.tabGroups.all.flatMap(group => group.tabs).filter(tab => tab.input instanceof TabInputCustom && featureIdOf(tab.input.uri) === feature.id)
await Promise.all([
this.save(removeFeature(this.features, slot)),
this.pool.release(slot),
this.qa.deleteTop(featureSubjectUri(feature.id)),
...(pages.length > 0 ? [window.tabGroups.close(pages)] : []),
])
pane?.dispose()
}
dispose(): void {
clearTimeout(this.timer)
for (const disposable of this.disposables)
disposable.dispose()
for (const pane of this.panes.values())
pane.dispose()
}
/**
* 让 AI 找一轮:只读、按 featureSchema 交结果,在第一个工作区文件夹里跑、别的加进来读。
* 接着上次的会话说时发 request.question 再要完整的新结果;没有会话(第一次、会话找不到了)发完整的要求,said 是用户接着说的话。
*/
private async start(feature: Feature, request: TopRequest, said?: string): Promise<string | undefined> {
const folders = workspace.workspaceFolders ?? []
if (folders.length === 0)
return '没打开工作区文件夹,没地方找。'
const names = folders.map(folder => folder.name)
const refused = await this.qa.askTop(featureSubjectUri(feature.id), request, {
prompt: featurePrompt(feature.question, folders.map(folder => ({ name: folder.name, path: folder.uri.fsPath })), said),
followUp: featureFollowUp(request.question),
schema: featureSchema(names),
addDirs: folders.slice(1).map(folder => folder.uri.fsPath),
}, answer => this.settle(feature.id, answer, names))
if (refused !== undefined)
this.panes.get(feature.slot)?.setRun({ status: 'failed', error: refused })
return refused
}
/** AI 答完了:交回的结构合格就存下、整栏换成它,返回对话里显示的回答;不合格说清哪里不对。 */
private async settle(id: string, answer: Answer, folders: readonly string[]): Promise<{ text: string } | { error: string }> {
const parsed = parseFeatureResult(answer.output, folders)
if ('error' in parsed)
return parsed
const feature = this.features.find(item => item.id === id)
if (!feature)
return { error: '这个功能已经删掉了。' }
await this.save(withResult(this.features, id, parsed.result))
this.panes.get(feature.slot)?.show(parsed.result)
return { text: featureAnswer(parsed.result, folders.length > 1) }
}
/** CodeQa 报的进展:功能对话页上的那一轮在找、找完、没找成、停下了,改那一栏上方的话。删掉了的功能那一轮停下后不再留着。 */
private answered(event: AskEvent): void {
const id = featureIdOf(Uri.parse(event.owner))
if (id === undefined)
return
const feature = this.features.find(item => item.id === id)
if (!feature) {
if (event.kind === 'end')
this.qa.forgetAsk(featureSubjectUri(id), PATH_ASK_ANCHOR)
return
}
const pane = this.panes.get(feature.slot)
if (event.kind === 'update') {
pane?.setRun({ status: 'running', ...(event.ask.activity !== undefined && { activity: event.ask.activity }) })
return
}
const { end } = event
pane?.setRun(end.status === 'done' ? undefined : end.status === 'failed' ? { status: 'failed', error: end.error } : { status: 'stopped' })
}
/** 文件改了、增删了:等一会儿,把有条目在这些文件里的栏重找一遍。 */
private touch(uri: Uri): void {
this.touched.set(uri.toString(), uri)
clearTimeout(this.timer)
this.timer = setTimeout(() => {
const uris = [...this.touched.values()]
this.touched.clear()
for (const pane of this.panes.values()) {
if (uris.some(touched => pane.covers(touched)))
pane.refresh()
}
}, REFRESH_DELAY_MS)
}
private refreshAll(): void {
for (const pane of this.panes.values())
pane.refresh()
}
private open(feature: Feature): void {
this.panes.set(feature.slot, new FeaturePane(feature, this.locate))
}
private async save(features: Feature[]): Promise<void> {
this.features = features
await this.state.update(FEATURES_KEY, features.length > 0 ? features : undefined)
}
}