✨ feat(vscode): 重写注释留会话记录、能接着说,允许画原型图

每块的重写记录存进 .spx/comment-rewrites.json(和问答分开,改名、删文件跟着走),块下面同问答区:灰字历史、当前这一轮、输入框;输入框里的话接着这块最近一次重写的会话说,交回新正文照样写回,写回失败也记下原因。
重写跑在 claude --restricted 下:只放开读搜、OpenPencil 的 op、建 prototypes 目录、改草稿目录,写注释的插件单独加载;超时 15 分钟。
进行中的、出错被叫停留着的那一轮记在扩展端,切走标签页再切回来照样接上(问 AI 同一套)。
This commit is contained in:
cruldra committed 2026-10-12 13:45:37 +11:00
1 parent ae4e2160ef
commit c9f60f5b30
32 files changed
+1654 -860

No files matched your search

-1
View File
@@ -402,7 +402,6 @@ export function toBlockViews(
const row = commentRow(block)
return { properties: row ? withCommentRow(rows, row) : rows }
}
/** 「重写注释」写到哪(见 CommentTarget):块前面的普通注释换掉,没有就新写在块上面。 */
/** 「重写注释」写到哪:有说明就换说明,否则换块前面的普通注释,都没有就按这门语言的说明新写。 */
function commentTarget(block: CodeBlock): Pick<BlockView, 'commentTarget'> {
if (!writable)
+50 -63
View File
@@ -1,8 +1,8 @@
import type { BlockView, CommentTarget } from './blockView'
import type { CommentEdit, CommentRewriteDeps, CommentRewriteJob, WriteResult } from './commentRewrite'
import type { CommentEdit, CommentJob, CommentWriteDeps, WriteResult } from './commentRewrite'
import { describe, expect, it } from 'vitest'
import { toBlockViews } from './blockView'
import { COMMENT_SKILL, commentEdit, commentRewritePrompt, commentSource, createCommentParser, findBlock, refillComment, rewriteBlockComment } from './commentRewrite'
import { COMMENT_SKILL, commentEdit, commentFollowUp, commentJob, commentRewritePrompt, commentSource, createCommentParser, findBlock, refillComment, writeCommentBody } from './commentRewrite'
import { commonIndent, replaceLines, splitLines } from './editText'
import { createPythonAdapter } from './languages/python'
import { createRustAdapter } from './languages/rust'
@@ -135,10 +135,15 @@ function structuredLines(body: string): string[] {
const resultLine = (fields: Record<string, unknown>): string => line({ type: 'result', subtype: 'success', is_error: false, ...fields })
describe('重写注释的提示', () => {
it('只有技能命令和代码块在哪:路径:起-止,行号 1 起', () => {
it('点按钮时只有技能命令和代码块在哪:路径:起-止,行号 1 起', () => {
expect(commentRewritePrompt('src/acquisition/repository.py', 12, 20)).toBe(`${COMMENT_SKILL} src/acquisition/repository.py:13-21`)
expect(commentRewritePrompt('a.ts', 0, 0)).toBe('/ecc:writing-code-comments a.ts:1-1')
})
it('用户说了话:开新会话时跟在技能命令后面;接着会话说时前面是块现在在哪(写回注释后行号会变)', () => {
expect(commentRewritePrompt('a.ts', 2, 9, '加上原型图')).toBe('/ecc:writing-code-comments a.ts:3-10\n\n加上原型图')
expect(commentFollowUp('a.ts', 2, 9, '加上原型图')).toBe('a.ts:3-10\n\n加上原型图')
})
})
describe('读模型交回的结构化结果', () => {
@@ -147,33 +152,37 @@ describe('读模型交回的结构化结果', () => {
const events = [
line({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/w/src/a.py' } }] } }),
...structuredLines('把 `a` 和 `b` 相加。'),
resultLine({ result: '{"body":"把 `a` 和 `b` 相加。"}', structured_output: { body: '把 `a` 和 `b` 相加。' } }),
resultLine({ result: '{"body":"把 `a` 和 `b` 相加。"}', structured_output: { body: '把 `a` 和 `b` 相加。' }, session_id: 's1' }),
].flatMap(parse)
expect(events[0]).toEqual({ kind: 'activity', label: '读取 a.py' })
expect(events.filter(event => event.kind === 'text').map(event => 'text' in event && event.text)).toEqual(['把 `', '把 `a` 和 `b` 相加。'])
expect(events.at(-1)).toEqual({ kind: 'result', body: '把 `a` 和 `b` 相加。' })
expect(events.at(-1)).toEqual({ kind: 'result', body: '把 `a` 和 `b` 相加。', sessionId: 's1' })
})
it('会话 id 以 result 行的为准,没有时用开头 init 行的', () => {
const parse = createCommentParser()
expect(parse(line({ type: 'system', subtype: 'init', session_id: 's0' }))).toEqual([])
expect(parse(resultLine({ structured_output: { body: '正文' } }))).toEqual([{ kind: 'result', body: '正文', sessionId: 's0' }])
})
it('模型只回了一段话、structured_output 是空的:结果里没有正文;Claude Code 报错时是失败', () => {
expect(createCommentParser()(resultLine({ result: '我改好了:把 a 和 b 相加。' }))).toEqual([{ kind: 'result' }])
expect(createCommentParser()(resultLine({ result: '', structured_output: { body: ' ' } }))).toEqual([{ kind: 'result' }])
expect(createCommentParser()(resultLine({ result: '我改好了:把 a 和 b 相加。', session_id: 's1' }))).toEqual([{ kind: 'result', sessionId: 's1' }])
expect(createCommentParser()(resultLine({ result: '', structured_output: { body: ' ' }, session_id: 's1' }))).toEqual([{ kind: 'result', sessionId: 's1' }])
expect(createCommentParser()(line({ type: 'result', is_error: true, result: '额度用完了' }))).toEqual([{ kind: 'error', message: '额度用完了' }])
})
})
/** 内存里的一份文件:按行读,写回时同预览一样换掉几行(补回缩进)或在某行上面插几行。 */
function memoryFile(text: string): { deps: (ask: CommentRewriteDeps['ask']) => CommentRewriteDeps, text: () => string, writes: CommentEdit[] } {
function memoryFile(text: string): { deps: CommentWriteDeps, text: () => string, writes: CommentEdit[] } {
let current = text
const writes: CommentEdit[] = []
const lines = (): string[] => splitLines(current)
return {
text: () => current,
writes,
deps: ask => ({
deps: {
lineAt: index => lines()[index],
lineCount: () => lines().length,
ask,
failure: err => `没答成:${err instanceof Error ? err.message : String(err)}`,
write: async (edit): Promise<WriteResult> => {
writes.push(edit)
if (edit.kind === 'replace') {
@@ -185,8 +194,7 @@ function memoryFile(text: string): { deps: (ask: CommentRewriteDeps['ask']) => C
}
return 'written'
},
signal: new AbortController().signal,
}),
},
}
}
@@ -201,10 +209,15 @@ async function pythonBlock(source: string, line: number): Promise<BlockView> {
return found
}
function job(block: BlockView, path = 'src/a.py'): CommentRewriteJob {
/** 开始重写时记下这块:写到哪、范围、这几行当时的原文。 */
function job(block: BlockView, source: string): CommentJob {
if (!block.commentTarget)
throw new Error('这块不能写注释')
return { target: block.commentTarget, startLine: block.startLine, endLine: block.endLine, path }
const lines = splitLines(source)
const started = commentJob(block.commentTarget, block.startLine, block.endLine, index => lines[index], () => lines.length)
if (!started)
throw new Error('块不在文件里')
return started
}
const SOURCE = [
@@ -219,18 +232,12 @@ const SOURCE = [
'',
].join('\n')
describe('重写一块代码的注释', () => {
it('块前面的注释(几段隔着空行的也算)换成模型交回的正文,套回 # 和缩进;提示只有技能命令和块的范围', async () => {
describe('把交回的正文写回这块代码', () => {
it('块前面的注释(几段隔着空行的也算)换成交回的正文,套回 # 和缩进', async () => {
const repo = await pythonBlock(SOURCE, 3)
expect(repo.commentTarget).toEqual({ kind: 'replace', startLine: 2, endLine: 4 })
const file = memoryFile(SOURCE)
const prompts: string[] = []
const outcome = await rewriteBlockComment(job(repo), file.deps(async (prompt) => {
prompts.push(prompt)
return '仓库:按名字找文件。'
}))
expect(outcome).toEqual({ status: 'done' })
expect(prompts).toEqual(['/ecc:writing-code-comments src/a.py:3-8'])
expect(await writeCommentBody(job(repo, SOURCE), '仓库:按名字找文件。', file.deps)).toBeUndefined()
expect(file.text()).toBe(SOURCE.replace('# 旧的说明\n\n# 第二段', '# 仓库:按名字找文件。'))
})
@@ -239,12 +246,7 @@ describe('重写一块代码的注释', () => {
const find = await pythonBlock(source, 2)
expect(find.commentTarget).toEqual({ kind: 'replace', startLine: 2, endLine: 2 })
const file = memoryFile(source)
const prompts: string[] = []
expect(await rewriteBlockComment(job(find), file.deps(async (prompt) => {
prompts.push(prompt)
return '按名字找\n\n找不到时返回 1'
}))).toEqual({ status: 'done' })
expect(prompts).toEqual(['/ecc:writing-code-comments src/a.py:2-4'])
expect(await writeCommentBody(job(find, source), '按名字找\n\n找不到时返回 1', file.deps)).toBeUndefined()
expect(file.text()).toBe('class Repo:\n def find(self):\n """按名字找\n\n 找不到时返回 1\n """\n return 1\n')
})
@@ -253,7 +255,7 @@ describe('重写一块代码的注释', () => {
const find = await pythonBlock(source, 1)
expect(find.commentTarget).toEqual({ kind: 'insert', line: 1, shell: '"""_"""' })
const file = memoryFile(source)
expect(await rewriteBlockComment(job(find), file.deps(async () => '按名字找。'))).toEqual({ status: 'done' })
expect(await writeCommentBody(job(find, source), '按名字找。', file.deps)).toBeUndefined()
expect(file.text()).toBe('def find(self):\n """按名字找。"""\n return 1\n')
})
@@ -262,7 +264,7 @@ describe('重写一块代码的注释', () => {
const find = await pythonBlock(source, 1)
expect(find.commentTarget).toMatchObject({ kind: 'insert', line: 0, shell: '"""_"""' })
const file = memoryFile(source)
expect(await rewriteBlockComment(job(find), file.deps(async () => '按名字找。'))).toEqual({ status: 'done' })
expect(await writeCommentBody(job(find, source), '按名字找。', file.deps)).toBeUndefined()
expect(file.text()).toBe('def find():\n """按名字找。"""\n return 1\n')
})
@@ -271,7 +273,7 @@ describe('重写一块代码的注释', () => {
const find = await pythonBlock(source, 1)
expect(find.commentTarget).toEqual({ kind: 'replace', startLine: 0, endLine: 0 })
const file = memoryFile(source)
expect(await rewriteBlockComment(job(find), file.deps(async () => '按名字找。'))).toEqual({ status: 'done' })
expect(await writeCommentBody(job(find, source), '按名字找。', file.deps)).toBeUndefined()
expect(file.text()).toBe('# 按名字找。\ndef find():\n return 1\n')
})
@@ -280,7 +282,7 @@ describe('重写一块代码的注释', () => {
const find = await pythonBlock(source, 1)
expect(find.commentTarget).toEqual({ kind: 'replace', startLine: 2, endLine: 2 })
const file = memoryFile(source)
expect(await rewriteBlockComment(job(find), file.deps(async () => '新的说明。'))).toEqual({ status: 'done' })
expect(await writeCommentBody(job(find, source), '新的说明。', file.deps)).toBeUndefined()
expect(file.text()).toBe('# 旧的说明\ndef find():\n """新的说明。"""\n return 1\n')
})
@@ -289,47 +291,32 @@ describe('重写一块代码的注释', () => {
const header = await pythonBlock(source, 1)
expect(header).toMatchObject({ kind: 'comment', commentTarget: { kind: 'replace', startLine: 0, endLine: 0 } })
const file = memoryFile(source)
const prompts: string[] = []
await rewriteBlockComment(job(header), file.deps(async (prompt) => {
prompts.push(prompt)
return '新的模块说明。'
}))
expect(prompts).toEqual(['/ecc:writing-code-comments src/a.py:1-1'])
expect(await writeCommentBody(job(header, source), '新的模块说明。', file.deps)).toBeUndefined()
expect(file.text()).toBe('"""新的模块说明。"""\n\nimport os\n')
})
it('没交回结构化的正文、模型出错:失败,文件不动', async () => {
it('没交回结构化的正文:不写,说原因', async () => {
const repo = await pythonBlock(SOURCE, 3)
const silent = memoryFile(SOURCE)
expect(await rewriteBlockComment(job(repo), silent.deps(async () => undefined))).toEqual({ status: 'failed', error: '模型没有按要求交回注释正文,没有改文件。' })
expect(await writeCommentBody(job(repo, SOURCE), undefined, silent.deps)).toBe('模型没有按要求交回注释正文,没有改文件。')
expect(silent.writes).toEqual([])
const broken = memoryFile(SOURCE)
expect(await rewriteBlockComment(job(repo), broken.deps(async () => {
throw new Error('额度用完了')
}))).toEqual({ status: 'failed', error: '没答成:额度用完了' })
expect(broken.writes).toEqual([])
})
it('重写期间这块代码变了:失败,不写;被叫停:stopped,不写', async () => {
it('重写期间这块代码变了、行号已经不在文件里:不写;编辑器没接受:说原因', async () => {
const repo = await pythonBlock(SOURCE, 3)
const file = memoryFile(SOURCE)
let edited = SOURCE
const deps = file.deps(async () => {
edited = SOURCE.replace('return os.sep', 'return "/"')
return '新说明'
})
const changing: CommentRewriteDeps = { ...deps, lineAt: index => splitLines(edited)[index] }
expect(await rewriteBlockComment(job(repo), changing)).toEqual({ status: 'failed', error: '这块代码在重写期间变了,没有写回。' })
const edited = splitLines(SOURCE.replace('return os.sep', 'return "/"'))
expect(await writeCommentBody(job(repo, SOURCE), '新说明', { ...file.deps, lineAt: index => edited[index] })).toBe('这块代码在重写期间变了,没有写回。')
expect(await writeCommentBody(job(repo, SOURCE), '新说明', { ...file.deps, lineCount: () => 3 })).toBe('这块代码在重写期间变了,没有写回。')
expect(file.writes).toEqual([])
expect(await writeCommentBody(job(repo, SOURCE), '新说明', { ...file.deps, write: async () => 'rejected' })).toBe('编辑器没接受这次修改,没有写回。')
expect(commentJob({ kind: 'replace', startLine: 0, endLine: 0 }, 5, 9, () => '', () => 3)).toBeUndefined()
})
const stop = new AbortController()
const stopped = memoryFile(SOURCE)
const result = await rewriteBlockComment(job(repo), { ...stopped.deps(async () => {
stop.abort()
return '新说明'
}), signal: stop.signal })
expect(result).toEqual({ status: 'stopped' })
expect(stopped.writes).toEqual([])
it('认不出定界符:不写,说原因', async () => {
const file = memoryFile('x = 1\n')
const started = commentJob({ kind: 'replace', startLine: 0, endLine: 0 }, 0, 0, () => 'x = 1', () => 2)
expect(started && await writeCommentBody(started, '说明', file.deps)).toBe('这段注释的定界符认不出来,没有改文件。')
})
it('typescript 已经有 JSDoc:换掉这段说明,不在声明上面再写 //', async () => {
+55 -48
View File
@@ -1,6 +1,6 @@
/**
* 「重写注释」:用写注释的技能给一块代码重写(没有就新写)讲它的普通注释。模型只读文件、按 JSON Schema 交回注释正文,
* 这里套回原来的定界符和缩进(没有注释时用语言的行注释记号),由扩展写回文件。
* 「重写注释」:用写注释的技能给一块代码重写它的注释(没有就新写一段)。模型按 JSON Schema 交回注释正文,
* 这里套回原来的定界符和缩进(没有注释时用语言给的空壳),由扩展写回文件。
* 认 Python 的三引号、井号行注释,以及块注释和双斜杠行注释。认不出就不改。
*/
@@ -8,8 +8,11 @@ import type { CommentTarget } from './blockView'
import { describeToolUse } from '../cc/claudeProgress'
import { commonIndent } from './editText'
/** 写注释的技能在哪个 Claude Code 插件里 */
export const COMMENT_PLUGIN = 'ecc'
/** 跟在对话框里输入这个命令一样,由 Claude Code 自己加载写注释的技能。 */
export const COMMENT_SKILL = '/ecc:writing-code-comments'
export const COMMENT_SKILL = `/${COMMENT_PLUGIN}:writing-code-comments`
/** 模型交回的结构(`--json-schema`):body 是注释正文,不带注释符号。 */
export const COMMENT_SCHEMA = JSON.stringify({
@@ -224,19 +227,30 @@ export function refillComment(comment: string, body: string): string | undefined
return refillDoc(comment, text) ?? refillLine(comment, text) ?? refillBlock(comment, text)
}
/** 发给模型的整句话:技能命令后面跟代码块在哪,`路径:起-止`(路径相对 claude 的工作目录,行号 1 起,连同讲它的注释),别的不说。startLine、endLine 0 起。 */
export function commentRewritePrompt(path: string, startLine: number, endLine: number): string {
return `${COMMENT_SKILL} ${path}:${startLine + 1}-${endLine + 1}`
/** 代码块在哪:`路径:起-止`(路径相对 claude 的工作目录,行号 1 起,连同讲它的注释)。startLine、endLine 0 起。 */
function commentRange(path: string, startLine: number, endLine: number): string {
return `${path}:${startLine + 1}-${endLine + 1}`
}
/** 开新会话时发给模型的:技能命令后面跟代码块在哪;用户说了话(request)就空一行跟在后面。 */
export function commentRewritePrompt(path: string, startLine: number, endLine: number, request?: string): string {
const command = `${COMMENT_SKILL} ${commentRange(path, startLine, endLine)}`
return request ? `${command}\n\n${request}` : command
}
/** 接着这块的重写会话说话时发的:块现在在哪(写回注释后行号会变),空一行跟着用户的话。 */
export function commentFollowUp(path: string, startLine: number, endLine: number, request: string): string {
return `${commentRange(path, startLine, endLine)}\n\n${request}`
}
/**
* 从 claude 的 stream-json 输出里读出来的:text 是注释正文到现在为止写出来的(结构化结果逐字长出来),activity 是正在做的事(「读取 routes.py」),
* result 是答完了(body 是结构化结果里的正文,没按 COMMENT_SCHEMA 交回、正文是空的时没有),error 是 Claude Code 自己报的失败。
* result 是答完了(body 是结构化结果里的正文,没按 COMMENT_SCHEMA 交回、正文是空的时没有;sessionId 是这次的会话),error 是 Claude Code 自己报的失败。
*/
export type CommentEvent
= | { kind: 'text', text: string }
| { kind: 'activity', label: string }
| { kind: 'result', body?: string }
| { kind: 'result', body?: string, sessionId: string }
| { kind: 'error', message: string }
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -276,6 +290,8 @@ export function createCommentParser(): (line: string) => CommentEvent[] {
/** 正在写的结构化结果(工具输入的 JSON 原文);不在写时没有 */
let json: string | undefined
let text = ''
/** 开头 init 行报的会话 id;result 行没带时用它 */
let sessionId = ''
const grow = (body: string | undefined): CommentEvent[] => {
if (body === undefined || body === text)
return []
@@ -312,6 +328,10 @@ export function createCommentParser(): (line: string) => CommentEvent[] {
// 子代理的消息(parent_tool_use_id 不空)不是这次的结果
if (!isRecord(line) || (line.parent_tool_use_id !== undefined && line.parent_tool_use_id !== null))
return []
if (line.type === 'system' && line.subtype === 'init') {
sessionId = str(line.session_id)
return []
}
if (line.type === 'stream_event')
return isRecord(line.event) ? streamEvent(line.event) : []
if (line.type === 'assistant')
@@ -323,7 +343,7 @@ export function createCommentParser(): (line: string) => CommentEvent[] {
return [{ kind: 'error', message: str(line.result).trim() || errors || str(line.subtype) || '没说原因' }]
}
const body = bodyOf(line.structured_output)
return [{ kind: 'result', ...(body !== undefined && { body }) }]
return [{ kind: 'result', ...(body !== undefined && { body }), sessionId: str(line.session_id) || sessionId }]
}
}
@@ -372,62 +392,49 @@ function splitSameLine(line: string, atLine: number, breakAt: number, doc: strin
/** 写回文件的结果:written 写进去了,unchanged 和文件里一样不用写,missing 行号已经不在,rejected 编辑器没接受。 */
export type WriteResult = 'written' | 'unchanged' | 'missing' | 'rejected'
/** 一次重写的结果:done 已经写进文件,failed 没写并带给人看的原因,stopped 是被叫停、没写。 */
export type RewriteOutcome
= | { status: 'done' }
| { status: 'stopped' }
| { status: 'failed', error: string }
/** 给哪块代码重写注释:「重写注释」写到哪,块的起止行(0 起,连同讲它的注释),文件相对 claude 工作目录的路径(`/` 分隔),语言的行注释记号。 */
export interface CommentRewriteJob {
/** 开始重写时记下的这块:「重写注释」写到哪,块的起止行(0 起,连同讲它的注释),这几行当时的原文(写回前比对,变了就不写)。 */
export interface CommentJob {
target: CommentTarget
startLine: number
endLine: number
path: string
snapshot: string
}
/** 重写时要用的:读文件现在的行,问模型(拿到结构化结果里的正文,没有正文时 undefined;失败时抛错),出错时给人看的话,写回文件。 */
export interface CommentRewriteDeps {
/** 读这几行当时的原文,记成一次重写;行号已经不在文件里时没有。 */
export function commentJob(target: CommentTarget, startLine: number, endLine: number, lineAt: (line: number) => string, lineCount: () => number): CommentJob | undefined {
const snapshot = rangeText(startLine, endLine, lineAt, lineCount)
return snapshot === undefined ? undefined : { target, startLine, endLine, snapshot }
}
function rangeText(startLine: number, endLine: number, lineAt: (line: number) => string, lineCount: () => number): string | undefined {
if (startLine < 0 || endLine < startLine || endLine >= lineCount())
return undefined
return Array.from({ length: endLine - startLine + 1 }, (_, i) => lineAt(startLine + i)).join('\n')
}
/** 写回时要用的:读文件现在的行,写回文件。 */
export interface CommentWriteDeps {
lineAt: (line: number) => string
lineCount: () => number
ask: (prompt: string) => Promise<string | undefined>
failure: (err: unknown) => string
write: (edit: CommentEdit) => Promise<WriteResult>
signal: AbortSignal
}
/**
* 重写一块代码的注释:问模型(提示只有技能命令和代码块在哪,见 commentRewritePrompt),拿结构化结果里的正文套回去写进文件。
* 没交回结构化的正文、这块代码在重写期间变了、写不回去都算失败,文件不动。
* 把模型交回的正文套回这块的注释写进文件:写进去了(或和文件里一样)返回 undefined;没写成返回给人看的原因,文件不动。
* 没交回正文、这块代码在重写期间变了、定界符认不出、编辑器没接受,都不写。
*/
export async function rewriteBlockComment(job: CommentRewriteJob, deps: CommentRewriteDeps): Promise<RewriteOutcome> {
const failed = (error: string): RewriteOutcome => ({ status: 'failed', error })
const rangeText = (): string | undefined => job.endLine < deps.lineCount()
? Array.from({ length: job.endLine - job.startLine + 1 }, (_, i) => deps.lineAt(job.startLine + i)).join('\n')
: undefined
const snapshot = job.startLine >= 0 && job.endLine >= job.startLine ? rangeText() : undefined
if (snapshot === undefined)
return failed('这块代码的位置已经变了,没有写回。')
let body: string | undefined
try {
body = await deps.ask(commentRewritePrompt(job.path, job.startLine, job.endLine))
}
catch (err) {
return deps.signal.aborted ? { status: 'stopped' } : failed(deps.failure(err))
}
if (deps.signal.aborted)
return { status: 'stopped' }
export async function writeCommentBody(job: CommentJob, body: string | undefined, deps: CommentWriteDeps): Promise<string | undefined> {
if (body === undefined)
return failed('模型没有按要求交回注释正文,没有改文件。')
if (rangeText() !== snapshot)
return failed('这块代码在重写期间变了,没有写回。')
return '模型没有按要求交回注释正文,没有改文件。'
if (rangeText(job.startLine, job.endLine, deps.lineAt, deps.lineCount) !== job.snapshot)
return '这块代码在重写期间变了,没有写回。'
const edit = commentEdit(job.target, deps.lineAt, body)
if (!edit)
return failed('这段注释的定界符认不出来,没有改文件。')
return '这段注释的定界符认不出来,没有改文件。'
const wrote = await deps.write(edit)
if (wrote === 'written' || wrote === 'unchanged')
return { status: 'done' }
return failed(wrote === 'rejected' ? '编辑器没接受这次修改,没有写回。' : '这块代码在重写期间变了,没有写回。')
return undefined
return wrote === 'rejected' ? '编辑器没接受这次修改,没有写回。' : '这块代码在重写期间变了,没有写回。'
}
/** 按记号在块树里找到这块。 */
+29 -16
View File
@@ -10,7 +10,7 @@ import type { CallTree } from '../callTree/model'
import type { DocumentDiff } from '../diff/previewDiff'
import type { ShownName } from '../nameResolution'
import type { ProfileChoice } from '../qa/askProfiles'
import type { RunningAsk } from '../qa/asks'
import type { AskRound } from '../qa/asks'
import type { Excluded, FilterState } from './blockFilter'
export type { Excluded, ShownName }
@@ -64,7 +64,9 @@ export interface DocumentMessage {
names: ShownName[]
}
/** 问过的一次(给预览看的):问题、回答(Markdown)、什么时候问的(ISO 时间)、用哪个 profile 答的。 */
/**
* 问过、重写过的一次(给预览看的):说的话、回答(Markdown;重写记录是交回的注释正文,没交回时是空的)、什么时候(ISO 时间)、用哪个 profile 答的。
*/
export interface QaEntryView {
id: string
question: string
@@ -72,23 +74,25 @@ export interface QaEntryView {
createdAt: string
/** 用哪个 profile 答的(profile 名);选「默认」时、以前存的没有 */
profile?: string
/** 问题带的图片,预览里能直接用的地址(webview.asWebviewUri);没带图片时没有 */
/** 带的图片,预览里能直接用的地址(webview.asWebviewUri);没带图片时没有 */
images?: string[]
/** 重写记录才有:没写进文件的原因 */
error?: string
}
/** 挂在一块上的问答;记号对不上这份文件里任何一块的不显示。 */
/** 挂在一块上的问答或重写记录;记号对不上这份文件里任何一块的不显示。 */
export interface QaThreadView {
anchor: string
entries: QaEntryView[]
}
/**
* 这份文件的问答:available 为假时文件不在工作区文件夹里、问答没地方存,不给「问 AI」;
* running 是正在答的问题(页面重新加载后靠它接上)。
* 这份文件的问答和重写记录:available 为假时文件不在工作区文件夹里、没地方存,不给「问 AI」和「重写注释」;
* current、rewriteCurrent 是当前这一轮:正在答的问题、正在重写的注释,和出错、被叫停没记下留着的(页面重新加载后靠它们接上)。
*/
export interface QaMessage {
type: 'qa'
/** 不在工作区文件夹里时没地方存,问 AI 不可用 */
/** 不在工作区文件夹里时没地方存,问 AI、重写注释不可用 */
available: boolean
/** 块下面的问答,按块的记号 */
threads: QaThreadView[]
@@ -96,7 +100,10 @@ export interface QaMessage {
path: QaEntryView[]
/** 顶部问的是这个文件还是这个文件夹 */
subject: 'file' | 'folder'
running: RunningAsk[]
current: AskRound[]
/** 块下面的重写记录,按块的记号 */
rewrites: QaThreadView[]
rewriteCurrent: AskRound[]
}
/**
@@ -121,8 +128,9 @@ export interface StickyScrollSettings {
* askProfiles:「问 AI」输入框旁边的 profile 下拉:选项和选中的那项;ready 后发一次,任何一个预览改了都发给所有预览
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
* answerEnd:askId 那个问题答完了:done 带最终的回答和存下的那条的 id,failed 带给人看的原因,stopped 是被叫停
* rewriteProgress:anchor 那块的注释正在按规范重写。question 是发给模型的那一句(技能命令和块在哪),text 是注释正文到现在为止写出来的,activity 是正在读什么
* rewriteEnd:这段重写结束了。done 是正文已经写进文件;failed 带给人看的原因,文件没改;stopped 是被叫停,文件没改
* rewriteProgress:askId 那一轮重写开始了、正文长了(text 是到现在为止写出来的)、或换了在做的事;question 是记在这一轮上的话(点按钮时是那句技能命令)
* rewriteEnd:askId 那一轮重写结束了:done 是模型答完、这一轮记下了,带交回的正文(text)、存下的那条的 id,没写进文件时带原因(error);
* failed 带给人看的原因,stopped 是被叫停,这两种都没记、文件没改
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现,由这两条命令发给活动的预览
* displayNames:version 这份文档里显示成中文名的名字,查到一步发一步(先本文件定义的,再别的文件定义的);不是页面上这个版本的不理
* diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有,见 DiffBase):每发一份文档之后算好了补发,
@@ -150,8 +158,8 @@ export type ExtensionToWebview
| ({ type: 'askProfiles' } & ProfileChoice)
| { type: 'answer', askId: string, text: string, activity?: string }
| { type: 'answerEnd', askId: string, status: 'done' | 'failed' | 'stopped', text?: string, error?: string, entryId?: string }
| { type: 'rewriteProgress', anchor: string, question: string, text: string, activity?: string }
| { type: 'rewriteEnd', anchor: string, status: 'done' | 'failed' | 'stopped', error?: string }
| { type: 'rewriteProgress', askId: string, question: string, text: string, activity?: string }
| { type: 'rewriteEnd', askId: string, status: 'done' | 'failed' | 'stopped', text?: string, error?: string, entryId?: string }
| { type: 'diff', diff: DocumentDiff | null }
| { type: 'repoLinksChecked', id: number, missing: string[], unresolved: string[] }
| { type: 'repoLinkBroken', path?: string, symbol?: string }
@@ -174,8 +182,11 @@ export type ExtensionToWebview
* setOnlyChanges:打开 / 关掉「只看改动」,扩展端记下后发 filter 给所有预览
* ask:问 anchor 那块代码;askId 由 webview 起,回答的消息带回去;扩展端接着这块最近一次问答的会话问,用眼下选的 profile;
* images 是贴进输入框的图片(没贴是空的),随问题发给 Claude Code,答成了跟这条一起存
* rewriteComment:按注释规范重写 anchor 那块的注释(还没有就新写一段)。过程用 rewriteProgress / rewriteEnd 发回页面,不写入问答
* stopRewrite:叫停 anchor 这段正在进行的重写,不写文件
* rewriteComment:按注释规范重写 anchor 那块的注释(还没有就新写一段);askId 由 webview 起,过程用 rewriteProgress / rewriteEnd 带回去。
* 没有 request 是点了按钮:发技能命令,开新会话;有 request 是在输入框里说的修改意见,接着这块最近一次重写的会话说,images 是贴的图片
* stopRewrite:叫停 askId 那一轮重写,不写文件、不记
* forgetRound:页面上收起了 anchor 那块的问答(ask)或重写(rewrite),扩展端去掉这块留着的那一轮(出错、被叫停的)
* deleteRewrite:删掉 anchor 那块下面的一条重写记录
* stopAsk:叫停 askId 那个问题,这次不存
* deleteQa:删掉 anchor 那块下面的一次问答(已经理解了)
* selectAskProfile:「问 AI」换 profile(id 是 profile 名,「默认」是 ''),扩展端记下后发 askProfiles 给所有预览
@@ -193,8 +204,10 @@ export type WebviewToExtension
| { type: 'setFilter', excluded: Excluded }
| { type: 'setOnlyChanges', on: boolean }
| { type: 'edit', startLine: number, endLine: number, text: string }
| { type: 'rewriteComment', anchor: string }
| { type: 'stopRewrite', anchor: string }
| { type: 'rewriteComment', askId: string, anchor: string, request?: string, images: ClaudeImage[] }
| { type: 'stopRewrite', askId: string }
| { type: 'deleteRewrite', anchor: string, entryId: string }
| { type: 'forgetRound', kind: 'ask' | 'rewrite', anchor: string }
| { type: 'selectTranslator', id: string }
| { type: 'visibleLine', line: number }
| { type: 'probeDefinition', id: number, version: number, offset: number }
@@ -1,7 +1,7 @@
import type { RewriteRequest } from '../qa/codeQa'
import type { ExtensionToWebview, WebviewToExtension } from './messages'
import { describe, expect, it, vi } from 'vitest'
import { window } from 'vscode'
import { askCommentBody } from '../qa/claudeAsk'
import { workspace } from 'vscode'
import { PreviewEditorProvider } from './previewEditor'
vi.mock('vscode', () => ({
@@ -10,27 +10,42 @@ vi.mock('vscode', () => ({
workspace: {
onDidChangeTextDocument: () => ({ dispose: () => {} }),
onDidChangeConfiguration: () => ({ dispose: () => {} }),
openTextDocument: vi.fn(),
applyEdit: vi.fn(async () => true),
},
/** 记下换了哪儿、换成什么 */
WorkspaceEdit: class {
readonly replaced: { range: unknown, text: string }[] = []
replace(_uri: unknown, range: unknown, text: string) { this.replaced.push({ range, text }) }
},
Range: class {
constructor(readonly startLine: number, readonly startCharacter: number, readonly endLine: number, readonly endCharacter: number) {}
},
EndOfLine: { LF: 1, CRLF: 2 },
}))
vi.mock('../../logging/logger', () => ({ logger: { add: vi.fn() } }))
vi.mock('../../webview/html', () => ({ webviewHtml: () => '', webviewUiRoot: () => ({}) }))
vi.mock('../folderStore', () => ({ locate: () => undefined }))
vi.mock('../folderStore', () => ({ locate: () => ({ folder: { uri: { fsPath: '/workspace' } }, key: 'web/a.ts' }) }))
vi.mock('./pathPreview', () => ({ pathPreviewOf: () => undefined }))
vi.mock('./occurrences', () => ({ occurrencesOf: vi.fn() }))
vi.mock('../qa/claudeAsk', () => ({ askCommentBody: vi.fn(), askFailure: (error: Error) => error.message }))
function preview(current: () => Promise<never>) {
const SOURCE = ['// 旧说明', 'export function load() {}']
function preview(rewrite: (uri: unknown, request: RewriteRequest) => Promise<string | undefined>, dirty = false) {
const subscribe = () => ({ dispose: () => {} })
const receive = vi.fn<(listener: (message: WebviewToExtension) => Promise<void>) => { dispose: () => void }>(subscribe)
const post = vi.fn<(message: ExtensionToWebview) => void>()
const uri = { fsPath: '/workspace/a.ts', scheme: 'file', toString: () => 'file:///workspace/a.ts' }
const uri = { fsPath: '/workspace/web/a.ts', scheme: 'file', toString: () => 'file:///workspace/web/a.ts' }
const document = { uri, isDirty: dirty, lineAt: (line: number) => ({ text: SOURCE[line] }), lineCount: SOURCE.length, eol: 1 }
vi.mocked(workspace.openTextDocument).mockResolvedValue(document as never)
const qa = { imageRoot: () => undefined, onDidChange: subscribe, onDidAnswer: subscribe, onDidRewrite: subscribe, profiles: { subscribe }, rewrite: vi.fn(rewrite) }
const provider = new PreviewEditorProvider(...[
{},
{},
{},
{ subscribe },
async () => [],
{ imageRoot: () => undefined, onDidChange: subscribe, onDidAnswer: subscribe, profiles: { subscribe, current } },
qa,
async () => [],
async () => [],
{ onDidChangeDiffBase: subscribe },
@@ -38,30 +53,74 @@ function preview(current: () => Promise<never>) {
async () => false,
] as unknown as ConstructorParameters<typeof PreviewEditorProvider>)
provider.resolveCustomTextEditor(...[
{ uri, isDirty: false },
document,
{ webview: { onDidReceiveMessage: receive, postMessage: post }, onDidChangeViewState: subscribe, onDidDispose: subscribe },
] as unknown as Parameters<PreviewEditorProvider['resolveCustomTextEditor']>)
const opened = (provider as unknown as { previews: Set<{ sent: unknown, dispose: () => void }> }).previews.values().next().value!
opened.sent = { blocks: [{ anchor: 'block', children: [], commentTarget: {}, startLine: 0, endLine: 1 }] }
return { send: receive.mock.calls[0]![0], post, dispose: () => opened.dispose() }
opened.sent = { blocks: [{ anchor: 'block', children: [], commentTarget: { kind: 'replace', startLine: 0, endLine: 0 }, startLine: 0, endLine: 1 }] }
return { send: receive.mock.calls[0]![0], post, qa, dispose: () => opened.dispose() }
}
describe('重写注释的模型选择失败', () => {
it('模型删除后仍发送失败结束消息和错误提示,不调用模型且允许再次尝试', async () => {
describe('预览里重写注释', () => {
it('点按钮:发技能命令、开新会话,记在这一轮上的就是这句命令;开不了时回一条带 askId 的失败,能再试', async () => {
const error = '文件问答模型不可用:official。请重新选择。'
const current = vi.fn<() => Promise<never>>().mockRejectedValue(new Error(error))
const editor = preview(current)
const editor = preview(async () => error)
try {
await expect(editor.send({ type: 'rewriteComment', anchor: 'block' })).resolves.toBeUndefined()
expect(editor.post).toHaveBeenLastCalledWith({ type: 'rewriteEnd', anchor: 'block', status: 'failed', error })
expect(window.showErrorMessage).toHaveBeenCalledWith(`代码阅读:${error}`)
expect(askCommentBody).not.toHaveBeenCalled()
await editor.send({ type: 'rewriteComment', anchor: 'block' })
expect(current).toHaveBeenCalledTimes(2)
expect(editor.post.mock.calls.filter(([message]) => message.type === 'rewriteEnd')).toHaveLength(2)
await editor.send({ type: 'rewriteComment', askId: 'r1', anchor: 'block', images: [] })
expect(editor.qa.rewrite).toHaveBeenCalledWith(expect.objectContaining({ fsPath: '/workspace/web/a.ts' }), expect.objectContaining({
askId: 'r1',
anchor: 'block',
question: '/ecc:writing-code-comments web/a.ts:1-2',
prompt: '/ecc:writing-code-comments web/a.ts:1-2',
images: [],
}))
expect(editor.qa.rewrite.mock.calls[0][1]).not.toHaveProperty('followUp')
expect(editor.post).toHaveBeenLastCalledWith({ type: 'rewriteEnd', askId: 'r1', status: 'failed', error })
await editor.send({ type: 'rewriteComment', askId: 'r2', anchor: 'block', images: [] })
expect(editor.qa.rewrite).toHaveBeenCalledTimes(2)
}
finally {
editor.dispose()
}
})
it('输入框里说的话:接着这块的重写会话说,带上块现在在哪;会话找不到时开新会话用的是技能命令加这句话', async () => {
const editor = preview(async () => undefined)
try {
const image = { mediaType: 'image/png', base64: 'iVBORw0KGgo=' }
await editor.send({ type: 'rewriteComment', askId: 'r1', anchor: 'block', request: '加上原型图', images: [image] })
expect(editor.qa.rewrite.mock.calls[0][1]).toMatchObject({
question: '加上原型图',
prompt: '/ecc:writing-code-comments web/a.ts:1-2\n\n加上原型图',
followUp: 'web/a.ts:1-2\n\n加上原型图',
images: [image],
})
}
finally {
editor.dispose()
}
})
it('模型答完写回:套回原来的 // 换掉那几行;文件还有没保存的修改时不开始', async () => {
const editor = preview(async () => undefined)
try {
await editor.send({ type: 'rewriteComment', askId: 'r1', anchor: 'block', images: [] })
expect(await editor.qa.rewrite.mock.calls[0][1].writeBack('新说明')).toBeUndefined()
const [edit] = vi.mocked(workspace.applyEdit).mock.calls.at(-1)! as unknown as [{ replaced: { text: string }[] }]
expect(edit.replaced.map(item => item.text)).toEqual(['// 新说明'])
expect(await editor.qa.rewrite.mock.calls[0][1].writeBack(undefined)).toBe('模型没有按要求交回注释正文,没有改文件。')
}
finally {
editor.dispose()
}
const dirty = preview(async () => undefined, true)
try {
await dirty.send({ type: 'rewriteComment', askId: 'r1', anchor: 'block', images: [] })
expect(dirty.qa.rewrite).not.toHaveBeenCalled()
expect(dirty.post).toHaveBeenLastCalledWith({ type: 'rewriteEnd', askId: 'r1', status: 'failed', error: '文件还有没保存的修改,模型读的是磁盘上的文件:先保存再重写注释。' })
}
finally {
dirty.dispose()
}
})
})
+110 -134
View File
@@ -1,8 +1,7 @@
import type { CustomTextEditorProvider, Disposable, DocumentSymbol, Event, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode'
import type { ClaudeProfile } from '../../cc/profiles'
import type { ClaudeImage } from '../../cc/spawnClaude'
import type { IndexFind } from '../callTree/endpoint'
import type { CommentEdit, RewriteOutcome, WriteResult } from '../commentRewrite'
import type { CommentEdit, CommentJob, WriteResult } from '../commentRewrite'
import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, SourceRange } from '../contract'
import type { DiffBase } from '../diff/diffBase'
import type { OldDocument, SentDocument } from '../diff/previewDiff'
@@ -18,20 +17,18 @@ import type { FoundLocation } from './definitions'
import type { DefinitionPeek, ExtensionToWebview, Occurrence, Reveal, WebviewToExtension } from './messages'
import type { TargetSpot } from './openTarget'
import { realpath } from 'node:fs/promises'
import { dirname, relative, sep } from 'node:path'
import { commands, EndOfLine, Position, Range, Uri, window, workspace, WorkspaceEdit } from 'vscode'
import { logger } from '../../logging/logger'
import { webviewHtml, webviewUiRoot } from '../../webview/html'
import { DocumentCallees } from '../calleeKinds'
import { loadBlockCalls } from '../callTree/query'
import { CallTreeSession } from '../callTree/session'
import { findBlock, rewriteBlockComment } from '../commentRewrite'
import { commentFollowUp, commentJob, commentRewritePrompt, findBlock, writeCommentBody } from '../commentRewrite'
import { PreviewDiff } from '../diff/previewDiff'
import { blockReplacement } from '../editText'
import { locate } from '../folderStore'
import { definitionFor, symbolAt, symbolOccurrences } from '../localSymbols'
import { DocumentNames, resolveNames } from '../nameResolution'
import { askCommentBody, askFailure } from '../qa/claudeAsk'
import { PATH_ASK_ANCHOR } from '../qa/pathQa'
import { askPrompt, followUpPrompt } from '../qa/prompt'
import { DocumentLookups } from '../ruleLookups'
@@ -181,12 +178,6 @@ class Preview {
private readonly definitions: DefinitionSources<FoundLocation>
/** webview 报来的视口顶部那块的起始行 */
visibleLine: number | undefined
/** 正在重写的注释(块的记号)。同一块没写完不再开一次 */
private readonly rewriting = new Set<string>()
/** 预览关掉时停掉还在跑的重写 */
private readonly rewriteAbort = new AbortController()
/** 每段正在重写的注释各自的停止开关;预览关掉时 [rewriteAbort] 会把它们都停掉 */
private readonly rewriteStops = new Map<string, AbortController>()
/** 最近一次发出去的文档(版本、块树、源码):问 AI 时按块的记号在里面找那块代码,对比基准变了时拿它重新比 */
private sent: SentDocument | undefined
/** 能看调用树的块(函数、类、接口卡片)落在几棵调用树里。文档发出去之后再数,不挡住第一次预览 */
@@ -314,6 +305,25 @@ class Preview {
})
this.postQa()
}),
owner.qa.onDidRewrite((event) => {
if (event.owner !== this.subject().uri.toString() || !this.ready)
return
if (event.kind === 'update') {
const { askId, question, text, activity } = event.ask
this.post({ type: 'rewriteProgress', askId, question, text, ...(activity !== undefined && { activity }) })
return
}
const { end, entryId } = event
this.post({
type: 'rewriteEnd',
askId: event.ask.askId,
status: end.status,
...(end.status !== 'stopped' && end.error !== undefined && { error: end.error }),
...(end.status === 'done' && end.body !== undefined && { text: end.body }),
...(entryId !== undefined && { entryId }),
})
this.postQa()
}),
]
}
@@ -474,7 +484,6 @@ class Preview {
dispose(): void {
this.callTrees.dispose()
this.rewriteAbort.abort()
clearTimeout(this.timer)
this.names.dispose()
this.lookups.dispose()
@@ -588,13 +597,22 @@ class Preview {
await this.filter.setOnlyChanges(msg.on)
return
case 'edit':
await this.applyEdit(msg.startLine, msg.endLine, msg.text)
await replaceDocumentLines(this.document, msg.startLine, msg.endLine, msg.text)
return
case 'rewriteComment':
await this.rewriteComment(msg.anchor)
await this.rewrite(msg.askId, msg.anchor, msg.request, msg.images)
return
case 'stopRewrite':
this.rewriteStops.get(msg.anchor)?.abort()
this.owner.qa.stopRewrite(msg.askId)
return
case 'deleteRewrite':
await this.owner.qa.deleteRewrite(this.subject().uri, msg.anchor, msg.entryId)
return
case 'forgetRound':
if (msg.kind === 'ask')
this.owner.qa.forgetAsk(this.subject().uri, msg.anchor)
else
this.owner.qa.forgetRewrite(this.subject().uri, msg.anchor)
return
case 'selectTranslator':
await this.registry.selectTranslator(this.languageId, msg.id)
@@ -970,132 +988,90 @@ class Preview {
this.post({ type: 'repoLinkOpened', ...(path !== undefined && { path }), ...(symbol !== undefined && { symbol }) })
}
/** 用写注释的技能重写这一段,只把正文套回原来的定界符,再走块编辑那条写回。 */
private async rewriteComment(anchor: string): Promise<void> {
if (this.rewriting.has(anchor) || this.rewriteAbort.signal.aborted)
return
this.rewriting.add(anchor)
const stop = new AbortController()
const onDispose = (): void => stop.abort()
this.rewriteAbort.signal.addEventListener('abort', onDispose)
this.rewriteStops.set(anchor, stop)
try {
const outcome = await this.rewriteCommentNow(anchor, stop.signal)
if (!this.rewriteAbort.signal.aborted)
this.post({ type: 'rewriteEnd', anchor, status: outcome.status, ...(outcome.status === 'failed' && { error: outcome.error }) })
}
finally {
this.rewriteAbort.signal.removeEventListener('abort', onDispose)
this.rewriteStops.delete(anchor)
this.rewriting.delete(anchor)
}
}
private failRewrite(message: string): void {
void window.showErrorMessage(`代码阅读:${message}`)
}
private async rewriteCommentNow(anchor: string, signal: AbortSignal): Promise<RewriteOutcome> {
const failed = (message: string): RewriteOutcome => {
this.failRewrite(message)
return { status: 'failed', error: message }
}
const block = this.sent && findBlock(this.sent.blocks, anchor)
if (!block)
return failed('这块代码刚改过,找不到了。等预览刷新后再重写。')
const target = block.commentTarget
if (!target)
return failed('这块不能写注释。')
/**
* 重写 anchor 那块的注释:按最近一次发出去的块树找到它,记下它现在的样子(写回前比对),把发给模型的话和写回的办法交给 CodeQa。
* 点按钮(没有 request)发技能命令、开新会话;输入框里说的话接着这块最近一次重写的会话说,带上块现在在哪。
* 找不到这块、文件有没保存的修改(模型读的是磁盘上的文件)、开不了时,直接回一条失败。
*/
private async rewrite(askId: string, anchor: string, request: string | undefined, images: ClaudeImage[]): Promise<void> {
const fail = (error: string): void => this.post({ type: 'rewriteEnd', askId, status: 'failed', error })
const { document } = this
if (document.isDirty)
return failed('文件还有没保存的修改,模型读的是磁盘上的文件:先保存再重写注释。')
const block = this.sent && findBlock(this.sent.blocks, anchor)
const job = block?.commentTarget && commentJob(block.commentTarget, block.startLine, block.endLine, line => document.lineAt(line).text, () => document.lineCount)
if (!block || !job) {
fail(block && !block.commentTarget ? '这块不能写注释。' : '这块代码刚改过,找不到了。等预览刷新后再重写。')
return
}
const at = locate(document.uri)
const cwd = at?.folder.uri.fsPath ?? dirname(document.uri.fsPath)
let profile: ClaudeProfile | undefined
try {
profile = await this.owner.qa.profiles.current()
if (!at) {
fail('这个文件不在工作区文件夹里,重写记录没地方存。')
return
}
catch (err) {
return failed(err instanceof Error ? err.message : String(err))
if (document.isDirty) {
fail('文件还有没保存的修改,模型读的是磁盘上的文件:先保存再重写注释。')
return
}
if (signal.aborted)
return { status: 'stopped' }
let question = ''
let text = ''
let activity: string | undefined
const publish = (): void => {
this.post({ type: 'rewriteProgress', anchor, question, text, ...(activity !== undefined && { activity }) })
const prompt = commentRewritePrompt(at.key, block.startLine, block.endLine, request)
const question = request ?? prompt
const refused = await this.owner.qa.rewrite(document.uri, {
askId,
anchor,
question,
prompt,
...(request !== undefined && { followUp: commentFollowUp(at.key, block.startLine, block.endLine, request) }),
images,
writeBack: body => this.writeBack(job, body),
})
if (refused !== undefined) {
fail(refused)
return
}
const outcome = await rewriteBlockComment({
target,
startLine: block.startLine,
endLine: block.endLine,
path: relative(cwd, document.uri.fsPath).split(sep).join('/'),
}, {
// 页面点按钮时还不知道那句技能命令,先报上去
this.post({ type: 'rewriteProgress', askId, question, text: '' })
this.postQa()
}
/** 把交回的正文写回这份文件;预览关了也照样按 uri 打开文档来写。 */
private async writeBack(job: CommentJob, body: string | undefined): Promise<string | undefined> {
const document = await workspace.openTextDocument(this.document.uri)
return writeCommentBody(job, body, {
lineAt: line => document.lineAt(line).text,
lineCount: () => document.lineCount,
ask: (prompt) => {
question = prompt
publish()
return askCommentBody({
prompt,
...(profile && { profilePath: profile.path }),
cwd,
signal,
onEvent: (event) => {
if (event.kind === 'text') {
text = event.text
activity = undefined
}
else {
activity = event.label
}
publish()
},
})
},
failure: askFailure,
write: edit => this.applyCommentEdit(edit),
signal,
write: edit => writeCommentEdit(document, edit),
})
if (outcome.status === 'failed')
this.failRewrite(outcome.error)
return outcome
}
/** 写回重写好的注释:换掉原来那几行(同块编辑),或在块上面插进新写的几行。 */
private async applyCommentEdit(edit: CommentEdit): Promise<WriteResult> {
if (edit.kind === 'replace')
return this.applyEdit(edit.startLine, edit.endLine, edit.text)
const { document } = this
if (edit.line >= document.lineCount)
return 'missing'
const eol = document.eol === EndOfLine.CRLF ? '\r\n' : '\n'
const change = new WorkspaceEdit()
change.insert(document.uri, new Position(edit.line, 0), edit.text.split('\n').join(eol) + eol)
if (!await workspace.applyEdit(change)) {
void window.showErrorMessage(`代码阅读:没能把修改写回 ${workspace.asRelativePath(document.uri)}`)
return 'rejected'
}
return 'written'
}
/** 把这几行换成 [text](见 WriteResult)。 */
private async applyEdit(startLine: number, endLine: number, text: string): Promise<WriteResult> {
const { document } = this
const replacement = blockReplacement(line => document.lineAt(line).text, document.lineCount, startLine, endLine, text)
if (!replacement) {
logger.add({ level: 'warn', source: 'codeReader', message: `块内修改的行号 ${startLine}-${endLine} 已不在 ${document.uri.fsPath} 里,没有写回` })
return 'missing'
}
if (replacement.unchanged)
return 'unchanged'
const edit = new WorkspaceEdit()
edit.replace(document.uri, new Range(replacement.startLine, 0, replacement.endLine, replacement.endCharacter), replacement.text)
if (!await workspace.applyEdit(edit)) {
void window.showErrorMessage(`代码阅读:没能把修改写回 ${workspace.asRelativePath(document.uri)}`)
return 'rejected'
}
return 'written'
}
}
/** 写回重写好的注释:换掉原来那几行(同块编辑),或在块上面插进新写的几行。 */
async function writeCommentEdit(document: TextDocument, edit: CommentEdit): Promise<WriteResult> {
if (edit.kind === 'replace')
return replaceDocumentLines(document, edit.startLine, edit.endLine, edit.text)
if (edit.line >= document.lineCount)
return 'missing'
const eol = document.eol === EndOfLine.CRLF ? '\r\n' : '\n'
const change = new WorkspaceEdit()
change.insert(document.uri, new Position(edit.line, 0), edit.text.split('\n').join(eol) + eol)
if (!await workspace.applyEdit(change)) {
void window.showErrorMessage(`代码阅读:没能把修改写回 ${workspace.asRelativePath(document.uri)}`)
return 'rejected'
}
return 'written'
}
/** 把这几行换成 [text](见 WriteResult):块编辑和重写注释都走这条。 */
async function replaceDocumentLines(document: TextDocument, startLine: number, endLine: number, text: string): Promise<WriteResult> {
const replacement = blockReplacement(line => document.lineAt(line).text, document.lineCount, startLine, endLine, text)
if (!replacement) {
logger.add({ level: 'warn', source: 'codeReader', message: `块内修改的行号 ${startLine}-${endLine} 已不在 ${document.uri.fsPath} 里,没有写回` })
return 'missing'
}
if (replacement.unchanged)
return 'unchanged'
const edit = new WorkspaceEdit()
edit.replace(document.uri, new Range(replacement.startLine, 0, replacement.endLine, replacement.endCharacter), replacement.text)
if (!await workspace.applyEdit(edit)) {
void window.showErrorMessage(`代码阅读:没能把修改写回 ${workspace.asRelativePath(document.uri)}`)
return 'rejected'
}
return 'written'
}
+51 -5
View File
@@ -1,8 +1,8 @@
import type { AskEnd, AskStart, RunningAsk } from './asks'
import type { Answer, AskRun } from './claudeAsk'
import { describe, expect, it } from 'vitest'
import { ClaudeCancelledError, ClaudeError } from '../../cc/spawnClaude'
import { QaAsks } from './asks'
import { ClaudeCancelledError, ClaudeError, ClaudeTimeoutError } from '../../cc/spawnClaude'
import { ASK_WORDING, QaAsks, REWRITE_WORDING } from './asks'
/** 假的回答:每次开问记下来,由测试决定什么时候答完 / 出错;叫停时像真的一样抛 ClaudeCancelledError */
function fakeRunner(): { runner: (run: AskRun) => Promise<Answer>, runs: (AskRun & { answer: (answer: Answer) => void, fail: (err: Error) => void })[] } {
@@ -51,11 +51,11 @@ describe('正在答的问题', () => {
{ askId: '1', anchor: '1', question: '问 1', text: '', activity: '读取 m.py' },
{ askId: '1', anchor: '1', question: '问 1', text: '答' },
])
expect(asks.runningFor('file:///a.py')).toEqual([{ askId: '1', anchor: '1', question: '问 1', text: '答' }])
expect(asks.currentFor('file:///a.py')).toEqual([{ askId: '1', anchor: '1', question: '问 1', text: '答', status: 'running' }])
runs[0].answer({ text: '答案', sessionId: 's' })
await events.ended
expect(events.ends).toEqual([[{ askId: '1', anchor: '1', question: '问 1', text: '答' }, { status: 'done', answer: { text: '答案', sessionId: 's' } }]])
expect(asks.runningFor('file:///a.py')).toEqual([])
expect(asks.currentFor('file:///a.py')).toEqual([])
})
it('叫停:结束 claude,报 stopped,这块可以再问', async () => {
@@ -91,7 +91,7 @@ describe('正在答的问题', () => {
it('不同块可以同时答,最多几个;答完一个又能问', async () => {
const { runner, runs } = fakeRunner()
const asks = new QaAsks(runner, 2)
const asks = new QaAsks(runner, ASK_WORDING, 2)
const first = listener()
asks.start(start('1'), first)
asks.start(start('2'), listener())
@@ -101,6 +101,52 @@ describe('正在答的问题', () => {
expect(asks.start(start('3'), listener())).toBeUndefined()
})
it('重写注释用自己的说法:这段已经在重写、同时重写的太多、等太久停掉', async () => {
const { runner, runs } = fakeRunner()
const rewrites = new QaAsks(runner, REWRITE_WORDING, 2)
const first = listener()
rewrites.start(start('1', 'function f'), first)
expect(rewrites.start(start('2', 'function f'), listener())).toBe('这段注释已经在重写了,等它写完或者先停下再说。')
rewrites.start(start('3', 'function g'), listener())
expect(rewrites.start(start('4', 'function h'), listener())).toBe('同时最多重写 2 段注释,等前面的写完再来。')
runs[0].fail(new ClaudeTimeoutError('Claude 调用超时(900s)', ''))
await first.ended
expect(first.ends[0][1]).toEqual({ status: 'failed', error: '等了 15 分钟还没写完,已经停掉。' })
})
it('出错、叫停的那一轮没记下,留在这里给重新加载的页面再画出来;这块再开一轮、收起这块时去掉,别的文件不算', async () => {
const { runner, runs } = fakeRunner()
const asks = new QaAsks(runner)
const failing = listener()
asks.start(start('1', 'function f'), failing)
runs[0].fail(new ClaudeError('网络断了', ''))
await failing.ended
expect(asks.currentFor('file:///a.py')).toEqual([{ askId: '1', anchor: 'function f', question: '问 1', text: '', status: 'failed', error: 'Claude Code 没答成:网络断了' }])
expect(asks.currentFor('file:///b.py')).toEqual([])
const stopping = listener()
expect(asks.start(start('2', 'function f'), stopping)).toBeUndefined()
expect(asks.currentFor('file:///a.py').map(ask => [ask.askId, ask.status])).toEqual([['2', 'running']])
asks.stop('2')
await stopping.ended
expect(asks.currentFor('file:///a.py').map(ask => [ask.askId, ask.status])).toEqual([['2', 'stopped']])
asks.forget('file:///b.py', 'function f')
expect(asks.currentFor('file:///a.py')).toHaveLength(1)
asks.forget('file:///a.py', 'function f')
expect(asks.currentFor('file:///a.py')).toEqual([])
})
it('留着的那一轮不占名额,收起时还在答的不去掉', async () => {
const { runner, runs } = fakeRunner()
const asks = new QaAsks(runner, ASK_WORDING, 1)
const failing = listener()
asks.start(start('1', 'function f'), failing)
runs[0].fail(new ClaudeError('网络断了', ''))
await failing.ended
expect(asks.start(start('2', 'function g'), listener())).toBeUndefined()
asks.forget('file:///a.py', 'function g')
expect(asks.currentFor('file:///a.py').map(ask => [ask.askId, ask.status])).toEqual([['1', 'failed'], ['2', 'running']])
})
it('全部叫停', async () => {
const { runner, runs } = fakeRunner()
const asks = new QaAsks(runner)
+108 -36
View File
@@ -1,16 +1,16 @@
/**
* 正在答的问题:一块同时只答一个,不同块可以同时答(最多 MAX_RUNNING 个),能叫停。
* 不碰 VS Code:答完怎么存、怎么告诉界面交给 AskListener。
* 块下面正在进行的对话(问 AI、重写注释各一份):一块同时只进行一轮,不同块可以同时进行(最多 MAX_RUNNING 个),能叫停。
* 不碰 VS Code:结束后怎么存、怎么告诉界面交给 AskListener。
*/
import type { Answer, AskRun, AskRunner } from './claudeAsk'
import type { Answer, AskRun } from './claudeAsk'
import { ClaudeCancelledError } from '../../cc/spawnClaude'
import { askFailure } from './claudeAsk'
import { askFailure, rewriteFailure } from './claudeAsk'
/** 同时答的问题最多几个:每个都是一个 claude 进程 */
/** 同时进行的最多几个:每个都是一个 claude 进程 */
export const MAX_RUNNING = 3
/** 正在答的一个问题,界面照它显示。 */
/** 正在进行的一轮,界面照它显示。 */
export interface RunningAsk {
askId: string
anchor: string
@@ -21,50 +21,101 @@ export interface RunningAsk {
activity?: string
}
export type AskEnd
= | { status: 'done', answer: Answer }
/** 进行中报上来的:会话 id、回答长出来的字、正在做的事。 */
export type RunEvent
= | { kind: 'session', sessionId: string }
| { kind: 'text', text: string }
| { kind: 'activity', label: string }
/** 跑一轮要的:叫停的开关,进行中的消息交给谁。 */
export interface RunControl {
signal: AbortSignal
onEvent: (event: RunEvent) => void
}
export type AskEnd<Result = Answer>
= | { status: 'done', answer: Result }
| { status: 'failed', error: string }
| { status: 'stopped' }
export interface AskStart {
export interface AskStart<Run = Omit<AskRun, 'signal' | 'onEvent'>> {
/** 哪份文件问的(文档 uri):同一块只看同一份文件里的 */
owner: string
askId: string
anchor: string
question: string
run: Omit<AskRun, 'signal' | 'onEvent'>
run: Run
}
export interface AskListener {
export interface AskListener<Result = Answer> {
update: (ask: RunningAsk) => void
end: (ask: RunningAsk, end: AskEnd) => void | Promise<void>
end: (ask: RunningAsk, end: AskEnd<Result>) => void | Promise<void>
}
interface Running {
/** 给人看的话:这块已经在进行、同时进行的太多、没成的原因。 */
export interface RunWording {
busy: string
full: (limit: number) => string
failure: (err: unknown) => string
}
export const ASK_WORDING: RunWording = {
busy: '这里已经在问了,等它答完或者先停下再问。',
full: limit => `同时最多问 ${limit} 个问题,等前面的答完再问。`,
failure: askFailure,
}
export const REWRITE_WORDING: RunWording = {
busy: '这段注释已经在重写了,等它写完或者先停下再说。',
full: limit => `同时最多重写 ${limit} 段注释,等前面的写完再来。`,
failure: rewriteFailure,
}
/** 这一轮眼下怎样:还在进行,或出错、被叫停(没记下,留着给人看)。 */
export interface AskRound extends RunningAsk {
status: 'running' | 'failed' | 'stopped'
/** failed 时给人看的原因 */
error?: string
}
interface Round {
owner: string
ask: RunningAsk
controller: AbortController
/** 出错、被叫停了;还在进行时没有 */
end?: Exclude<AskEnd, { status: 'done' }>
}
export class QaAsks {
private readonly running = new Map<string, Running>()
/**
* Run 是跑一轮要的全部,叫停的开关和进行中的消息由这里给。
* 答成了的那一轮交给 listener 记下,这里就不管了;出错、被叫停的没记下,留在这里(页面重新加载后靠 currentFor 再画出来),
* 这块再开一轮、或页面上收起这块时去掉(forget)。
*/
export class QaAsks<Run extends { signal: AbortSignal } = AskRun, Result = Answer> {
private readonly rounds = new Map<string, Round>()
constructor(private readonly runner: AskRunner, private readonly limit = MAX_RUNNING) {}
constructor(
private readonly runner: (run: Run) => Promise<Result>,
private readonly wording: RunWording = ASK_WORDING,
private readonly limit = MAX_RUNNING,
) {}
/** 开始答;这块已经在答、同时答的太多时不开始,返回原因(给人看的话)。 */
start(start: AskStart, listener: AskListener): string | undefined {
/** 开始一轮;这块已经在进行、同时进行的太多时不开始,返回原因(给人看的话)。这块留着的上一轮去掉。 */
start(start: AskStart<Omit<Run, keyof RunControl>>, listener: AskListener<Result>): string | undefined {
const { owner, askId, anchor, question } = start
if (this.running.has(askId))
if (this.rounds.has(askId))
return '这个问题已经在问了。'
if ([...this.running.values()].some(item => item.owner === owner && item.ask.anchor === anchor))
return '这里已经在问了,等它答完或者先停下再问。'
if (this.running.size >= this.limit)
return `同时最多问 ${this.limit} 个问题,等前面的答完再问。`
const running = [...this.rounds.values()].filter(round => !round.end)
if (running.some(round => round.owner === owner && round.ask.anchor === anchor))
return this.wording.busy
if (running.length >= this.limit)
return this.wording.full(this.limit)
this.forget(owner, anchor)
const controller = new AbortController()
const ask: RunningAsk = { askId, anchor, question, text: '' }
this.running.set(askId, { owner, ask, controller })
void this.runner({
...start.run,
const round: Round = { owner, ask, controller }
this.rounds.set(askId, round)
const control: RunControl = {
signal: controller.signal,
onEvent: (event) => {
if (event.kind === 'text') {
@@ -79,11 +130,16 @@ export class QaAsks {
}
listener.update({ ...ask })
},
}).then(
(answer): AskEnd => ({ status: 'done', answer }),
(err: unknown): AskEnd => err instanceof ClaudeCancelledError ? { status: 'stopped' } : { status: 'failed', error: askFailure(err) },
}
// Run 里除了这两项就是 start.run,拼回去还是 Run
void this.runner({ ...start.run, ...control } as unknown as Run).then(
(answer): AskEnd<Result> => ({ status: 'done', answer }),
(err: unknown): AskEnd<Result> => err instanceof ClaudeCancelledError ? { status: 'stopped' } : { status: 'failed', error: this.wording.failure(err) },
).then(async (end) => {
this.running.delete(askId)
if (end.status === 'done')
this.rounds.delete(askId)
else
round.end = end
await listener.end({ ...ask }, end)
})
return undefined
@@ -91,17 +147,33 @@ export class QaAsks {
/** 叫停:结束 claude 进程,这次不存。 */
stop(askId: string): void {
this.running.get(askId)?.controller.abort()
const round = this.rounds.get(askId)
if (round && !round.end)
round.controller.abort()
}
/** owner 这份文件正在答的问题,按开始的先后。 */
runningFor(owner: string): RunningAsk[] {
return [...this.running.values()].filter(item => item.owner === owner).map(item => ({ ...item.ask }))
/** owner 这份文件当前的各轮:还在进行的,和出错、被叫停留着的,按开始的先后。 */
currentFor(owner: string): AskRound[] {
return [...this.rounds.values()].filter(round => round.owner === owner).map(({ ask, end }) => ({
...ask,
status: end?.status ?? 'running',
...(end?.status === 'failed' && { error: end.error }),
}))
}
/** 去掉 owner 这份文件 anchor 那块留着的那一轮(页面上收起了这块);还在进行的不动。 */
forget(owner: string, anchor: string): void {
for (const [askId, round] of this.rounds) {
if (round.end && round.owner === owner && round.ask.anchor === anchor)
this.rounds.delete(askId)
}
}
/** 全部叫停(插件停用时)。 */
stopAll(): void {
for (const item of this.running.values())
item.controller.abort()
for (const round of this.rounds.values()) {
if (!round.end)
round.controller.abort()
}
}
}
+143 -20
View File
@@ -1,9 +1,9 @@
import type { runClaudeLines } from '../../cc/spawnClaude'
import type { AskDeps, AskRun } from './claudeAsk'
import type { AskDeps, AskRun, CommentAsk, CommentAskDeps } from './claudeAsk'
import { describe, expect, it } from 'vitest'
import { CLAUDE_MISSING_MSG, ClaudeCancelledError, ClaudeError, ClaudeTimeoutError } from '../../cc/spawnClaude'
import { COMMENT_SCHEMA } from '../commentRewrite'
import { askArgs, askClaude, askCommentBody, askFailure } from './claudeAsk'
import { askArgs, askClaude, askCommentBody, askFailure, commentPluginRoot, pluginRootIn, REWRITE_TIMEOUT_MS, rewriteArgs, rewriteFailure } from './claudeAsk'
type RunLinesOptions = Parameters<typeof runClaudeLines>[0]
@@ -170,27 +170,150 @@ describe('失败了给人看的话', () => {
})
})
describe('重写注释问一次', () => {
const structured = (body: string): string => line({ type: 'result', subtype: 'success', is_error: false, result: JSON.stringify({ body }), structured_output: { body }, session_id: 's1' })
describe('重写注释的命令行参数', () => {
const SCRATCH = '/private/var/folders/T/spx-comment-s1'
const allowed = 'Read,Grep,Glob,Bash(/Applications/OpenPencil.app/Contents/MacOS/op *),Bash(mkdir -p */prototypes),Edit(//private/var/folders/T/spx-comment-s1/**)'
it('只读、按 JSON Schema 交结构化结果:参数在只读那套后面加 --json-schema,stdin 里只有那一句提示;返回结构化结果里的正文', async () => {
const { deps, calls } = fakeClaude([{ lines: [structured('按名字找文件。')] }])
const events: unknown[] = []
const body = await askCommentBody({ prompt: '/ecc:writing-code-comments src/a.py:3-8', profilePath: '/p/sonnet.json', cwd: '/work', signal: new AbortController().signal, onEvent: event => events.push(event) }, deps)
expect(body).toBe('按名字找文件。')
expect(calls[0].args).toEqual([...askArgs({ profilePath: '/p/sonnet.json' }), '--json-schema', COMMENT_SCHEMA])
expect(calls[0].args).toEqual(expect.arrayContaining(['--tools', 'Read,Grep,Glob', '--permission-mode', 'dontAsk']))
it('不读用户、项目的设置(--restricted),写注释的插件单独加载、能读;只放开读搜、OpenPencil 的 op、建 prototypes 目录、改草稿目录,别的一律拒、不加载 MCP;按 JSON Schema 交结果', () => {
const args = rewriteArgs({ pluginRoot: '/plugins/ecc', scratch: SCRATCH, profilePath: '/p/sonnet.json', session: { start: 's1' } })
expect(args).toEqual([
'--settings',
'/p/sonnet.json',
'--restricted',
'--plugin-dir',
'/plugins/ecc',
'--add-dir',
'/plugins/ecc',
'--add-dir',
SCRATCH,
'--tools',
'Read,Grep,Glob,Bash,Edit,Write',
'--allowedTools',
allowed,
'--permission-mode',
'dontAsk',
'--strict-mcp-config',
'--session-id',
's1',
'-p',
'--input-format',
'stream-json',
'--output-format',
'stream-json',
'--verbose',
'--include-partial-messages',
'--json-schema',
COMMENT_SCHEMA,
])
expect(args.join(' ')).not.toMatch(/dangerously|bypassPermissions/)
expect(JSON.parse(COMMENT_SCHEMA)).toEqual({ type: 'object', properties: { body: { type: 'string' } }, required: ['body'], additionalProperties: false })
expect(sent(calls[0])).toEqual({ text: '/ecc:writing-code-comments src/a.py:3-8', images: [] })
expect(calls[0].cwd).toBe('/work')
})
it('只回了一段话、没交结构化结果:没有正文;Claude Code 报错、没给结果:抛错', async () => {
const free = fakeClaude([{ lines: [result('我改好了。', 's1')] }])
expect(await askCommentBody({ prompt: 'p', cwd: '/w', signal: new AbortController().signal, onEvent: () => {} }, free.deps)).toBeUndefined()
const failing = fakeClaude([{ lines: [line({ type: 'result', is_error: true, result: '额度用完了' })] }])
await expect(askCommentBody({ prompt: 'p', cwd: '/w', signal: new AbortController().signal, onEvent: () => {} }, failing.deps)).rejects.toThrow('额度用完了')
const empty = fakeClaude([{ lines: [] }])
await expect(askCommentBody({ prompt: 'p', cwd: '/w', signal: new AbortController().signal, onEvent: () => {} }, empty.deps)).rejects.toThrow('没给出结果')
it('接着说时 --resume 那次会话、不带 --session-id;没有 profile 就不带 --settings', () => {
const args = rewriteArgs({ pluginRoot: '/plugins/ecc', scratch: SCRATCH, session: { resume: 's1' } })
expect(args[0]).toBe('--restricted')
expect(args).not.toContain('--session-id')
expect(args.slice(args.indexOf('--resume'), args.indexOf('--resume') + 3)).toEqual(['--resume', 's1', '-p'])
})
})
describe('写注释的插件在哪', () => {
const list = JSON.stringify([
{ id: 'claude-mem@thedotmack', enabled: true, installPath: '/plugins/mem' },
{ id: 'ecc@ecc', version: '2.2.52', enabled: true, installPath: '/plugins/ecc/2.2.52' },
])
it('claude plugin list --json 里启用着的 ecc 的目录;没装、没启用、读不出来都没有', () => {
expect(pluginRootIn(list, 'ecc')).toBe('/plugins/ecc/2.2.52')
expect(pluginRootIn(JSON.stringify([{ id: 'ecc@ecc', enabled: false, installPath: '/plugins/ecc' }]), 'ecc')).toBeUndefined()
expect(pluginRootIn(JSON.stringify([{ id: 'ecc-tools@x', enabled: true, installPath: '/plugins/tools' }]), 'ecc')).toBeUndefined()
expect(pluginRootIn('not json', 'ecc')).toBeUndefined()
})
it('在这次的目录里问 claude(项目里装的插件也算);找不到时说清楚要先装上', async () => {
const calls: RunLinesOptions[] = []
const found = await commentPluginRoot('/work', async (options) => {
calls.push(options)
JSON.stringify(JSON.parse(list), null, 2).split('\n').forEach(options.onLine)
})
expect(found).toBe('/plugins/ecc/2.2.52')
expect(calls[0]).toMatchObject({ args: ['plugin', 'list', '--json'], cwd: '/work' })
await expect(commentPluginRoot('/work', async options => options.onLine('[]'))).rejects.toThrow('没找到启用着的 ecc 插件')
})
})
describe('重写注释问一次', () => {
const structured = (body: string, session = 's1'): string => line({ type: 'result', subtype: 'success', is_error: false, result: JSON.stringify({ body }), structured_output: { body }, session_id: session })
/** 假的 claude 再加上找插件、建草稿目录、起会话 id:都记下来 */
function commentDeps(runs: { lines: string[], error?: Error }[]) {
const { deps, calls } = fakeClaude(runs)
const scratches: string[] = []
const lookups: string[] = []
let next = 0
const commentDeps: CommentAskDeps = {
...deps,
pluginRoot: async (cwd) => {
lookups.push(cwd)
return '/plugins/ecc'
},
scratch: async (sessionId) => {
scratches.push(sessionId)
return `/tmp/spx-comment-${sessionId}`
},
newSessionId: () => `new-${++next}`,
}
return { deps: commentDeps, calls, scratches, lookups }
}
function comment(overrides: Partial<CommentAsk> = {}): CommentAsk & { events: unknown[] } {
const events: unknown[] = []
return { prompt: '/ecc:writing-code-comments src/a.py:3-8', images: [], cwd: '/work', signal: new AbortController().signal, onEvent: event => events.push(event), events, ...overrides }
}
it('开新会话:自己起会话 id、按它建草稿目录,带上插件目录和超时;stdin 里是那句提示和贴的图;返回正文和会话 id', async () => {
const { deps, calls, scratches, lookups } = commentDeps([{ lines: [structured('按名字找文件。', 'new-1')] }])
const ask = comment({ profilePath: '/p/sonnet.json', images: [PNG] })
expect(await askCommentBody(ask, deps)).toEqual({ body: '按名字找文件。', sessionId: 'new-1' })
expect(lookups).toEqual(['/work'])
expect(scratches).toEqual(['new-1'])
expect(calls[0].args).toEqual(rewriteArgs({ pluginRoot: '/plugins/ecc', scratch: '/tmp/spx-comment-new-1', profilePath: '/p/sonnet.json', session: { start: 'new-1' } }))
expect(calls[0]).toMatchObject({ cwd: '/work', signal: ask.signal, timeoutMs: REWRITE_TIMEOUT_MS })
expect(sent(calls[0])).toMatchObject({ text: '/ecc:writing-code-comments src/a.py:3-8', images: [{ source: { data: PNG.base64 } }] })
})
it('接着说:--resume 这块最近一次重写的会话,用那个会话的草稿目录,发块现在在哪和用户的话', async () => {
const { deps, calls, scratches } = commentDeps([{ lines: [structured('短一点。', 's0')] }])
expect(await askCommentBody(comment({ followUp: { sessionId: 's0', prompt: 'src/a.py:3-10\n\n短一点' } }), deps)).toEqual({ body: '短一点。', sessionId: 's0' })
expect(scratches).toEqual(['s0'])
expect(calls[0].args).toEqual(rewriteArgs({ pluginRoot: '/plugins/ecc', scratch: '/tmp/spx-comment-s0', session: { resume: 's0' } }))
expect(sent(calls[0]).text).toBe('src/a.py:3-10\n\n短一点')
})
it('那次会话找不到了:开新会话按技能命令重新来,图片照样带上', async () => {
const { deps, calls, scratches } = commentDeps([
{ lines: [line({ type: 'result', subtype: 'error_during_execution', is_error: true, errors: ['No conversation found with session ID: s0'], session_id: 's0' })] },
{ lines: [structured('重新写的。', 'new-1')] },
])
const ask = comment({ prompt: '/ecc:writing-code-comments src/a.py:3-10\n\n短一点', images: [PNG], followUp: { sessionId: 's0', prompt: 'src/a.py:3-10\n\n短一点' } })
expect(await askCommentBody(ask, deps)).toEqual({ body: '重新写的。', sessionId: 'new-1' })
expect(scratches).toEqual(['s0', 'new-1'])
expect(calls[1].args).toContain('--session-id')
expect(calls[1].args).not.toContain('--resume')
expect(sent(calls[1])).toMatchObject({ text: '/ecc:writing-code-comments src/a.py:3-10\n\n短一点', images: [{ source: { data: PNG.base64 } }] })
})
it('只回了一段话、没交结构化结果:只有会话 id 没有正文;Claude Code 报错、没给结果:抛错,别的失败不重开', async () => {
expect(await askCommentBody(comment(), commentDeps([{ lines: [result('我改好了。', 'new-1')] }]).deps)).toEqual({ sessionId: 'new-1' })
await expect(askCommentBody(comment(), commentDeps([{ lines: [line({ type: 'result', is_error: true, result: '额度用完了' })] }]).deps)).rejects.toThrow('额度用完了')
await expect(askCommentBody(comment(), commentDeps([{ lines: [] }]).deps)).rejects.toThrow('没给出结果')
const timeout = commentDeps([{ lines: [], error: new ClaudeTimeoutError('超时', '') }, { lines: [structured('不该到这')] }])
await expect(askCommentBody(comment({ followUp: { sessionId: 's0', prompt: 'p' } }), timeout.deps)).rejects.toBeInstanceOf(ClaudeTimeoutError)
expect(timeout.calls).toHaveLength(1)
})
it('等太久停掉时说的是没写完', () => {
expect(rewriteFailure(new ClaudeTimeoutError('超时', ''))).toBe(`等了 ${REWRITE_TIMEOUT_MS / 60_000} 分钟还没写完,已经停掉。`)
expect(rewriteFailure(new ClaudeError('Claude 退出码非零 (1): 网络断了', ''))).toBe('Claude Code 没答成:Claude 退出码非零 (1): 网络断了')
})
})
+166 -36
View File
@@ -1,13 +1,17 @@
/**
* 用 Claude Code(`claude -p`,只读)回答代码问答:和插件别处的 `claude -p` 一样找 claude、清环境变量、
* 用问的时候选的 profile(见 askProfiles);回答按 stream-json 逐字交出来,可以叫停。
* 用 Claude Code(`claude -p`)回答代码问答、重写注释:和插件别处的 `claude -p` 一样找 claude、清环境变量、
* 用当时选的 profile(见 askProfiles);过程按 stream-json 逐字交出来,可以叫停。问答只读;重写注释另外只能画原型图。
*/
import type { ClaudeImage } from '../../cc/spawnClaude'
import type { CommentEvent } from '../commentRewrite'
import type { AnswerEvent } from './answerStream'
import { randomUUID } from 'node:crypto'
import { mkdir, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import * as path from 'node:path'
import { buildStreamJsonLine, CLAUDE_MISSING_MSG, ClaudeCancelledError, ClaudeError, ClaudeTimeoutError, runClaudeLines } from '../../cc/spawnClaude'
import { COMMENT_SCHEMA, createCommentParser } from '../commentRewrite'
import { COMMENT_PLUGIN, COMMENT_SCHEMA, createCommentParser } from '../commentRewrite'
import { createAnswerParser } from './answerStream'
/** 只给读、搜文件的工具。 */
@@ -15,13 +19,18 @@ const READ_ONLY_TOOLS = 'Read,Grep,Glob'
export const ASK_TIMEOUT_MS = 5 * 60_000
/** 重写注释要读代码、画原型图、导出图片,比问答慢 */
export const REWRITE_TIMEOUT_MS = 15 * 60_000
/** OpenPencil 装在应用里的命令行(写注释的技能用它画前端组件的原型图) */
export const OPENPENCIL_CLI = '/Applications/OpenPencil.app/Contents/MacOS/op'
/**
* 一次提问的命令行参数;问题连同贴的图片从 stdin 按 stream-json 发(见 askClaude),没贴图也一样。只读靠三层:可用的内置工具只剩读、搜(`--tools`);这几个不用再批(`--allowedTools`),
* 别的一律拒绝、不等人批(`--permission-mode dontAsk`,盖过 profile 里的 bypassPermissions);
* 不加载任何 MCP 服务(`--strict-mcp-config` 又不给 `--mcp-config`),免得 MCP 工具能改东西。
* jsonSchema:要模型按这个 JSON Schema 交结构化结果(`--json-schema`,结果在 result 行的 structured_output 里)。
*/
export function askArgs(options: { profilePath?: string, resume?: string, jsonSchema?: string } = {}): string[] {
export function askArgs(options: { profilePath?: string, resume?: string } = {}): string[] {
return [
...(options.profilePath ? ['--settings', options.profilePath] : []),
'--tools',
@@ -39,7 +48,45 @@ export function askArgs(options: { profilePath?: string, resume?: string, jsonSc
'stream-json',
'--verbose',
'--include-partial-messages',
...(options.jsonSchema ? ['--json-schema', options.jsonSchema] : []),
]
}
/**
* 一次重写注释的命令行参数,按 COMMENT_SCHEMA 交结构化结果(`--json-schema`)。除了读、搜,只放开写注释的技能画原型图要的:
* 运行 OpenPencil 的 op、建组件旁边的 `prototypes/` 目录、在草稿目录里写 build.js(`--allowedTools` 里的规则),别的一律拒绝、不等人批。
* `--restricted` 不读用户、项目的设置文件,那里放行的命令(比如 `git -C`)进不来,文件工具也只认工作目录;
* 写注释的插件因此要单独加载(`--plugin-dir`),它的目录和草稿目录加进工作目录(`--add-dir`),技能才读得到它的参考文档、写得了草稿。
* session:新会话用 start 当会话 id(草稿目录按它起名),接着说时 resume 那次会话。
*/
export function rewriteArgs(options: { pluginRoot: string, scratch: string, profilePath?: string, session: { start: string } | { resume: string } }): string[] {
const { pluginRoot, scratch, profilePath, session } = options
const allowed = [READ_ONLY_TOOLS, `Bash(${OPENPENCIL_CLI} *)`, 'Bash(mkdir -p */prototypes)', `Edit(/${scratch}/**)`].join(',')
return [
...(profilePath ? ['--settings', profilePath] : []),
'--restricted',
'--plugin-dir',
pluginRoot,
'--add-dir',
pluginRoot,
'--add-dir',
scratch,
'--tools',
`${READ_ONLY_TOOLS},Bash,Edit,Write`,
'--allowedTools',
allowed,
'--permission-mode',
'dontAsk',
'--strict-mcp-config',
...('resume' in session ? ['--resume', session.resume] : ['--session-id', session.start]),
'-p',
'--input-format',
'stream-json',
'--output-format',
'stream-json',
'--verbose',
'--include-partial-messages',
'--json-schema',
COMMENT_SCHEMA,
]
}
@@ -117,45 +164,121 @@ export async function askClaude(run: AskRun, deps: AskDeps = DEFAULT_DEPS): Prom
}
export interface CommentAsk {
/** 只有技能命令和代码块在哪的一句话(commentRewritePrompt) */
/** 开新会话时发的:技能命令和代码块在哪(commentRewritePrompt),用户说了话就跟在后面 */
prompt: string
/** 用户贴的图片,接着说、找不到会话重新开时一样带上 */
images: ClaudeImage[]
profilePath?: string
/** 接着 sessionId 那次重写的会话发 prompt(块现在在哪和用户的话);会话找不到了就按上面的 prompt 开新会话 */
followUp?: { sessionId: string, prompt: string }
cwd: string
signal: AbortSignal
/** 重写的过程中:正文写出来的字、正在读什么 */
/** 重写的过程中:正文写出来的字、正在做什么 */
onEvent: (event: Extract<CommentEvent, { kind: 'text' | 'activity' }>) => void
}
/** 重写一次的结果:结构化结果里的注释正文(只回了一段话时没有),这次的会话 id。 */
export interface CommentAnswer {
body?: string
sessionId: string
}
export interface CommentAskDeps extends AskDeps {
/** 写注释的技能所在插件的目录,按 cwd 问(项目里装的插件也算) */
pluginRoot: (cwd: string) => Promise<string>
/** 这个会话画原型用的草稿目录,建好了返回 */
scratch: (sessionId: string) => Promise<string>
/** 新会话的 id */
newSessionId: () => string
}
/** 这个会话画原型的草稿目录:系统临时目录下的 `spx-comment-<会话 id>`,同一会话每轮都是它。用真实路径,权限规则按它比对。不删,留给系统清。 */
async function commentScratch(sessionId: string): Promise<string> {
const dir = path.join(await realpath(tmpdir()), `spx-comment-${sessionId}`)
await mkdir(dir, { recursive: true })
return dir
}
const COMMENT_DEPS: CommentAskDeps = {
runLines: runClaudeLines,
pluginRoot: cwd => commentPluginRoot(cwd),
scratch: commentScratch,
newSessionId: randomUUID,
}
/**
* 「重写注释」问一次:同问 AI 一样只读(模型能读那个文件,改不了,写回归扩展),按 COMMENT_SCHEMA 交结构化结果。
* 返回结果里的注释正文;只回了一段话、没交结构化结果时是 undefined。失败、超时、被叫停时抛 ClaudeError(及其子类),用 askFailure 换成给人看的话。
* 「重写注释」问一次:参数见 rewriteArgs,按 COMMENT_SCHEMA 交结构化结果,写回归扩展。有 followUp 就接着那次会话说,会话找不到了按 prompt 开新会话。
* 返回结果里的注释正文和会话 id;只回了一段话、没交结构化结果时没有正文。失败、超时、被叫停时抛 ClaudeError(及其子类),用 rewriteFailure 换成给人看的话。
*/
export async function askCommentBody(ask: CommentAsk, deps: AskDeps = DEFAULT_DEPS): Promise<string | undefined> {
const parse = createCommentParser()
let result: { body?: string } | undefined
let failure: string | undefined
await deps.runLines({
args: askArgs({ profilePath: ask.profilePath, jsonSchema: COMMENT_SCHEMA }),
input: buildStreamJsonLine(ask.prompt, []),
cwd: ask.cwd,
signal: ask.signal,
timeoutMs: ASK_TIMEOUT_MS,
onLine: (line) => {
for (const event of parse(line)) {
if (event.kind === 'result')
result = event
else if (event.kind === 'error')
failure = event.message
else
ask.onEvent(event)
}
},
})
if (failure !== undefined)
throw new ClaudeError(failure, '')
if (!result)
throw new ClaudeError('Claude Code 结束了,但没给出结果', '')
return result.body
export async function askCommentBody(ask: CommentAsk, deps: CommentAskDeps = COMMENT_DEPS): Promise<CommentAnswer> {
const pluginRoot = await deps.pluginRoot(ask.cwd)
const once = async (prompt: string, session: { start: string } | { resume: string }): Promise<CommentAnswer> => {
const scratch = await deps.scratch('resume' in session ? session.resume : session.start)
const parse = createCommentParser()
let result: CommentAnswer | undefined
let failure: string | undefined
await deps.runLines({
args: rewriteArgs({ pluginRoot, scratch, profilePath: ask.profilePath, session }),
input: buildStreamJsonLine(prompt, ask.images),
cwd: ask.cwd,
signal: ask.signal,
timeoutMs: REWRITE_TIMEOUT_MS,
onLine: (line) => {
for (const event of parse(line)) {
if (event.kind === 'result')
result = { ...(event.body !== undefined && { body: event.body }), sessionId: event.sessionId }
else if (event.kind === 'error')
failure = event.message
else
ask.onEvent(event)
}
},
})
if (failure !== undefined)
throw new ClaudeError(failure, '')
if (!result)
throw new ClaudeError('Claude Code 结束了,但没给出结果', '')
return result
}
if (ask.followUp) {
try {
return await once(ask.followUp.prompt, { resume: ask.followUp.sessionId })
}
catch (err) {
if (!missingSession(err))
throw err
}
}
return once(ask.prompt, { start: deps.newSessionId() })
}
/** `claude plugin list --json` 里启用着的 name 插件的目录;没装、没启用、读不出来时没有。 */
export function pluginRootIn(listJson: string, name: string): string | undefined {
let list: unknown
try {
list = JSON.parse(listJson)
}
catch {
return undefined
}
if (!Array.isArray(list))
return undefined
const found = list.find((item: unknown) => isRecord(item) && typeof item.id === 'string' && item.id.startsWith(`${name}@`) && item.enabled === true && typeof item.installPath === 'string')
return isRecord(found) && typeof found.installPath === 'string' ? found.installPath : undefined
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** 写注释的技能所在插件的目录:在 cwd 里问 Claude Code 装了哪些插件。没找到时抛错,说清楚要先装上。 */
export async function commentPluginRoot(cwd: string, runLines: typeof runClaudeLines = runClaudeLines): Promise<string> {
const lines: string[] = []
await runLines({ args: ['plugin', 'list', '--json'], cwd, onLine: line => lines.push(line), timeoutMs: 30_000 })
const root = pluginRootIn(lines.join('\n'), COMMENT_PLUGIN)
if (!root)
throw new ClaudeError(`没找到启用着的 ${COMMENT_PLUGIN} 插件,写注释的技能在它里面:在 Claude Code 里装上并启用后再重写。`, '')
return root
}
const AUTH_HINT = /\/login|log ?in|auth|401|403|api key|unauthori[sz]ed|credential|oauth/i
@@ -171,3 +294,10 @@ export function askFailure(err: unknown): string {
return `Claude Code 没登录或登录过期了:在终端里运行 claude 登录一下再问。(原话:${message})`
return `Claude Code 没答成:${message}`
}
/** 重写注释失败了,给人看的一句话:超时说的是没写完,别的同 askFailure。 */
export function rewriteFailure(err: unknown): string {
if (err instanceof ClaudeTimeoutError)
return `等了 ${Math.round(REWRITE_TIMEOUT_MS / 60_000)} 分钟还没写完,已经停掉。`
return askFailure(err)
}
+100 -6
View File
@@ -1,10 +1,12 @@
import type { ExtensionContext, Memento, Uri } from 'vscode'
import type { AskRunner } from './claudeAsk'
import type { AskRunner, CommentAnswer, CommentAsk } from './claudeAsk'
import type { RewriteEvent, RewriteRequest } from './codeQa'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { listClaudeProfiles } from '../../cc/profiles'
import { ClaudeCancelledError } from '../../cc/spawnClaude'
import { getSettings, saveSettings } from '../../settings/store'
import { CodeQa } from './codeQa'
@@ -22,14 +24,21 @@ vi.mock('vscode', async () => {
dispose() { this.emitter.removeAllListeners() }
},
Disposable: { from: () => ({ dispose: () => {} }) },
Uri: {},
Uri: { joinPath: (base: { fsPath: string }, ...parts: string[]) => ({ fsPath: [base.fsPath, ...parts].join('/') }) },
window: { showErrorMessage: vi.fn() },
}
})
vi.mock('../folderStore', () => ({
/** 内存里的一份表:按键取、改了就换 */
FolderStore: class {
get() { return {} }
private map: Record<string, unknown> = {}
readonly onDidChange = () => ({ dispose: () => {} })
get(_folder: unknown, key: string) { return this.map[key] }
async update(_folder: unknown, change: (map: Record<string, unknown>) => Record<string, unknown>) {
this.map = change(this.map)
}
dispose() {}
},
locate: () => ({ folder: { uri: { fsPath: '/workspace', toString: () => 'file:///workspace' } }, key: 'a.ts' }),
@@ -60,13 +69,13 @@ function context(): ExtensionContext {
} as unknown as ExtensionContext
}
function service(ctx: ExtensionContext) {
function service(ctx: ExtensionContext, rewriter = vi.fn<(ask: CommentAsk) => Promise<CommentAnswer>>()) {
const runner = vi.fn<AskRunner>(run => new Promise((_resolve, reject) => {
run.signal.addEventListener('abort', () => reject(new Error('停止')))
}))
const qa = new CodeQa(ctx, ctx.workspaceState as Memento, runner)
const qa = new CodeQa(ctx, ctx.workspaceState as Memento, runner, rewriter)
services.push(qa)
return { qa, runner }
return { qa, runner, rewriter }
}
beforeEach(() => vi.mocked(listClaudeProfiles).mockResolvedValue(AVAILABLE))
@@ -134,3 +143,88 @@ describe('问这个文件实际使用设置的默认模型', () => {
expect(runner).not.toHaveBeenCalled()
})
})
describe('重写注释的记录', () => {
const SKILL = '/ecc:writing-code-comments a.ts:1-3'
function request(askId: string, overrides: Partial<RewriteRequest> = {}): RewriteRequest {
return { askId, anchor: 'function f', question: SKILL, prompt: SKILL, images: [], writeBack: async () => undefined, ...overrides }
}
/** 等这一轮结束的事件 */
function ended(qa: CodeQa, askId: string): Promise<Extract<RewriteEvent, { kind: 'end' }>> {
return new Promise((resolve) => {
qa.onDidRewrite((event) => {
if (event.kind === 'end' && event.ask.askId === askId)
resolve(event)
})
})
}
const rewritesOf = (qa: CodeQa) => qa.view(uri, file => file.fsPath, 'file').rewrites
it('答完就记在重写记录里、不进问答:写进文件的记正文;接着说时接这块最近一次重写的会话,写回失败也记下原因', async () => {
const ctx = context()
const { qa, runner, rewriter } = service(ctx)
rewriter.mockResolvedValueOnce({ body: '按名字找。', sessionId: 'r1' })
const first = ended(qa, '1')
expect(await qa.rewrite(uri, request('1'))).toBeUndefined()
expect(rewriter.mock.calls[0][0]).toMatchObject({ prompt: SKILL, cwd: '/workspace', images: [] })
expect(rewriter.mock.calls[0][0]).not.toHaveProperty('followUp')
expect((await first).end).toEqual({ status: 'done', body: '按名字找。' })
expect(rewritesOf(qa)).toEqual([{ anchor: 'function f', entries: [expect.objectContaining({ question: SKILL, answer: '按名字找。', id: (await first).entryId })] }])
expect(qa.view(uri, file => file.fsPath, 'file').threads).toEqual([])
rewriter.mockResolvedValueOnce({ body: '短的。', sessionId: 'r1' })
const writeBack = vi.fn(async () => '编辑器没接受这次修改,没有写回。')
const second = ended(qa, '2')
await qa.rewrite(uri, request('2', { question: '短一点', prompt: `${SKILL}\n\n短一点`, followUp: 'a.ts:1-5\n\n短一点', writeBack }))
expect(rewriter.mock.calls[1][0].followUp).toEqual({ sessionId: 'r1', prompt: 'a.ts:1-5\n\n短一点' })
expect((await second).end).toEqual({ status: 'done', body: '短的。', error: '编辑器没接受这次修改,没有写回。' })
expect(writeBack).toHaveBeenCalledWith('短的。')
expect(rewritesOf(qa)[0].entries.map(entry => [entry.question, entry.answer, entry.error])).toEqual([[SKILL, '按名字找。', undefined], ['短一点', '短的。', '编辑器没接受这次修改,没有写回。']])
await qa.ask(uri, { askId: '3', anchor: 'function f', question: '为什么', prompt: '完整的', followUp: '短的', images: [] })
expect(runner.mock.calls[0][0]).not.toHaveProperty('followUp')
})
it('点按钮从头重写(不带 followUp)开新会话;模型没交回正文也记下原因', async () => {
const ctx = context()
const { qa, rewriter } = service(ctx)
rewriter.mockResolvedValueOnce({ body: '旧的。', sessionId: 'r1' })
const first = ended(qa, '1')
await qa.rewrite(uri, request('1'))
await first
rewriter.mockResolvedValueOnce({ sessionId: 'r2' })
const second = ended(qa, '2')
await qa.rewrite(uri, request('2', { writeBack: async body => body === undefined ? '模型没有按要求交回注释正文,没有改文件。' : undefined }))
expect(rewriter.mock.calls[1][0]).not.toHaveProperty('followUp')
expect((await second).end).toEqual({ status: 'done', error: '模型没有按要求交回注释正文,没有改文件。' })
expect(rewritesOf(qa)[0].entries.at(-1)).toMatchObject({ answer: '', error: '模型没有按要求交回注释正文,没有改文件。' })
})
it('出错、被叫停的不记,也不写文件;进行中的、没记下留着的那一轮在 rewriteCurrent 里,页面重新加载能再画出来,收起这块时去掉', async () => {
const ctx = context()
const { qa, rewriter } = service(ctx)
rewriter.mockRejectedValueOnce(new Error('额度用完了'))
const writeBack = vi.fn(async () => undefined)
const failed = ended(qa, '1')
await qa.rewrite(uri, request('1', { writeBack }))
expect((await failed).end).toEqual({ status: 'failed', error: 'Claude Code 没答成:额度用完了' })
expect(qa.view(uri, file => file.fsPath, 'file').rewriteCurrent).toMatchObject([{ askId: '1', status: 'failed', error: 'Claude Code 没答成:额度用完了' }])
rewriter.mockImplementationOnce(ask => new Promise((_resolve, reject) => {
ask.signal.addEventListener('abort', () => reject(new ClaudeCancelledError('')))
}))
const stopped = ended(qa, '2')
await qa.rewrite(uri, request('2', { writeBack }))
expect(qa.view(uri, file => file.fsPath, 'file').rewriteCurrent).toEqual([{ askId: '2', anchor: 'function f', question: SKILL, text: '', status: 'running' }])
qa.stopRewrite('2')
expect((await stopped).end).toEqual({ status: 'stopped' })
expect(writeBack).not.toHaveBeenCalled()
expect(rewritesOf(qa)).toEqual([])
expect(qa.view(uri, file => file.fsPath, 'file').rewriteCurrent).toEqual([{ askId: '2', anchor: 'function f', question: SKILL, text: '', status: 'stopped' }])
expect(qa.view(uri, file => file.fsPath, 'file').current).toEqual([])
qa.forgetRewrite(uri, 'function f')
expect(qa.view(uri, file => file.fsPath, 'file').rewriteCurrent).toEqual([])
})
})
+174 -58
View File
@@ -1,30 +1,42 @@
import type { Event, ExtensionContext, Memento } from 'vscode'
import type { ClaudeProfile } from '../../cc/profiles'
import type { ClaudeImage } from '../../cc/spawnClaude'
import type { QaMessage } from '../editor/messages'
import type { QaEntryView, QaMessage } from '../editor/messages'
import type { PathSubject } from '../editor/pathPreview'
import type { PathLocation } from '../folderStore'
import type { AskEnd, RunningAsk } from './asks'
import type { AskRunner } from './claudeAsk'
import type { QaEntry } from './qaMap'
import type { AskRunner, CommentAnswer, CommentAsk } from './claudeAsk'
import type { ChatEntry, QaEntry, RewriteEntry } from './qaMap'
import { randomUUID } from 'node:crypto'
import { Disposable, EventEmitter, Uri, window } from 'vscode'
import { getSettings, onDidChangeSettings } from '../../settings/store'
import { pathQaLocation } from '../editor/pathPreview'
import { FolderStore, locate } from '../folderStore'
import { AskProfiles } from './askProfiles'
import { QaAsks } from './asks'
import { askClaude } from './claudeAsk'
import { QaAsks, REWRITE_WORDING } from './asks'
import { askClaude, askCommentBody } from './claudeAsk'
import { describePath } from './pathAskDescribe'
import { pathAskFollowUp, pathAskPrompt } from './pathAskPrompt'
import { parsePathQa, PATH_ASK_ANCHOR, PATH_QA_STATE_KEY, pathQaEntries, pathQaKey, withoutPathQa, withPathQa } from './pathQa'
import { isQaImageType, QA_IMAGE_DIR, removeQaImages, writeQaImages } from './qaImages'
import { latestSession, QA_FILE, withEntry, withoutEntry } from './qaMap'
import { latestSession, QA_FILE, REWRITE_FILE, withEntry, withoutEntry } from './qaMap'
/** 正在答的问题有了进展(update),或答完了(end;答成了带存下的那条的 id)。owner 是问的那份文件的 uri。 */
export type AskEvent
= | { owner: string, kind: 'update', ask: RunningAsk }
| { owner: string, kind: 'end', ask: RunningAsk, end: AskEnd, entryId?: string }
/** 一次重写怎么结束的:done 是模型答完、这一轮记下了(body 是交回的正文,没交回时没有;error 是没写进文件的原因);failed、stopped 没记,文件没动。 */
export type RewriteEnd
= | { status: 'done', body?: string, error?: string }
| { status: 'failed', error: string }
| { status: 'stopped' }
/** 正在重写的注释有了进展(update),或结束了(end;记下了的带那条的 id)。owner 是那份文件的 uri。 */
export type RewriteEvent
= | { owner: string, kind: 'update', ask: RunningAsk }
| { owner: string, kind: 'end', ask: RunningAsk, end: RewriteEnd, entryId?: string }
/** prompt 是带代码上下文的完整问题;followUp 是接着这块上一次的会话问时发的话(只有问题和是哪块) */
export interface AskRequest {
askId: string
@@ -36,15 +48,59 @@ export interface AskRequest {
images: ClaudeImage[]
}
/** 重写一段注释:question 是记在这一轮上的话(点按钮是技能命令,之后是用户在输入框里说的)。 */
export interface RewriteRequest {
askId: string
anchor: string
question: string
/** 开新会话时发的:技能命令和块在哪,用户说了话就跟在后面 */
prompt: string
/** 接着这块最近一次重写的会话说时发的:块现在在哪和用户的话。点按钮从头重写时没有,开新会话 */
followUp?: string
/** 贴进输入框的图片,随这句话发;记下时存进 `.spx/code-qa/` */
images: ClaudeImage[]
/** 模型答完后把交回的正文写回文件:写进去了返回 undefined,没写成返回原因 */
writeBack: (body: string | undefined) => Promise<string | undefined>
}
/** 记在这条上的图片换成预览里能用的地址;不在工作区文件夹里(root 没有)、没带图片时没有 */
function viewEntry(root: Uri | undefined, imageUrl: (file: Uri) => string, entry: ChatEntry): Omit<QaEntryView, 'answer'> {
const { id, question, createdAt, profile, images } = entry
return {
id,
question,
createdAt,
...(profile && { profile }),
...(root && images && { images: images.map(name => imageUrl(Uri.joinPath(root, name))) }),
}
}
/** 答完的一轮共有的几项:起 id、把带的图片写进 `<rootPath>/.spx/code-qa/`。图片写不进时抛错。 */
async function chatEntry(rootPath: string, question: string, sessionId: string, profile: ClaudeProfile | undefined, images: readonly ClaudeImage[]): Promise<ChatEntry> {
const id = randomUUID()
const names = await writeQaImages(rootPath, id, images)
return {
id,
question,
createdAt: new Date().toISOString(),
sessionId,
...(profile && { profile: profile.name }),
...(names.length > 0 && { images: names }),
}
}
/**
* 代码问答:块下面的问 Claude Code、答完存进各工作区文件夹的 `.spx/code-qa.json`
* (改名、删文件、外部改动的跟随同文件注释,见 FolderStore),挂在块的位置记号上。
* 预览顶部问的是整个文件或文件夹,按路径记在这个窗口的 workspaceState 里,不进那份文件。
* 块下面的对话:问 AI 和重写注释,各自记在各工作区文件夹的一份文件里(`.spx/code-qa.json`、`.spx/comment-rewrites.json`;
* 改名、删文件、外部改动的跟随同文件注释,见 FolderStore),挂在块的位置记号上,各自接着自己最近一次的会话。
* 预览顶部问的是整个文件或文件夹,按路径记在这个窗口的 workspaceState 里,不进那两份文件。
*/
export class CodeQa implements Disposable {
private readonly store = new FolderStore(QA_FILE)
private readonly rewriteStore = new FolderStore(REWRITE_FILE)
private readonly asks: QaAsks
private readonly rewrites: QaAsks<CommentAsk, CommentAnswer>
private readonly answered = new EventEmitter<AskEvent>()
private readonly rewritten = new EventEmitter<RewriteEvent>()
/** 顶部问答变了的那些文件或文件夹 */
private readonly pathChanged = new EventEmitter<Uri[]>()
/** 顶部问答的读写排成一队,两次同时答完不会互相盖掉 */
@@ -52,10 +108,11 @@ export class CodeQa implements Disposable {
private readonly settingsSubscription: Disposable
/** 所有预览共用当前模型,临时切换不会写入默认设置。 */
readonly profiles: AskProfiles
/** 块下面或顶部的问答变了的那些文件 / 文件夹 */
/** 块下面的问答、重写记录或顶部的问答变了的那些文件 / 文件夹 */
readonly onDidChange: Event<Uri[]> = (listener, thisArgs, disposables) => {
const subscription = Disposable.from(
this.store.onDidChange(listener, thisArgs),
this.rewriteStore.onDidChange(listener, thisArgs),
this.pathChanged.event(listener, thisArgs),
)
disposables?.push(subscription)
@@ -63,11 +120,13 @@ export class CodeQa implements Disposable {
}
readonly onDidAnswer = this.answered.event
readonly onDidRewrite = this.rewritten.event
constructor(
context: ExtensionContext,
private readonly paths: Pick<Memento, 'get' | 'update'>,
runner: AskRunner = askClaude,
rewriter: (ask: CommentAsk) => Promise<CommentAnswer> = askCommentBody,
) {
this.profiles = new AskProfiles(() => getSettings(context).defaultAskProfilePath)
this.settingsSubscription = onDidChangeSettings(context, () => {
@@ -76,6 +135,7 @@ export class CodeQa implements Disposable {
})
})
this.asks = new QaAsks(runner)
this.rewrites = new QaAsks(rewriter, REWRITE_WORDING)
}
/** uri 这份文件或文件夹的问答带的图片放在哪个目录(预览要能读到它才画得出缩略图);不在工作区文件夹里时没有。 */
@@ -85,31 +145,28 @@ export class CodeQa implements Disposable {
}
/**
* 发给预览的问答。块下面的按块的记号;顶部的按这条路径,subject 说是文件还是文件夹。
* 不在工作区文件夹里时没地方存,问 AI 不可用。
* 问题带的图片换成预览里能用的地址(imageUrl,即 webview.asWebviewUri)。
* 发给预览的问答和重写记录。块下面的按块的记号;顶部的按这条路径,subject 说是文件还是文件夹。
* 不在工作区文件夹里时没地方存,问 AI、重写注释都不可用。
* 带的图片换成预览里能用的地址(imageUrl,即 webview.asWebviewUri)。重写记录的回答是交回的正文,没交回时是空的。
*/
view(uri: Uri, imageUrl: (file: Uri) => string, subject: PathSubject): QaMessage {
const at = locate(uri)
const pathAt = pathQaLocation(uri)
const threads = at ? this.store.get(at.folder, at.key) ?? [] : []
const rewrites = at ? this.rewriteStore.get(at.folder, at.key) ?? [] : []
const pathEntries = pathAt ? pathQaEntries(this.pathMap(), pathQaKey(pathAt.folder.uri.toString(), pathAt.key)) : []
const root = this.imageRoot(uri)
const show = ({ id, question, answer, createdAt, profile, images }: QaEntry) => ({
id,
question,
answer,
createdAt,
...(profile && { profile }),
...(root && images && { images: images.map(name => imageUrl(Uri.joinPath(root, name))) }),
})
const show = (entry: QaEntry): QaEntryView => ({ ...viewEntry(root, imageUrl, entry), answer: entry.answer })
const showRewrite = (entry: RewriteEntry): QaEntryView => ({ ...viewEntry(root, imageUrl, entry), answer: entry.body ?? '', ...(entry.error && { error: entry.error }) })
return {
type: 'qa',
available: pathAt !== undefined,
subject,
threads: threads.map(({ anchor, entries }) => ({ anchor, entries: entries.map(show) })),
path: pathEntries.map(show),
running: this.asks.runningFor(uri.toString()),
current: this.asks.currentFor(uri.toString()),
rewrites: rewrites.map(({ anchor, entries }) => ({ anchor, entries: entries.map(showRewrite) })),
rewriteCurrent: this.rewrites.currentFor(uri.toString()),
}
}
@@ -120,19 +177,11 @@ export class CodeQa implements Disposable {
* 带的图片随问题发,答成了才写进 `.spx/code-qa/`、记在这条上;没答成不留文件。
*/
async ask(uri: Uri, request: AskRequest): Promise<string | undefined> {
const at = locate(uri)
if (!at)
return '这个文件不在工作区文件夹里,问答没地方存。'
const ready = await this.prepare(uri, request.images, '问答')
if (typeof ready === 'string')
return ready
const { at, profile } = ready
const { askId, anchor, question, prompt, followUp, images } = request
if (!images.every(image => isQaImageType(image.mediaType)))
return '只能带 PNG、JPEG、GIF、WebP 图片。'
let profile: ClaudeProfile | undefined
try {
profile = await this.profiles.current()
}
catch (err) {
return err instanceof Error ? err.message : String(err)
}
const owner = uri.toString()
const sessionId = latestSession(this.store.get(at.folder, at.key), anchor)
const run = {
@@ -149,31 +198,104 @@ export class CodeQa implements Disposable {
this.answered.fire({ owner, kind: 'end', ask, end })
return
}
const id = randomUUID()
let names: string[]
let entry: QaEntry
try {
names = await writeQaImages(at.folder.uri.fsPath, id, images)
const { id, question: asked, ...rest } = await chatEntry(at.folder.uri.fsPath, question, end.answer.sessionId, profile, images)
entry = { id, question: asked, answer: end.answer.text, ...rest }
}
catch (err) {
const error = `回答没存上:图片写不进 ${QA_IMAGE_DIR}(${err instanceof Error ? err.message : String(err)})`
this.answered.fire({ owner, kind: 'end', ask, end: { status: 'failed', error } })
return
}
const entry: QaEntry = {
id,
question,
answer: end.answer.text,
createdAt: new Date().toISOString(),
sessionId: end.answer.sessionId,
...(profile && { profile: profile.name }),
...(names.length > 0 && { images: names }),
}
await this.store.update(at.folder, map => withEntry(map, at.key, anchor, entry))
this.answered.fire({ owner, kind: 'end', ask, end, entryId: entry.id })
},
})
}
/**
* 重写一段注释;开不了时返回原因(给人看的话)。在工作区文件夹里跑 claude(见 askCommentBody),用眼下选的 profile。
* 带 followUp 时接着这块最近一次重写的会话说(和问答的会话分开),没重写过就开新会话;不带时(点按钮)总是开新会话。
* 模型答完先写回文件,再把这一轮记下:用户的话、交回的正文、没写进文件的原因、会话 id、profile、图片。Claude Code 报错、被叫停的不记,文件不动。
*/
async rewrite(uri: Uri, request: RewriteRequest): Promise<string | undefined> {
const ready = await this.prepare(uri, request.images, '重写记录')
if (typeof ready === 'string')
return ready
const { at, profile } = ready
const { askId, anchor, question, prompt, followUp, images, writeBack } = request
const owner = uri.toString()
const sessionId = followUp === undefined ? undefined : latestSession(this.rewriteStore.get(at.folder, at.key), anchor)
const run = {
prompt,
images,
...(sessionId && followUp !== undefined && { followUp: { sessionId, prompt: followUp } }),
...(profile && { profilePath: profile.path }),
cwd: at.folder.uri.fsPath,
}
return this.rewrites.start({ owner, askId, anchor, question, run }, {
update: ask => this.rewritten.fire({ owner, kind: 'update', ask }),
end: async (ask, end) => {
if (end.status !== 'done') {
this.rewritten.fire({ owner, kind: 'end', ask, end })
return
}
const { body } = end.answer
const error = await writeBack(body)
let entry: RewriteEntry
try {
const { id, question: said, ...rest } = await chatEntry(at.folder.uri.fsPath, question, end.answer.sessionId, profile, images)
entry = { id, question: said, ...(body !== undefined && { body }), ...(error !== undefined && { error }), ...rest }
}
catch (err) {
const reason = `重写记录没存上:图片写不进 ${QA_IMAGE_DIR}(${err instanceof Error ? err.message : String(err)})`
this.rewritten.fire({ owner, kind: 'end', ask, end: { status: 'failed', error: reason } })
return
}
await this.rewriteStore.update(at.folder, map => withEntry(map, at.key, anchor, entry))
this.rewritten.fire({ owner, kind: 'end', ask, end: { status: 'done', ...(body !== undefined && { body }), ...(error !== undefined && { error }) }, entryId: entry.id })
},
})
}
stopRewrite(askId: string): void {
this.rewrites.stop(askId)
}
/** 页面上收起了 anchor 那块的问答:去掉留着的那一轮(出错、被叫停的),重新加载后不再画出来。 */
forgetAsk(uri: Uri, anchor: string): void {
this.asks.forget(uri.toString(), anchor)
}
/** 页面上收起了 anchor 那块的重写:去掉留着的那一轮。 */
forgetRewrite(uri: Uri, anchor: string): void {
this.rewrites.forget(uri.toString(), anchor)
}
/** 删掉一条重写记录,不用确认;带的图片跟着删。 */
async deleteRewrite(uri: Uri, anchor: string, entryId: string): Promise<void> {
const at = locate(uri)
if (at)
await this.rewriteStore.update(at.folder, map => withoutEntry(map, at.key, anchor, entryId))
}
/** 块下面开一轮前都要的:文件在工作区文件夹里(记录才有地方存)、图片格式认得、眼下选的 profile 还在;不行时返回原因。 */
private async prepare(uri: Uri, images: readonly ClaudeImage[], record: string): Promise<{ at: PathLocation, profile?: ClaudeProfile } | string> {
const at = locate(uri)
if (!at)
return `这个文件不在工作区文件夹里,${record}没地方存。`
if (!images.every(image => isQaImageType(image.mediaType)))
return '只能带 PNG、JPEG、GIF、WebP 图片。'
try {
const profile = await this.profiles.current()
return { at, ...(profile && { profile }) }
}
catch (err) {
return err instanceof Error ? err.message : String(err)
}
}
/**
* 问这一整个文件或文件夹。材料是路径(文件再加开头若干行,文件夹再加这一层的名字),
* 答成了记在 workspaceState 里,不进按块存的那份文件。没答成不记。
@@ -218,25 +340,16 @@ export class CodeQa implements Disposable {
this.answered.fire({ owner, kind: 'end', ask, end })
return
}
const id = randomUUID()
let names: string[]
let entry: QaEntry
try {
names = await writeQaImages(at.folder.uri.fsPath, id, images)
const { id, question: asked, ...rest } = await chatEntry(at.folder.uri.fsPath, question, end.answer.sessionId, profile, images)
entry = { id, question: asked, answer: end.answer.text, ...rest }
}
catch (err) {
const error = `回答没存上:图片写不进 ${QA_IMAGE_DIR}(${err instanceof Error ? err.message : String(err)})`
this.answered.fire({ owner, kind: 'end', ask, end: { status: 'failed', error } })
return
}
const entry: QaEntry = {
id,
question,
answer: end.answer.text,
createdAt: new Date().toISOString(),
sessionId: end.answer.sessionId,
...(profile && { profile: profile.name }),
...(names.length > 0 && { images: names }),
}
await this.enqueuePath(async () => {
await this.paths.update(PATH_QA_STATE_KEY, withPathQa(this.pathMap(), key, entry))
})
@@ -264,9 +377,12 @@ export class CodeQa implements Disposable {
dispose(): void {
this.settingsSubscription.dispose()
this.asks.stopAll()
this.rewrites.stopAll()
this.answered.dispose()
this.rewritten.dispose()
this.pathChanged.dispose()
this.store.dispose()
this.rewriteStore.dispose()
}
private pathMap() {
+49 -5
View File
@@ -1,4 +1,4 @@
import type { QaEntry, QaMap } from './qaMap'
import type { QaEntry, QaMap, RewriteEntry, RewriteMap } from './qaMap'
import { Buffer } from 'node:buffer'
import { promises as fsp } from 'node:fs'
import * as os from 'node:os'
@@ -6,7 +6,7 @@ import * as path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { detachedFiles, dropEntries, moveEntries, readPathMap, removeFiles, writePathMap } from '../pathMap'
import { writeQaImages } from './qaImages'
import { latestSession, QA_FILE, withEntry, withoutEntry } from './qaMap'
import { latestSession, QA_FILE, REWRITE_FILE, withEntry, withoutEntry } from './qaMap'
function entry(id: string, createdAt: string, question = `问 ${id}`): QaEntry {
return { id, question, answer: `答 ${id}`, createdAt, sessionId: `s-${id}` }
@@ -177,8 +177,8 @@ describe('问题带的图片', () => {
})
it('删一次问答,它的图片跟着删;别的问答的图片留着', async () => {
const a = { ...entry('a', '2026-09-25T10:00:00.000Z'), images: await writeQaImages(root, 'a', [png('a1'), png('a2')]) }
const b = { ...entry('b', '2026-09-26T10:00:00.000Z'), images: await writeQaImages(root, 'b', [png('b1')]) }
const a: QaEntry = { ...entry('a', '2026-09-25T10:00:00.000Z'), images: await writeQaImages(root, 'a', [png('a1'), png('a2')]) }
const b: QaEntry = { ...entry('b', '2026-09-26T10:00:00.000Z'), images: await writeQaImages(root, 'b', [png('b1')]) }
await update(map => withEntry(withEntry(map, 'a.py', IF, a), 'a.py', DISPLAY, b))
expect(await exists(imageFile('a-1.png'))).toBe(true)
await update(map => withoutEntry(map, 'a.py', IF, 'a'))
@@ -188,7 +188,7 @@ describe('问题带的图片', () => {
})
it('源文件删了、问答跟着删时图片也删;改名、挪位置按问答 id 起的名字不用动', async () => {
const a = { ...entry('a', '2026-09-25T10:00:00.000Z'), images: await writeQaImages(root, 'a', [png('a1')]) }
const a: QaEntry = { ...entry('a', '2026-09-25T10:00:00.000Z'), images: await writeQaImages(root, 'a', [png('a1')]) }
await update(map => withEntry(map, 'src/a.py', IF, a))
await update(map => moveEntries(map, 'src', 'lib'))
expect(await readPathMap(root, QA_FILE)).toEqual({ 'lib/a.py': [{ anchor: IF, entries: [a] }] })
@@ -197,3 +197,47 @@ describe('问题带的图片', () => {
expect(await exists(imageFile('a-1.png'))).toBe(false)
})
})
describe('重写注释的记录:另存一份,和问答分开', () => {
const rewriteFile = (): string => path.join(root, '.spx', 'comment-rewrites.json')
const exists = (file: string): Promise<boolean> => fsp.access(file).then(() => true, () => false)
function rewrite(id: string, createdAt: string, extra: Partial<RewriteEntry> = {}): RewriteEntry {
return { id, question: '/ecc:writing-code-comments a.py:3-8', body: `正文 ${id}`, createdAt, sessionId: `r-${id}`, ...extra }
}
it('存在 .spx/comment-rewrites.json,不进问答那份;正文、没写进文件的原因跟着这条读得回来,接着最近一次重写的会话', async () => {
const map: RewriteMap = withEntry(withEntry({}, 'a.py', IF, rewrite('a', '2026-09-25T10:00:00.000Z')), 'a.py', IF, rewrite('b', '2026-09-26T10:00:00.000Z', { error: '这段注释的定界符认不出来,没有改文件。' }))
await writePathMap(root, REWRITE_FILE, map)
expect(await exists(rewriteFile())).toBe(true)
expect(await exists(qaFile())).toBe(false)
expect(await readPathMap(root, REWRITE_FILE)).toEqual(map)
expect(await readPathMap(root, QA_FILE)).toEqual({})
expect(latestSession(map['a.py'], IF)).toBe('r-b')
})
it('模型没交回正文的那次只记原因;正文和原因都没有、正文是空的不认', async () => {
await fsp.mkdir(path.dirname(rewriteFile()), { recursive: true })
const silent = rewrite('s', '2026-09-25T10:00:00.000Z', { error: '模型没有按要求交回注释正文,没有改文件。' })
delete silent.body
await fsp.writeFile(rewriteFile(), JSON.stringify({ 'a.py': [{ anchor: IF, entries: [
silent,
{ ...rewrite('bare', '2026-09-25T11:00:00.000Z'), body: undefined },
rewrite('blank', '2026-09-25T12:00:00.000Z', { body: ' ' }),
] }] }), 'utf8')
expect(await readPathMap(root, REWRITE_FILE)).toEqual({ 'a.py': [{ anchor: IF, entries: [silent] }] })
})
it('带的图片和问答的放在同一个目录,删一条重写记录只删它自己的', async () => {
const png = { mediaType: 'image/png', base64: Buffer.from('r').toString('base64') }
const image = path.join(root, '.spx', 'code-qa', 'r-1.png')
const withImage = rewrite('r', '2026-09-25T10:00:00.000Z', { images: await writeQaImages(root, 'r', [png]) })
const before = withEntry({}, 'a.py', IF, withImage)
await writePathMap(root, REWRITE_FILE, before)
expect(await readPathMap(root, REWRITE_FILE)).toEqual(before)
const after = withoutEntry(before, 'a.py', IF, 'r')
await writePathMap(root, REWRITE_FILE, after)
await removeFiles(root, detachedFiles(REWRITE_FILE, before, after))
expect(await exists(image)).toBe(false)
})
})
+83 -43
View File
@@ -1,33 +1,44 @@
/**
* 代码问答的存法:每个工作区文件夹一份 `<workspace>/.spx/code-qa.json`,读写的规矩同文件注释(见 pathMap)。
* 形状是 `{ "相对路径": [{ anchor, entries: [{ id, question, answer, createdAt, sessionId, profile?, images? }] }] }`:
* 一份文件里挂着问答的每一块一项(anchor 见 BlockAnchors),块下面问过的按时间排。
* 问题带的图片是 `.spx/code-qa/` 里的文件,条目删了(删一次问答、删源文件)文件跟着删(见 attachments)。
* 块下面的对话怎么存:每个工作区文件夹里每种对话一份文件,读写的规矩同文件注释(见 pathMap)。
* 问 AI 记在 `.spx/code-qa.json`,重写注释记在 `.spx/comment-rewrites.json`:两份分开,各自接着自己最近一次的会话。
* 形状是 `{ "相对路径": [{ anchor, entries: [...] }] }`:一份文件里有记录的每一块一项(anchor 见 BlockAnchors),块下面的按时间排。
* 带的图片是 `.spx/code-qa/` 里的文件,条目删了(删一条、删源文件)文件跟着删(见 attachments)。
*/
import type { PathMap, PathMapFile } from '../pathMap'
import { isQaImageName, QA_IMAGE_DIR } from './qaImages'
/** 问过的一次:问题、回答(Markdown)、什么时候问的(ISO 时间)、Claude Code 的会话 id。 */
export interface QaEntry {
/** 块下面的一轮:说的话、什么时候(ISO 时间)、Claude Code 的会话 id。 */
export interface ChatEntry {
id: string
question: string
answer: string
createdAt: string
sessionId: string
/** 用哪个 profile 答的(profile 名);选「默认」时、以前存的没有 */
profile?: string
/** 问题带的图片:`.spx/code-qa/` 里的文件名(见 qaImages),按贴的先后;没带图片时没有 */
/** 带的图片:`.spx/code-qa/` 里的文件名(见 qaImages),按贴的先后;没带图片时没有 */
images?: string[]
}
/** 挂在一块上的问答。 */
export interface QaThread {
/** 问过的一次,带着回答(Markdown)。 */
export interface QaEntry extends ChatEntry {
answer: string
}
/** 重写过的一次:question 是用户说的话,第一轮是技能命令。body 是模型交回的注释正文,没交回时没有;error 是没写进文件的原因,写进去了没有。 */
export interface RewriteEntry extends ChatEntry {
body?: string
error?: string
}
/** 挂在一块上的记录。 */
export interface QaThread<E extends ChatEntry = QaEntry> {
anchor: string
entries: QaEntry[]
entries: E[]
}
export type QaMap = PathMap<QaThread[]>
export type RewriteMap = PathMap<QaThread<RewriteEntry>[]>
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -37,17 +48,15 @@ function text(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() !== '' ? value : undefined
}
function parseEntry(value: unknown): QaEntry | undefined {
if (!isRecord(value))
return undefined
const [id, question, answer, createdAt, profile] = [text(value.id), text(value.question), text(value.answer), text(value.createdAt), text(value.profile)]
if (!id || !question || !answer || !createdAt || Number.isNaN(Date.parse(createdAt)))
/** 一轮共有的几项:没有 id、说的话,时间不对的不认;没有会话 id 的照样认,再说话时重新开会话。 */
function parseChat(value: Record<string, unknown>): ChatEntry | undefined {
const [id, question, createdAt, profile] = [text(value.id), text(value.question), text(value.createdAt), text(value.profile)]
if (!id || !question || !createdAt || Number.isNaN(Date.parse(createdAt)))
return undefined
const images = Array.isArray(value.images) ? value.images.filter((name): name is string => typeof name === 'string' && isQaImageName(name)) : []
return {
id,
question,
answer,
createdAt,
sessionId: typeof value.sessionId === 'string' ? value.sessionId : '',
...(profile && { profile }),
@@ -55,9 +64,32 @@ function parseEntry(value: unknown): QaEntry | undefined {
}
}
/** 同一块的并成一项;块按记号排,问答按时间排:写出来的顺序稳定,git diff 干净。 */
function tidy(threads: QaThread[]): QaThread[] {
const byAnchor = new Map<string, QaEntry[]>()
function parseQaEntry(value: unknown): QaEntry | undefined {
if (!isRecord(value))
return undefined
const chat = parseChat(value)
const answer = text(value.answer)
if (!chat || !answer)
return undefined
const { id, question, ...rest } = chat
return { id, question, answer, ...rest }
}
/** 正文和没写成的原因至少有一样:两样都没有的不认。 */
function parseRewriteEntry(value: unknown): RewriteEntry | undefined {
if (!isRecord(value))
return undefined
const chat = parseChat(value)
const [body, error] = [text(value.body), text(value.error)]
if (!chat || (!body && !error))
return undefined
const { id, question, ...rest } = chat
return { id, question, ...(body && { body }), ...(error && { error }), ...rest }
}
/** 同一块的并成一项;块按记号排,一块下面的按时间排:写出来的顺序稳定,git diff 干净。 */
function tidy<E extends ChatEntry>(threads: QaThread<E>[]): QaThread<E>[] {
const byAnchor = new Map<string, E[]>()
for (const thread of threads)
byAnchor.set(thread.anchor, [...byAnchor.get(thread.anchor) ?? [], ...thread.entries])
return [...byAnchor]
@@ -69,34 +101,42 @@ function tidy(threads: QaThread[]): QaThread[] {
}))
}
/** 一份文件的问答:不是数组、一项都认不出的当没有;认不出的块、问答跳过。 */
function parseThreads(value: unknown): QaThread[] | undefined {
if (!Array.isArray(value))
return undefined
const threads = value.flatMap((item): QaThread[] => {
const anchor = isRecord(item) ? text(item.anchor) : undefined
if (!anchor || !isRecord(item) || !Array.isArray(item.entries))
return []
return [{ anchor, entries: item.entries.flatMap(entry => parseEntry(entry) ?? []) }]
})
const tidied = tidy(threads)
return tidied.length > 0 ? tidied : undefined
/** 一份文件的记录:不是数组、一项都认不出的当没有;认不出的块、条目跳过。 */
function threadsParser<E extends ChatEntry>(parseEntry: (value: unknown) => E | undefined): (value: unknown) => QaThread<E>[] | undefined {
return (value) => {
if (!Array.isArray(value))
return undefined
const threads = value.flatMap((item): QaThread<E>[] => {
const anchor = isRecord(item) ? text(item.anchor) : undefined
if (!anchor || !isRecord(item) || !Array.isArray(item.entries))
return []
return [{ anchor, entries: item.entries.flatMap(entry => parseEntry(entry) ?? []) }]
})
const tidied = tidy(threads)
return tidied.length > 0 ? tidied : undefined
}
}
export const QA_FILE: PathMapFile<QaThread[]> = {
path: '.spx/code-qa.json',
label: '代码问答',
parse: parseThreads,
attachments: threads => threads.flatMap(thread => thread.entries.flatMap(entry => (entry.images ?? []).map(name => `${QA_IMAGE_DIR}/${name}`))),
function threadsFile<E extends ChatEntry>(path: string, label: string, parseEntry: (value: unknown) => E | undefined): PathMapFile<QaThread<E>[]> {
return {
path,
label,
parse: threadsParser(parseEntry),
attachments: threads => threads.flatMap(thread => thread.entries.flatMap(entry => (entry.images ?? []).map(name => `${QA_IMAGE_DIR}/${name}`))),
}
}
/** 在 key 这份文件的 anchor 那块下面记一次问答。 */
export function withEntry(map: QaMap, key: string, anchor: string, entry: QaEntry): QaMap {
export const QA_FILE = threadsFile('.spx/code-qa.json', '代码问答', parseQaEntry)
export const REWRITE_FILE = threadsFile('.spx/comment-rewrites.json', '重写注释记录', parseRewriteEntry)
/** 在 key 这份文件的 anchor 那块下面记一条。 */
export function withEntry<E extends ChatEntry>(map: PathMap<QaThread<E>[]>, key: string, anchor: string, entry: E): PathMap<QaThread<E>[]> {
return { ...map, [key]: tidy([...map[key] ?? [], { anchor, entries: [entry] }]) }
}
/** 删掉一次问答;块下面删光了这块就不挂了,文件里删光了这份文件就不记了。 */
export function withoutEntry(map: QaMap, key: string, anchor: string, entryId: string): QaMap {
/** 删掉一条;块下面删光了这块就不挂了,文件里删光了这份文件就不记了。 */
export function withoutEntry<E extends ChatEntry>(map: PathMap<QaThread<E>[]>, key: string, anchor: string, entryId: string): PathMap<QaThread<E>[]> {
const threads = tidy((map[key] ?? []).map(thread => thread.anchor === anchor
? { anchor, entries: thread.entries.filter(entry => entry.id !== entryId) }
: thread))
@@ -105,7 +145,7 @@ export function withoutEntry(map: QaMap, key: string, anchor: string, entryId: s
return threads.length > 0 ? { ...next, [key]: threads } : next
}
/** 再问 anchor 那块时接着的会话:这块最近一次问答的会话 id;没问过、或那次没记会话 id 时没有,重新开会话。 */
export function latestSession(threads: readonly QaThread[] | undefined, anchor: string): string | undefined {
/** 在 anchor 那块接着说时接着的会话:这块最近一条的会话 id;没有记录、或那条没记会话 id 时没有,重新开会话。 */
export function latestSession(threads: readonly QaThread<ChatEntry>[] | undefined, anchor: string): string | undefined {
return threads?.find(thread => thread.anchor === anchor)?.entries.at(-1)?.sessionId || undefined
}
+4 -6
View File
@@ -59,8 +59,8 @@ export function App(): ReactElement | null {
const qa = useQa()
/** 侧栏「问 AI」要聚焦顶部输入框时加一 */
const [pathFocus, setPathFocus] = useState(0)
/** 按注释规范重写的过程,只在这一页上 */
const rewrites = useRewrite()
/** 按注释规范重写:和问 AI 共用 profile 下拉 */
const rewrite = useRewrite(qa.state.profiles, qa.onSelectProfile)
const [doc, setDoc] = useState<DocumentMessage | null>(null)
const [view, setView] = useState<BlockModes>(savedBlockModes)
const [focusId, setFocusId] = useState<string | null>(null)
@@ -213,9 +213,6 @@ export function App(): ReactElement | null {
editing: prev.editing.includes(id) ? prev.editing.filter(other => other !== id) : startEditing(doc.blocks, prev.editing, id),
}))
},
rewrites: rewrites.runs,
onRewriteComment: rewrites.onRewrite,
onStopRewrite: rewrites.onStop,
},
collapsed,
hidden: filter.hidden,
@@ -225,6 +222,7 @@ export function App(): ReactElement | null {
onToggleFold: id => setFolded([id], !collapsed.has(id)),
onSetFolds: folds => setView(prev => ({ ...prev, folds: { ...prev.folds, ...folds } })),
...(qa.state.available && { qa }),
...(rewrite.state.available && { rewrite }),
...(changes && { diff: changes }),
...(folded && { folded }),
onOpenRun: id => setOpenedRuns(prev => new Set([...prev, id])),
@@ -233,7 +231,7 @@ export function App(): ReactElement | null {
...(openCall?.version === doc.version && { openAnchor: openCall.anchor }),
onOpen: onOpenCall,
},
}, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, rewrites, qa, changes, folded, callCounts, openCall, onOpenCall])
}, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, rewrite, qa, changes, folded, callCounts, openCall, onOpenCall])
if (!doc || !tree)
return null
@@ -3,7 +3,7 @@ import type { Ask } from './qa'
import type { ProfileChoice, QaEntryView } from './types'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { AskThread } from './AskThread'
import { AskThread, REWRITE_THREAD } from './AskThread'
const NOW = new Date(2026, 8, 26, 15, 0).getTime()
@@ -112,3 +112,41 @@ describe('块下面的问答区', () => {
expect(html).not.toContain('data-pending')
})
})
describe('块下面的重写记录(样子和问答一样)', () => {
const REWRITES: QaEntryView[] = [
{ id: 'r1', question: '/ecc:writing-code-comments web/a.tsx:3-40', answer: '我的智能体页:用卡片网格列出自己的智能体。', createdAt: new Date(2026, 8, 25, 10, 0).toISOString() },
{ id: 'r2', question: '加上原型图', answer: '我的智能体页。\n\n![原型](web/prototypes/A.png)', createdAt: new Date(2026, 8, 26, 14, 0).toISOString(), error: '编辑器没接受这次修改,没有写回。' },
]
it('没展开:一行「重写过 N 次」,下面灰字历史,没写进文件的那条带着原因、能删;没有输入框', () => {
const html = render({ wording: REWRITE_THREAD, history: REWRITES })
expect(text(html)).toMatch(/^重写过 2 次/)
expect(text(html)).toContain('/ecc:writing-code-comments web/a.tsx:3-40')
expect(text(html)).toContain('没写进文件:编辑器没接受这次修改,没有写回。')
expect(html.match(/data-tooltip="删除这条记录" aria-label="删除这条记录"/g)).toHaveLength(2)
expect(html).not.toContain('<textarea')
expect(html).not.toContain('问过')
})
it('展开:当前这一轮流出来的正文、正在做什么、能停;输入框说修改意见,旁边是 profile 下拉', () => {
const html = render({ wording: REWRITE_THREAD, open: true, history: REWRITES, asks: [ask({ question: '/ecc:writing-code-comments web/a.tsx:3-40', text: '新的**注释**', activity: '运行 op export' })] })
expect(html).toContain('aria-label="按注释规范重写"')
expect(html).toContain('data-streaming=""')
expect(text(html)).toContain('运行 op export')
expect(html).toContain('placeholder="说说这段注释要怎么改… Enter 发送,Esc 收起"')
expect(html).toContain('aria-label="对这段注释的修改意见"')
expect(html).toContain('cr-qa-profile')
expect(html).toContain('data-tooltip="停止" aria-label="停止"')
})
it('答完了但没写进文件:照常显示交回的正文,下面写着原因;出错、停止同问答', () => {
const done = render({ wording: REWRITE_THREAD, open: true, asks: [ask({ status: 'done', text: '正文', error: '这块代码在重写期间变了,没有写回。', entryId: 'r3' })] })
expect(done).toContain('role="alert"')
expect(text(done)).toContain('正文')
expect(text(done)).toContain('这块代码在重写期间变了,没有写回。')
const written = render({ wording: REWRITE_THREAD, open: true, asks: [ask({ status: 'done', text: '正文', entryId: 'r3' })] })
expect(written).not.toContain('role="alert"')
expect(text(render({ wording: REWRITE_THREAD, open: true, asks: [ask({ status: 'stopped' })] }))).toContain('已停止,这次没存。')
})
})
+79 -58
View File
@@ -1,9 +1,9 @@
import type { LucideIcon } from 'lucide-react'
import type { ClipboardEvent, DragEvent, KeyboardEvent, ReactElement } from 'react'
import type { Ask } from './qa'
import type { PendingImage } from './qaImages'
import type { RewriteRun } from './rewriteRun'
import type { ProfileChoice, QaEntryView } from './types'
import { ChevronDown, ChevronRight, CircleStop, LoaderCircle, SendHorizontal, Sparkles, Trash2, TriangleAlert, X } from 'lucide-react'
import { ChevronDown, ChevronRight, CircleStop, LoaderCircle, MessageSquareText, SendHorizontal, Sparkles, Trash2, TriangleAlert, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { SelectMenu } from '@/components/ui/select-menu'
@@ -11,6 +11,39 @@ import { renderMarkdown, renderMarkdownOnce } from './markdown'
import { answerPreview, relativeTime } from './qa'
import { MAX_IMAGES, pickImages, readImage, transferFiles } from './qaImages'
/** 一种对话在界面上怎么称呼:区域的名字、图标、「问过 N 次」、删一条、输入框的提示和名字、回答长出来时的状态 */
export interface ThreadWording {
label: string
icon: LucideIcon
summary: (count: number) => string
deleteLabel: string
/** first:还没说过;hint:「问」后面的对象 */
placeholder: (first: boolean, hint: string) => string
inputLabel: string
answering: string
}
export const ASK_THREAD: ThreadWording = {
label: '问 AI',
icon: Sparkles,
summary: count => `问过 ${count} 次`,
deleteLabel: '已理解,删除',
placeholder: (first, hint) => first ? `问${hint}…` : '继续问…',
inputLabel: '问 AI 的问题',
answering: '正在回答…',
}
/** 重写注释:第一轮由块右上角的按钮发技能命令,输入框里说的是对这段注释的修改意见 */
export const REWRITE_THREAD: ThreadWording = {
label: '按注释规范重写',
icon: MessageSquareText,
summary: count => `重写过 ${count} 次`,
deleteLabel: '删除这条记录',
placeholder: () => '说说这段注释要怎么改…',
inputLabel: '对这段注释的修改意见',
answering: '正在写…',
}
/** 问题带的图片:一排小缩略图,点一张看大图;给了 onRemove 的(贴了还没发的)右上角有「去掉」。没有图片时什么都不画。 */
function Thumbs({ urls, pending, onOpen, onRemove }: { urls: string[], pending?: boolean, onOpen: (url: string) => void, onRemove?: (index: number) => void }): ReactElement | null {
if (urls.length === 0)
@@ -64,14 +97,18 @@ function ImagePreview({ url, onClose }: { url: string, onClose: () => void }): R
)
}
/** 问过的一次,灰字:问题、时间、「已理解,删除」,问题带的图片,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排) */
function HistoryItem({ entry, now, onDelete, onOpenImage }: { entry: QaEntryView, now: number, onDelete: () => void, onOpenImage: (url: string) => void }): ReactElement {
/**
* 说过的一次,灰字:说的话、时间、删除(问答叫「已理解,删除」),带的图片,下面回答的前两行;点问题或预览展开整段回答(照样灰字,按 Markdown 排)。
* 重写记录没写进文件时,最下面写着原因
*/
function HistoryItem({ entry, wording, now, onDelete, onOpenImage }: { entry: QaEntryView, wording: ThreadWording, now: number, onDelete: () => void, onOpenImage: (url: string) => void }): ReactElement {
const [expanded, setExpanded] = useState(false)
const toggle = (): void => setExpanded(value => !value)
const Icon = wording.icon
return (
<div className="cr-qa-entry" data-history="">
<div className="cr-qa-head">
<Sparkles size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
<Icon size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
<button type="button" className="cr-qa-question" aria-expanded={expanded} data-tooltip={expanded ? '收起回答' : '展开整段回答'} onClick={toggle}>
{entry.question}
</button>
@@ -81,7 +118,7 @@ function HistoryItem({ entry, now, onDelete, onOpenImage }: { entry: QaEntryView
</time>
{entry.profile && <span data-tooltip="用这个 Claude Code 配置(profile)答的">{` · ${entry.profile}`}</span>}
</span>
<button type="button" className="cr-tool cr-qa-delete" data-tooltip="已理解,删除" aria-label="已理解,删除" onClick={onDelete}>
<button type="button" className="cr-tool cr-qa-delete" data-tooltip={wording.deleteLabel} aria-label={wording.deleteLabel} onClick={onDelete}>
<Trash2 size={13} strokeWidth={1.8} aria-hidden />
</button>
</div>
@@ -93,18 +130,25 @@ function HistoryItem({ entry, now, onDelete, onOpenImage }: { entry: QaEntryView
{answerPreview(entry.answer)}
</button>
)}
{entry.error && (
<div className="cr-qa-status" data-warning="">
<TriangleAlert size={12} strokeWidth={1.8} className="shrink-0" aria-hidden />
{`没写进文件:${entry.error}`}
</div>
)}
</div>
)
}
/** 当前这一轮问的一次,照常颜色:问题,回答逐字长出来(按 Markdown 排),在做的事、出错、停止写在下面 */
function CurrentItem({ ask, onOpenImage, onStop }: { ask: Ask, onOpenImage: (url: string) => void, onStop?: () => void }): ReactElement {
/** 当前这一轮说的一次,照常颜色:说的话,回答逐字长出来(按 Markdown 排),在做的事、出错(重写注释没写进文件也算)、停止写在下面 */
function CurrentItem({ ask, wording, onOpenImage }: { ask: Ask, wording: ThreadWording, onOpenImage: (url: string) => void }): ReactElement {
const running = ask.status === 'running'
const label = ask.activity ?? (ask.text === '' ? '正在想…' : '正在回答…')
const label = ask.activity ?? (ask.text === '' ? '正在想…' : wording.answering)
const Icon = wording.icon
return (
<div className="cr-qa-entry" data-current="">
<div className="cr-qa-head">
<Sparkles size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
<Icon size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
<span className="cr-qa-question">{ask.question}</span>
</div>
<Thumbs urls={ask.images ?? []} onOpen={onOpenImage} />
@@ -118,15 +162,10 @@ function CurrentItem({ ask, onOpenImage, onStop }: { ask: Ask, onOpenImage: (url
{running && (
<div className="cr-qa-status" role="status">
<LoaderCircle size={12} strokeWidth={1.8} className="cr-qa-spin" aria-hidden />
{onStop ? <span className="min-w-0 flex-1">{label}</span> : label}
{onStop && (
<button type="button" className="cr-tool" data-tooltip="停止" aria-label="停止" onClick={onStop}>
<CircleStop size={14} strokeWidth={1.8} aria-hidden />
</button>
)}
{label}
</div>
)}
{ask.status === 'failed' && (
{ask.error !== undefined && ask.status !== 'running' && (
<div className="cr-qa-error" role="alert">
<TriangleAlert size={13} strokeWidth={1.8} className="mt-[3px] shrink-0" aria-hidden />
<span>{ask.error}</span>
@@ -138,12 +177,12 @@ function CurrentItem({ ask, onOpenImage, onStop }: { ask: Ask, onOpenImage: (url
}
/** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止。能贴图、拖图,贴的图在输入框上面一排,随下一个问题发 */
function AskInput({ running, first, hint, profiles, onSelectProfile, onAsk, onStop, onClose, closable, onOpenImage, autoFocus, focusToken }: {
function AskInput({ running, placeholder, label, profiles, onSelectProfile, onAsk, onStop, onClose, closable, onOpenImage, autoFocus, focusToken }: {
running: Ask | undefined
/** 还没问过:提示「问…」,问过了是「继续问…」 */
first: boolean
/** 「问」后面的对象:块下面是「这块代码」,顶部是「这个文件」或「这个文件夹」 */
hint: string
/** 输入框里的提示(后面接着怎么发送、收起) */
placeholder: string
/** 输入框读屏用的名字 */
label: string
profiles: ProfileChoice
onSelectProfile: (id: string) => void
onAsk: (question: string, images: PendingImage[]) => void
@@ -228,8 +267,8 @@ function AskInput({ running, first, hint, profiles, onSelectProfile, onAsk, onSt
ref={textarea}
rows={1}
value={value}
placeholder={`${first ? `问${hint}…` : '继续问…'} Enter 发送${closable ? ',Esc 收起' : ''}`}
aria-label="问 AI 的问题"
placeholder={`${placeholder} Enter 发送${closable ? ',Esc 收起' : ''}`}
aria-label={label}
onChange={event => setValue(event.target.value)}
onKeyDown={onKeyDown}
onPaste={onPaste}
@@ -268,6 +307,8 @@ export interface AskThreadProps {
profiles: ProfileChoice
/** 「问」后面的对象,默认「这块代码」 */
hint?: string
/** 哪种对话,默认问 AI */
wording?: ThreadWording
/** 展开时聚焦输入框,默认聚焦。一直摊开的顶部聊天不聚焦 */
autoFocus?: boolean
/** 变大时把光标放进输入框 */
@@ -276,7 +317,7 @@ export interface AskThreadProps {
closable?: boolean
now?: number
onSelectProfile: (id: string) => void
/** images 是贴进输入框的图片,随问题一起发 */
/** images 是贴进输入框的图片,随这句话一起发 */
onAsk: (question: string, images: PendingImage[]) => void
onStop: (askId: string) => void
onDelete: (entryId: string) => void
@@ -284,14 +325,16 @@ export interface AskThreadProps {
}
/**
* 块下面的问答区(左边一条竖线):展开时是灰字的历史、当前这一轮、输入框;
* 没展开但问过的块是一行「问过 N 次」、下面默认摊开灰字历史,点这一行收起 / 再摊开,读代码时一眼看到这里问过什么。
* 块下面的对话区(问 AI、重写注释,左边一条竖线):展开时是灰字的历史、当前这一轮、输入框;
* 没展开但说过的块是一行「问过 N 次」(重写注释是「重写过 N 次」)、下面默认摊开灰字历史,点这一行收起 / 再摊开,读代码时一眼看到这里说过什么。
*/
export function AskThread({ history, asks, open, profiles, hint = '这块代码', autoFocus = true, focusToken = 0, closable = true, now = Date.now(), onSelectProfile, onAsk, onStop, onDelete, onClose }: AskThreadProps): ReactElement | null {
export function AskThread({ history, asks, open, profiles, hint = '这块代码', wording = ASK_THREAD, autoFocus = true, focusToken = 0, closable = true, now = Date.now(), onSelectProfile, onAsk, onStop, onDelete, onClose }: AskThreadProps): ReactElement | null {
const [showHistory, setShowHistory] = useState(true)
/** 正在看大图的那张 */
const [preview, setPreview] = useState<string>()
const lightbox = preview !== undefined && <ImagePreview url={preview} onClose={() => setPreview(undefined)} />
const Icon = wording.icon
const historyItems = history.map(entry => <HistoryItem key={entry.id} entry={entry} wording={wording} now={now} onDelete={() => onDelete(entry.id)} onOpenImage={setPreview} />)
if (!open) {
if (history.length === 0)
return null
@@ -299,26 +342,26 @@ export function AskThread({ history, asks, open, profiles, hint = '这块代码'
return (
<div className="cr-qa" data-collapsed="">
<button type="button" className="cr-qa-summary" aria-expanded={showHistory} onClick={() => setShowHistory(value => !value)}>
<Sparkles size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
{`问过 ${history.length} 次`}
<Icon size={12} strokeWidth={1.8} className="cr-qa-icon" aria-hidden />
{wording.summary(history.length)}
<Chevron size={12} strokeWidth={1.8} aria-hidden />
</button>
{showHistory && history.map(entry => <HistoryItem key={entry.id} entry={entry} now={now} onDelete={() => onDelete(entry.id)} onOpenImage={setPreview} />)}
{showHistory && historyItems}
{lightbox}
</div>
)
}
return (
<section className="cr-qa" aria-label="问 AI">
{history.map(entry => <HistoryItem key={entry.id} entry={entry} now={now} onDelete={() => onDelete(entry.id)} onOpenImage={setPreview} />)}
<section className="cr-qa" aria-label={wording.label}>
{historyItems}
{history.length > 0 && asks.length > 0 && <hr className="cr-qa-divider" />}
{asks.map(ask => <CurrentItem key={ask.askId} ask={ask} onOpenImage={setPreview} />)}
{asks.map(ask => <CurrentItem key={ask.askId} ask={ask} wording={wording} onOpenImage={setPreview} />)}
<AskInput
running={asks.find(ask => ask.status === 'running')}
first={history.length === 0 && asks.length === 0}
placeholder={wording.placeholder(history.length === 0 && asks.length === 0, hint)}
label={wording.inputLabel}
profiles={profiles}
onSelectProfile={onSelectProfile}
hint={hint}
onAsk={onAsk}
onStop={onStop}
onClose={onClose}
@@ -331,25 +374,3 @@ export function AskThread({ history, asks, open, profiles, hint = '这块代码'
</section>
)
}
/**
* 注释块下面的重写过程,样子和展开的「问 AI」一样:左边竖线、问题、流出来的回答、正在读哪个文件。
*
* 问题是发给模型的那句固定要求。没有输入框,不记历史。写进文件后由调用方拿掉;失败或停下就留在这里。
*/
export function RewriteThread({ run, onStop }: { run: RewriteRun, onStop: () => void }): ReactElement {
const ask: Ask = {
askId: 'rewrite',
anchor: '',
question: run.question,
text: run.text,
...(run.activity !== undefined && { activity: run.activity }),
status: run.status,
...(run.error !== undefined && { error: run.error }),
}
return (
<section className="cr-qa" aria-label="按注释规范重写">
<CurrentItem ask={ask} onOpenImage={() => {}} onStop={run.status === 'running' ? onStop : undefined} />
</section>
)
}
@@ -2,13 +2,13 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { BlockTools } from './BlockTools'
function render(editing: boolean, ask?: { asking: boolean }, rewrite?: { rewriting: boolean, fresh?: boolean }, callTree?: { count: number }): string {
function render(editing: boolean, ask?: { asking: boolean }, rewrite?: { rewriting: boolean, fresh?: boolean, reopen?: boolean }, callTree?: { count: number }): string {
return renderToStaticMarkup(
<BlockTools
editing={editing}
onToggleEdit={() => {}}
{...(ask && { asking: ask.asking, onToggleAsk: () => {} })}
{...(rewrite && { rewrite: { rewriting: rewrite.rewriting, fresh: rewrite.fresh ?? false, onRewrite: () => {} } })}
{...(rewrite && { rewrite: { rewriting: rewrite.rewriting, fresh: rewrite.fresh ?? false, reopen: rewrite.reopen ?? false, onRewrite: () => {} } })}
{...(callTree && { callTree: { anchor: 'fn', count: callTree.count, open: false, onOpen: () => {} } })}
/>,
)
@@ -66,6 +66,13 @@ describe('块右上角的图标按钮', () => {
expect(buttons[2]).toContain('data-tooltip="编辑"')
})
it('重写过、下面的重写记录收着时,同一个按钮先展开记录(接着说),叫「展开重写记录」', () => {
const reopen = render(false, { asking: false }, { rewriting: false, reopen: true })
const buttons = [...reopen.matchAll(/<button[^>]*>/g)].map(match => match[0])
expect(buttons[0]).toContain('data-tooltip="展开重写记录" aria-label="展开重写记录"')
expect(buttons[0]).not.toContain('disabled')
})
it('调用树排在编辑后面;棵数大于 0 时按钮上带这个数字,是 0 时只有图标', () => {
const html = render(false, { asking: false }, { rewriting: false }, { count: 3 })
const buttons = [...html.matchAll(/<button[^>]*>/g)].map(match => match[0])
@@ -2,16 +2,21 @@ import type { ReactElement } from 'react'
import { Eye, GitBranch, MessageSquareText, Pencil, Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils'
/** 「重写注释」按钮的样子和动作:fresh 是块还没有注释、点了新写一段;正在重写时不能再点。 */
/**
* 「重写注释」按钮的样子和动作:fresh 是块还没有注释、点了新写一段;正在重写时不能再点;
* reopen 是重写过、下面的重写记录收着,点了先展开它(好接着说),不重写。
*/
export interface RewriteTool {
fresh: boolean
rewriting: boolean
reopen: boolean
onRewrite: () => void
}
/**
* 块右上角的一排图标按钮(普通块、属性表、库规则的卡片共用):只放图标,名字写在悬停提示(data-tooltip)和读屏用的 aria-label 里。
* 「按注释规范重写」:用写注释的技能重写这块代码的注释并写回文件,块还没有注释时叫「按注释规范写注释」、新写一段;正在重写时不能再点。图标是注释。
* 「按注释规范重写」:用写注释的技能重写这块代码的注释并写回文件,块还没有注释时叫「按注释规范写注释」、新写一段;正在重写时不能再点。
* 重写过、记录收着时叫「展开重写记录」,点了只展开。图标是注释。
* 「问 AI」:点开 / 收起块下面的问答区,展开时带按下的底色。
* 「编辑 / 预览」切换:预览时是铅笔「编辑」,编辑时是眼睛「预览」、带按下的底色。
*/
@@ -29,7 +34,7 @@ export function BlockTools({ editing, onToggleEdit, asking, onToggleAsk, rewrite
}): ReactElement {
const label = editing ? '预览' : '编辑'
const Icon = editing ? Eye : Pencil
const rewriteLabel = rewrite?.fresh ? '按注释规范写注释' : '按注释规范重写'
const rewriteLabel = rewrite?.reopen ? '展开重写记录' : rewrite?.fresh ? '按注释规范写注释' : '按注释规范重写'
return (
<div className="flex h-5 shrink-0 items-center gap-0.5" role="group" aria-label="块操作">
{rewrite && (
@@ -1,4 +1,5 @@
import type { BlockTreeState } from './BlockTree'
import type { BlockTreeState, RewriteTree } from './BlockTree'
import type { ChatState } from './qa'
import type { BlockView, KindBadge } from './types'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
@@ -11,6 +12,7 @@ import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh
import { BlockLevel } from './BlockTree'
import { applyFilter } from './filter'
import { FilterBar } from './FilterBar'
import { EMPTY_CHAT } from './qa'
import { collapsedIds } from './viewState'
// 高亮的上下文所在的模块、编辑框都连着和 VS Code 收发消息的那一套,渲染成 HTML 用不着
@@ -58,7 +60,12 @@ const CARD: BlockView = {
],
}
function state(collapsed: ReadonlySet<string>): BlockTreeState {
/** 重写注释的状态和动作:动作什么都不做 */
function rewriteTree(state: ChatState): RewriteTree {
return { state, profiles: { options: [], selected: '' }, onRewrite: () => {}, onAsk: () => {}, onStop: () => {}, onDelete: () => {}, onOpen: () => {}, onClose: () => {}, onSelectProfile: () => {} }
}
function state(collapsed: ReadonlySet<string>, rewrite: ChatState = { ...EMPTY_CHAT, available: true }): BlockTreeState {
return {
edit: {
version: 1,
@@ -66,10 +73,8 @@ function state(collapsed: ReadonlySet<string>): BlockTreeState {
focusId: null,
onFocused: () => {},
onToggleEdit: () => {},
rewrites: new Map(),
onRewriteComment: () => {},
onStopRewrite: () => {},
},
rewrite: rewriteTree(rewrite),
collapsed,
hidden: new Set(),
temporary: new Set(),
@@ -197,6 +202,19 @@ describe('「注释」一行和「重写注释」按钮', () => {
inToolbar(markup, '按注释规范重写')
})
it('重写过的块:下面一行「重写过 N 次」带灰字历史;记录收着时按钮先展开它,展开着时再点才重写', () => {
const block = view('5', 'statement', 'save()', { commentTarget: { kind: 'replace', startLine: 0, endLine: 0 } })
const entry = { id: 'r1', question: '/ecc:writing-code-comments a.py:1-1', answer: '保存。', createdAt: '2026-09-25T10:00:00.000Z' }
const closed = { ...EMPTY_CHAT, available: true, threads: [{ anchor: block.anchor, entries: [entry] }] }
const collapsedMarkup = renderToStaticMarkup(<BlockLevel blocks={[block]} tree={state(new Set(), closed)} />)
expect(collapsedMarkup.replace(/<[^>]*>/g, '')).toContain('重写过 1 次')
inToolbar(collapsedMarkup, '展开重写记录')
const opened = renderToStaticMarkup(<BlockLevel blocks={[block]} tree={state(new Set(), { ...closed, open: [block.anchor] })} />)
expect(opened).toMatch(/role="group" aria-label="块操作"><button[^>]*aria-label="按注释规范重写"/)
expect(opened).toContain('<section class="cr-qa" aria-label="按注释规范重写">')
expect(opened).toContain('aria-label="对这段注释的修改意见"')
})
it('还没有注释的块:按钮同样在右上角那排,叫「按注释规范写注释」;不能写注释的块没有按钮', () => {
inToolbar(html(view('3', 'statement', 'save()', { commentTarget: { kind: 'insert', line: 0 } })), '按注释规范写注释')
expect(html(view('4', 'statement', 'save()'))).not.toContain('按注释规范')
+53 -21
View File
@@ -2,13 +2,12 @@ import type { CSSProperties, ReactElement, ReactNode } from 'react'
import type { RewriteTool } from './BlockTools'
import type { DiffTree, FoldedRuns, PlacedRemoved } from './diffView'
import type { GridSlots } from './PropertyGrid'
import type { QaState } from './qa'
import type { ChatState, QaState } from './qa'
import type { PendingImage } from './qaImages'
import type { RewriteRun } from './rewriteRun'
import type { BlockMarks, BlockView } from './types'
import type { BlockMarks, BlockView, ProfileChoice } from './types'
import { ChevronDown, ChevronRight, ChevronsUpDown, EyeOff } from 'lucide-react'
import { Fragment, useState } from 'react'
import { AskThread, RewriteThread } from './AskThread'
import { AskThread, REWRITE_THREAD } from './AskThread'
import { BlockTools } from './BlockTools'
import { CodeEditor } from './CodeEditor'
import { DiffText } from './DiffText'
@@ -20,7 +19,7 @@ import { TextPreview } from './TextPreview'
import { tableId } from './viewState'
/**
* 块树里改代码的那部分:哪些块在编辑、点「编辑」、按注释规范重写。页面上的块树才有;定义卡片浮窗里的块树没有,
* 块树里改代码的那部分:哪些块在编辑、点「编辑」。页面上的块树才有;定义卡片浮窗里的块树没有,
* 卡片只读,不画右上角的按钮
*/
export interface EditTree {
@@ -30,11 +29,6 @@ export interface EditTree {
focusId: string | null
onFocused: () => void
onToggleEdit: (id: string) => void
/** 按注释规范重写的过程,按块的记号。只在这一页上 */
rewrites: ReadonlyMap<string, RewriteRun>
onRewriteComment: (anchor: string) => void
/** 叫停这块正在进行的重写,不写文件 */
onStopRewrite: (anchor: string) => void
}
/** 函数、类落在几棵调用树里。定义浮窗不传,跟问 AI 一样不画这排按钮。 */
@@ -64,6 +58,8 @@ export interface BlockTreeState {
onSetFolds: (folds: Record<string, boolean>) => void
/** 问 AI;文件不在工作区文件夹里(问答没地方存)时没有 */
qa?: QaTree
/** 按注释规范重写;同问 AI,文件不在工作区文件夹里时没有 */
rewrite?: RewriteTree
/** 代码差异:新增、修改的块和删掉的块;侧栏开着「未提交的更改」或审查工单、基准里有这个文件时才有 */
diff?: DiffTree
/** 「只看改动」开着时折起来的连着没变的块(见 foldUnchanged);onOpenRun 点开一段(按第一块的 id) */
@@ -71,6 +67,21 @@ export interface BlockTreeState {
onOpenRun: (id: string) => void
}
/** 重写注释的状态和动作,块按自己的记号(anchor)取;profile 下拉和问 AI 共用 */
export interface RewriteTree {
state: ChatState
profiles: ProfileChoice
/** 点了块右上角的按钮:按技能命令重写这块(开新会话),展开它的重写记录 */
onRewrite: (anchor: string) => void
/** 在输入框里说的修改意见,接着这块最近一次重写的会话说;images 是贴的图片 */
onAsk: (anchor: string, request: string, images: PendingImage[]) => void
onStop: (askId: string) => void
onDelete: (anchor: string, entryId: string) => void
onOpen: (anchor: string) => void
onClose: (anchor: string) => void
onSelectProfile: (id: string) => void
}
/** 问 AI 的状态和动作,块按自己的记号(anchor)取 */
export interface QaTree {
state: QaState
@@ -171,7 +182,7 @@ export function BlockLevel({ blocks, owner, tree, slotted = false }: { blocks: B
<BlockCard block={block} tree={tree} />
</ChangeRow>
{tree.qa && <BlockQa key={block.anchor} anchor={block.anchor} qa={tree.qa} />}
{tree.edit && <BlockRewrite anchor={block.anchor} edit={tree.edit} />}
{tree.rewrite && <BlockRewrite key={`rewrite ${block.anchor}`} anchor={block.anchor} rewrite={tree.rewrite} />}
{hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && (
<BlockLevel blocks={block.children} owner={block} tree={tree} />
)}
@@ -203,11 +214,22 @@ function BlockQa({ anchor, qa }: { anchor: string, qa: QaTree }): ReactElement {
)
}
function BlockRewrite({ anchor, edit }: { anchor: string, edit: EditTree }): ReactElement | null {
const run = edit.rewrites.get(anchor)
if (!run)
return null
return <RewriteThread run={run} onStop={() => edit.onStopRewrite(anchor)} />
/** 块下面的重写记录,紧跟在问答区后面;样子和问答区一样,输入框里说的是对这段注释的修改意见 */
function BlockRewrite({ anchor, rewrite }: { anchor: string, rewrite: RewriteTree }): ReactElement | null {
return (
<AskThread
wording={REWRITE_THREAD}
history={historyOf(rewrite.state, anchor)}
asks={asksOf(rewrite.state, anchor)}
open={rewrite.state.open.includes(anchor)}
profiles={rewrite.profiles}
onSelectProfile={rewrite.onSelectProfile}
onAsk={(request, images) => rewrite.onAsk(anchor, request, images)}
onStop={rewrite.onStop}
onDelete={entryId => rewrite.onDelete(anchor, entryId)}
onClose={() => rewrite.onClose(anchor)}
/>
)
}
/**
@@ -248,13 +270,23 @@ function callTreeTool(block: BlockView, tree: BlockTreeState) {
}
}
function rewriteTool(block: BlockView, edit: EditTree | undefined): RewriteTool | undefined {
if (!edit || !block.commentTarget)
/**
* 这块的「重写注释」按钮:能写注释的块才有。重写过、记录收着时点了先展开记录(好接着说);展开着、或还没重写过时点了按技能命令重写。
* 展开着又正在写时不能再点。
*/
function rewriteTool(block: BlockView, tree: BlockTreeState): RewriteTool | undefined {
const { edit, rewrite } = tree
if (!edit || !rewrite || !block.commentTarget)
return undefined
const { anchor } = block
const open = rewrite.state.open.includes(anchor)
const asks = asksOf(rewrite.state, anchor)
const reopen = !open && (asks.length > 0 || historyOf(rewrite.state, anchor).length > 0)
return {
fresh: block.commentTarget.kind === 'insert',
rewriting: edit.rewrites.get(block.anchor)?.status === 'running',
onRewrite: () => edit.onRewriteComment(block.anchor),
rewriting: open && asks.some(ask => ask.status === 'running'),
reopen,
onRewrite: () => reopen ? rewrite.onOpen(anchor) : rewrite.onRewrite(anchor),
}
}
@@ -339,7 +371,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
onToggleEdit={() => edit.onToggleEdit(block.id)}
asking={tree.qa?.state.open.includes(block.anchor)}
onToggleAsk={tree.qa && (() => tree.qa?.onToggle(block.anchor))}
rewrite={rewriteTool(block, edit)}
rewrite={rewriteTool(block, tree)}
callTree={callTreeTool(block, tree)}
/>
)}
@@ -62,9 +62,6 @@ function tree(callTree?: BlockTreeState['callTree'], edit = true): BlockTreeStat
focusId: null,
onFocused: () => {},
onToggleEdit: () => {},
rewrites: new Map(),
onRewriteComment: () => {},
onStopRewrite: () => {},
},
}),
collapsed: new Set(),
@@ -1,41 +0,0 @@
import type { RewriteRun } from './rewriteRun'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { RewriteThread } from './AskThread'
const QUESTION = '/ecc:writing-code-comments\n\n只按这个技能重写这一段注释。'
function render(run: RewriteRun): string {
return renderToStaticMarkup(<RewriteThread run={run} onStop={() => {}} />)
}
const text = (html: string): string => html.replace(/<[^>]*>/g, '')
describe('按注释规范重写的过程', () => {
const running: RewriteRun = { question: QUESTION, text: '新的**注释**', activity: '读取 routes.py', status: 'running' }
it('进行中画出固定的要求、流出来的回答和正在读的文件,能停;没有输入框、没有「问过 N 次」', () => {
const html = render(running)
expect(html).toContain('aria-label="按注释规范重写"')
expect(html).toContain('class="cr-qa"')
expect(text(html)).toContain('/ecc:writing-code-comments')
expect(html).toContain('data-streaming=""')
expect(text(html)).toContain('新的注释')
expect(text(html)).toContain('读取 routes.py')
expect(html).toContain('aria-label="停止"')
expect(html).not.toContain('<textarea')
expect(html).not.toContain('问过')
expect(html).not.toContain('继续问')
expect(html).not.toContain('cr-qa-profile')
})
it('没写成时原因留在这块上,不收成「问过 N 次」', () => {
const html = render({ ...running, text: '一半', status: 'failed', error: '模型没有给出注释正文,没有改文件。' })
expect(html).toContain('role="alert"')
expect(text(html)).toContain('模型没有给出注释正文,没有改文件。')
expect(text(html)).toContain('/ecc:writing-code-comments')
expect(html).not.toContain('问过')
expect(html).not.toContain('<textarea')
expect(html).not.toContain('aria-label="停止"')
})
})
@@ -793,6 +793,10 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-group[data-colored] { --
@keyframes cr-qa-spin {
to { transform: rotate(360deg); }
}
/* 重写记录没写进文件:灰字历史里写一行原因,警告色 */
.cr-qa-status[data-warning] {
color: var(--vscode-editorWarning-foreground, var(--vscode-descriptionForeground));
}
.cr-qa-error {
display: flex;
gap: 6px;
+43 -14
View File
@@ -1,7 +1,7 @@
import type { QaAction, QaState } from './qa'
import type { QaMessage, QaThreadView } from './types'
import { describe, expect, it } from 'vitest'
import { answerPreview, asksOf, EMPTY_QA, historyOf, PATH_ASK_ANCHOR, qaReducer, relativeTime } from './qa'
import { answerPreview, asksOf, chatReducer, EMPTY_CHAT, EMPTY_QA, historyOf, PATH_ASK_ANCHOR, qaReducer, relativeTime } from './qa'
const IF = 'function get_user > if @3fa1c2d0'
@@ -9,12 +9,12 @@ function thread(anchor: string, ...ids: string[]): QaThreadView {
return { anchor, entries: ids.map(id => ({ id, question: `问 ${id}`, answer: `答 ${id}`, createdAt: '2026-09-25T10:00:00.000Z' })) }
}
function message(threads: QaThreadView[], running: QaMessage['running'] = []): QaMessage {
return { type: 'qa', available: true, threads, path: [], subject: 'file', running }
function message(threads: QaThreadView[], current: QaMessage['current'] = []): QaMessage {
return { type: 'qa', available: true, threads, path: [], subject: 'file', current, rewrites: [], rewriteCurrent: [] }
}
function qa(threads: QaThreadView[], running: QaMessage['running'] = []): QaAction {
return { type: 'qa', message: message(threads, running) }
function qa(threads: QaThreadView[], current: QaMessage['current'] = []): QaAction {
return { type: 'qa', message: message(threads, current) }
}
function run(...actions: QaAction[]): QaState {
@@ -28,7 +28,7 @@ describe('问 AI 的状态', () => {
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', images: [], text: '', status: 'running' }])
state = qaReducer(state, { type: 'answer', askId: 'a1', text: '因为', activity: '读取 a.py' })
expect(asksOf(state, IF)[0]).toMatchObject({ text: '因为', activity: '读取 a.py' })
state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', text: '因为要鉴权。', entryId: 'new' } })
state = qaReducer(state, { type: 'answerEnd', end: { askId: 'a1', status: 'done', text: '因为要鉴权。', entryId: 'new' } })
state = qaReducer(state, qa([thread(IF, 'old', 'new')]))
expect(asksOf(state, IF)).toEqual([{ askId: 'a1', anchor: IF, question: '为什么?', images: [], text: '因为要鉴权。', status: 'done', entryId: 'new' }])
expect(historyOf(state, IF).map(entry => entry.id)).toEqual(['old'])
@@ -51,7 +51,9 @@ describe('问 AI 的状态', () => {
threads: [],
path: [{ id: 'p', question: '这文件夹干什么', answer: '放源码', createdAt: '2026-09-25T10:00:00.000Z' }],
subject: 'folder',
running: [],
current: [],
rewrites: [],
rewriteCurrent: [],
},
})
expect(state.path.map(entry => entry.id)).toEqual(['p'])
@@ -64,7 +66,7 @@ describe('问 AI 的状态', () => {
let state = run(
qa([thread(IF, 'e1')]),
{ type: 'ask', askId: 'a1', anchor: IF, question: '1', images: [] },
{ type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'done', entryId: 'e1' } },
{ type: 'answerEnd', end: { askId: 'a1', status: 'done', entryId: 'e1' } },
{ type: 'ask', askId: 'a2', anchor: IF, question: '2', images: [] },
{ type: 'close', anchor: IF },
)
@@ -76,20 +78,20 @@ describe('问 AI 的状态', () => {
})
it('页面重新加载后:扩展端还在答的接上、展开它那块;已知道的不重复', () => {
const running = [{ askId: 'a1', anchor: IF, question: '1', text: '答了一半' }]
const running = [{ askId: 'a1', anchor: IF, question: '1', text: '答了一半', status: 'running' as const }]
let state = run(qa([], running))
expect(state.open).toEqual([IF])
expect(asksOf(state, IF)).toEqual([{ ...running[0], status: 'running' }])
expect(asksOf(state, IF)).toEqual([running[0]])
state = qaReducer(state, qa([], running))
expect(state.asks).toHaveLength(1)
})
it('停止、失败照记;不认识的问题的消息不理', () => {
let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '1', images: [] }, { type: 'answer', askId: 'a1', text: '一半' })
state = qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status: 'stopped' } })
state = qaReducer(state, { type: 'answerEnd', end: { askId: 'a1', status: 'stopped' } })
expect(asksOf(state, IF)[0]).toMatchObject({ status: 'stopped', text: '一半' })
expect(qaReducer(state, { type: 'answer', askId: 'a1', text: '晚到的' })).toEqual(state)
expect(qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'nope', status: 'done' } })).toEqual(state)
expect(qaReducer(state, { type: 'answerEnd', end: { askId: 'nope', status: 'done' } })).toEqual(state)
})
it('问题带的图片跟着这一问:答完、失败、停止都还在;页面重新加载后接上的没有', () => {
@@ -97,12 +99,39 @@ describe('问 AI 的状态', () => {
let state = run(qa([]), { type: 'ask', askId: 'a1', anchor: IF, question: '看图', images })
expect(asksOf(state, IF)[0].images).toEqual(images)
for (const status of ['done', 'failed', 'stopped'] as const)
expect(asksOf(qaReducer(state, { type: 'answerEnd', end: { type: 'answerEnd', askId: 'a1', status } }), IF)[0].images).toEqual(images)
state = run(qa([], [{ askId: 'a2', anchor: IF, question: '1', text: '' }]))
expect(asksOf(qaReducer(state, { type: 'answerEnd', end: { askId: 'a1', status } }), IF)[0].images).toEqual(images)
state = run(qa([], [{ askId: 'a2', anchor: IF, question: '1', text: '', status: 'running' }]))
expect(asksOf(state, IF)[0].images).toBeUndefined()
})
})
describe('重写注释的状态:和问 AI 同一套', () => {
const SKILL = '/ecc:writing-code-comments a.py:3-8'
it('页面重新加载后:扩展端还在写的那一轮接上、展开它那块,后面的进展接得上;出错、被叫停留着的照原样画出来', () => {
let state = chatReducer(EMPTY_CHAT, { type: 'sync', available: true, threads: [], current: [
{ askId: 'r1', anchor: IF, question: SKILL, text: '一半', status: 'running' },
{ askId: 'r0', anchor: 'class A', question: '短一点', text: '', status: 'failed', error: '额度用完了' },
] })
expect(state.open).toEqual([IF, 'class A'])
expect(asksOf(state, IF)).toEqual([{ askId: 'r1', anchor: IF, question: SKILL, text: '一半', status: 'running' }])
expect(asksOf(state, 'class A')).toEqual([{ askId: 'r0', anchor: 'class A', question: '短一点', text: '', status: 'failed', error: '额度用完了' }])
state = chatReducer(state, { type: 'answer', askId: 'r1', question: SKILL, text: '写完了大半', activity: '运行 op export' })
expect(asksOf(state, IF)[0]).toMatchObject({ text: '写完了大半', activity: '运行 op export', status: 'running' })
state = chatReducer(state, { type: 'answerEnd', end: { askId: 'r1', status: 'done', text: '正文', error: '编辑器没接受这次修改,没有写回。', entryId: 'e1' } })
expect(asksOf(state, IF)[0]).toMatchObject({ status: 'done', text: '正文', error: '编辑器没接受这次修改,没有写回。', entryId: 'e1' })
state = chatReducer(state, { type: 'sync', available: true, threads: [], current: [{ askId: 'r0', anchor: 'class A', question: '短一点', text: '', status: 'failed', error: '额度用完了' }] })
expect(state.asks).toHaveLength(2)
})
it('点按钮那一轮开始时还不知道那句技能命令,扩展端开始后报上来就换上', () => {
let state = chatReducer(EMPTY_CHAT, { type: 'ask', askId: 'r1', anchor: IF, question: '', images: [] })
expect(state.open).toEqual([IF])
state = chatReducer(state, { type: 'answer', askId: 'r1', question: SKILL, text: '' })
expect(asksOf(state, IF)[0]).toMatchObject({ question: SKILL, status: 'running' })
})
})
describe('问的时间', () => {
const now = new Date(2026, 8, 26, 15, 0).getTime()
const at = (year: number, month: number, day: number, hour: number, minute: number, second = 0): string =>
+64 -40
View File
@@ -1,24 +1,24 @@
/**
* 「问 AI」在页面上的状态和规则(不碰 DOM):块下面的问答按块的记号挂;
* 顶部的问答按路径记在 path 上,不挂到任何一块。
* 这次打开以来问的是「当前这一轮」,照常颜色显示,别的都是灰字的历史。
* 块下面的对话(问 AI、重写注释)在页面上的状态和规则(不碰 DOM):两种各一份 ChatState,按块的记号挂;
* 问 AI 另有顶部的问答,按路径记在 path 上,不挂到任何一块。
* 这次打开以来说的是「当前这一轮」,照常颜色显示,别的都是灰字的历史。
*/
import type { AnswerEnd, ProfileChoice, QaEntryView, QaMessage, QaThreadView } from './types'
import type { AskRound, ProfileChoice, QaEntryView, QaMessage, QaThreadView, RoundEnd } from './types'
/** 这次打开以来问的一个问题 */
/** 这次打开以来的一轮 */
export interface Ask {
askId: string
anchor: string
question: string
/** 问题带的图片(这边贴的 data: 地址);页面重新加载后接上的、扩展端还在答的没有 */
/** 带的图片(这边贴的 data: 地址);页面重新加载后接上的、扩展端还在进行的没有 */
images?: string[]
/** 回答到现在为止的全文(Markdown) */
text: string
/** 正在做的事:「读取 routes.py」 */
activity?: string
status: 'running' | 'done' | 'failed' | 'stopped'
/** failed 时给人看的原因 */
/** failed 时给人看的原因;重写注释 done 了但没写进文件时也有 */
error?: string
/** done 时存下的那条:历史里不再重复显示它 */
entryId?: string
@@ -27,68 +27,79 @@ export interface Ask {
/** 顶部问答的记号。跟扩展端 pathQa 里的是同一个,块的记号不会是这个。 */
export const PATH_ASK_ANCHOR = '\0path'
export interface QaState {
/** 文件不在工作区文件夹里时问答没地方存,不给「问 AI」 */
/** 一种块下面的对话的状态 */
export interface ChatState {
/** 文件不在工作区文件夹里时记录没地方存,不给这种对话 */
available: boolean
threads: QaThreadView[]
/** 当前这一轮的,按先后 */
asks: Ask[]
/** 展开着对话区(历史、当前这一轮、输入框)的块 */
open: string[]
}
export interface QaState extends ChatState {
/** 这一页顶部按路径记的问答 */
path: QaEntryView[]
/** 顶部问的是这个文件还是这个文件夹 */
subject: 'file' | 'folder'
/** 当前这一轮问的,按先后 */
asks: Ask[]
/** 展开着对话区(历史、当前这一轮、输入框)的块 */
open: string[]
/** 输入框旁边的 profile 下拉,所有块共用 */
profiles: ProfileChoice
}
export const EMPTY_QA: QaState = {
export const EMPTY_CHAT: ChatState = {
available: false,
threads: [],
path: [],
subject: 'file',
asks: [],
open: [],
}
export const EMPTY_QA: QaState = {
...EMPTY_CHAT,
path: [],
subject: 'file',
profiles: { options: [], selected: '' },
}
export type QaAction
= | { type: 'qa', message: QaMessage }
| { type: 'profiles', profiles: ProfileChoice }
| { type: 'answer', askId: string, text: string, activity?: string }
| { type: 'answerEnd', end: AnswerEnd }
/**
* sync:扩展端发来的记录和它记着的当前一轮;answer:askId 那一轮的回答长了(question 是扩展端报上来的这一轮的话,点按钮那一轮开始时页面还不知道)
*/
export type ChatAction
= | { type: 'sync', available: boolean, threads: QaThreadView[], current: AskRound[] }
| { type: 'answer', askId: string, text: string, activity?: string, question?: string }
| { type: 'answerEnd', end: RoundEnd }
| { type: 'ask', askId: string, anchor: string, question: string, images: string[] }
| { type: 'open', anchor: string }
| { type: 'close', anchor: string }
export type QaAction
= | ChatAction
| { type: 'qa', message: QaMessage }
| { type: 'profiles', profiles: ProfileChoice }
function withOpen(open: string[], anchor: string): string[] {
return open.includes(anchor) ? open : [...open, anchor]
}
export function qaReducer(state: QaState, action: QaAction): QaState {
export function chatReducer<S extends ChatState>(state: S, action: ChatAction): S {
switch (action.type) {
case 'qa': {
// 页面重新加载、或同一文件在别处开着的预览问的:扩展端还在答的接上,展开它那块
case 'sync': {
// 页面重新加载、或同一文件在别处开着的预览开的:扩展端记着的当前一轮接上(还在进行的、出错被叫停留着的),展开它那块
const known = new Set(state.asks.map(ask => ask.askId))
const adopted = action.message.running.filter(ask => !known.has(ask.askId))
const adopted = action.current.filter(round => !known.has(round.askId))
return {
...state,
available: action.message.available,
threads: action.message.threads,
path: action.message.path,
subject: action.message.subject,
asks: [...state.asks, ...adopted.map((ask): Ask => ({ ...ask, status: 'running' }))],
open: adopted.reduce((open, ask) => withOpen(open, ask.anchor), state.open),
available: action.available,
threads: action.threads,
asks: [...state.asks, ...adopted],
open: adopted.reduce((open, round) => withOpen(open, round.anchor), state.open),
}
}
case 'profiles':
return { ...state, profiles: action.profiles }
case 'answer':
return {
...state,
asks: state.asks.map(ask => ask.askId === action.askId && ask.status === 'running'
? { ...ask, text: action.text, activity: action.activity }
? { ...ask, text: action.text, activity: action.activity, ...(action.question && { question: action.question }) }
: ask),
}
case 'answerEnd': {
@@ -109,7 +120,7 @@ export function qaReducer(state: QaState, action: QaAction): QaState {
case 'open':
return { ...state, open: withOpen(state.open, action.anchor) }
case 'close':
// 收起就结束这一轮:答完的都回到灰字历史里;还在答的留着,答完照样存
// 收起就结束这一轮:答完的都回到灰字历史里;还在进行的留着,答完照样存
return {
...state,
asks: state.asks.filter(ask => ask.anchor !== action.anchor || ask.status === 'running'),
@@ -118,16 +129,29 @@ export function qaReducer(state: QaState, action: QaAction): QaState {
}
}
/** 当前这一轮在 anchor 那块问的 */
export function asksOf(state: QaState, anchor: string): Ask[] {
export function qaReducer(state: QaState, action: QaAction): QaState {
switch (action.type) {
case 'qa': {
const { available, threads, current, path, subject } = action.message
return { ...chatReducer(state, { type: 'sync', available, threads, current }), path, subject }
}
case 'profiles':
return { ...state, profiles: action.profiles }
default:
return chatReducer(state, action)
}
}
/** 当前这一轮在 anchor 那块说的 */
export function asksOf(state: ChatState, anchor: string): Ask[] {
return state.asks.filter(ask => ask.anchor === anchor)
}
/** anchor 那块的灰字历史:存下的问答,当前这一轮问的那几条除外 */
export function historyOf(state: QaState, anchor: string): QaEntryView[] {
/** anchor 那块的灰字历史:存下的记录,当前这一轮的那几条除外;顶部问答的在 path 上 */
export function historyOf(state: ChatState & { path?: QaEntryView[] }, anchor: string): QaEntryView[] {
const current = new Set(asksOf(state, anchor).map(ask => ask.entryId))
const entries = anchor === PATH_ASK_ANCHOR
? state.path
? state.path ?? []
: state.threads.find(thread => thread.anchor === anchor)?.entries ?? []
return entries.filter(entry => !current.has(entry.id))
}
@@ -1,38 +0,0 @@
import type { RewriteRuns } from './rewriteRun'
import { describe, expect, it } from 'vitest'
import { rewriteEnd, rewriteProgress, startRewrite } from './rewriteRun'
const ANCHOR = 'c1'
const QUESTION = '/ecc:writing-code-comments\n\n只按这个技能重写这一段注释。'
describe('重写过程只留在页面上', () => {
it('进行中换上全文和正在读的文件;新的一段正文来了就不再标着上一个文件', () => {
const started = startRewrite(new Map(), ANCHOR)
const reading = rewriteProgress(started, ANCHOR, QUESTION, '', '读取 routes.py')
expect(reading.get(ANCHOR)).toEqual({ question: QUESTION, text: '', status: 'running', activity: '读取 routes.py' })
const writing = rewriteProgress(reading, ANCHOR, QUESTION, '新的注释', undefined)
expect(writing.get(ANCHOR)).toEqual({ question: QUESTION, text: '新的注释', status: 'running' })
})
it('写进文件就拿掉;没写成把原因留在这块上;叫停也留着,不再进行', () => {
const running = rewriteProgress(startRewrite(new Map(), ANCHOR), ANCHOR, QUESTION, '一半', '读取 a.py')
expect(rewriteEnd(running, ANCHOR, 'done').has(ANCHOR)).toBe(false)
const failed = rewriteEnd(running, ANCHOR, 'failed', '模型没有给出注释正文,没有改文件。')
expect(failed.get(ANCHOR)).toMatchObject({ question: QUESTION, text: '一半', status: 'failed', error: '模型没有给出注释正文,没有改文件。' })
expect(rewriteEnd(running, ANCHOR, 'stopped').get(ANCHOR)?.status).toBe('stopped')
})
it('已经结束的,迟到的字不再把过程画出来;正在进行时再点一次也不重开', () => {
const running = startRewrite(new Map(), ANCHOR)
const done = rewriteEnd(running, ANCHOR, 'done')
expect(rewriteProgress(done, ANCHOR, QUESTION, '迟到了').has(ANCHOR)).toBe(false)
expect(startRewrite(running, ANCHOR)).toBe(running)
const failed = rewriteEnd(running, ANCHOR, 'failed', '没写成')
expect(startRewrite(failed, ANCHOR).get(ANCHOR)?.status).toBe('running')
})
it('这一页上没有的结束消息不动原来的', () => {
const runs: RewriteRuns = new Map()
expect(rewriteEnd(runs, ANCHOR, 'failed', '没有')).toBe(runs)
})
})
@@ -1,58 +0,0 @@
/** 一段注释正在按规范重写、或没写成时留在页面上的过程。只在这一页上,刷新或关掉预览就没了,不写入问答。 */
export interface RewriteRun {
/** 发给模型的要求,以 `/ecc:writing-code-comments` 开头;还没从扩展端来时是空的 */
question: string
/** 回答到现在为止的全文(Markdown) */
text: string
/** 正在做的事,例如「读取 routes.py」 */
activity?: string
status: 'running' | 'failed' | 'stopped'
/** [status] 是 `failed` 时给人看的原因 */
error?: string
}
/** 各段注释的重写过程,按块的记号 */
export type RewriteRuns = ReadonlyMap<string, RewriteRun>
/** 点了重写:这块标成正在进行。已经在进行就不动。 */
export function startRewrite(runs: RewriteRuns, anchor: string): RewriteRuns {
if (runs.get(anchor)?.status === 'running')
return runs
const next = new Map(runs)
next.set(anchor, { question: '', text: '', status: 'running' })
return next
}
/**
* 模型又吐出一段,或换了正在读的文件。
*
* 这块不在进行中(已经结束,或这一页上没有)就丢掉,避免写完之后迟到的字再把过程画出来。
*/
export function rewriteProgress(runs: RewriteRuns, anchor: string, question: string, text: string, activity?: string): RewriteRuns {
if (runs.get(anchor)?.status !== 'running')
return runs
const next = new Map(runs)
next.set(anchor, { question, text, status: 'running', ...(activity !== undefined && { activity }) })
return next
}
/** 结束:正文写进文件了就拿掉这段过程;没写成或被叫停就留着,失败的原因写在上面。 */
export function rewriteEnd(runs: RewriteRuns, anchor: string, status: 'done' | 'failed' | 'stopped', error?: string): RewriteRuns {
const current = runs.get(anchor)
if (current === undefined)
return runs
if (status === 'done') {
const next = new Map(runs)
next.delete(anchor)
return next
}
const next = new Map(runs)
next.set(anchor, {
question: current.question,
text: current.text,
status,
...(current.activity !== undefined && { activity: current.activity }),
...(error !== undefined && { error }),
})
return next
}
+40 -18
View File
@@ -258,7 +258,7 @@ export interface ShownName {
definition?: true
}
/** 问过的一次:问题、回答(Markdown)、什么时候问的(ISO 时间)、用哪个 profile 答的 */
/** 问过、重写过的一次:说的话、回答(Markdown;重写记录是交回的注释正文,没交回时是空的)、什么时候(ISO 时间)、用哪个 profile 答的 */
export interface QaEntryView {
id: string
question: string
@@ -266,8 +266,10 @@ export interface QaEntryView {
createdAt: string
/** 用哪个 profile 答的(profile 名);选「默认」时、以前存的没有 */
profile?: string
/** 问题带的图片,能直接放进 img 的地址;没带图片时没有 */
/** 带的图片,能直接放进 img 的地址;没带图片时没有 */
images?: string[]
/** 重写记录才有:没写进文件的原因 */
error?: string
}
/** 问题带的一张图片,发给扩展端的样子:格式(`image/png` 等)和不带 `data:` 前缀的 base64 */
@@ -282,18 +284,23 @@ export interface QaThreadView {
entries: QaEntryView[]
}
/** 扩展端正在答的一个问题:text 是到现在为止的回答,activity 是正在做的事(「读取 routes.py」) */
export interface RunningAsk {
/**
* 扩展端记着的当前一轮:还在进行的,或出错、被叫停没记下留着的。text 是到现在为止的回答,activity 是正在做的事(「读取 routes.py」),
* error 是 failed 时给人看的原因
*/
export interface AskRound {
askId: string
anchor: string
question: string
text: string
activity?: string
status: 'running' | 'failed' | 'stopped'
error?: string
}
/**
* 这份文件的问答:available 为假时文件不在工作区文件夹里、问答没地方存,不给「问 AI」;
* running 是扩展端正在答的问题(页面重新加载后靠它接上)
* 这份文件的问答和重写记录:available 为假时文件不在工作区文件夹里、没地方存,不给「问 AI」和「重写注释」;
* current、rewriteCurrent 是扩展端记着的当前一轮(页面重新加载后靠它们接上)
*/
export interface QaMessage {
type: 'qa'
@@ -303,7 +310,10 @@ export interface QaMessage {
path: QaEntryView[]
/** 顶部问的是这个文件还是这个文件夹 */
subject: 'file' | 'folder'
running: RunningAsk[]
current: AskRound[]
/** 块下面的重写记录,按块的记号 */
rewrites: QaThreadView[]
rewriteCurrent: AskRound[]
}
/** 「问 AI」用的 Claude Code profile 的下拉一项:id 是 profile 名;「默认」(不带 `--settings`)的 id 是 '' */
@@ -319,11 +329,10 @@ export interface ProfileChoice {
}
/**
* 一个问题怎么结束的:done 带最终的回答(逐字发来的最后一份可能没赶上)和存下的那条的 id,
* 一轮怎么结束的:done 带最终的回答(逐字发来的最后一份可能没赶上)和存下的那条的 id,重写注释没写进文件时还带原因(error);
* failed 带给人看的原因,stopped 是被叫停、没存
*/
export interface AnswerEnd {
type: 'answerEnd'
export interface RoundEnd {
askId: string
status: 'done' | 'failed' | 'stopped'
text?: string
@@ -331,6 +340,14 @@ export interface AnswerEnd {
entryId?: string
}
export interface AnswerEnd extends RoundEnd {
type: 'answerEnd'
}
export interface RewriteEnd extends RoundEnd {
type: 'rewriteEnd'
}
/** 一截是新加的还是删掉的;没变的不写 */
export type PartChange = 'add' | 'del'
@@ -443,8 +460,8 @@ export interface StickyScrollSettings {
* askProfiles:「问 AI」的 profile 下拉;ready 后来一次,任何一个预览改了都会来
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
* answerEnd:askId 那个问题答完了,见 AnswerEnd
* rewriteProgress:anchor 那块的注释正在按规范重写。question 是发给模型的那一句(技能命令和块在哪),text 是注释正文到现在为止写出来的,activity 是正在读什么
* rewriteEnd:这段重写结束了。done 是正文已经写进文件;failed 带给人看的原因,文件没改;stopped 是被叫停,文件没改
* rewriteProgress:askId 那一轮重写开始了、正文长了(text 是到现在为止写出来的)、或换了在做的事;question 是记在这一轮上的话(点按钮时是那句技能命令)
* rewriteEnd:askId 那一轮重写结束了,见 RewriteEnd:done 是模型答完、记下了(没写进文件时带原因);failed、stopped 没记,文件没改
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现
* displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理
* diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有);每来一份文档之后补来,基准变了再来;null 是不比了
@@ -510,8 +527,8 @@ export type ExtensionToWebview
| ({ type: 'askProfiles' } & ProfileChoice)
| { type: 'answer', askId: string, text: string, activity?: string }
| AnswerEnd
| { type: 'rewriteProgress', anchor: string, question: string, text: string, activity?: string }
| { type: 'rewriteEnd', anchor: string, status: 'done' | 'failed' | 'stopped', error?: string }
| { type: 'rewriteProgress', askId: string, question: string, text: string, activity?: string }
| RewriteEnd
| { type: 'diff', diff: DocumentDiff | null }
| { type: 'repoLinksChecked', id: number, missing: string[], unresolved: string[] }
| { type: 'repoLinkBroken', path?: string, symbol?: string }
@@ -534,8 +551,11 @@ export type ExtensionToWebview
* setOnlyChanges:打开 / 关掉「只看改动」;同样等扩展端发回 filter 再按它显示
* ask:问 anchor 那块代码;askId 由这边起,回答的消息带回来;扩展端接着这块最近一次问答的会话问,用眼下选的 profile;
* images 是贴进输入框的图片(没贴是空的),随问题发给 Claude Code,答成了跟这条一起存
* rewriteComment:按注释规范重写 anchor 那块的注释(还没有就新写一段)。过程用 rewriteProgress / rewriteEnd 回来,不写入问答
* stopRewrite:叫停 anchor 这段正在进行的重写,不写文件
* rewriteComment:按注释规范重写 anchor 那块的注释(还没有就新写一段);askId 由这边起,过程用 rewriteProgress / rewriteEnd 回来。
* 没有 request 是点了按钮:发技能命令、开新会话;有 request 是在输入框里说的修改意见,接着这块最近一次重写的会话说,images 是贴的图片
* stopRewrite:叫停 askId 那一轮重写,不写文件、不记
* deleteRewrite:删掉 anchor 那块下面的一条重写记录
* forgetRound:收起了 anchor 那块的问答(ask)或重写(rewrite),扩展端去掉这块留着的那一轮(出错、被叫停的),重新加载后不再画出来
* stopAsk:叫停 askId 那个问题,这次不存
* deleteQa:删掉 anchor 那块下面的一次问答(已经理解了)
* selectAskProfile:「问 AI」换 profile;不在本地先改,等扩展端发回 askProfiles 再按它显示
@@ -552,8 +572,10 @@ export type WebviewToExtension
| { type: 'setFilter', excluded: Excluded }
| { type: 'setOnlyChanges', on: boolean }
| { type: 'edit', startLine: number, endLine: number, text: string }
| { type: 'rewriteComment', anchor: string }
| { type: 'stopRewrite', anchor: string }
| { type: 'rewriteComment', askId: string, anchor: string, request?: string, images: AskImage[] }
| { type: 'stopRewrite', askId: string }
| { type: 'deleteRewrite', anchor: string, entryId: string }
| { type: 'forgetRound', kind: 'ask' | 'rewrite', anchor: string }
| { type: 'selectTranslator', id: string }
| { type: 'visibleLine', line: number }
| { type: 'probeDefinition', id: number, version: number, offset: number }
+20 -13
View File
@@ -49,17 +49,24 @@ export function useQa(): QaTree {
}
}, [])
return useMemo((): QaTree => ({
state,
onToggle: anchor => dispatch({ type: current.current.open.includes(anchor) ? 'close' : 'open', anchor }),
onClose: anchor => dispatch({ type: 'close', anchor }),
onAsk: (anchor, question, images) => {
const askId = newAskId()
dispatch({ type: 'ask', askId, anchor, question, images: images.map(image => image.url) })
postMessage({ type: 'ask', askId, anchor, question, images: images.map(toAskImage) })
},
onStop: askId => postMessage({ type: 'stopAsk', askId }),
onDelete: (anchor, entryId) => postMessage({ type: 'deleteQa', anchor, entryId }),
onSelectProfile: id => postMessage({ type: 'selectAskProfile', id }),
}), [state])
return useMemo((): QaTree => {
/** 收起就结束这一轮,扩展端留着的那一轮(出错、被叫停的)也去掉 */
const close = (anchor: string): void => {
dispatch({ type: 'close', anchor })
postMessage({ type: 'forgetRound', kind: 'ask', anchor })
}
return {
state,
onToggle: anchor => current.current.open.includes(anchor) ? close(anchor) : dispatch({ type: 'open', anchor }),
onClose: close,
onAsk: (anchor, question, images) => {
const askId = newAskId()
dispatch({ type: 'ask', askId, anchor, question, images: images.map(image => image.url) })
postMessage({ type: 'ask', askId, anchor, question, images: images.map(toAskImage) })
},
onStop: askId => postMessage({ type: 'stopAsk', askId }),
onDelete: (anchor, entryId) => postMessage({ type: 'deleteQa', anchor, entryId }),
onSelectProfile: id => postMessage({ type: 'selectAskProfile', id }),
}
}, [state])
}
+55 -37
View File
@@ -1,43 +1,42 @@
import type { RewriteRuns } from './rewriteRun'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { rewriteEnd, rewriteProgress, startRewrite } from './rewriteRun'
import type { RewriteTree } from './BlockTree'
import type { ChatState } from './qa'
import type { ProfileChoice } from './types'
import { useEffect, useMemo, useReducer, useRef } from 'react'
import { chatReducer, EMPTY_CHAT, newAskId } from './qa'
import { toAskImage } from './qaImages'
import { ANSWER_FRAME_MS } from './useQa'
import { onMessage, postMessage } from './vscode'
/** 按注释规范重写:过程只在这一页上,按块的记号记。不写入问答,刷新或关掉预览就没了。 */
export function useRewrite(): { runs: RewriteRuns, onRewrite: (anchor: string) => void, onStop: (anchor: string) => void } {
const [runs, setRuns] = useState<RewriteRuns>(() => new Map())
/** 动作里读眼下的过程(这块是不是已经在重写) */
const current = useRef(runs)
current.current = runs
/**
* 按注释规范重写:收扩展端的重写记录、当前一轮的进展,给块树用的状态和动作。和问 AI 同一套:
* 进行中的、出错被叫停留着的那一轮记在扩展端,页面重新加载后随 qa 消息的 rewriteCurrent 接上。profile 下拉和问 AI 共用。
*/
export function useRewrite(profiles: ProfileChoice, onSelectProfile: (id: string) => void): RewriteTree {
const [state, dispatch] = useReducer(chatReducer<ChatState>, EMPTY_CHAT)
useEffect(() => {
/** 攒着没画的进展:anchor → 最新的一份 */
/** 攒着没画的进展:askId → 最新的一份 */
const pending = new Map<string, { question: string, text: string, activity?: string }>()
let timer: ReturnType<typeof setTimeout> | undefined
const flush = (): void => {
timer = undefined
setRuns((prev) => {
let next = prev
for (const [anchor, progress] of pending)
next = rewriteProgress(next, anchor, progress.question, progress.text, progress.activity)
return next
})
for (const [askId, progress] of pending)
dispatch({ type: 'answer', askId, ...progress })
pending.clear()
}
const off = onMessage((msg) => {
if (msg.type === 'rewriteProgress') {
pending.set(msg.anchor, { question: msg.question, text: msg.text, ...(msg.activity !== undefined && { activity: msg.activity }) })
timer ??= setTimeout(flush, ANSWER_FRAME_MS)
return
}
if (msg.type === 'rewriteEnd') {
pending.delete(msg.anchor)
if (pending.size === 0) {
switch (msg.type) {
case 'qa':
dispatch({ type: 'sync', available: msg.available, threads: msg.rewrites, current: msg.rewriteCurrent })
return
case 'rewriteProgress':
pending.set(msg.askId, { question: msg.question, text: msg.text, ...(msg.activity !== undefined && { activity: msg.activity }) })
timer ??= setTimeout(flush, ANSWER_FRAME_MS)
return
case 'rewriteEnd':
clearTimeout(timer)
timer = undefined
}
setRuns(prev => rewriteEnd(prev, msg.anchor, msg.status, msg.error))
flush()
dispatch({ type: 'answerEnd', end: msg })
}
})
return () => {
@@ -46,14 +45,33 @@ export function useRewrite(): { runs: RewriteRuns, onRewrite: (anchor: string) =
}
}, [])
const onRewrite = useCallback((anchor: string) => {
if (current.current.get(anchor)?.status === 'running')
return
setRuns(prev => startRewrite(prev, anchor))
postMessage({ type: 'rewriteComment', anchor })
}, [])
const onStop = useCallback((anchor: string) => {
postMessage({ type: 'stopRewrite', anchor })
}, [])
return useMemo(() => ({ runs, onRewrite, onStop }), [runs, onRewrite, onStop])
/** 动作里读眼下的状态(这块是不是已经在写) */
const current = useRef(state)
current.current = state
return useMemo((): RewriteTree => ({
state,
profiles,
onSelectProfile,
onRewrite: (anchor) => {
if (current.current.asks.some(ask => ask.anchor === anchor && ask.status === 'running'))
return
const askId = newAskId()
// 那句技能命令由扩展端拼好,开始后随 rewriteProgress 报上来
dispatch({ type: 'ask', askId, anchor, question: '', images: [] })
postMessage({ type: 'rewriteComment', askId, anchor, images: [] })
},
onAsk: (anchor, request, images) => {
const askId = newAskId()
dispatch({ type: 'ask', askId, anchor, question: request, images: images.map(image => image.url) })
postMessage({ type: 'rewriteComment', askId, anchor, request, images: images.map(toAskImage) })
},
onStop: askId => postMessage({ type: 'stopRewrite', askId }),
onDelete: (anchor, entryId) => postMessage({ type: 'deleteRewrite', anchor, entryId }),
onOpen: anchor => dispatch({ type: 'open', anchor }),
onClose: (anchor) => {
dispatch({ type: 'close', anchor })
postMessage({ type: 'forgetRound', kind: 'rewrite', anchor })
},
}), [state, profiles, onSelectProfile])
}