✨ 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

+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'
}