/** * 侧栏文件行、文件夹行的「问 AI」:单独一个面板,不属于任何一个文件的预览。 * 先问问题,再在同一面板里把回答流出来;不写进某一块代码的问答记录。 */ import type { WebviewPanel } from 'vscode' import type { AskProfiles } from '../qa/askProfiles' import type { PathAskSubject, PathChild } from '../qa/pathAskPrompt' import type { ExcludeSetting } from './fileEntries' import type { FileNode } from './fileTree' import * as path from 'node:path' import { commands, Disposable, FileType, ViewColumn, window, workspace } from 'vscode' import { ClaudeCancelledError } from '../../cc/spawnClaude' import { makeNonce } from '../../webview/html' import { askClaude, askFailure } from '../qa/claudeAsk' import { readFileHead } from '../qa/pathAskContext' import { pathAskPrompt } from '../qa/pathAskPrompt' import { createExcludeMatcher, sortEntries } from './fileEntries' export const ASK_PATH_COMMAND = 'superpowers.codeReader.askPath' const VIEW_TYPE = 'superpowers.codeReader.pathAsk' type PathTarget = Pick & { relativePath: string } interface ResetMessage { type: 'reset', path: string, kind: 'file' | 'folder' } interface AskedMessage { type: 'asked', question: string } interface StreamMessage { type: 'stream', text?: string, activity?: string } interface EndMessage { type: 'end', status: 'done' | 'failed' | 'stopped', error?: string } type ToPanel = ResetMessage | AskedMessage | StreamMessage | EndMessage /** 打开或切到问路径的面板。点侧栏另一行时换到那一行,正在答的停掉。 */ export function registerPathAsk(profiles: AskProfiles): Disposable { const panel = new PathAskPanel(profiles) return Disposable.from( commands.registerCommand(ASK_PATH_COMMAND, (node?: FileNode) => { void panel.open(node) }), panel, ) } class PathAskPanel implements Disposable { private panel: WebviewPanel | undefined private whenReady = Promise.resolve() private target: PathTarget | undefined /** 换了路径就加一,旧的回答不再往面板上画 */ private generation = 0 private controller: AbortController | undefined constructor(private readonly profiles: AskProfiles) {} async open(node?: FileNode): Promise { if (!node || node.isRoot) return const gen = ++this.generation this.controller?.abort() this.controller = undefined const relativePath = path.posix.relative(node.folder.uri.path, node.uri.path) this.target = { uri: node.uri, folder: node.folder, isDirectory: node.isDirectory, relativePath } const title = `问 AI · ${relativePath}` if (!this.panel) { this.create(title) } else { this.panel.title = title this.panel.reveal(ViewColumn.Active, false) } await this.whenReady if (gen !== this.generation || !this.panel) return this.post({ type: 'reset', path: relativePath, kind: node.isDirectory ? 'folder' : 'file' }) } dispose(): void { this.generation++ this.controller?.abort() this.controller = undefined this.panel?.dispose() this.panel = undefined } private create(title: string): void { const panel = window.createWebviewPanel(VIEW_TYPE, title, { viewColumn: ViewColumn.Active, preserveFocus: false }, { enableScripts: true, retainContextWhenHidden: true, }) this.panel = panel let markReady: () => void = () => {} this.whenReady = new Promise((resolve) => { markReady = resolve }) const nonce = makeNonce() panel.webview.html = pathAskHtml(nonce) panel.webview.onDidReceiveMessage((msg: unknown) => { if (!isRecord(msg) || typeof msg.type !== 'string') return if (msg.type === 'ready') markReady() else if (msg.type === 'ask' && typeof msg.question === 'string') void this.submit(msg.question) else if (msg.type === 'stop') this.controller?.abort() }) panel.onDidDispose(() => { if (this.panel !== panel) return this.panel = undefined this.generation++ this.controller?.abort() this.controller = undefined }) } /** 空问题不发。回答过程里的字和「正在读哪个文件」直接画在这个面板上。 */ private async submit(question: string): Promise { const text = question.trim() const target = this.target if (!text || !target || this.controller) return const gen = this.generation const controller = new AbortController() this.controller = controller this.post({ type: 'asked', question: text }) try { const described = await describePath(target) if (gen !== this.generation) return const profile = await this.profiles.current() if (gen !== this.generation) return const prompt = pathAskPrompt(described, text) await askClaude({ prompt, images: [], ...(profile && { profilePath: profile.path }), cwd: target.folder.uri.fsPath, signal: controller.signal, onEvent: (event) => { if (gen !== this.generation) return if (event.kind === 'text') this.post({ type: 'stream', text: event.text }) else if (event.kind === 'activity') this.post({ type: 'stream', activity: event.label }) }, }) if (gen !== this.generation) return this.post({ type: 'end', status: 'done' }) } catch (err) { if (gen !== this.generation) return if (err instanceof ClaudeCancelledError) this.post({ type: 'end', status: 'stopped' }) else this.post({ type: 'end', status: 'failed', error: askFailure(err) }) } finally { if (this.controller === controller) this.controller = undefined } } private post(message: ToPanel): void { void this.panel?.webview.postMessage(message).then(undefined, () => {}) } } /** 文件带开头,文件夹带这一层(和侧栏同一套排除、同一排序)。 */ async function describePath(target: PathTarget): Promise { if (target.isDirectory) return { kind: 'folder', relativePath: target.relativePath, children: await folderChildren(target) } return { kind: 'file', relativePath: target.relativePath, head: await readFileHead(target.uri.fsPath) } } async function folderChildren(target: PathTarget): Promise { let entries: [string, FileType][] try { entries = await workspace.fs.readDirectory(target.uri) } catch (err) { const message = err instanceof Error ? err.message : String(err) return { unavailable: `列不出来(${message})。` } } const excluded = createExcludeMatcher(workspace.getConfiguration('files', target.folder).get('exclude')) const names = new Set(entries.map(([name]) => name)) const dir = target.relativePath return sortEntries(entries.map(([name, type]) => ({ name, isDirectory: (type & FileType.Directory) !== 0 }))) .filter(entry => !excluded(dir ? `${dir}/${entry.name}` : entry.name, names)) .map(entry => ({ name: entry.name, kind: entry.isDirectory ? 'folder' : 'file' })) } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } /** 面板里的一页:暗色遮罩铺满,中间一张卡片。问、流式回答、停止都在这张卡片上。 */ function pathAskHtml(nonce: string): string { return ` 问 AI ` }