diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index b89d76c..4d5c3a6 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -28,7 +28,7 @@ | 高亮名字的各处出现 | 和普通编辑器一样单击名字就高亮它在这个文件里的各处出现,由语言扩展按语义认(同名的别的东西不算),颜色用 VS Code 的单词高亮色;看不见的(折叠、筛掉的)不自动展开,筛选栏上写「名字 · N 处」 | | 侧栏文件树 | 只看和打开:展开目录、打开文件、刷新、全部折叠,外加给目录 / 文件记注释;不做新建 / 重命名 / 删除 | | 文件注释 | 读代码时给目录 / 文件随手记一句:存在各工作区文件夹的 `.spx/code-notes.json`,跟着仓库走;在 VS Code 里改名、挪位置、删除时注释跟着变 | -| 问 AI | 块右上角「问 AI」,在块下面问 Claude Code(`claude -p`,只读,用插件默认的 profile)这块代码,回答逐字出来按 Markdown 显示、能追问(接着这块最近一次的会话,收起再展开、重启 VS Code 也接得上)、能停;问答存进各工作区文件夹的 `.spx/code-qa.json`,灰字挂在块下面免得重复问,理解了就删。问答按块的位置记号挂,记号只由和语言无关的块数据(种类、facts 里的名字、源码哈希)算出,文件别处改了照样认得;认不回来的不显示,留在文件里,代码改回来又挂回去。见「问 AI」 | +| 问 AI | 块右上角「问 AI」,在块下面问 Claude Code(`claude -p`,只读,用输入框旁边选的 profile,记住上次选的,没选过用插件默认的)这块代码,回答逐字出来按 Markdown 显示、能追问(接着这块最近一次的会话,收起再展开、重启 VS Code 也接得上)、能停;问答存进各工作区文件夹的 `.spx/code-qa.json`,灰字挂在块下面免得重复问,理解了就删。问答按块的位置记号挂,记号只由和语言无关的块数据(种类、facts 里的名字、源码哈希)算出,文件别处改了照样认得;认不回来的不显示,留在文件里,代码改回来又挂回去。见「问 AI」 | | 译文用词 | 结构照抄原语言,只翻关键字、内置类型、运算符;用专业术语(`export` → 公共,`catch` → 捕获,`===` → 全等) | | 函数、类、导入、常量、变量块 | 预览不显示一行译文,显示树形属性表;属性值里的类型、表达式照常直译 | | 字典 / 对象字面量 | 常量、变量的值是 Python 字典 / TypeScript 对象字面量时不挤成一长行:值写「字典 N 项」/「对象 N 项」,各项展开成「键 \| 值」两列的子行,值又是字典 / 对象时一层层往下展开;参数默认值照旧一行 | @@ -212,19 +212,22 @@ - 入口:块右上角那排图标按钮里的「问 AI」(每种块都有:普通块、属性表块、库规则的卡片)。点一下在块下面展开问答区,再点(或在输入框里按 Esc)收起。文件不在任何工作区文件夹里时问答没地方存,不给这个按钮。 - 问答区紧跟在块下面、子块前面,和块同宽,左边一条 2px 的链接色竖线(`--vscode-textLink-foreground` 50%)。从上到下: - - 灰字的历史:这块以前问过的,每条一行问题(`descriptionForeground`,一行放不下截断)、右边时间(「刚刚」「5 分钟前」「3 小时前」「昨天」「3 天前」,一个月以前写日期,悬停看完整时间)和垃圾桶「已理解,删除」;下面是回答的前两行(去掉 Markdown 记号的纯文字,更淡)。点问题或这两行展开整段回答,照样灰字,按 Markdown 排(同注释的 Markdown 渲染),再点收起。 + - 灰字的历史:这块以前问过的,每条一行问题(`descriptionForeground`,一行放不下截断)、右边时间(「刚刚」「5 分钟前」「3 小时前」「昨天」「3 天前」,一个月以前写日期,悬停看完整时间)跟着用哪个 profile 答的(「3 天前 · sonnet」;选「默认」答的、这功能以前存的只写时间)和垃圾桶「已理解,删除」;下面是回答的前两行(去掉 Markdown 记号的纯文字,更淡)。点问题或这两行展开整段回答,照样灰字,按 Markdown 排(同注释的 Markdown 渲染),再点收起。 - 当前这一轮(展开以来问的,和历史之间一条细分隔线):问题照常颜色、加粗,回答照常颜色、按 Markdown 排,逐字长出来,末尾一个闪的光标;下面一行灰字写正在做的事(「读取 exceptions.py」「搜索 get_user」,还没开口时「正在想…」)。出错时这一问下面一个红框写原因(找不到 claude、退出码非零带它的原话、没登录时提示在终端里运行 claude 登录、超时);叫停的写「已停止,这次没存。」 - - 输入框:没问过时提示「问这块代码… Enter 发送,Esc 收起」,问过了「继续问… Enter 发送,Esc 收起」;Enter 发送,Shift+Enter 换行,输入法选字时的 Enter 不算;Esc 收起(不清掉名字高亮);展开时光标就在输入框里。右边是发送按钮(没字时不能点),正在答时换成「停止」。 + - 输入框:没问过时提示「问这块代码… Enter 发送,Esc 收起」,问过了「继续问… Enter 发送,Esc 收起」;Enter 发送,Shift+Enter 换行,输入法选字时的 Enter 不算;Esc 收起(不清掉名字高亮);展开时光标就在输入框里。右边先是 profile 下拉,再是发送按钮(没字时不能点),正在答时换成「停止」。 + - profile 下拉(同筛选栏的下拉 `SelectMenu`,`ghost` 样子):只写选中的 profile 名和一个小箭头,灰字 12px、高 20px,最宽 140px,长名字截断,输入框还是主角;悬停 `--vscode-toolbar-hoverBackground`,打开时 `--vscode-toolbar-activeBackground`,键盘聚焦描 1px `--vscode-focusBorder`。点开(或聚焦后按 ↓ / ↑ / Enter / 空格)列出能用的 profile,↑ ↓ 挑、Enter 选、Esc 关;profile 多了弹层顶上有搜索框。悬停提示「用哪个 Claude Code 配置(profile)回答,下一个问题起用它」。 + - 选项是插件的 profile 目录(同看板、会话:`cc/profiles` 的 `listClaudeProfiles`,名字是文件名去掉 `.json`)。插件的默认 profile(`getDefaultProfilePath`,`offical.json`)在里面时,没选过就选它;不在(一个 profile 都没有,或没有 `offical.json`)时最前面多一项「默认」:不带 `--settings`,claude 用 `~/.claude` 的登录,和以前一样照问,没选过就选它。 + - 选了从下一个问题起用它(正在答的不受影响)。所有预览、所有块共用一份,记在扩展的 `globalState`(`superpowers.codeReader.askProfile`,记 profile 名,「默认」记 `''`)里:换个预览、重启 VS Code 都还是它;记着的那个没了(删了、目录换了)就回到默认那项。 - 没展开、但问过的块:块下面只露一行灰字「问过 N 次」,读的时候一眼知道这里问过;点开看灰字历史(能展开、能删),没有输入框。 - 收起就结束这一轮:这一轮答完的都回到灰字历史里(再问照样接着会话,见「追问」);还没答完的接着答,答完照样存。同一块同时只答一个问题(正在答时不能再发),不同块可以同时问,全插件同时最多 3 个,再多就在这一问下面说「同时最多问 3 个问题,等前面的答完再问」。 -- 追问:这块存过问答,再问就接着最近一次(按问的时间)存下的那条的会话(`--resume <会话 id>`,会话 id 取 stream-json 的 init 消息,和问答一起存在 `code-qa.json` 里),只发问题和「还是第几行那块」;收起再展开、预览重新加载、重启 VS Code 都一样接得上,前面问过的照旧灰字显示。那条没记会话 id、或会话找不到了(过期、被清理)就按第一次那样带上代码上下文重新开会话。删掉最近那条,就接着剩下里最近的;一块下面删光了就是没问过。 +- 追问:这块存过问答,再问就接着最近一次(按问的时间)存下的那条的会话(`--resume <会话 id>`,会话 id 取 stream-json 的 init 消息,和问答一起存在 `code-qa.json` 里),只发问题和「还是第几行那块」;收起再展开、预览重新加载、重启 VS Code 都一样接得上,前面问过的照旧灰字显示。换了 profile 也照样接着那次会话问(`--resume` 和这次选的 `--settings` 一起带,本机 claude 2.1 试过:换 profile 后会话 id 不变、记得前面说过的),线程里不另外提示。那条没记会话 id、或会话找不到了(过期、被清理)就按第一次那样带上代码上下文重新开会话。删掉最近那条,就接着剩下里最近的;一块下面删光了就是没问过。 - 删除:点垃圾桶马上删,不用确认;一块下面删光了这块就不挂了,一份文件删光了文件里就没有这份文件。 - 找不到原来位置的问答:记号(见下)对不上这份文件里任何一块的问答不显示,也不动它,留在 `code-qa.json` 里;代码改回来、记号又对上了,就又挂回那块下面。 - 预览重新加载(标签页切走再切回来)后,扩展端还在答的问题接上(展开它那块、接着长),答完照样显示。同一份文件开在几处,问答、正在答的都一样。 怎么问(扩展端 `qa/`): -- 用 Claude Code 命令行:和插件别处的 `claude -p` 一样找 claude(`resolveClaudeBin`)、清环境变量(`ANTHROPIC_*`、嵌套会话标记)、带默认 profile(`getDefaultProfilePath`,读不到就不带 `--settings`),在这份文件所在的工作区文件夹里跑,它能自己读项目里别的文件。 +- 用 Claude Code 命令行:和插件别处的 `claude -p` 一样找 claude(`resolveClaudeBin`)、清环境变量(`ANTHROPIC_*`、嵌套会话标记)、带问的时候选的 profile(`--settings <它的路径>`,选「默认」不带;每次问都现读 profile 目录,见上面的 profile 下拉),在这份文件所在的工作区文件夹里跑,它能自己读项目里别的文件。 - 只读,靠三层:`--tools Read,Grep,Glob`(可用的内置工具只剩读、搜,编辑、写文件、跑命令的工具根本没有),`--allowedTools Read,Grep,Glob`(这几个不用再批),`--permission-mode dontAsk`(别的一律拒、不等人批,盖过 profile 里的 bypassPermissions),外加 `--strict-mcp-config` 且不给 `--mcp-config`(不加载任何 MCP 服务,免得 MCP 工具能改东西)。不带 `--dangerously-skip-permissions`。 - 输出 `--output-format stream-json --verbose --include-partial-messages`:init 消息给会话 id;逐字的 `text_delta` 拼成回答;工具调用换成「读取 X」「搜索 Y」;新的一条回复(读完文件后开口)一开口就换掉前面那句(「我先看看 X」);答完以 result 行的最终回答为准,`is_error` 当失败。超时 5 分钟;「停止」给进程发 SIGTERM,这次不存。 - 第一次问的话:要求用中文、大白话、简洁,只读,提到文件写相对项目根目录的路径(带行号);带上文件的相对路径和语言、外面套着的各层块的开头(每层最多 6 行,带行号,例如类和它里面的函数)、这块代码(从块的第一行到它最后一个子孙的最后一行,带行号,超过 120 行只贴开头并说剩下的在哪几行)、问题。追问只发问题和「还是 路径 第 a–b 行那块代码,代码可能刚改过,需要时重新读文件」。这些都只用视图数据的行号、记号和树形,不认识任何语言。 @@ -237,7 +240,7 @@ 存法: -- 每个工作区文件夹一份 `<工作区文件夹>/.spx/code-qa.json`,和 `.spx/code-notes.json` 同一套写法(`pathMap.ts`、`folderStore.ts` 两者共用):`{ "相对路径": [{ "anchor": 记号, "entries": [{ "id", "question", "answer", "createdAt", "sessionId" }] }] }`,回答是 Markdown 原文,`createdAt` 是 ISO 时间;文件缺失或解析失败当作没有,认不出的块、问答跳过;写时按需建 `.spx`,两格缩进、文件按路径排、块按记号排、问答按时间排、末尾换行;存第一条前不生成文件,删光后留 `{}`。 +- 每个工作区文件夹一份 `<工作区文件夹>/.spx/code-qa.json`,和 `.spx/code-notes.json` 同一套写法(`pathMap.ts`、`folderStore.ts` 两者共用):`{ "相对路径": [{ "anchor": 记号, "entries": [{ "id", "question", "answer", "createdAt", "sessionId", "profile"? }] }] }`,回答是 Markdown 原文,`createdAt` 是 ISO 时间,`profile` 是用哪个 profile 答的(profile 名;选「默认」答的没有这一项);文件缺失或解析失败当作没有,认不出的块、问答跳过;写时按需建 `.spx`,两格缩进、文件按路径排、块按记号排、问答按时间排、末尾换行;存第一条前不生成文件,删光后留 `{}`。 - 在 VS Code 里改名、挪位置、删除文件 / 目录时问答跟着走(同文件注释);外部改了 `code-qa.json`(git pull、手改)文件监听重读,开着的预览跟着变。 ## 通用契约 @@ -938,7 +941,8 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | - `{ type: 'definitionProbed', id, found }`:回答第 `id` 次 `probeDefinition`。 - `{ type: 'occurrencesFound', id, occurrences: { start, end, kind: 'text' | 'read' | 'write' }[] }`:回答第 `id` 次 `findOccurrences`,`[start, end)` 是源码下标,`kind` 同 `DocumentHighlightKind`;语言扩展没答就是空数组。 - `{ type: 'filter', excluded: Record<属性 id, 值 id[]> }`:整个工作区共用的块筛选。webview 发 `ready` 后先于第一份文档发一次(第一份文档按筛选画),之后任何一个预览改了都发给所有开着的预览(包括改的那个)。 - - `{ type: 'qa', available, threads: { anchor, entries: { id, question, answer, createdAt }[] }[], running: { askId, anchor, question, text, activity? }[] }`:这份文件的问答(见「问 AI」)。`ready` 后发一次,存的问答、正在答的问题变了再发给开着这份文件的所有预览;`available` 为假时文件不在工作区文件夹里,不给「问 AI」;`running` 让重新加载的页面接上还在答的问题。 + - `{ type: 'qa', available, threads: { anchor, entries: { id, question, answer, createdAt, profile? }[] }[], running: { askId, anchor, question, text, activity? }[] }`:这份文件的问答(见「问 AI」)。`ready` 后发一次,存的问答、正在答的问题变了再发给开着这份文件的所有预览;`available` 为假时文件不在工作区文件夹里,不给「问 AI」;`running` 让重新加载的页面接上还在答的问题。 + - `{ type: 'askProfiles', options: { id, label }[], selected }`:「问 AI」的 profile 下拉(`id` 是 profile 名,「默认」的 `id` 是 `''`、`label` 是「默认」)。`ready` 后发一次(每次现读 profile 目录),任何一个预览选了都发给所有开着的预览(包括选的那个)。 - `{ type: 'answer', askId, text, activity? }`:`askId` 那个问题的回答长了(`text` 是到现在为止的全文)或换了在做的事。webview 攒着、最多每 80ms 画一次。 - `{ type: 'answerEnd', askId, status: 'done' | 'failed' | 'stopped', text?, error?, entryId? }`:答完了。`done` 带最终回答和存下的那条的 id(历史里不再重复显示它),`failed` 带给人看的原因,`stopped` 是被叫停、没存。 - webview → 扩展: @@ -949,9 +953,10 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | - `{ type: 'edit', startLine, endLine, text }`:用 `WorkspaceEdit` 把这几行整行替换成 `text`(每行补回原来第一行的缩进)。 - `{ type: 'selectTranslator', id }`:按 `languageId` 记到 `globalState`,重发文档。 - `{ type: 'setFilter', excluded }`:改块筛选(整份替换);扩展端逐项核对后记进 `workspaceState`,再发 `filter` 给所有预览。webview 不在本地先改,只按扩展端发来的显示。 - - `{ type: 'ask', askId, anchor, question }`:问 `anchor` 那块;`askId` 由 webview 起。扩展端按最近一次发出去的块树和源码找这块、拼问题,这块存过问答就接着最近那条的会话追问(见「问 AI」的追问);找不到(代码刚改过)或问不了(这块正在答、同时答的太多)时直接回 `answerEnd` failed。 + - `{ type: 'ask', askId, anchor, question }`:问 `anchor` 那块;`askId` 由 webview 起。扩展端按最近一次发出去的块树和源码找这块、拼问题,这块存过问答就接着最近那条的会话追问(见「问 AI」的追问),用扩展端记着的 profile;找不到(代码刚改过)或问不了(这块正在答、同时答的太多)时直接回 `answerEnd` failed。 - `{ type: 'stopAsk', askId }`:叫停,结束 claude 进程,这次不存。 - `{ type: 'deleteQa', anchor, entryId }`:删掉一次问答。 + - `{ type: 'selectAskProfile', id }`:「问 AI」换 profile;扩展端记进 `globalState` 后发 `askProfiles` 给所有预览。webview 不在本地先改,只按扩展端发来的显示。 - 块的编辑状态和用户的折叠选择在 webview 里按块 id 记(`vscode.setState`):`{ editing: string[], folds: Record, scroll? }`。`folds` 只记用户点过的(id → 折叠着没有),属性行和带下一层的子行也记在里面:属性行 id 是 `<块 id>/p<行序号>`(例如 `6.1.0/p3`),子行 id 是上一层(行或子行)的 id 加 `/<子行序号>`,一层层往后接(例如 `6.1.0/p3/1` 是第 3 行的第 1 个子行,`6.1.0/p3/1/0` 是它下面的第 0 个);块 id 只有数字和点,只有行那一节带 `p`、其余各节是纯数字,所以块、行、各层子行的 id 互不相撞。子行 id 也是跳到定义时在页面上找要闪的那一项用的。没记的按默认:函数、类、枚举块和库规则的卡片折叠,其余块、属性行和子行展开。眼下折叠着哪些由「默认 + folds」算出来,界面、跳到定义的展开都只看这一份。跳到定义展开祖先块、属性行时也记进 `folds`(记成展开)。读回来的状态逐项核对,旧版本存的、形状不对的项当没存过。文档刷新后保留;正在编辑且有焦点的块,刷新时不覆盖输入框内容。 ## 颜色 @@ -1013,9 +1018,10 @@ vscode/src/codeReader/ qa/qaMap.ts .spx/code-qa.json 的形状、每条怎么认、记一次 / 删一次、再问时接着哪个会话(纯函数) qa/prompt.ts 问 AI 的话:找到那块、外层块的开头、带行号的代码、追问 qa/answerStream.ts stream-json 的每行 → 会话 id、逐字的回答、在做的事、最终回答 / 失败 - qa/claudeAsk.ts 命令行参数(只读)、问一次(追问找不到会话时重新问)、失败给人看的话 + qa/claudeAsk.ts 命令行参数(只读、选的 profile)、问一次(追问找不到会话时重新问)、失败给人看的话 + qa/askProfiles.ts AskProfiles:问 AI 用哪个 profile,下拉的选项、记在 globalState、通知所有预览(不碰 VS Code) qa/asks.ts QaAsks:正在答的问题,一块一个、最多 3 个、能叫停(不碰 VS Code) - qa/codeQa.ts CodeQa:问答的 FolderStore、开问(问过的块接着最近的会话)、答完存、删,发给预览的问答 + qa/codeQa.ts CodeQa:问答的 FolderStore、开问(问过的块接着最近的会话,用选的 profile)、答完存(带 profile 名)、删,发给预览的问答 (cc/spawnClaude.ts 的 runClaudeLines:跑 claude、stdout 逐行交出、超时、叫停,问 AI 用它) editor/previewEditor.ts CustomTextEditorProvider editor/messages.ts 消息类型 @@ -1059,13 +1065,14 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 块右上角的图标按钮(webview `BlockTools.test.tsx`,渲染成 HTML):一组一个按钮、没有字、`title` 和 `aria-label` 是「编辑」/「预览」、有图标、编辑时带按下标记;给了「问 AI」时它排在前面、只有图标、问答区展开时带按下标记和 `aria-expanded`。 - 问 AI: - 位置记号(`qa/anchor.test.ts`,真实的 Python 解析):有名字 / 没名字的块的记号、同一层重复的编号、两个函数里一样的条件互不影响;上面插几行、改空白记号不变;改别的函数的名字只动它和它里面的块;没名字的块自己的源码改了记号变、有名字的块改签名不变。 - - 存法(`qa/qaMap.test.ts`):记一次、删一次、删光不挂 / 不记;文件缺失、解析失败、不是对象当没有;认不出的块和问答跳过、没会话 id 的照认;再问时接着这块最近那条的会话(删了最近的接着剩下里最近的,没问过、没记会话 id 的重新开);手改的顺序、重复的块读进来整理好;写出来排好序、两格缩进、读得回来、删光留 `{}`;改名、挪目录、删除跟着走。 + - 存法(`qa/qaMap.test.ts`):记一次、删一次、删光不挂 / 不记;文件缺失、解析失败、不是对象当没有;认不出的块和问答跳过、没会话 id 的照认;再问时接着这块最近那条的会话(删了最近的接着剩下里最近的,没问过、没记会话 id 的重新开);用哪个 profile 答的跟着这条存、读得回来,没有的、空的、不是字符串的当没有;手改的顺序、重复的块读进来整理好;写出来排好序、两格缩进、读得回来、删光留 `{}`;改名、挪目录、删除跟着走。 - 问的话(`qa/prompt.test.ts`):完整问题逐字(要求、文件、外层块的开头、带行号的代码、问题),顶层块没有外层、函数连主体一起贴,很长的块只贴开头、外层块每层最多几行,找不到这块没有,追问的话。 - - 命令行(`qa/claudeAsk.test.ts`,假的 claude):只读的参数、没有跳过权限、追问带 `--resume`;问一次的过程和结果、在工作区文件夹里跑、带超时和叫停;追问接着会话、会话找不到时按完整问题重问、别的失败不重试;失败给人看的话。 + - 命令行(`qa/claudeAsk.test.ts`,假的 claude):只读的参数、没有跳过权限、追问带 `--resume`;问一次的过程和结果、在工作区文件夹里跑、带超时和叫停、带选的 profile 的 `--settings`(「默认」不带);追问接着会话(换了 profile 也带 `--resume` 和新的 `--settings`)、会话找不到时按完整问题重问(照样带选的 profile)、别的失败不重试;失败给人看的话。 + - profile 下拉(`qa/askProfiles.test.ts`):profile 列表 → 选项(默认 profile 在不在里面、一个都没有只剩「默认」)、没选过选哪个、选过的不在了回到默认;选了记进 `globalState`、新的实例读得回来、所有订阅的预览收到;选的那个的路径进命令行的 `--settings`,「默认」不带。 - stream-json(`qa/answerStream.test.ts`,照本机 claude 2.1 的输出截的):会话 id、逐字的回答、读文件、新回复换掉前面那句、没开逐字片段时一样、一条回复里两段空一行、result 没带回答时用眼下的、失败的原因、坏行和子代理的消息不算。 - 正在答的问题(`qa/asks.test.ts`):进展、答完、叫停(结束进程、报 stopped、这块能再问)、出错、同一块只答一个、最多几个、全部叫停。 - 跑 claude(`cc/spawnClaude.test.ts`,假的 claude 脚本):逐行交出、最后没换行的那段、参数和 cwd、退出码非零带原话、叫停马上结束进程、超时。 - - 页面(`qa.test.ts`、`AskThread.test.tsx`):问一次、答完记下存成的那条、收起结束这一轮、重新加载接上还在答的、停止和失败、不认识的消息不理;问的时间、回答的前两行;问答区没问过不画、没展开只露「问过 N 次」、展开的历史 / 当前这一轮 / 输入框、出错和停止。 + - 页面(`qa.test.ts`、`AskThread.test.tsx`):问一次、答完记下存成的那条、收起结束这一轮、重新加载接上还在答的、停止和失败、不认识的消息不理;问的时间、回答的前两行;问答区没问过不画、没展开只露「问过 N 次」、展开的历史 / 当前这一轮 / 输入框、出错和停止;profile 下拉在输入框和发送按钮中间、写选中的名字、是能用键盘打开的弹出列表按钮、没收到选项时写「默认」,扩展端发来的下拉问答刷新不丢;历史每条的灰字写「时间 · profile 名」,没有 profile 的只写时间。(webview 的单元测试认 `@/`,靠 `vscode/vitest.config.mts` 里同 Vite 的别名。) ## 收尾 diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index d1d4daf..df4b53f 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -4,6 +4,7 @@ import type { FileAttribute } from '../attributes' import type { BlockView } from '../blockView' +import type { ProfileChoice } from '../qa/askProfiles' import type { RunningAsk } from '../qa/asks' import type { Excluded } from './blockFilter' @@ -45,12 +46,14 @@ export interface DocumentMessage { reveal?: Reveal } -/** 问过的一次(给预览看的):问题、回答(Markdown)、什么时候问的(ISO 时间)。 */ +/** 问过的一次(给预览看的):问题、回答(Markdown)、什么时候问的(ISO 时间)、用哪个 profile 答的。 */ export interface QaEntryView { id: string question: string answer: string createdAt: string + /** 用哪个 profile 答的(profile 名);选「默认」时、以前存的没有 */ + profile?: string } /** 挂在一块上的问答;记号对不上这份文件里任何一块的不显示。 */ @@ -76,6 +79,7 @@ export interface QaMessage { * occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的 * filter:整个工作区共用的块筛选;ready 后先于第一份文档发一次,之后任何一个预览改了都发给所有预览 * qa:这份文件的问答;ready 后发一次,问答、正在答的问题变了再发 + * askProfiles:「问 AI」输入框旁边的 profile 下拉:选项和选中的那项;ready 后发一次,任何一个预览改了都发给所有预览 * answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事 * answerEnd:askId 那个问题答完了:done 带最终的回答和存下的那条的 id,failed 带给人看的原因,stopped 是被叫停 */ @@ -86,6 +90,7 @@ export type ExtensionToWebview | { type: 'occurrencesFound', id: number, occurrences: Occurrence[] } | { type: 'filter', excluded: Excluded } | QaMessage + | ({ 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 } @@ -97,9 +102,10 @@ export type ExtensionToWebview * goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义 * findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition * setFilter:改块筛选(整份替换),扩展端记下后发 filter 给所有预览 - * ask:问 anchor 那块代码;askId 由 webview 起,回答的消息带回去;扩展端接着这块最近一次问答的会话问 + * ask:问 anchor 那块代码;askId 由 webview 起,回答的消息带回去;扩展端接着这块最近一次问答的会话问,用眼下选的 profile * stopAsk:叫停 askId 那个问题,这次不存 * deleteQa:删掉 anchor 那块下面的一次问答(已经理解了) + * selectAskProfile:「问 AI」换 profile(id 是 profile 名,「默认」是 ''),扩展端记下后发 askProfiles 给所有预览 */ export type WebviewToExtension = | { type: 'ready' } @@ -113,3 +119,4 @@ export type WebviewToExtension | { type: 'ask', askId: string, anchor: string, question: string } | { type: 'stopAsk', askId: string } | { type: 'deleteQa', anchor: string, entryId: string } + | { type: 'selectAskProfile', id: string } diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 6ff8f1e..7aea27a 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -143,6 +143,10 @@ class Preview { if (uris.some(uri => uri.toString() === document.uri.toString())) this.postQa() }), + owner.qa.profiles.subscribe((choice) => { + if (this.ready) + this.post({ type: 'askProfiles', ...choice }) + }), owner.qa.onDidAnswer((event) => { if (event.owner !== document.uri.toString() || !this.ready) return @@ -218,18 +222,25 @@ class Preview { this.post(this.owner.qa.view(this.document.uri)) } + /** profile 列表每次现读(同看板),读完页面还在才发。 */ + private async postProfiles(): Promise { + const choice = await this.owner.qa.profiles.choice() + if (this.ready) + this.post({ type: 'askProfiles', ...choice }) + } + /** * 问 anchor 那块代码:按最近一次发出去的块树找到它,把完整问题和追问的话都交给 CodeQa,接不接着上次的会话由它定。 * 找不到这块(代码刚改过、还没重新发)或问不了时,直接回一条失败。 */ - private ask(askId: string, anchor: string, question: string): void { + private async ask(askId: string, anchor: string, question: string): Promise { const at = locate(this.document.uri) const subject = at && this.sent && { path: at.key, languageId: this.document.languageId, ...this.sent, anchor } const prompt = subject && askPrompt(subject, question) const followUp = subject && followUpPrompt(subject, question) const refused = prompt === undefined || followUp === undefined ? '这块代码刚改过,找不到了。等预览刷新后重新点「问 AI」再问。' - : this.owner.qa.ask(this.document.uri, { askId, anchor, question, prompt, followUp }) + : await this.owner.qa.ask(this.document.uri, { askId, anchor, question, prompt, followUp }) if (refused !== undefined) { this.post({ type: 'answerEnd', askId, status: 'failed', error: refused }) return @@ -249,10 +260,14 @@ class Preview { // 先于文档发:第一份文档按筛选画,不会先整份露出来再藏 this.post({ type: 'filter', excluded: this.filter.excluded }) this.postQa() + void this.postProfiles() await this.push() return case 'ask': - this.ask(msg.askId, msg.anchor, msg.question) + await this.ask(msg.askId, msg.anchor, msg.question) + return + case 'selectAskProfile': + await this.owner.qa.profiles.select(msg.id) return case 'stopAsk': this.owner.qa.stop(msg.askId) diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index 3dfacfa..6b8df40 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -63,7 +63,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { return runtime.parse(adapter, languageId, source) }, report)) const resolve = (reference: RuleReference): Promise => resolveReference(reference, registry.libraryRules(), indexes, report) - const qa = new CodeQa() + const qa = new CodeQa(context.globalState) const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa) const notes = new FileNotes() const files = new FileTreeProvider(OPEN_COMMAND, notes) diff --git a/vscode/src/codeReader/qa/askProfiles.test.ts b/vscode/src/codeReader/qa/askProfiles.test.ts new file mode 100644 index 0000000..4064dd0 --- /dev/null +++ b/vscode/src/codeReader/qa/askProfiles.test.ts @@ -0,0 +1,93 @@ +import type { Memento } from 'vscode' +import type { ClaudeProfile } from '../../cc/profiles' +import type { ProfileChoice, ProfileSource } from './askProfiles' +import { describe, expect, it } from 'vitest' +import { ASK_PROFILE_KEY, AskProfiles, DEFAULT_OPTION, profileChoice } from './askProfiles' +import { askArgs } from './claudeAsk' + +const DIR = '/home/me/profiles' +const profile = (name: string): ClaudeProfile => ({ name, path: `${DIR}/${name}.json` }) +const PROFILES = [profile('dsflash'), profile('offical'), profile('sonnet')] +const DEFAULT_PATH = `${DIR}/offical.json` + +function memento(initial: Record = {}): Pick & { store: Record } { + const store = { ...initial } + return { + store, + get: (key: string): T | undefined => store[key] as T | undefined, + update: async (key: string, value: unknown) => { + store[key] = value + }, + } as Pick & { store: Record } +} + +function source(profiles: ClaudeProfile[] = PROFILES): ProfileSource { + return { list: async () => profiles, defaultPath: () => DEFAULT_PATH } +} + +describe('profile 列表 → 下拉的选项', () => { + it('插件的默认 profile 在列表里:选项就是各个 profile(名字),没选过选它', () => { + expect(profileChoice(PROFILES, DEFAULT_PATH, undefined)).toEqual({ + options: [{ id: 'dsflash', label: 'dsflash' }, { id: 'offical', label: 'offical' }, { id: 'sonnet', label: 'sonnet' }], + selected: 'offical', + }) + }) + + it('默认 profile 不在列表里:最前面加一项「默认」(不带 --settings),没选过选它', () => { + const withoutDefault = [profile('dsflash'), profile('sonnet')] + expect(profileChoice(withoutDefault, DEFAULT_PATH, undefined)).toEqual({ + options: [DEFAULT_OPTION, { id: 'dsflash', label: 'dsflash' }, { id: 'sonnet', label: 'sonnet' }], + selected: '', + }) + expect(DEFAULT_OPTION).toEqual({ id: '', label: '默认' }) + }) + + it('一个 profile 都没有:只有「默认」', () => { + expect(profileChoice([], DEFAULT_PATH, 'sonnet')).toEqual({ options: [DEFAULT_OPTION], selected: '' }) + }) + + it('选过的还在就选它;不在了(删了、目录换了)、记的是「默认」但已经有默认 profile,都回到默认那项', () => { + expect(profileChoice(PROFILES, DEFAULT_PATH, 'sonnet').selected).toBe('sonnet') + expect(profileChoice(PROFILES, DEFAULT_PATH, 'gone').selected).toBe('offical') + expect(profileChoice(PROFILES, DEFAULT_PATH, '').selected).toBe('offical') + expect(profileChoice(PROFILES, DEFAULT_PATH, 42).selected).toBe('offical') + }) +}) + +describe('记住最近选的 profile', () => { + it('选了记进 globalState,换个预览、重启 VS Code(新的实例)读得回来', async () => { + const state = memento() + await new AskProfiles(state, source()).select('sonnet') + expect(state.store[ASK_PROFILE_KEY]).toBe('sonnet') + expect((await new AskProfiles(state, source()).choice()).selected).toBe('sonnet') + }) + + it('一个预览选了,所有订阅的预览都收到新的选项和选中的那项;取消订阅后不再收到', async () => { + const profiles = new AskProfiles(memento(), source()) + const first: ProfileChoice[] = [] + const second: string[] = [] + profiles.subscribe(choice => first.push(choice)) + const off = profiles.subscribe(choice => second.push(choice.selected)) + await profiles.select('dsflash') + off.dispose() + await profiles.select('offical') + expect(first.map(choice => choice.selected)).toEqual(['dsflash', 'offical']) + expect(first[0]!.options).toHaveLength(3) + expect(second).toEqual(['dsflash']) + }) + + it('下一个问题用选的那个 profile:命令行带它的 --settings;选「默认」时没有、不带 --settings', async () => { + const profiles = new AskProfiles(memento({ [ASK_PROFILE_KEY]: 'sonnet' }), source()) + const chosen = await profiles.current() + expect(chosen).toEqual(profile('sonnet')) + expect(askArgs('问题', { profilePath: chosen?.path }).slice(0, 2)).toEqual(['--settings', `${DIR}/sonnet.json`]) + + const none = await new AskProfiles(memento(), source([])).current() + expect(none).toBeUndefined() + expect(askArgs('问题', { profilePath: none?.path })).not.toContain('--settings') + }) + + it('没选过:用插件的默认 profile(和以前问 AI 用的一样)', async () => { + expect(await new AskProfiles(memento(), source()).current()).toEqual(profile('offical')) + }) +}) diff --git a/vscode/src/codeReader/qa/askProfiles.ts b/vscode/src/codeReader/qa/askProfiles.ts new file mode 100644 index 0000000..e49e959 --- /dev/null +++ b/vscode/src/codeReader/qa/askProfiles.ts @@ -0,0 +1,77 @@ +import type { Disposable, Memento } from 'vscode' +import type { ClaudeProfile } from '../../cc/profiles' +import { getDefaultProfilePath, listClaudeProfiles } from '../../cc/profiles' + +/** globalState 里记的「问 AI」最近选的 profile 名(「默认」记成 '')。 */ +export const ASK_PROFILE_KEY = 'superpowers.codeReader.askProfile' + +/** 下拉里的一项:id 是 profile 名;「默认」(不带 `--settings`,claude 用 ~/.claude 的登录)的 id 是 ''。 */ +export interface ProfileOption { + id: string + label: string +} + +export interface ProfileChoice { + options: ProfileOption[] + selected: string +} + +export const DEFAULT_OPTION: ProfileOption = { id: '', label: '默认' } + +/** + * 下拉的选项和选中的那项。插件的默认 profile(getDefaultProfilePath)在列表里时,没选过就选它; + * 不在列表里(一个 profile 都没有,或没有 offical.json)时最前面加一项「默认」,没选过就选它。 + * 记着的那项不在列表里了(profile 删了、目录换了)也回到这一项。 + */ +export function profileChoice(profiles: readonly ClaudeProfile[], defaultPath: string, remembered: unknown): ProfileChoice { + const fallback = profiles.find(profile => profile.path === defaultPath) + const options = [...(fallback ? [] : [DEFAULT_OPTION]), ...profiles.map(({ name }) => ({ id: name, label: name }))] + const selected = options.find(option => option.id === remembered)?.id ?? fallback?.name ?? DEFAULT_OPTION.id + return { options, selected } +} + +export interface ProfileSource { + list: () => Promise + defaultPath: () => string +} + +const PROFILE_SOURCE: ProfileSource = { list: listClaudeProfiles, defaultPath: getDefaultProfilePath } + +/** + * 「问 AI」用哪个 Claude Code profile:所有预览共用一份,记在 globalState 里,换个预览、重启 VS Code 都还是它; + * 一个预览改了,所有订阅的预览都收到(包括改的那个,webview 只按扩展端发来的显示)。 + * profile 列表每次现读(同看板:cc/profiles 的目录),不依赖 vscode 运行时,便于单元测试。 + */ +export class AskProfiles { + private readonly listeners = new Set<(choice: ProfileChoice) => void>() + + constructor(private readonly state: Pick, private readonly source: ProfileSource = PROFILE_SOURCE) {} + + async choice(): Promise { + return (await this.read()).choice + } + + /** 下一个问题用的 profile;选的是「默认」时没有(不带 `--settings`)。 */ + async current(): Promise { + const { profiles, choice } = await this.read() + return profiles.find(profile => profile.name === choice.selected) + } + + /** 选了就调 listener,带上新的选项和选中的那项。 */ + subscribe(listener: (choice: ProfileChoice) => void): Disposable { + this.listeners.add(listener) + return { dispose: () => this.listeners.delete(listener) } + } + + async select(id: string): Promise { + await this.state.update(ASK_PROFILE_KEY, id) + const choice = await this.choice() + for (const listener of [...this.listeners]) + listener(choice) + } + + private async read(): Promise<{ profiles: ClaudeProfile[], choice: ProfileChoice }> { + const profiles = await this.source.list() + return { profiles, choice: profileChoice(profiles, this.source.defaultPath(), this.state.get(ASK_PROFILE_KEY)) } + } +} diff --git a/vscode/src/codeReader/qa/claudeAsk.test.ts b/vscode/src/codeReader/qa/claudeAsk.test.ts index 53aecc9..36f84b4 100644 --- a/vscode/src/codeReader/qa/claudeAsk.test.ts +++ b/vscode/src/codeReader/qa/claudeAsk.test.ts @@ -17,7 +17,6 @@ function fakeClaude(runs: { lines: string[], error?: Error }[]): { deps: AskDeps return { calls, deps: { - profilePath: () => '/profiles/offical.json', runLines: async (options) => { const run = runs[calls.length] calls.push(options) @@ -67,18 +66,25 @@ describe('命令行参数', () => { describe('问一次', () => { it('在工作区文件夹里跑、带超时和叫停;过程交给 onEvent,答完返回最终回答和会话 id', async () => { const { deps, calls } = fakeClaude([{ lines: [init('s1'), delta('答'), delta('案'), result('答案', 's1')] }]) - const run = request() + const run = request({ profilePath: '/profiles/sonnet.json' }) expect(await askClaude(run, deps)).toEqual({ text: '答案', sessionId: 's1' }) expect(run.events).toEqual([{ kind: 'session', sessionId: 's1' }, { kind: 'text', text: '答' }, { kind: 'text', text: '答案' }]) expect(calls).toHaveLength(1) expect(calls[0]).toMatchObject({ cwd: '/work', signal: run.signal, timeoutMs: 300_000 }) - expect(calls[0].args).toContain('/profiles/offical.json') + expect(calls[0].args.slice(0, 2)).toEqual(['--settings', '/profiles/sonnet.json']) expect(calls[0].args[calls[0].args.indexOf('-p') + 1]).toBe('完整的问题') }) - it('追问接着上次的会话问短问题', async () => { + it('选的是「默认」(没给 profile):不带 --settings', async () => { + const { deps, calls } = fakeClaude([{ lines: [result('答案', 's1')] }]) + await askClaude(request(), deps) + expect(calls[0].args).not.toContain('--settings') + }) + + it('追问接着上次的会话问短问题;换了 profile 也接着问,带上这次选的 --settings', async () => { const { deps, calls } = fakeClaude([{ lines: [result('接着答', 's1')] }]) - expect(await askClaude(request({ followUp: { sessionId: 's1', prompt: '短问题' } }), deps)).toEqual({ text: '接着答', sessionId: 's1' }) + expect(await askClaude(request({ profilePath: '/profiles/opus.json', followUp: { sessionId: 's1', prompt: '短问题' } }), deps)).toEqual({ text: '接着答', sessionId: 's1' }) + expect(calls[0].args.slice(0, 2)).toEqual(['--settings', '/profiles/opus.json']) expect(calls[0].args.slice(calls[0].args.indexOf('--resume'), calls[0].args.indexOf('--resume') + 4)).toEqual(['--resume', 's1', '-p', '短问题']) }) @@ -87,9 +93,10 @@ describe('问一次', () => { { lines: [], error: new ClaudeError('Claude 退出码非零 (1): No conversation found with session ID: s0', 'No conversation found with session ID: s0') }, { lines: [init('s2'), result('重新答', 's2')] }, ]) - expect(await askClaude(request({ followUp: { sessionId: 's0', prompt: '短问题' } }), deps)).toEqual({ text: '重新答', sessionId: 's2' }) + expect(await askClaude(request({ profilePath: '/profiles/opus.json', followUp: { sessionId: 's0', prompt: '短问题' } }), deps)).toEqual({ text: '重新答', sessionId: 's2' }) expect(calls).toHaveLength(2) expect(calls[1].args).not.toContain('--resume') + expect(calls[1].args.slice(0, 2)).toEqual(['--settings', '/profiles/opus.json']) expect(calls[1].args[calls[1].args.indexOf('-p') + 1]).toBe('完整的问题') }) diff --git a/vscode/src/codeReader/qa/claudeAsk.ts b/vscode/src/codeReader/qa/claudeAsk.ts index eebeda6..57da6c2 100644 --- a/vscode/src/codeReader/qa/claudeAsk.ts +++ b/vscode/src/codeReader/qa/claudeAsk.ts @@ -1,11 +1,9 @@ /** * 用 Claude Code(`claude -p`,只读)回答代码问答:和插件别处的 `claude -p` 一样找 claude、清环境变量、 - * 用默认 profile(见 cc/profiles);回答按 stream-json 逐字交出来,可以叫停。 + * 用问的时候选的 profile(见 askProfiles);回答按 stream-json 逐字交出来,可以叫停。 */ import type { AnswerEvent } from './answerStream' -import { accessSync, constants } from 'node:fs' -import { getDefaultProfilePath } from '../../cc/profiles' import { CLAUDE_MISSING_MSG, ClaudeCancelledError, ClaudeError, ClaudeTimeoutError, runClaudeLines } from '../../cc/spawnClaude' import { createAnswerParser } from './answerStream' @@ -39,21 +37,11 @@ export function askArgs(prompt: string, options: { profilePath?: string, resume? ] } -/** 默认 profile 读得到就用它,读不到就不带 `--settings`(claude 回落到 ~/.claude 的登录)。 */ -function defaultProfile(): string | undefined { - const profile = getDefaultProfilePath() - try { - accessSync(profile, constants.R_OK) - return profile - } - catch { - return undefined - } -} - export interface AskRun { /** 带着代码上下文的完整问题 */ prompt: string + /** 用这个 profile 的设置(`--settings`);没有就不带,claude 用 ~/.claude 的登录。追问换了 profile 照样接着会话问 */ + profilePath?: string /** 追问:接着 sessionId 那次会话问 prompt;会话找不到了(过期、被清理)就按完整问题重新开一次 */ followUp?: { sessionId: string, prompt: string } cwd: string @@ -71,10 +59,9 @@ export type AskRunner = (run: AskRun) => Promise export interface AskDeps { runLines: typeof runClaudeLines - profilePath: () => string | undefined } -const DEFAULT_DEPS: AskDeps = { runLines: runClaudeLines, profilePath: defaultProfile } +const DEFAULT_DEPS: AskDeps = { runLines: runClaudeLines } function missingSession(err: unknown): boolean { return err instanceof ClaudeError && !(err instanceof ClaudeCancelledError) && /no conversation found/i.test(`${err.message}\n${err.stderr}`) @@ -82,13 +69,12 @@ function missingSession(err: unknown): boolean { /** 问一次,答完返回最终回答;失败、超时、被叫停时抛 ClaudeError(及其子类),用 askFailure 换成给人看的话。 */ export async function askClaude(run: AskRun, deps: AskDeps = DEFAULT_DEPS): Promise { - const profilePath = deps.profilePath() const once = async (prompt: string, resume?: string): Promise => { const parse = createAnswerParser() let answer: Answer | undefined let failure: string | undefined await deps.runLines({ - args: askArgs(prompt, { profilePath, resume }), + args: askArgs(prompt, { profilePath: run.profilePath, resume }), cwd: run.cwd, signal: run.signal, timeoutMs: ASK_TIMEOUT_MS, diff --git a/vscode/src/codeReader/qa/codeQa.ts b/vscode/src/codeReader/qa/codeQa.ts index 23e2f4c..4c62ecb 100644 --- a/vscode/src/codeReader/qa/codeQa.ts +++ b/vscode/src/codeReader/qa/codeQa.ts @@ -1,4 +1,4 @@ -import type { Disposable, Uri } from 'vscode' +import type { Disposable, Memento, Uri } from 'vscode' import type { QaMessage } from '../editor/messages' import type { AskEnd, RunningAsk } from './asks' import type { AskRunner } from './claudeAsk' @@ -6,6 +6,7 @@ import type { QaEntry } from './qaMap' import { randomUUID } from 'node:crypto' import { EventEmitter } from 'vscode' import { FolderStore, locate } from '../folderStore' +import { AskProfiles } from './askProfiles' import { QaAsks } from './asks' import { askClaude } from './claudeAsk' import { latestSession, QA_FILE, withEntry, withoutEntry } from './qaMap' @@ -32,11 +33,14 @@ export class CodeQa implements Disposable { private readonly store = new FolderStore(QA_FILE) private readonly asks: QaAsks private readonly answered = new EventEmitter() + /** 问的时候用哪个 profile(所有预览共用,记在 globalState 里) */ + readonly profiles: AskProfiles /** 问答变了的那些文件 */ readonly onDidChange = this.store.onDidChange readonly onDidAnswer = this.answered.event - constructor(runner: AskRunner = askClaude) { + constructor(state: Pick, runner: AskRunner = askClaude) { + this.profiles = new AskProfiles(state) this.asks = new QaAsks(runner) } @@ -49,7 +53,7 @@ export class CodeQa implements Disposable { available: at !== undefined, threads: threads.map(({ anchor, entries }) => ({ anchor, - entries: entries.map(({ id, question, answer, createdAt }) => ({ id, question, answer, createdAt })), + entries: entries.map(({ id, question, answer, createdAt, profile }) => ({ id, question, answer, createdAt, ...(profile && { profile }) })), })), running: this.asks.runningFor(uri.toString()), } @@ -58,15 +62,22 @@ export class CodeQa implements Disposable { /** * 开始问;问不了时返回原因(给人看的话)。在工作区文件夹里跑 claude,它读得到整个项目。 * 这块问过的就接着最近一次的会话问(收起再展开、重启 VS Code 都一样),没问过就带上代码上下文开新会话。 + * 用眼下选的 profile;和那次会话开头用的不一样也照样接着问(`--resume` 和 `--settings` 能一起用)。 */ - ask(uri: Uri, request: AskRequest): string | undefined { + async ask(uri: Uri, request: AskRequest): Promise { const at = locate(uri) if (!at) return '这个文件不在工作区文件夹里,问答没地方存。' + const profile = await this.profiles.current() const owner = uri.toString() const { askId, anchor, question, prompt, followUp } = request const sessionId = latestSession(this.store.get(at.folder, at.key), anchor) - const run = { prompt, ...(sessionId && { followUp: { sessionId, prompt: followUp } }), cwd: at.folder.uri.fsPath } + const run = { + prompt, + ...(sessionId && { followUp: { sessionId, prompt: followUp } }), + ...(profile && { profilePath: profile.path }), + cwd: at.folder.uri.fsPath, + } return this.asks.start({ owner, askId, anchor, question, run }, { update: ask => this.answered.fire({ owner, kind: 'update', ask }), end: async (ask, end) => { @@ -80,6 +91,7 @@ export class CodeQa implements Disposable { answer: end.answer.text, createdAt: new Date().toISOString(), sessionId: end.answer.sessionId, + ...(profile && { profile: profile.name }), } await this.store.update(at.folder, map => withEntry(map, at.key, anchor, entry)) this.answered.fire({ owner, kind: 'end', ask, end, entryId: entry.id }) diff --git a/vscode/src/codeReader/qa/qaMap.test.ts b/vscode/src/codeReader/qa/qaMap.test.ts index f176363..ab550f2 100644 --- a/vscode/src/codeReader/qa/qaMap.test.ts +++ b/vscode/src/codeReader/qa/qaMap.test.ts @@ -113,6 +113,16 @@ describe('读写 .spx/code-qa.json', () => { }) }) + it('用哪个 profile 答的跟着这条存、读得回来;以前存的、选「默认」答的没有这一项,空的、不是字符串的当没有', async () => { + const sonnet: QaEntry = { ...entry('p', '2026-09-26T10:00:00.000Z'), profile: 'sonnet' } + const map = withEntry(withEntry({}, 'a.py', IF, sonnet), 'a.py', IF, entry('old', '2026-09-25T10:00:00.000Z')) + await writePathMap(root, QA_FILE, map) + expect(await fsp.readFile(qaFile(), 'utf8')).toContain('"profile": "sonnet"') + expect(await readPathMap(root, QA_FILE)).toEqual(map) + await writeRaw({ 'a.py': [{ anchor: IF, entries: [{ ...entry('x', '2026-09-25T10:00:00.000Z'), profile: '' }, { ...entry('y', '2026-09-26T10:00:00.000Z'), profile: 3 }] }] }) + expect(await readPathMap(root, QA_FILE)).toEqual({ 'a.py': [{ anchor: IF, entries: [entry('x', '2026-09-25T10:00:00.000Z'), entry('y', '2026-09-26T10:00:00.000Z')] }] }) + }) + it('手改过的顺序、重复的块读进来整理好', async () => { await writeRaw({ 'a.py': [ { anchor: IF, entries: [entry('b', '2026-09-26T10:00:00.000Z')] }, diff --git a/vscode/src/codeReader/qa/qaMap.ts b/vscode/src/codeReader/qa/qaMap.ts index 6921816..a9e93c2 100644 --- a/vscode/src/codeReader/qa/qaMap.ts +++ b/vscode/src/codeReader/qa/qaMap.ts @@ -13,6 +13,8 @@ export interface QaEntry { answer: string createdAt: string sessionId: string + /** 用哪个 profile 答的(profile 名);选「默认」时、以前存的没有 */ + profile?: string } /** 挂在一块上的问答。 */ @@ -34,10 +36,10 @@ function text(value: unknown): string | undefined { function parseEntry(value: unknown): QaEntry | undefined { if (!isRecord(value)) return undefined - const [id, question, answer, createdAt] = [text(value.id), text(value.question), text(value.answer), text(value.createdAt)] + 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))) return undefined - return { id, question, answer, createdAt, sessionId: typeof value.sessionId === 'string' ? value.sessionId : '' } + return { id, question, answer, createdAt, sessionId: typeof value.sessionId === 'string' ? value.sessionId : '', ...(profile && { profile }) } } /** 同一块的并成一项;块按记号排,问答按时间排:写出来的顺序稳定,git diff 干净。 */ diff --git a/vscode/vitest.config.mts b/vscode/vitest.config.mts new file mode 100644 index 0000000..a702b16 --- /dev/null +++ b/vscode/vitest.config.mts @@ -0,0 +1,11 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vitest/config' + +// webview 源码里的 `@/` 指 webview-ui/src(同 webview-ui/vite.config.ts),跑 webview 的单元测试时一样认 +export default defineConfig({ + resolve: { + alias: { + '@': fileURLToPath(new URL('./webview-ui/src', import.meta.url)), + }, + }, +}) diff --git a/vscode/webview-ui/src/codeReader/AskThread.test.tsx b/vscode/webview-ui/src/codeReader/AskThread.test.tsx index 2f39427..de4fa6a 100644 --- a/vscode/webview-ui/src/codeReader/AskThread.test.tsx +++ b/vscode/webview-ui/src/codeReader/AskThread.test.tsx @@ -1,6 +1,6 @@ import type { AskThreadProps } from './AskThread' import type { Ask } from './qa' -import type { QaEntryView } from './types' +import type { ProfileChoice, QaEntryView } from './types' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it } from 'vitest' import { AskThread } from './AskThread' @@ -12,8 +12,10 @@ const HISTORY: QaEntryView[] = [ { id: 'e2', question: 'UserRole 还有哪些角色?', answer: '只有 ADMIN 和 USER。', createdAt: new Date(2026, 8, 25, 10, 0).toISOString() }, ] +const PROFILES: ProfileChoice = { options: [{ id: 'offical', label: 'offical' }, { id: 'sonnet', label: 'sonnet' }], selected: 'sonnet' } + function render(props: Partial): string { - return renderToStaticMarkup( {}} onStop={() => {}} onDelete={() => {}} onClose={() => {}} {...props} />) + return renderToStaticMarkup( {}} onAsk={() => {}} onStop={() => {}} onDelete={() => {}} onClose={() => {}} {...props} />) } const text = (html: string): string => html.replace(/<[^>]*>/g, '') @@ -67,4 +69,27 @@ describe('块下面的问答区', () => { expect(text(render({ open: true, asks: [ask({ status: 'stopped', text: '一半' })] }))).toContain('已停止,这次没存。') expect(text(render({ open: true, asks: [ask({})] }))).toContain('正在想…') }) + + it('输入框右边、发送按钮前面是 profile 下拉:显示选中的名字,键盘能打开(按钮、弹出列表);没收到选项时写「默认」', () => { + const html = render({ open: true }) + const row = html.slice(html.indexOf('
')) + const [textarea, select, send] = [' row.indexOf(mark)) + expect(textarea).toBeGreaterThanOrEqual(0) + expect(select).toBeGreaterThan(textarea) + expect(send).toBeGreaterThan(select) + const trigger = row.slice(row.lastIndexOf('', select)) + expect(trigger).toContain('aria-haspopup="listbox"') + expect(trigger).toContain('aria-expanded="false"') + expect(trigger).toContain('title="用哪个 Claude Code 配置(profile)回答,下一个问题起用它"') + expect(text(trigger)).toBe('sonnet') + const none = render({ open: true, profiles: { options: [], selected: '' } }) + expect(text(none.slice(none.indexOf('cr-qa-profile')))).toContain('默认') + }) + + it('历史每条的灰字写时间和用哪个 profile 答的;以前存的没有 profile 只写时间', () => { + const html = render({ open: true, history: [{ ...HISTORY[0]!, profile: 'sonnet' }, HISTORY[1]!] }) + expect(text(html)).toContain('为什么普通用户不能看别人的资料?3 天前 · sonnet') + expect(text(html)).toContain('UserRole 还有哪些角色?昨天') + expect(text(html)).not.toContain('昨天 ·') + }) }) diff --git a/vscode/webview-ui/src/codeReader/AskThread.tsx b/vscode/webview-ui/src/codeReader/AskThread.tsx index 849ebfe..3643d1b 100644 --- a/vscode/webview-ui/src/codeReader/AskThread.tsx +++ b/vscode/webview-ui/src/codeReader/AskThread.tsx @@ -1,8 +1,9 @@ import type { KeyboardEvent, ReactElement } from 'react' import type { Ask } from './qa' -import type { QaEntryView } from './types' +import type { ProfileChoice, QaEntryView } from './types' import { ChevronDown, ChevronRight, CircleStop, LoaderCircle, SendHorizontal, Sparkles, Trash2, TriangleAlert } from 'lucide-react' import { useState } from 'react' +import { SelectMenu } from '@/components/ui/select-menu' import { renderMarkdown, renderMarkdownOnce } from './markdown' import { answerPreview, relativeTime } from './qa' @@ -17,9 +18,12 @@ function HistoryItem({ entry, now, onDelete }: { entry: QaEntryView, now: number - + + + {entry.profile && {` · ${entry.profile}`}} + @@ -69,10 +73,12 @@ function CurrentItem({ ask }: { ask: Ask }): ReactElement { } /** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止 */ -function AskInput({ running, first, onAsk, onStop, onClose }: { +function AskInput({ running, first, profiles, onSelectProfile, onAsk, onStop, onClose }: { running: Ask | undefined /** 这块还没问过:提示「问这块代码…」,问过了是「继续问…」 */ first: boolean + profiles: ProfileChoice + onSelectProfile: (id: string) => void onAsk: (question: string) => void onStop: (askId: string) => void onClose: () => void @@ -107,6 +113,15 @@ function AskInput({ running, first, onAsk, onStop, onClose }: { onChange={event => setValue(event.target.value)} onKeyDown={onKeyDown} /> + ({ value: option.id, label: option.label }))} + onChange={onSelectProfile} + placeholder="默认" + title="用哪个 Claude Code 配置(profile)回答,下一个问题起用它" + className="cr-qa-profile" + /> {running ? (