✨ 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

+78 -1
View File
@@ -1,6 +1,6 @@
import type { ExtensionContext, Memento, Uri } from 'vscode'
import type { AskRunner, CommentAnswer, CommentAsk } from './claudeAsk'
import type { RewriteEvent, RewriteRequest } from './codeQa'
import type { AskEvent, RewriteEvent, RewriteRequest } from './codeQa'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
@@ -45,6 +45,7 @@ vi.mock('../folderStore', () => ({
}))
vi.mock('../editor/pathPreview', () => ({
pathQaLocation: () => ({ folder: { uri: { fsPath: '/workspace', toString: () => 'file:///workspace' } }, key: 'a.ts' }),
topQaLocation: () => ({ folder: { uri: { fsPath: '/workspace', toString: () => 'file:///workspace' } }, key: 'file:///workspace\na.ts' }),
}))
vi.mock('./pathAskDescribe', () => ({ describePath: async () => ({ kind: 'file', relativePath: 'a.ts', head: { unavailable: '测试' } }) }))
vi.mock('../../cc/profiles', () => ({
@@ -262,3 +263,79 @@ describe('重写注释的记录', () => {
expect(writeBack).toHaveBeenCalledWith('第二段。')
})
})
describe('顶部聊天要结构化结果的一轮(了解一块代码)', () => {
/** 记在内存里的 workspaceState:顶部问答记在这里 */
function memento(): Pick<Memento, 'get' | 'update'> {
const values = new Map<string, unknown>()
return { get: <T>(key: string) => values.get(key) as T, update: async (key: string, value: unknown) => void values.set(key, value) }
}
function setup() {
const runner = vi.fn<AskRunner>()
const qa = new CodeQa(context(), memento(), runner)
services.push(qa)
return { qa, runner }
}
/** 等这一轮结束的事件 */
function ended(qa: CodeQa, askId: string): Promise<Extract<AskEvent, { kind: 'end' }>> {
return new Promise((resolve) => {
qa.onDidAnswer((event) => {
if (event.kind === 'end' && event.ask.askId === askId)
resolve(event)
})
})
}
const run = { prompt: '完整的话', followUp: '接着说的话', schema: '{"type":"object"}', addDirs: ['/ws/server'] }
const historyOf = (qa: CodeQa) => qa.view(uri, file => file.fsPath, 'feature').path
it('带上 schema 和加进来的目录;答完交给 settle,记下它给的回答;下一轮接着最近一次的会话说', async () => {
const { qa, runner } = setup()
const output = { groups: [] }
runner.mockResolvedValueOnce({ text: '找到了。', sessionId: 's1', output })
const settle = vi.fn(async () => ({ text: '整理好的回答' }))
const first = ended(qa, '1')
expect(await qa.askTop(uri, { askId: '1', question: '用户登录', images: [] }, run, settle)).toBeUndefined()
expect(runner.mock.calls[0][0]).toMatchObject({ prompt: '完整的话', schema: '{"type":"object"}', addDirs: ['/ws/server'], cwd: '/workspace' })
expect(runner.mock.calls[0][0]).not.toHaveProperty('followUp')
expect((await first).end).toEqual({ status: 'done', answer: { text: '整理好的回答', sessionId: 's1', output } })
expect(settle).toHaveBeenCalledWith({ text: '找到了。', sessionId: 's1', output })
expect(historyOf(qa)).toEqual([expect.objectContaining({ question: '用户登录', answer: '整理好的回答' })])
runner.mockResolvedValueOnce({ text: '', sessionId: 's1', output })
const second = ended(qa, '2')
await qa.askTop(uri, { askId: '2', question: '还漏了续费失败重试', images: [] }, run, settle)
await second
expect(runner.mock.calls[1][0].followUp).toEqual({ sessionId: 's1', prompt: '接着说的话' })
expect(historyOf(qa).map(entry => entry.question)).toEqual(['用户登录', '还漏了续费失败重试'])
})
it('settle 说结构不合格:这一轮算失败、说清原因,不记', async () => {
const { qa, runner } = setup()
runner.mockResolvedValueOnce({ text: '只说了话', sessionId: 's1' })
const failed = ended(qa, '1')
await qa.askTop(uri, { askId: '1', question: '用户登录', images: [] }, run, async () => ({ error: 'AI 没按要求的结构交回结果(只回了一段话)。' }))
expect((await failed).end).toEqual({ status: 'failed', error: 'AI 没按要求的结构交回结果(只回了一段话)。' })
expect(historyOf(qa)).toEqual([])
// 没答成的这一轮留着,页面重新加载后照样画出来
await vi.waitFor(() => expect(qa.view(uri, file => file.fsPath, 'feature').current).toMatchObject([{ askId: '1', status: 'failed', error: 'AI 没按要求的结构交回结果(只回了一段话)。' }]))
})
it('删掉这一页的全部问答:记下的没了,正在问的叫停', async () => {
const { qa, runner } = setup()
runner.mockResolvedValueOnce({ text: '', sessionId: 's1', output: {} })
const first = ended(qa, '1')
await qa.askTop(uri, { askId: '1', question: '用户登录', images: [] }, run, async () => ({ text: '回答' }))
await first
runner.mockImplementationOnce(ask => new Promise((_resolve, reject) => {
ask.signal.addEventListener('abort', () => reject(new ClaudeCancelledError('')))
}))
const stopped = ended(qa, '2')
await qa.askTop(uri, { askId: '2', question: '接着找', images: [] }, run, async () => ({ text: '回答' }))
await qa.deleteTop(uri)
expect((await stopped).end).toEqual({ status: 'stopped' })
expect(historyOf(qa)).toEqual([])
})
})