✨ feat(vscode): 问 AI 输入框旁边能选 Claude profile,记住上次选的,问答灰字写用哪个 profile 答的

This commit is contained in:
cruldra committed 2026-09-26 22:28:29 +08:00
1 parent 1eb42d8389
commit 9efae1f496
20 files changed
+397 -63

No files matched your search

+9 -2
View File
@@ -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 }
+18 -3
View File
@@ -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<void> {
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<void> {
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)
+1 -1
View File
@@ -63,7 +63,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
return runtime.parse(adapter, languageId, source)
}, report))
const resolve = (reference: RuleReference): Promise<RuleLocation[]> => 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)
@@ -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<string, unknown> = {}): Pick<Memento, 'get' | 'update'> & { store: Record<string, unknown> } {
const store = { ...initial }
return {
store,
get: <T>(key: string): T | undefined => store[key] as T | undefined,
update: async (key: string, value: unknown) => {
store[key] = value
},
} as Pick<Memento, 'get' | 'update'> & { store: Record<string, unknown> }
}
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'))
})
})
+77
View File
@@ -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<ClaudeProfile[]>
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<Memento, 'get' | 'update'>, private readonly source: ProfileSource = PROFILE_SOURCE) {}
async choice(): Promise<ProfileChoice> {
return (await this.read()).choice
}
/** 下一个问题用的 profile;选的是「默认」时没有(不带 `--settings`)。 */
async current(): Promise<ClaudeProfile | undefined> {
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<void> {
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)) }
}
}
+13 -6
View File
@@ -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('完整的问题')
})
+5 -19
View File
@@ -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<Answer>
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<Answer> {
const profilePath = deps.profilePath()
const once = async (prompt: string, resume?: string): Promise<Answer> => {
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,
+17 -5
View File
@@ -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<AskEvent>()
/** 问的时候用哪个 profile(所有预览共用,记在 globalState 里) */
readonly profiles: AskProfiles
/** 问答变了的那些文件 */
readonly onDidChange = this.store.onDidChange
readonly onDidAnswer = this.answered.event
constructor(runner: AskRunner = askClaude) {
constructor(state: Pick<Memento, 'get' | 'update'>, 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<string | undefined> {
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 })
+10
View File
@@ -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')] },
+4 -2
View File
@@ -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 干净。 */
+11
View File
@@ -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)),
},
},
})
@@ -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<AskThreadProps>): string {
return renderToStaticMarkup(<AskThread history={[]} asks={[]} open={false} now={NOW} onAsk={() => {}} onStop={() => {}} onDelete={() => {}} onClose={() => {}} {...props} />)
return renderToStaticMarkup(<AskThread history={[]} asks={[]} open={false} profiles={PROFILES} now={NOW} onSelectProfile={() => {}} 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('<div class="cr-qa-input">'))
const [textarea, select, send] = ['<textarea', 'cr-qa-profile', 'aria-label="发送"'].map(mark => row.indexOf(mark))
expect(textarea).toBeGreaterThanOrEqual(0)
expect(select).toBeGreaterThan(textarea)
expect(send).toBeGreaterThan(select)
const trigger = row.slice(row.lastIndexOf('<button', select), row.indexOf('</button>', 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('昨天 ·')
})
})
+26 -6
View File
@@ -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
<button type="button" className="cr-qa-question" aria-expanded={expanded} title={expanded ? '收起回答' : '展开整段回答'} onClick={toggle}>
{entry.question}
</button>
<time className="cr-qa-when" dateTime={entry.createdAt} title={new Date(entry.createdAt).toLocaleString()}>
{relativeTime(entry.createdAt, now)}
</time>
<span className="cr-qa-when">
<time dateTime={entry.createdAt} title={new Date(entry.createdAt).toLocaleString()}>
{relativeTime(entry.createdAt, now)}
</time>
{entry.profile && <span title="用这个 Claude Code 配置(profile)答的">{` · ${entry.profile}`}</span>}
</span>
<button type="button" className="cr-tool cr-qa-delete" title="已理解,删除" aria-label="已理解,删除" onClick={onDelete}>
<Trash2 size={13} strokeWidth={1.8} aria-hidden />
</button>
@@ -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}
/>
<SelectMenu
variant="ghost"
value={profiles.selected}
options={profiles.options.map(option => ({ value: option.id, label: option.label }))}
onChange={onSelectProfile}
placeholder="默认"
title="用哪个 Claude Code 配置(profile)回答,下一个问题起用它"
className="cr-qa-profile"
/>
{running
? (
<button type="button" className="cr-tool" title="停止" aria-label="停止" onClick={() => onStop(running.askId)}>
@@ -127,7 +142,10 @@ export interface AskThreadProps {
asks: Ask[]
/** 点了「问 AI」:展开全部历史、当前这一轮和输入框 */
open: boolean
/** 输入框旁边的 profile 下拉 */
profiles: ProfileChoice
now?: number
onSelectProfile: (id: string) => void
onAsk: (question: string) => void
onStop: (askId: string) => void
onDelete: (entryId: string) => void
@@ -138,7 +156,7 @@ export interface AskThreadProps {
* 块下面的问答区(左边一条竖线):展开时是灰字的历史、当前这一轮、输入框;
* 没展开但问过的块只露一行「问过 N 次」,点开看灰字历史,读代码时一眼知道这里问过什么。
*/
export function AskThread({ history, asks, open, now = Date.now(), onAsk, onStop, onDelete, onClose }: AskThreadProps): ReactElement | null {
export function AskThread({ history, asks, open, profiles, now = Date.now(), onSelectProfile, onAsk, onStop, onDelete, onClose }: AskThreadProps): ReactElement | null {
const [showHistory, setShowHistory] = useState(false)
if (!open) {
if (history.length === 0)
@@ -163,6 +181,8 @@ export function AskThread({ history, asks, open, now = Date.now(), onAsk, onStop
<AskInput
running={asks.find(ask => ask.status === 'running')}
first={history.length === 0 && asks.length === 0}
profiles={profiles}
onSelectProfile={onSelectProfile}
onAsk={onAsk}
onStop={onStop}
onClose={onClose}
@@ -40,6 +40,8 @@ export interface QaTree {
onAsk: (anchor: string, question: string) => void
onStop: (askId: string) => void
onDelete: (anchor: string, entryId: string) => void
/** 换「问 AI」用的 profile(所有块共用),id 同 ProfileOption.id */
onSelectProfile: (id: string) => void
}
/** 块下面有没有画出来的子块:子块都被筛掉、被盖住时不留空的一层、不给折叠箭头。 */
@@ -97,6 +99,8 @@ function BlockQa({ anchor, qa }: { anchor: string, qa: QaTree }): ReactElement {
history={historyOf(qa.state, anchor)}
asks={asksOf(qa.state, anchor)}
open={qa.state.open.includes(anchor)}
profiles={qa.state.profiles}
onSelectProfile={qa.onSelectProfile}
onAsk={question => qa.onAsk(anchor, question)}
onStop={qa.onStop}
onDelete={entryId => qa.onDelete(anchor, entryId)}
@@ -661,6 +661,28 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(-
.cr-qa-input .cr-tool:disabled {
opacity: 0.4;
}
/* 发送按钮前面的 profile 下拉:只占名字那么宽,长名字截断,输入框还是主角;字号同弹出的选项(弹层按它量宽) */
.cr-qa-input .cr-qa-profile {
height: 20px;
max-width: 140px;
flex-shrink: 0;
padding: 0 4px 0 6px;
border-radius: 4px;
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.cr-qa-input .cr-qa-profile:hover {
background: var(--vscode-toolbar-hoverBackground);
color: var(--vscode-foreground);
}
.cr-qa-input .cr-qa-profile[aria-expanded="true"] {
background: var(--vscode-toolbar-activeBackground);
color: var(--vscode-foreground);
}
.cr-qa-input .cr-qa-profile:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
.cr-qa-summary {
display: inline-flex;
width: fit-content;
@@ -34,6 +34,14 @@ describe('问 AI 的状态', () => {
expect(historyOf(state, IF).map(entry => entry.id)).toEqual(['old'])
})
it('profile 下拉:扩展端发来什么就显示什么,问答刷新不丢', () => {
const profiles = { options: [{ id: '', label: '默认' }, { id: 'sonnet', label: 'sonnet' }], selected: 'sonnet' }
expect(EMPTY_QA.profiles).toEqual({ options: [], selected: '' })
const state = run({ type: 'profiles', profiles }, qa([thread(IF, 'e1')]))
expect(state.profiles).toEqual(profiles)
expect(state.threads).toHaveLength(1)
})
it('收起就结束这一轮:答完的回到历史,还在答的留着;再展开', () => {
let state = run(
qa([thread(IF, 'e1')]),
+8 -2
View File
@@ -3,7 +3,7 @@
* 这次打开以来问的是「当前这一轮」,照常颜色显示,别的都是灰字的历史;记号对不上任何块的问答不显示。
*/
import type { AnswerEnd, QaEntryView, QaMessage, QaThreadView } from './types'
import type { AnswerEnd, ProfileChoice, QaEntryView, QaMessage, QaThreadView } from './types'
/** 这次打开以来问的一个问题 */
export interface Ask {
@@ -29,12 +29,15 @@ export interface QaState {
asks: Ask[]
/** 展开着对话区(历史、当前这一轮、输入框)的块 */
open: string[]
/** 输入框旁边的 profile 下拉,所有块共用 */
profiles: ProfileChoice
}
export const EMPTY_QA: QaState = { available: false, threads: [], asks: [], open: [] }
export const EMPTY_QA: QaState = { available: false, threads: [], asks: [], open: [], 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 }
| { type: 'ask', askId: string, anchor: string, question: string }
@@ -52,12 +55,15 @@ export function qaReducer(state: QaState, action: QaAction): QaState {
const known = new Set(state.asks.map(ask => ask.askId))
const adopted = action.message.running.filter(ask => !known.has(ask.askId))
return {
...state,
available: action.message.available,
threads: action.message.threads,
asks: [...state.asks, ...adopted.map((ask): Ask => ({ ...ask, status: 'running' }))],
open: adopted.reduce((open, ask) => withOpen(open, ask.anchor), state.open),
}
}
case 'profiles':
return { ...state, profiles: action.profiles }
case 'answer':
return {
...state,
+20 -2
View File
@@ -156,12 +156,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
}
/** 挂在一块上的问答;记号对不上这份文件里任何一块的不显示 */
@@ -190,6 +192,18 @@ export interface QaMessage {
running: RunningAsk[]
}
/** 「问 AI」用的 Claude Code profile 的下拉一项:id 是 profile 名;「默认」(不带 `--settings`)的 id 是 '' */
export interface ProfileOption {
id: string
label: string
}
/** 「问 AI」输入框旁边的 profile 下拉:选项和选中的那项,所有预览共用、扩展端记着 */
export interface ProfileChoice {
options: ProfileOption[]
selected: string
}
/**
* 一个问题怎么结束的:done 带最终的回答(逐字发来的最后一份可能没赶上)和存下的那条的 id,
* failed 带给人看的原因,stopped 是被叫停、没存
@@ -209,6 +223,7 @@ export interface AnswerEnd {
* occurrencesFound:id 那次 findOccurrences 的回答,语言扩展没答(或没装)就是空的
* filter:整个工作区共用的块筛选;ready 后先于第一份文档来一次,之后任何一个预览改了都会来
* qa:这份文件的问答;ready 后来一次,问答、正在答的问题变了再来
* askProfiles:「问 AI」的 profile 下拉;ready 后来一次,任何一个预览改了都会来
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
* answerEnd:askId 那个问题答完了,见 AnswerEnd
*/
@@ -219,6 +234,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 }
| AnswerEnd
@@ -230,9 +246,10 @@ export type ExtensionToWebview
* goToDefinition:⌘ / Ctrl + 点击,跳到 offset 处名字的定义
* findOccurrences:单击带下标的名字,问它在这份文档里出现在哪几处;id、version 同 probeDefinition
* setFilter:改块筛选(整份替换);不在本地先改,等扩展端发回 filter 再按它显示
* ask:问 anchor 那块代码;askId 由这边起,回答的消息带回来;扩展端接着这块最近一次问答的会话问
* ask:问 anchor 那块代码;askId 由这边起,回答的消息带回来;扩展端接着这块最近一次问答的会话问,用眼下选的 profile
* stopAsk:叫停 askId 那个问题,这次不存
* deleteQa:删掉 anchor 那块下面的一次问答(已经理解了)
* selectAskProfile:「问 AI」换 profile;不在本地先改,等扩展端发回 askProfiles 再按它显示
*/
export type WebviewToExtension
= | { type: 'ready' }
@@ -246,3 +263,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 }
@@ -29,6 +29,9 @@ export function useQa(): QaTree {
case 'qa':
dispatch({ type: 'qa', message: msg })
return
case 'askProfiles':
dispatch({ type: 'profiles', profiles: { options: msg.options, selected: msg.selected } })
return
case 'answer':
pending.set(msg.askId, { text: msg.text, activity: msg.activity })
timer ??= setTimeout(flush, ANSWER_FRAME_MS)
@@ -56,5 +59,6 @@ export function useQa(): QaTree {
},
onStop: askId => postMessage({ type: 'stopAsk', askId }),
onDelete: (anchor, entryId) => postMessage({ type: 'deleteQa', anchor, entryId }),
onSelectProfile: id => postMessage({ type: 'selectAskProfile', id }),
}), [state])
}