✨ 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

+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 () => {