diff --git a/vscode/src/codeReader/explorer/pathAskPanel.ts b/vscode/src/codeReader/explorer/pathAskPanel.ts new file mode 100644 index 0000000..1c45016 --- /dev/null +++ b/vscode/src/codeReader/explorer/pathAskPanel.ts @@ -0,0 +1,415 @@ +/** + * 侧栏文件行、文件夹行的「问 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 + + + + + + +` +} diff --git a/vscode/src/codeReader/explorer/rowMenus.test.ts b/vscode/src/codeReader/explorer/rowMenus.test.ts index 30a57ea..483c8e9 100644 --- a/vscode/src/codeReader/explorer/rowMenus.test.ts +++ b/vscode/src/codeReader/explorer/rowMenus.test.ts @@ -27,6 +27,7 @@ const root = entryContext({ isDirectory: true, isRoot: true }) /** 「未提交的更改」里「更改」组的文件行 */ const unstaged = entryContext({ isDirectory: false, isRoot: false }, UNSTAGED_MARKER) const STAGE = 'superpowers.codeReader.stage' +const ASK = 'superpowers.codeReader.askPath' const noted = (context: string): string => noteDecoration(context, 'x', '注释')!.contextValue const orderOf = (item: MenuItem): number => Number(item.group?.split('@')[1] ?? 0) @@ -56,17 +57,17 @@ const FOLDER_COPIES = [ ] describe('侧栏行尾按钮', () => { - it('先「复制」再注释:没注释的是「添加注释」,有注释的是「编辑注释」;工作区根都没有', () => { + it('先「问 AI」再「复制」再注释:没注释的是「添加注释」,有注释的是「编辑注释」;工作区根都没有', () => { for (const context of [file, folder]) { - expect(itemsFor('view/item/context', 'inline', context)).toEqual([COPY_MENU, ADD_NOTE]) - expect(itemsFor('view/item/context', 'inline', noted(context))).toEqual([COPY_MENU, EDIT_NOTE]) + expect(itemsFor('view/item/context', 'inline', context)).toEqual([ASK, COPY_MENU, ADD_NOTE]) + expect(itemsFor('view/item/context', 'inline', noted(context))).toEqual([ASK, COPY_MENU, EDIT_NOTE]) } expect(itemsFor('view/item/context', 'inline', root)).toEqual([]) }) it('「更改」组的文件行多一个「暂存」,排在最后;别的行都没有', () => { - expect(itemsFor('view/item/context', 'inline', unstaged)).toEqual([COPY_MENU, ADD_NOTE, STAGE]) - expect(itemsFor('view/item/context', 'inline', noted(unstaged))).toEqual([COPY_MENU, EDIT_NOTE, STAGE]) + expect(itemsFor('view/item/context', 'inline', unstaged)).toEqual([ASK, COPY_MENU, ADD_NOTE, STAGE]) + expect(itemsFor('view/item/context', 'inline', noted(unstaged))).toEqual([ASK, COPY_MENU, EDIT_NOTE, STAGE]) for (const context of [file, folder, root, noted(file), noted(folder)]) expect(itemsFor('view/item/context', 'inline', context)).not.toContain(STAGE) }) @@ -82,6 +83,21 @@ describe('侧栏行尾按钮', () => { expect(commands.find(item => item.command === ADD_NOTE)?.icon).toBe('$(note)') expect(commands.find(item => item.command === EDIT_NOTE)?.icon).toBe('$(edit)') }) + + it('「问 AI」在复制左边,文件和文件夹(含未提交、有注释)都有,审查时也不藏,命令面板里没有', () => { + const ask = menus['view/item/context'].filter(item => item.command === ASK) + expect(ask).toEqual([{ + command: ASK, + when: 'view == superpowers.codeReader.files && viewItem =~ /^(file( unstaged)?|folder)( noted)?$/', + group: 'inline@0', + }]) + expect(ask[0].when.includes('filter')).toBe(false) + for (const context of [file, folder, unstaged, noted(file), noted(folder), noted(unstaged)]) + expect(itemsFor('view/item/context', 'inline', context)[0]).toBe(ASK) + expect(itemsFor('view/item/context', 'inline', root)).not.toContain(ASK) + expect(commands.find(item => item.command === ASK)).toMatchObject({ title: '问 AI', icon: '$(sparkle)' }) + expect(menus.commandPalette.find(item => item.command === ASK)?.when).toBe('false') + }) }) describe('复制菜单', () => { diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index 273d855..60b9d71 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -13,6 +13,7 @@ import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './editor/previewEditor import { showPreview, showSource } from './editor/switchEditor' import { registerCopyCommands } from './explorer/copyCommands' import { FileTreeProvider } from './explorer/fileTree' +import { registerPathAsk } from './explorer/pathAskPanel' import { TreeFilters } from './explorer/treeFilters' import { createBashAdapter } from './languages/bash' import { createDockerfileAdapter } from './languages/dockerfile' @@ -67,7 +68,7 @@ async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise { } /** - * 接入代码阅读:注册表、侧栏文件树(复制、文件注释、筛选:按审查工单只看 PR 改过的文件、只看未提交的更改)、预览编辑器(含问 AI)、编辑器右上角的「预览 / 编辑」切换。 + * 接入代码阅读:注册表、侧栏文件树(问路径、复制、文件注释、筛选:按审查工单只看 PR 改过的文件、只看未提交的更改)、预览编辑器(含问 AI)、编辑器右上角的「预览 / 编辑」切换。 * 内置的语言插件、翻译插件、库规则和命令规则与其他扩展一样通过返回的 API 注册。 */ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { @@ -101,6 +102,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { indexes, registerNoteCommands(notes), registerCopyCommands(), + registerPathAsk(qa.profiles), registry.onDidChange(() => { void commands.executeCommand('setContext', LANGUAGE_IDS_CONTEXT, registry.languageIds()) previews.refresh() diff --git a/vscode/src/codeReader/qa/pathAskContext.ts b/vscode/src/codeReader/qa/pathAskContext.ts new file mode 100644 index 0000000..2a53b38 --- /dev/null +++ b/vscode/src/codeReader/qa/pathAskContext.ts @@ -0,0 +1,51 @@ +/** + * 问路径时带上的上下文:文件只读开头若干行,到字节上限为止,避免整份大文件读进内存。 + */ + +import type { PathHead } from './pathAskPrompt' +import { Buffer } from 'node:buffer' +import { open } from 'node:fs/promises' +import { PATH_ASK_HEAD_LINES } from './pathAskPrompt' + +/** 开头最多读这么多字节;一行没写完的尾巴丢掉。 */ +const HEAD_BYTES = 64 * 1024 + +/** 文件开头最多 maxLines 行。二进制或读失败时不附内容,只留原因。 */ +export async function readFileHead(fsPath: string, maxLines = PATH_ASK_HEAD_LINES): Promise { + let handle: Awaited> | undefined + try { + handle = await open(fsPath, 'r') + const buf = Buffer.alloc(HEAD_BYTES) + const { bytesRead } = await handle.read(buf, 0, HEAD_BYTES, 0) + if (bytesRead === 0) + return { lines: [], truncated: false } + const chunk = buf.subarray(0, bytesRead) + if (chunk.includes(0)) + return { unavailable: '这是二进制文件,没有附上内容。' } + const more = (await handle.stat()).size > bytesRead + return headLines(chunk.toString('utf8'), more, maxLines) + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { unavailable: `开头读不了(${message})。` } + } + finally { + await handle?.close() + } +} + +/** + * 从已经读到的开头文本里取出最多 maxLines 行。 + * more 表示文件在这段后面还有:这段不以换行结束时,最后一行是被截断的,丢掉。 + */ +export function headLines(text: string, more: boolean, maxLines: number): PathHead { + if (text === '') + return { lines: [], truncated: more } + let lines = text.split(/\r?\n/) + if (text.endsWith('\n')) + lines = lines.slice(0, -1) + else if (more) + lines = lines.slice(0, -1) + const truncated = more || lines.length > maxLines + return { lines: lines.slice(0, maxLines), truncated } +} diff --git a/vscode/src/codeReader/qa/pathAskPrompt.test.ts b/vscode/src/codeReader/qa/pathAskPrompt.test.ts new file mode 100644 index 0000000..cd3a52e --- /dev/null +++ b/vscode/src/codeReader/qa/pathAskPrompt.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { headLines } from './pathAskContext' +import { PATH_ASK_HEAD_LINES, pathAskPrompt } from './pathAskPrompt' + +const FILE = 'app/[[...section]]/page.tsx' +const FOLDER = 'app/[[...section]]' + +describe('问路径时发给模型的话', () => { + it('文件:写明是文件,带相对路径和开头', () => { + const prompt = pathAskPrompt({ + kind: 'file', + relativePath: FILE, + head: { lines: ['export default function Page() {', ' return null', '}'], truncated: false }, + }, '这个名字是什么意思') + expect(prompt).toContain(`这是一个文件,相对路径:${FILE}`) + expect(prompt).toContain('1 | export default function Page() {') + expect(prompt).toContain('3 | }') + expect(prompt).toContain('问题:这个名字是什么意思') + expect(prompt).toContain('只读,不要改任何东西') + expect(prompt).not.toContain('剩下的需要时自己读') + }) + + it('文件开头超过上限时说明只贴了开头,相对路径仍在', () => { + const lines = Array.from({ length: PATH_ASK_HEAD_LINES }, (_, index) => `line ${index}`) + const prompt = pathAskPrompt({ + kind: 'file', + relativePath: FILE, + head: { lines, truncated: true }, + }, '?') + expect(prompt).toContain(`这是一个文件,相对路径:${FILE}`) + expect(prompt).toContain(`上面只贴了开头 ${PATH_ASK_HEAD_LINES} 行`) + expect(prompt).toContain(`80 | line ${PATH_ASK_HEAD_LINES - 1}`) + expect(prompt).not.toContain('line 80') + }) + + it('文件夹:写明是文件夹,带相对路径,只列这一层的名字', () => { + const prompt = pathAskPrompt({ + kind: 'folder', + relativePath: FOLDER, + children: [ + { name: 'components', kind: 'folder' }, + { name: 'page.tsx', kind: 'file' }, + ], + }, '[[...section]] 是啥意思') + expect(prompt).toContain(`这是一个文件夹,相对路径:${FOLDER}`) + expect(prompt).toContain('- 文件夹 components\n- 文件 page.tsx') + expect(prompt).toContain('只列了这一层的名字') + expect(prompt).toContain('问题:[[...section]] 是啥意思') + expect(prompt).not.toContain('export default') + }) +}) + +describe('文件开头截取', () => { + it('超过行数上限时只留前 80 行,并标明没贴完', () => { + const text = Array.from({ length: 100 }, (_, index) => `L${index}`).join('\n') + expect(headLines(text, false, PATH_ASK_HEAD_LINES)).toEqual({ + lines: Array.from({ length: PATH_ASK_HEAD_LINES }, (_, index) => `L${index}`), + truncated: true, + }) + }) + + it('字节没读完时丢掉被截断的最后一行', () => { + expect(headLines('alpha\nbeta-cut', true, PATH_ASK_HEAD_LINES)).toEqual({ + lines: ['alpha'], + truncated: true, + }) + }) + + it('空文件没有行', () => { + expect(headLines('', false, PATH_ASK_HEAD_LINES)).toEqual({ lines: [], truncated: false }) + }) +}) diff --git a/vscode/src/codeReader/qa/pathAskPrompt.ts b/vscode/src/codeReader/qa/pathAskPrompt.ts new file mode 100644 index 0000000..fff89fe --- /dev/null +++ b/vscode/src/codeReader/qa/pathAskPrompt.ts @@ -0,0 +1,65 @@ +/** + * 侧栏问一个路径时发给模型的话:写明是文件还是文件夹、相对工作区的路径。 + * 文件附上开头若干行;文件夹只列这一层的名字,不把里面的代码贴进去。 + */ + +/** 文件开头最多贴这么多行,剩下的让模型自己去读。 */ +export const PATH_ASK_HEAD_LINES = 80 + +export interface PathChild { + name: string + kind: 'file' | 'folder' +} + +/** 文件开头:贴得下的行,或者为什么没附上。 */ +export type PathHead + = | { lines: string[], truncated: boolean } + | { unavailable: string } + +export interface PathAskFile { + kind: 'file' + /** 相对所在工作区文件夹的路径 */ + relativePath: string + head: PathHead +} + +export interface PathAskFolder { + kind: 'folder' + /** 相对所在工作区文件夹的路径 */ + relativePath: string + /** 这一层的子文件和子文件夹,调用方排好序;列不出来时是原因 */ + children: PathChild[] | { unavailable: string } +} + +export type PathAskSubject = PathAskFile | PathAskFolder + +const LEAD = '我在看项目里的一个路径,想问它。请用中文、大白话、简洁地回答,不绕弯子。需要时可以自己去读、搜项目里的文件,但只读,不要改任何东西。提到文件时写相对项目根目录的路径。' + +/** 一次提问的全文。不写进某一块代码的问答记录。 */ +export function pathAskPrompt(subject: PathAskSubject, question: string): string { + const where = subject.kind === 'file' + ? `这是一个文件,相对路径:${subject.relativePath}` + : `这是一个文件夹,相对路径:${subject.relativePath}` + const detail = subject.kind === 'file' ? fileHead(subject.head) : folderList(subject.children) + return [LEAD, where, detail, `问题:${question}`].join('\n\n') +} + +function fileHead(head: PathHead): string { + if ('unavailable' in head) + return `文件开头没附上:${head.unavailable}` + if (head.lines.length === 0) + return head.truncated ? '文件开头一行都没读全,需要时自己读这个文件。' : '文件是空的。' + const width = String(head.lines.length).length + const body = head.lines.map((line, index) => `${String(index + 1).padStart(width)} | ${line}`).join('\n') + const rest = head.truncated ? `\n(上面只贴了开头 ${head.lines.length} 行,剩下的需要时自己读这个文件)` : '' + return `文件开头:\n${body}${rest}` +} + +function folderList(children: PathAskFolder['children']): string { + if (!Array.isArray(children)) + return `这一层没列出来:${children.unavailable}` + if (children.length === 0) + return '里面这一层是空的。' + const lines = children.map(child => `- ${child.kind === 'folder' ? '文件夹' : '文件'} ${child.name}`) + return `里面这一层有:\n${lines.join('\n')}\n只列了这一层的名字,没有把里面的代码贴进来。侧栏里不显示的名字这里也没列。需要时自己去读这些文件。` +}