diff --git a/vscode/package.json b/vscode/package.json index 811a5ab..7d828a9 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -76,6 +76,10 @@ "selector": [ { "filenamePattern": "*" + }, + { + "filenamePattern": "*", + "scheme": "code-reader-path" } ], "priority": "option" diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 7866189..54cd27b 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -75,8 +75,14 @@ export interface QaThreadView { */ export interface QaMessage { type: 'qa' + /** 不在工作区文件夹里时没地方存,问 AI 不可用 */ available: boolean + /** 块下面的问答,按块的记号 */ threads: QaThreadView[] + /** 这一页顶部、按路径记的问答;不挂在块上 */ + path: QaEntryView[] + /** 顶部问的是这个文件还是这个文件夹 */ + subject: 'file' | 'folder' running: RunningAsk[] } @@ -119,6 +125,7 @@ export type ExtensionToWebview | { type: 'repoLinkBroken', path: string, symbol?: string } | { type: 'repoLinkOpened', path: string, symbol?: string } | { type: 'repoImagesChecked', id: number, images: { path: string, src?: string }[] } + | { type: 'focusPathAsk' } /** * edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进) diff --git a/vscode/src/codeReader/editor/pathPreview.ts b/vscode/src/codeReader/editor/pathPreview.ts new file mode 100644 index 0000000..b4ee289 --- /dev/null +++ b/vscode/src/codeReader/editor/pathPreview.ts @@ -0,0 +1,38 @@ +/** + * 文件夹、以及代码阅读不认识的文件,用这个协议打开代码阅读预览: + * 文档是空的,页面上没有代码块,只在顶部聊天。源码文件直接开它自己的预览。 + */ + +import type { Uri, WorkspaceFolder } from 'vscode' +import * as path from 'node:path' +import { workspace } from 'vscode' +import { relativeQaKey } from '../qa/pathQa' + +export const PATH_PREVIEW_SCHEME = 'code-reader-path' + +export type PathSubject = 'file' | 'folder' + +export function pathPreviewUri(target: Uri, kind: PathSubject): Uri { + return target.with({ scheme: PATH_PREVIEW_SCHEME, query: kind, fragment: '' }) +} + +/** 预览文档是路径页时,还原成真实文件或文件夹;普通源码预览不是。 */ +export function pathPreviewOf(uri: Uri): { uri: Uri, kind: PathSubject } | undefined { + if (uri.scheme !== PATH_PREVIEW_SCHEME) + return undefined + if (uri.query !== 'file' && uri.query !== 'folder') + return undefined + return { uri: uri.with({ scheme: 'file', query: '', fragment: '' }), kind: uri.query } +} + +/** + * 这条路径在哪个工作区文件夹、相对路径是什么。 + * 工作区根本身的相对路径是空字符串(locate 不认根,顶部问答要认)。 + */ +export function pathQaLocation(uri: Uri): { folder: WorkspaceFolder, key: string } | undefined { + const folder = workspace.getWorkspaceFolder(uri) + if (!folder) + return undefined + const key = relativeQaKey(path.posix.normalize(folder.uri.path), path.posix.normalize(uri.path)) + return key === undefined ? undefined : { folder, key } +} diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index d5a07da..9186987 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -25,6 +25,7 @@ import { locate } from '../folderStore' import { definitionFor, symbolAt, symbolOccurrences } from '../localSymbols' import { DocumentNames, resolveNames } from '../nameResolution' import { askClaude, askFailure } from '../qa/claudeAsk' +import { PATH_ASK_ANCHOR } from '../qa/pathQa' import { askPrompt, followUpPrompt } from '../qa/prompt' import { DocumentLookups } from '../ruleLookups' import { definitionAction } from './definitionAction' @@ -33,6 +34,7 @@ import { DefinitionSources } from './definitionSources' import { documentMessage } from './documentMessage' import { NavigationContext, OCCURRENCES_CONTEXT } from './occurrenceNavigation' import { occurrencesOf } from './occurrences' +import { pathPreviewOf } from './pathPreview' import { findRepoSymbol, isImageFile, isRepoImagePath, repoRootOf, resolveRepoEntry } from './repoPath' export const PREVIEW_VIEW_TYPE = 'superpowers.codeReader.preview' @@ -52,6 +54,8 @@ export interface DiffBases { */ export class PreviewEditorProvider implements CustomTextEditorProvider { private readonly previews = new Set() + /** 侧栏「问 AI」打开的预览,解析时把光标放进顶部输入框;已经开着的由 openAsking 自己聚焦 */ + private readonly pathFocus = new Set() /** 按文档 uri:等着交给马上要打开的新预览的位置(从文本编辑器切过来时它顶部那一行,或跳到的定义) */ private readonly reveals = new Map() /** 哪个预览眼下能用键盘在高亮的各处出现之间跳,跟着它设快捷键 when 子句用的上下文键 */ @@ -74,13 +78,32 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { readonly diffBases: DiffBases, ) {} + /** 侧栏「问 AI」打开预览,并把光标放进顶部输入框。已经开着的就揭到前面再聚焦。 */ + async openAsking(uri: Uri): Promise { + const key = uri.toString() + this.pathFocus.add(key) + try { + await commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, { preview: true }) + } + finally { + // 新开的预览在解析时已经拿走标记;还在的是本来就开着的那页,这里聚焦 + if (this.pathFocus.delete(key)) { + for (const preview of this.previews) { + if (preview.uri.toString() === key) + preview.focusPathAsk() + } + } + } + } + resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void { // 问答带的图片(工作区文件夹的 .spx/code-qa/)也要能读,历史里才画得出缩略图 - const imageRoot = this.qa.imageRoot(document.uri) + const imageRoot = this.qa.imageRoot(pathPreviewOf(document.uri)?.uri ?? document.uri) panel.webview.options = { enableScripts: true, localResourceRoots: [webviewUiRoot(this.extensionUri), ...(imageRoot ? [imageRoot] : [])] } panel.webview.html = webviewHtml(panel.webview, this.extensionUri, 'code-reader.html') const key = document.uri.toString() - const preview = new Preview(document, panel, this, this.registry, this.runtime, this.filter, this.reveals.get(key)) + const focusPath = this.pathFocus.delete(key) + const preview = new Preview(document, panel, this, this.registry, this.runtime, this.filter, this.reveals.get(key), focusPath) this.reveals.delete(key) this.previews.add(preview) panel.onDidDispose(() => { @@ -140,6 +163,8 @@ type RewriteOutcome class Preview { /** webview 发过 ready 之后才发消息;标签页切走时页面被销毁,切回来重新加载、再发 ready,这期间也不发 */ private ready = false + /** 侧栏「问 AI」打开的:页面准备好后把光标放进顶部输入框 */ + private pendingPathFocus = false /** 解析是异步的:只发最后一次请求的结果,慢的旧结果不能盖掉新的 */ private latest = 0 private timer: NodeJS.Timeout | undefined @@ -179,7 +204,9 @@ class Preview { * 之后页面因标签页切走重新加载,按 webview 自己记的位置恢复 */ private pendingReveal: Reveal | undefined, + focusPath: boolean, ) { + this.pendingPathFocus = focusPath this.definitions = new DefinitionSources({ rule: async reference => ruleDefinitions(await owner.resolveReference(reference)), language: async (offset) => { @@ -227,7 +254,7 @@ class Preview { this.post({ type: 'filter', ...state }) }), owner.qa.onDidChange((uris) => { - if (uris.some(uri => uri.toString() === document.uri.toString())) + if (uris.some(uri => uri.toString() === this.subject().uri.toString())) this.postQa() }), owner.qa.profiles.subscribe((choice) => { @@ -235,7 +262,7 @@ class Preview { this.post({ type: 'askProfiles', ...choice }) }), owner.qa.onDidAnswer((event) => { - if (event.owner !== document.uri.toString() || !this.ready) + if (event.owner !== this.subject().uri.toString() || !this.ready) return if (event.kind === 'update') { const { askId, text, activity } = event.ask @@ -272,6 +299,23 @@ class Preview { if (!this.ready) return const request = ++this.latest + if (pathPreviewOf(this.document.uri)) { + this.sent = undefined + const reveal = this.pendingReveal + this.pendingReveal = undefined + this.post({ + type: 'document', + version: this.document.version, + languageId: '', + blocks: [], + attributes: [], + translators: [], + translatorId: '', + names: [], + ...(reveal && { reveal }), + }) + return + } const { languageId, version } = this.document const source = this.document.getText() const session = this.lookups.session() @@ -405,8 +449,25 @@ class Preview { } private postQa(): void { - if (this.ready) - this.post(this.owner.qa.view(this.document.uri, file => this.panel.webview.asWebviewUri(file).toString())) + if (!this.ready) + return + const subject = this.subject() + this.post(this.owner.qa.view(subject.uri, file => this.panel.webview.asWebviewUri(file).toString(), subject.kind)) + } + + /** 这一页问的是哪个文件或文件夹。路径页还原成真实路径,源码预览就是文档自己。 */ + private subject(): { uri: Uri, kind: 'file' | 'folder' } { + return pathPreviewOf(this.document.uri) ?? { uri: this.document.uri, kind: 'file' } + } + + /** 把光标放进顶部输入框;页面还没准备好就等 ready。 */ + focusPathAsk(): void { + if (!this.ready) { + this.pendingPathFocus = true + return + } + this.pendingPathFocus = false + this.post({ type: 'focusPathAsk' }) } /** profile 列表每次现读(同看板),读完页面还在才发。 */ @@ -421,13 +482,22 @@ class Preview { * 找不到这块(代码刚改过、还没重新发)或问不了时,直接回一条失败。 */ private async ask(askId: string, anchor: string, question: string, images: ClaudeImage[]): Promise { - const at = locate(this.document.uri) - const subject = at && this.sent && { path: at.key, languageId: this.document.languageId, blocks: this.sent.blocks, source: this.sent.source, anchor } - const prompt = subject && askPrompt(subject, question) - const followUp = subject && followUpPrompt(subject, question) + const subject = this.subject() + if (anchor === PATH_ASK_ANCHOR) { + const refused = await this.owner.qa.askPath(subject.uri, subject.kind, { askId, question, images }) + if (refused !== undefined) + this.post({ type: 'answerEnd', askId, status: 'failed', error: refused }) + else + this.postQa() + return + } + const at = locate(subject.uri) + const block = at && this.sent && { path: at.key, languageId: this.document.languageId, blocks: this.sent.blocks, source: this.sent.source, anchor } + const prompt = block && askPrompt(block, question) + const followUp = block && followUpPrompt(block, question) const refused = prompt === undefined || followUp === undefined ? '这块代码刚改过,找不到了。等预览刷新后重新点「问 AI」再问。' - : await this.owner.qa.ask(this.document.uri, { askId, anchor, question, prompt, followUp, images }) + : await this.owner.qa.ask(subject.uri, { askId, anchor, question, prompt, followUp, images }) if (refused !== undefined) { this.post({ type: 'answerEnd', askId, status: 'failed', error: refused }) return @@ -449,6 +519,8 @@ class Preview { this.postQa() void this.postProfiles() await this.push() + if (this.pendingPathFocus) + this.focusPathAsk() return case 'ask': await this.ask(msg.askId, msg.anchor, msg.question, msg.images) @@ -460,7 +532,7 @@ class Preview { this.owner.qa.stop(msg.askId) return case 'deleteQa': - await this.owner.qa.delete(this.document.uri, msg.anchor, msg.entryId) + await this.owner.qa.delete(this.subject().uri, msg.anchor, msg.entryId) return case 'setFilter': await this.filter.set(msg.excluded) diff --git a/vscode/src/codeReader/explorer/pathAskPanel.ts b/vscode/src/codeReader/explorer/pathAskPanel.ts index 1c45016..f7899f3 100644 --- a/vscode/src/codeReader/explorer/pathAskPanel.ts +++ b/vscode/src/codeReader/explorer/pathAskPanel.ts @@ -1,415 +1,34 @@ /** - * 侧栏文件行、文件夹行的「问 AI」:单独一个面板,不属于任何一个文件的预览。 - * 先问问题,再在同一面板里把回答流出来;不写进某一块代码的问答记录。 + * 侧栏文件行、文件夹行的「问 AI」:打开代码阅读预览,在那一页顶部问。 + * 源码文件开它自己的预览(下面仍是代码块)。文件夹、以及代码阅读不认识的文件,开一页没有代码块的预览。 */ -import type { WebviewPanel } from 'vscode' -import type { AskProfiles } from '../qa/askProfiles' -import type { PathAskSubject, PathChild } from '../qa/pathAskPrompt' -import type { ExcludeSetting } from './fileEntries' +import type { Disposable, Uri } from 'vscode' import type { FileNode } from './fileTree' -import * as path from 'node:path' -import { commands, Disposable, FileType, ViewColumn, window, workspace } from 'vscode' -import { ClaudeCancelledError } from '../../cc/spawnClaude' -import { makeNonce } from '../../webview/html' -import { askClaude, askFailure } from '../qa/claudeAsk' -import { readFileHead } from '../qa/pathAskContext' -import { pathAskPrompt } from '../qa/pathAskPrompt' -import { createExcludeMatcher, sortEntries } from './fileEntries' +import { commands } from 'vscode' +import { pathPreviewUri } from '../editor/pathPreview' export const ASK_PATH_COMMAND = 'superpowers.codeReader.askPath' -const VIEW_TYPE = 'superpowers.codeReader.pathAsk' - -type PathTarget = Pick & { relativePath: string } - -interface ResetMessage { type: 'reset', path: string, kind: 'file' | 'folder' } -interface AskedMessage { type: 'asked', question: string } -interface StreamMessage { type: 'stream', text?: string, activity?: string } -interface EndMessage { type: 'end', status: 'done' | 'failed' | 'stopped', error?: string } -type ToPanel = ResetMessage | AskedMessage | StreamMessage | EndMessage - -/** 打开或切到问路径的面板。点侧栏另一行时换到那一行,正在答的停掉。 */ -export function registerPathAsk(profiles: AskProfiles): Disposable { - const panel = new PathAskPanel(profiles) - return Disposable.from( - commands.registerCommand(ASK_PATH_COMMAND, (node?: FileNode) => { - void panel.open(node) - }), - panel, - ) +export function registerPathAsk( + isSource: (uri: Uri) => Promise, + openAsking: (uri: Uri) => Promise, +): Disposable { + return commands.registerCommand(ASK_PATH_COMMAND, (node?: FileNode) => { + void openAsked(node, isSource, openAsking) + }) } -class PathAskPanel implements Disposable { - private panel: WebviewPanel | undefined - private whenReady = Promise.resolve() - private target: PathTarget | undefined - /** 换了路径就加一,旧的回答不再往面板上画 */ - private generation = 0 - private controller: AbortController | undefined - - constructor(private readonly profiles: AskProfiles) {} - - async open(node?: FileNode): Promise { - if (!node || node.isRoot) - return - const gen = ++this.generation - this.controller?.abort() - this.controller = undefined - const relativePath = path.posix.relative(node.folder.uri.path, node.uri.path) - this.target = { uri: node.uri, folder: node.folder, isDirectory: node.isDirectory, relativePath } - const title = `问 AI · ${relativePath}` - if (!this.panel) { - this.create(title) - } - else { - this.panel.title = title - this.panel.reveal(ViewColumn.Active, false) - } - await this.whenReady - if (gen !== this.generation || !this.panel) - return - this.post({ type: 'reset', path: relativePath, kind: node.isDirectory ? 'folder' : 'file' }) - } - - dispose(): void { - this.generation++ - this.controller?.abort() - this.controller = undefined - this.panel?.dispose() - this.panel = undefined - } - - private create(title: string): void { - const panel = window.createWebviewPanel(VIEW_TYPE, title, { viewColumn: ViewColumn.Active, preserveFocus: false }, { - enableScripts: true, - retainContextWhenHidden: true, - }) - this.panel = panel - let markReady: () => void = () => {} - this.whenReady = new Promise((resolve) => { - markReady = resolve - }) - const nonce = makeNonce() - panel.webview.html = pathAskHtml(nonce) - panel.webview.onDidReceiveMessage((msg: unknown) => { - if (!isRecord(msg) || typeof msg.type !== 'string') - return - if (msg.type === 'ready') - markReady() - else if (msg.type === 'ask' && typeof msg.question === 'string') - void this.submit(msg.question) - else if (msg.type === 'stop') - this.controller?.abort() - }) - panel.onDidDispose(() => { - if (this.panel !== panel) - return - this.panel = undefined - this.generation++ - this.controller?.abort() - this.controller = undefined - }) - } - - /** 空问题不发。回答过程里的字和「正在读哪个文件」直接画在这个面板上。 */ - private async submit(question: string): Promise { - const text = question.trim() - const target = this.target - if (!text || !target || this.controller) - return - const gen = this.generation - const controller = new AbortController() - this.controller = controller - this.post({ type: 'asked', question: text }) - try { - const described = await describePath(target) - if (gen !== this.generation) - return - const profile = await this.profiles.current() - if (gen !== this.generation) - return - const prompt = pathAskPrompt(described, text) - await askClaude({ - prompt, - images: [], - ...(profile && { profilePath: profile.path }), - cwd: target.folder.uri.fsPath, - signal: controller.signal, - onEvent: (event) => { - if (gen !== this.generation) - return - if (event.kind === 'text') - this.post({ type: 'stream', text: event.text }) - else if (event.kind === 'activity') - this.post({ type: 'stream', activity: event.label }) - }, - }) - if (gen !== this.generation) - return - this.post({ type: 'end', status: 'done' }) - } - catch (err) { - if (gen !== this.generation) - return - if (err instanceof ClaudeCancelledError) - this.post({ type: 'end', status: 'stopped' }) - else - this.post({ type: 'end', status: 'failed', error: askFailure(err) }) - } - finally { - if (this.controller === controller) - this.controller = undefined - } - } - - private post(message: ToPanel): void { - void this.panel?.webview.postMessage(message).then(undefined, () => {}) +async function openAsked( + node: FileNode | undefined, + isSource: (uri: Uri) => Promise, + openAsking: (uri: Uri) => Promise, +): Promise { + if (!node || node.isRoot) + return + if (node.isDirectory) { + await openAsking(pathPreviewUri(node.uri, 'folder')) + return } -} - -/** 文件带开头,文件夹带这一层(和侧栏同一套排除、同一排序)。 */ -async function describePath(target: PathTarget): Promise { - if (target.isDirectory) - return { kind: 'folder', relativePath: target.relativePath, children: await folderChildren(target) } - return { kind: 'file', relativePath: target.relativePath, head: await readFileHead(target.uri.fsPath) } -} - -async function folderChildren(target: PathTarget): Promise { - let entries: [string, FileType][] - try { - entries = await workspace.fs.readDirectory(target.uri) - } - catch (err) { - const message = err instanceof Error ? err.message : String(err) - return { unavailable: `列不出来(${message})。` } - } - const excluded = createExcludeMatcher(workspace.getConfiguration('files', target.folder).get('exclude')) - const names = new Set(entries.map(([name]) => name)) - const dir = target.relativePath - return sortEntries(entries.map(([name, type]) => ({ name, isDirectory: (type & FileType.Directory) !== 0 }))) - .filter(entry => !excluded(dir ? `${dir}/${entry.name}` : entry.name, names)) - .map(entry => ({ name: entry.name, kind: entry.isDirectory ? 'folder' : 'file' })) -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - -/** 面板里的一页:暗色遮罩铺满,中间一张卡片。问、流式回答、停止都在这张卡片上。 */ -function pathAskHtml(nonce: string): string { - return ` - - - - - -问 AI - - - - - - -` + await openAsking(await isSource(node.uri) ? node.uri : pathPreviewUri(node.uri, 'file')) } diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index 60b9d71..c5a6510 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -9,6 +9,7 @@ import { shellCommandRules } from './commands/shellCommands' import { systemCommandRules } from './commands/systemCommands' import { BlockFilter } from './editor/blockFilter' import { NEXT_OCCURRENCE, PREVIOUS_OCCURRENCE } from './editor/occurrenceNavigation' +import { PATH_PREVIEW_SCHEME } from './editor/pathPreview' import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './editor/previewEditor' import { showPreview, showSource } from './editor/switchEditor' import { registerCopyCommands } from './explorer/copyCommands' @@ -82,7 +83,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(context.globalState) + const qa = new CodeQa(context.globalState, context.workspaceState) const namesIn = async (languageId: string, name: string): Promise => { const adapter = registry.languageFor(languageId) return adapter ? indexedNames((index, key) => indexes.find(index, key), adapter, name) : [] @@ -102,7 +103,16 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { indexes, registerNoteCommands(notes), registerCopyCommands(), - registerPathAsk(qa.profiles), + registerPathAsk( + async (uri) => { + const languageId = await languageOf(uri) + return languageId !== undefined && registry.languageFor(languageId) !== undefined + }, + uri => previews.openAsking(uri), + ), + workspace.registerTextDocumentContentProvider(PATH_PREVIEW_SCHEME, { + provideTextDocumentContent: () => '', + }), registry.onDidChange(() => { void commands.executeCommand('setContext', LANGUAGE_IDS_CONTEXT, registry.languageIds()) previews.refresh() diff --git a/vscode/src/codeReader/qa/asks.test.ts b/vscode/src/codeReader/qa/asks.test.ts index 9459648..1e70cc2 100644 --- a/vscode/src/codeReader/qa/asks.test.ts +++ b/vscode/src/codeReader/qa/asks.test.ts @@ -84,7 +84,7 @@ describe('正在答的问题', () => { const { runner } = fakeRunner() const asks = new QaAsks(runner) asks.start(start('1', 'function f'), listener()) - expect(asks.start(start('2', 'function f'), listener())).toBe('这块已经在问了,等它答完或者先停下再问。') + expect(asks.start(start('2', 'function f'), listener())).toBe('这里已经在问了,等它答完或者先停下再问。') expect(asks.start(start('3', 'function f', 'file:///b.py'), listener())).toBeUndefined() expect(asks.start(start('1', 'function g'), listener())).toBe('这个问题已经在问了。') }) diff --git a/vscode/src/codeReader/qa/asks.ts b/vscode/src/codeReader/qa/asks.ts index dc76639..8a57491 100644 --- a/vscode/src/codeReader/qa/asks.ts +++ b/vscode/src/codeReader/qa/asks.ts @@ -57,7 +57,7 @@ export class QaAsks { if (this.running.has(askId)) return '这个问题已经在问了。' if ([...this.running.values()].some(item => item.owner === owner && item.ask.anchor === anchor)) - return '这块已经在问了,等它答完或者先停下再问。' + return '这里已经在问了,等它答完或者先停下再问。' if (this.running.size >= this.limit) return `同时最多问 ${this.limit} 个问题,等前面的答完再问。` const controller = new AbortController() diff --git a/vscode/src/codeReader/qa/codeQa.ts b/vscode/src/codeReader/qa/codeQa.ts index df9f818..37d0443 100644 --- a/vscode/src/codeReader/qa/codeQa.ts +++ b/vscode/src/codeReader/qa/codeQa.ts @@ -1,16 +1,21 @@ -import type { Disposable, Memento } from 'vscode' +import type { Event, Memento } from 'vscode' import type { ClaudeImage } from '../../cc/spawnClaude' import type { QaMessage } from '../editor/messages' +import type { PathSubject } from '../editor/pathPreview' import type { AskEnd, RunningAsk } from './asks' import type { AskRunner } from './claudeAsk' import type { QaEntry } from './qaMap' import { randomUUID } from 'node:crypto' -import { EventEmitter, Uri } from 'vscode' +import { Disposable, EventEmitter, Uri, window } from 'vscode' +import { pathQaLocation } from '../editor/pathPreview' import { FolderStore, locate } from '../folderStore' import { AskProfiles } from './askProfiles' import { QaAsks } from './asks' import { askClaude } from './claudeAsk' -import { isQaImageType, QA_IMAGE_DIR, writeQaImages } from './qaImages' +import { describePath } from './pathAskDescribe' +import { pathAskFollowUp, pathAskPrompt } from './pathAskPrompt' +import { parsePathQa, PATH_ASK_ANCHOR, PATH_QA_STATE_KEY, pathQaEntries, pathQaKey, withoutPathQa, withPathQa } from './pathQa' +import { isQaImageType, QA_IMAGE_DIR, removeQaImages, writeQaImages } from './qaImages' import { latestSession, QA_FILE, withEntry, withoutEntry } from './qaMap' /** 正在答的问题有了进展(update),或答完了(end;答成了带存下的那条的 id)。owner 是问的那份文件的 uri。 */ @@ -30,52 +35,72 @@ export interface AskRequest { } /** - * 代码问答:问 Claude Code、答完存进各工作区文件夹的 `.spx/code-qa.json`(改名、删文件、外部改动的跟随同文件注释,见 FolderStore)。 - * 问答挂在块的位置记号上(见 BlockAnchors),按文件取给预览。 + * 代码问答:块下面的问 Claude Code、答完存进各工作区文件夹的 `.spx/code-qa.json` + * (改名、删文件、外部改动的跟随同文件注释,见 FolderStore),挂在块的位置记号上。 + * 预览顶部问的是整个文件或文件夹,按路径记在这个窗口的 workspaceState 里,不进那份文件。 */ export class CodeQa implements Disposable { private readonly store = new FolderStore(QA_FILE) private readonly asks: QaAsks private readonly answered = new EventEmitter() + /** 顶部问答变了的那些文件或文件夹 */ + private readonly pathChanged = new EventEmitter() + /** 顶部问答的读写排成一队,两次同时答完不会互相盖掉 */ + private pathQueue: Promise = Promise.resolve() /** 问的时候用哪个 profile(所有预览共用,记在 globalState 里) */ readonly profiles: AskProfiles - /** 问答变了的那些文件 */ - readonly onDidChange = this.store.onDidChange + /** 块下面或顶部的问答变了的那些文件 / 文件夹 */ + readonly onDidChange: Event = (listener, thisArgs, disposables) => { + const subscription = Disposable.from( + this.store.onDidChange(listener, thisArgs), + this.pathChanged.event(listener, thisArgs), + ) + disposables?.push(subscription) + return subscription + } + readonly onDidAnswer = this.answered.event - constructor(state: Pick, runner: AskRunner = askClaude) { - this.profiles = new AskProfiles(state) + constructor( + profilesState: Pick, + private readonly paths: Pick, + runner: AskRunner = askClaude, + ) { + this.profiles = new AskProfiles(profilesState) this.asks = new QaAsks(runner) } - /** uri 这份文件的问答带的图片放在哪个目录(预览要能读到它才画得出缩略图);文件不在工作区文件夹里时没有。 */ + /** uri 这份文件或文件夹的问答带的图片放在哪个目录(预览要能读到它才画得出缩略图);不在工作区文件夹里时没有。 */ imageRoot(uri: Uri): Uri | undefined { - const at = locate(uri) + const at = pathQaLocation(uri) return at && Uri.joinPath(at.folder.uri, ...QA_IMAGE_DIR.split('/')) } /** - * 发给预览的这份文件的问答;文件不在工作区文件夹里时没地方存,问 AI 不可用。 + * 发给预览的问答。块下面的按块的记号;顶部的按这条路径,subject 说是文件还是文件夹。 + * 不在工作区文件夹里时没地方存,问 AI 不可用。 * 问题带的图片换成预览里能用的地址(imageUrl,即 webview.asWebviewUri)。 */ - view(uri: Uri, imageUrl: (file: Uri) => string): QaMessage { + view(uri: Uri, imageUrl: (file: Uri) => string, subject: PathSubject): QaMessage { const at = locate(uri) + const pathAt = pathQaLocation(uri) const threads = at ? this.store.get(at.folder, at.key) ?? [] : [] + const pathEntries = pathAt ? pathQaEntries(this.pathMap(), pathQaKey(pathAt.folder.uri.toString(), pathAt.key)) : [] const root = this.imageRoot(uri) + const show = ({ id, question, answer, createdAt, profile, images }: QaEntry) => ({ + id, + question, + answer, + createdAt, + ...(profile && { profile }), + ...(root && images && { images: images.map(name => imageUrl(Uri.joinPath(root, name))) }), + }) return { type: 'qa', - available: at !== undefined, - threads: threads.map(({ anchor, entries }) => ({ - anchor, - entries: entries.map(({ id, question, answer, createdAt, profile, images }) => ({ - id, - question, - answer, - createdAt, - ...(profile && { profile }), - ...(root && images && { images: images.map(name => imageUrl(Uri.joinPath(root, name))) }), - })), - })), + available: pathAt !== undefined, + subject, + threads: threads.map(({ anchor, entries }) => ({ anchor, entries: entries.map(show) })), + path: pathEntries.map(show), running: this.asks.runningFor(uri.toString()), } } @@ -135,12 +160,82 @@ export class CodeQa implements Disposable { }) } + /** + * 问这一整个文件或文件夹。材料是路径(文件再加开头若干行,文件夹再加这一层的名字), + * 答成了记在 workspaceState 里,不进按块存的那份文件。没答成不记。 + * 问过的接着最近一次的会话;进行中再问、问不了时返回给人看的原因。 + */ + async askPath(uri: Uri, kind: PathSubject, request: { askId: string, question: string, images: ClaudeImage[] }): Promise { + const at = pathQaLocation(uri) + if (!at) + return '这个路径不在工作区文件夹里,问答没地方存。' + const { askId, question, images } = request + if (!images.every(image => isQaImageType(image.mediaType))) + return '只能带 PNG、JPEG、GIF、WebP 图片。' + const described = await describePath({ uri, folder: at.folder, isDirectory: kind === 'folder', relativePath: at.key }) + const profile = await this.profiles.current() + const owner = uri.toString() + const key = pathQaKey(at.folder.uri.toString(), at.key) + const sessionId = pathQaEntries(this.pathMap(), key).at(-1)?.sessionId + const prompt = pathAskPrompt(described, question) + const followUp = pathAskFollowUp(described.relativePath, kind, question) + return this.asks.start({ + owner, + askId, + anchor: PATH_ASK_ANCHOR, + question, + run: { + prompt, + images, + ...(sessionId && { followUp: { sessionId, prompt: followUp } }), + ...(profile && { profilePath: profile.path }), + cwd: at.folder.uri.fsPath, + }, + }, { + update: ask => this.answered.fire({ owner, kind: 'update', ask }), + end: async (ask, end) => { + if (end.status !== 'done') { + this.answered.fire({ owner, kind: 'end', ask, end }) + return + } + const id = randomUUID() + let names: string[] + try { + names = await writeQaImages(at.folder.uri.fsPath, id, images) + } + catch (err) { + const error = `回答没存上:图片写不进 ${QA_IMAGE_DIR}(${err instanceof Error ? err.message : String(err)})` + this.answered.fire({ owner, kind: 'end', ask, end: { status: 'failed', error } }) + return + } + const entry: QaEntry = { + id, + question, + answer: end.answer.text, + createdAt: new Date().toISOString(), + sessionId: end.answer.sessionId, + ...(profile && { profile: profile.name }), + ...(names.length > 0 && { images: names }), + } + await this.enqueuePath(async () => { + await this.paths.update(PATH_QA_STATE_KEY, withPathQa(this.pathMap(), key, entry)) + }) + this.answered.fire({ owner, kind: 'end', ask, end, entryId: entry.id }) + this.pathChanged.fire([uri]) + }, + }) + } + stop(askId: string): void { this.asks.stop(askId) } - /** 删掉一次问答,不用确认;块下面删光了这块就不挂了,带的图片跟着删(见 QA_FILE.attachments)。 */ + /** 删掉一次问答,不用确认。块下面的从按块存的文件里删(图片跟着删);顶部的从路径记录里删。 */ async delete(uri: Uri, anchor: string, entryId: string): Promise { + if (anchor === PATH_ASK_ANCHOR) { + await this.deletePath(uri, entryId) + return + } const at = locate(uri) if (at) await this.store.update(at.folder, map => withoutEntry(map, at.key, anchor, entryId)) @@ -149,6 +244,36 @@ export class CodeQa implements Disposable { dispose(): void { this.asks.stopAll() this.answered.dispose() + this.pathChanged.dispose() this.store.dispose() } + + private pathMap() { + return parsePathQa(this.paths.get(PATH_QA_STATE_KEY)) + } + + private enqueuePath(task: () => Promise): Promise { + const run = this.pathQueue.then(task) + this.pathQueue = run.catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + void window.showErrorMessage(`顶部问答没存上:${message}`) + }) + return run + } + + /** 删顶部的一次问答和它带的图片,然后告诉开着的预览。 */ + private async deletePath(uri: Uri, entryId: string): Promise { + const at = pathQaLocation(uri) + if (!at) + return + const key = pathQaKey(at.folder.uri.toString(), at.key) + let images: string[] = [] + await this.enqueuePath(async () => { + const map = this.pathMap() + images = pathQaEntries(map, key).find(entry => entry.id === entryId)?.images ?? [] + await this.paths.update(PATH_QA_STATE_KEY, withoutPathQa(map, key, entryId)) + }) + await removeQaImages(at.folder.uri.fsPath, images) + this.pathChanged.fire([uri]) + } } diff --git a/vscode/src/codeReader/qa/pathAskDescribe.ts b/vscode/src/codeReader/qa/pathAskDescribe.ts new file mode 100644 index 0000000..9195fe6 --- /dev/null +++ b/vscode/src/codeReader/qa/pathAskDescribe.ts @@ -0,0 +1,35 @@ +/** + * 顶部问答带给模型的材料:文件是相对路径加开头若干行,文件夹是相对路径加这一层的名字。 + */ + +import type { Uri, WorkspaceFolder } from 'vscode' +import type { ExcludeSetting } from '../explorer/fileEntries' +import type { PathAskSubject, PathChild } from './pathAskPrompt' +import { FileType, workspace } from 'vscode' +import { createExcludeMatcher, sortEntries } from '../explorer/fileEntries' +import { readFileHead } from './pathAskContext' +import { displayRelativePath } from './pathQa' + +export async function describePath(target: { uri: Uri, folder: WorkspaceFolder, isDirectory: boolean, relativePath: string }): Promise { + const relativePath = displayRelativePath(target.relativePath) + if (target.isDirectory) + return { kind: 'folder', relativePath, children: await folderChildren(target.uri, target.folder, target.relativePath) } + return { kind: 'file', relativePath, head: await readFileHead(target.uri.fsPath) } +} + +/** 文件夹这一层的子文件和子目录;files.exclude 排除的不列。列不出来时只留原因。 */ +async function folderChildren(uri: Uri, folder: WorkspaceFolder, relativePath: string): Promise { + let entries: [string, FileType][] + try { + entries = await workspace.fs.readDirectory(uri) + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { unavailable: `列不出来(${message})。` } + } + const excluded = createExcludeMatcher(workspace.getConfiguration('files', folder).get('exclude')) + const names = new Set(entries.map(([name]) => name)) + return sortEntries(entries.map(([name, type]) => ({ name, isDirectory: (type & FileType.Directory) !== 0 }))) + .filter(entry => !excluded(relativePath ? `${relativePath}/${entry.name}` : entry.name, names)) + .map(entry => ({ name: entry.name, kind: entry.isDirectory ? 'folder' : 'file' })) +} diff --git a/vscode/src/codeReader/qa/pathAskPrompt.ts b/vscode/src/codeReader/qa/pathAskPrompt.ts index fff89fe..1085004 100644 --- a/vscode/src/codeReader/qa/pathAskPrompt.ts +++ b/vscode/src/codeReader/qa/pathAskPrompt.ts @@ -44,6 +44,12 @@ export function pathAskPrompt(subject: PathAskSubject, question: string): string return [LEAD, where, detail, `问题:${question}`].join('\n\n') } +/** 这条路径已经问过、接着上一次会话问时发的话:只说还是这个文件或文件夹,材料在会话里。 */ +export function pathAskFollowUp(relativePath: string, kind: 'file' | 'folder', question: string): string { + const noun = kind === 'folder' ? '文件夹' : '文件' + return `接着问(还是 ${relativePath} 这个${noun},需要时重新读):${question}` +} + function fileHead(head: PathHead): string { if ('unavailable' in head) return `文件开头没附上:${head.unavailable}` diff --git a/vscode/src/codeReader/qa/pathQa.test.ts b/vscode/src/codeReader/qa/pathQa.test.ts new file mode 100644 index 0000000..791e4f1 --- /dev/null +++ b/vscode/src/codeReader/qa/pathQa.test.ts @@ -0,0 +1,34 @@ +import type { QaEntry } from './qaMap' +import { describe, expect, it } from 'vitest' +import { parsePathQa, pathQaEntries, pathQaKey, relativeQaKey, withoutPathQa, withPathQa } from './pathQa' + +function entry(id: string): QaEntry { + return { id, question: `问 ${id}`, answer: `答 ${id}`, createdAt: '2026-10-02T00:00:00.000Z', sessionId: `s-${id}` } +} + +const FOLDER = 'file:///work/app' +const FILE = pathQaKey(FOLDER, 'src/main.ts') +const DIR = pathQaKey(FOLDER, 'src') + +describe('顶部问答按路径记', () => { + it('同一路径再读回上次的问答,另一条路径读不到', () => { + const stored = withPathQa(withPathQa({}, FILE, entry('1')), FILE, entry('2')) + const again = parsePathQa(JSON.parse(JSON.stringify(stored))) + expect(pathQaEntries(again, FILE).map(item => item.question)).toEqual(['问 1', '问 2']) + expect(pathQaEntries(again, DIR)).toEqual([]) + expect(pathQaEntries(again, pathQaKey('file:///work/other', 'src/main.ts'))).toEqual([]) + }) + + it('文件夹和文件各记各的;删一条不影响另一条', () => { + const stored = withoutPathQa(withPathQa(withPathQa({}, FILE, entry('f')), DIR, entry('d')), FILE, 'f') + expect(pathQaEntries(stored, FILE)).toEqual([]) + expect(pathQaEntries(stored, DIR).map(item => item.id)).toEqual(['d']) + }) + + it('工作区根是空相对路径,走不出去的路径没有键', () => { + expect(relativeQaKey('/work/app', '/work/app')).toBe('') + expect(relativeQaKey('/work/app', '/work/app/src/main.ts')).toBe('src/main.ts') + expect(relativeQaKey('/work/app', '/work/other')).toBeUndefined() + expect(pathQaKey(FOLDER, '')).not.toBe(pathQaKey(FOLDER, 'src')) + }) +}) diff --git a/vscode/src/codeReader/qa/pathQa.ts b/vscode/src/codeReader/qa/pathQa.ts new file mode 100644 index 0000000..95adeab --- /dev/null +++ b/vscode/src/codeReader/qa/pathQa.ts @@ -0,0 +1,97 @@ +/** + * 预览顶部的问答:按「工作区文件夹 + 相对路径」记在这个窗口的 workspaceState 里。 + * 文件和文件夹各用各的路径;不写进按块存的 `.spx/code-qa.json`。 + */ + +import type { QaEntry } from './qaMap' +import * as path from 'node:path' +import { normalizeKey } from '../pathMap' + +/** workspaceState 里这份表的键 */ +export const PATH_QA_STATE_KEY = 'codeReader.pathQa' + +/** + * 顶部这一轮的记号。块的记号是「种类 名字」,不会是这个, + * 所以页面上按记号取问答时,顶部的不会挂到某一块下面。 + */ +export const PATH_ASK_ANCHOR = '\0path' + +/** 工作区文件夹 Uri → 那条路径上的问答(按问的先后) */ +export type PathQaMap = Record + +/** 工作区根的相对路径是空字符串,跟任何一个文件或子目录都不同。 */ +export function relativeQaKey(rootPath: string, targetPath: string): string | undefined { + const relative = path.posix.relative(rootPath, targetPath) + if (relative === '') + return '' + return normalizeKey(relative) +} + +/** 同一工作区文件夹、同一相对路径才是同一条记录。 */ +export function pathQaKey(folderUri: string, relativePath: string): string { + return `${folderUri}\n${relativePath}` +} + +/** 给人看的相对路径;工作区根写成 `.`。 */ +export function displayRelativePath(relativePath: string): string { + return relativePath === '' ? '.' : relativePath +} + +export function pathQaEntries(map: PathQaMap, key: string): QaEntry[] { + return map[key] ?? [] +} + +/** 新的一次问答接在这条路径已有的后面,别的路径不动。 */ +export function withPathQa(map: PathQaMap, key: string, entry: QaEntry): PathQaMap { + return { ...map, [key]: [...pathQaEntries(map, key), entry] } +} + +/** 删掉这一次;这条路径问光了就把键拿掉。没有这一次时原样返回。 */ +export function withoutPathQa(map: PathQaMap, key: string, entryId: string): PathQaMap { + const entries = pathQaEntries(map, key) + const kept = entries.filter(entry => entry.id !== entryId) + if (kept.length === entries.length) + return map + if (kept.length === 0) { + const next = { ...map } + delete next[key] + return next + } + return { ...map, [key]: kept } +} + +/** 读 workspaceState 里存的表;认不出的丢掉,不让一条坏数据把整份弄丢。 */ +export function parsePathQa(value: unknown): PathQaMap { + if (!isRecord(value)) + return {} + const map: PathQaMap = {} + for (const [key, entries] of Object.entries(value)) { + if (!Array.isArray(entries)) + continue + const parsed = entries.flatMap((entry) => { + const one = parseEntry(entry) + return one ? [one] : [] + }) + if (parsed.length > 0) + map[key] = parsed + } + return map +} + +function parseEntry(value: unknown): QaEntry | undefined { + if (!isRecord(value)) + return undefined + const { id, question, answer, createdAt, sessionId, profile, images } = value + if (typeof id !== 'string' || typeof question !== 'string' || typeof answer !== 'string' || typeof createdAt !== 'string' || typeof sessionId !== 'string') + return undefined + const entry: QaEntry = { id, question, answer, createdAt, sessionId } + if (typeof profile === 'string' && profile !== '') + entry.profile = profile + if (Array.isArray(images) && images.every(name => typeof name === 'string')) + entry.images = images + return entry +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/vscode/src/codeReader/qa/qaImages.ts b/vscode/src/codeReader/qa/qaImages.ts index 9bc1dd0..e3c955f 100644 --- a/vscode/src/codeReader/qa/qaImages.ts +++ b/vscode/src/codeReader/qa/qaImages.ts @@ -38,3 +38,10 @@ export async function writeQaImages(rootPath: string, entryId: string, images: r await Promise.all(images.map((image, index) => fsp.writeFile(path.join(dir, names[index]), Buffer.from(image.base64, 'base64')))) return names } + +/** 删掉这条问答带的图片。名字不是这边起的不碰;文件已经没了也当删完。 */ +export async function removeQaImages(rootPath: string, names: readonly string[]): Promise { + await Promise.all(names.filter(isQaImageName).map(name => + fsp.unlink(path.join(rootPath, ...QA_IMAGE_DIR.split('/'), name)).catch(() => undefined), + )) +} diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index 436f791..d07ab33 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -6,6 +6,7 @@ import type { OccurrencePage } from './useOccurrences' import type { ViewState } from './viewState' import { TriangleAlert } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { AskThread } from './AskThread' import { BlockLevel } from './BlockTree' import { DiffBar, NewFileBanner } from './DiffBar' import { changeStops, diffTree, foldUnchanged, openedByDiff } from './diffView' @@ -14,6 +15,7 @@ import { coveredByEditing, startEditing } from './editing' import { applyFilter, excludedBy, keptWhileEditing } from './filter' import { FilterBar } from './FilterBar' import { OverviewRuler } from './OverviewRuler' +import { asksOf, historyOf, PATH_ASK_ANCHOR } from './qa' import { useChangeNav } from './useChangeNav' import { useDefinitionLinks } from './useDefinitionLinks' import { HighlightContext, useOccurrences } from './useOccurrences' @@ -39,6 +41,8 @@ function savedBlockModes(): BlockModes { export function App(): ReactElement | null { // 先于下面发 ready 的那个 effect 挂上收消息:ready 后扩展端马上发问答 const qa = useQa() + /** 侧栏「问 AI」要聚焦顶部输入框时加一 */ + const [pathFocus, setPathFocus] = useState(0) /** 按注释规范重写的过程,只在这一页上 */ const rewrites = useRewrite() const [doc, setDoc] = useState(null) @@ -121,6 +125,9 @@ export function App(): ReactElement | null { return case 'diff': setDiff(msg.diff) + return + case 'focusPathAsk': + setPathFocus(n => n + 1) } }) postMessage({ type: 'ready' }) @@ -191,6 +198,25 @@ export function App(): ReactElement | null { /> {changes && diff && }
+ {qa.state.available && ( +
+ qa.onAsk(PATH_ASK_ANCHOR, question, images)} + onStop={qa.onStop} + onDelete={entryId => qa.onDelete(PATH_ASK_ANCHOR, entryId)} + onClose={() => undefined} + /> +
+ )} {diff?.newFile !== undefined && doc.error === undefined && } {doc.error !== undefined && (
@@ -205,7 +231,7 @@ export function App(): ReactElement | null {
)} {doc.blocks.length === 0 - ? doc.error === undefined &&
这个文件里没有代码。
+ ? doc.error === undefined && qa.state.subject !== 'folder' &&
这个文件里没有代码。
: doc.blocks.some(block => !filter.hidden.has(block.id)) ? :
所有块都被筛选隐藏了。
} diff --git a/vscode/webview-ui/src/codeReader/AskThread.tsx b/vscode/webview-ui/src/codeReader/AskThread.tsx index c300040..6d3e3cb 100644 --- a/vscode/webview-ui/src/codeReader/AskThread.tsx +++ b/vscode/webview-ui/src/codeReader/AskThread.tsx @@ -138,22 +138,38 @@ function CurrentItem({ ask, onOpenImage, onStop }: { ask: Ask, onOpenImage: (url } /** 底下的输入框:Enter 发送、Shift+Enter 换行、Esc 收起;正在答时发送换成停止。能贴图、拖图,贴的图在输入框上面一排,随下一个问题发 */ -function AskInput({ running, first, profiles, onSelectProfile, onAsk, onStop, onClose, onOpenImage }: { +function AskInput({ running, first, hint, profiles, onSelectProfile, onAsk, onStop, onClose, closable, onOpenImage, autoFocus, focusToken }: { running: Ask | undefined - /** 这块还没问过:提示「问这块代码…」,问过了是「继续问…」 */ + /** 还没问过:提示「问…」,问过了是「继续问…」 */ first: boolean + /** 「问」后面的对象:块下面是「这块代码」,顶部是「这个文件」或「这个文件夹」 */ + hint: string profiles: ProfileChoice onSelectProfile: (id: string) => void onAsk: (question: string, images: PendingImage[]) => void onStop: (askId: string) => void onClose: () => void + /** Esc 收起。顶部那条一直摊开,不收 */ + closable: boolean onOpenImage: (url: string) => void + /** 展开时把光标放进输入框。顶部那条一直摊开,不要抢阅读时光标 */ + autoFocus: boolean + /** 大于 0 时再放一次光标;侧栏「问 AI」打开已经在的预览时用 */ + focusToken: number }): ReactElement { const [value, setValue] = useState('') const [images, setImages] = useState([]) /** 贴的、拖的有没加上的:给人看的原因,下次贴图或发送时清掉 */ const [notice, setNotice] = useState() const textarea = useRef(null) + useEffect(() => { + if (autoFocus) + textarea.current?.focus() + }, [autoFocus]) + useEffect(() => { + if (focusToken > 0) + textarea.current?.focus() + }, [focusToken]) const attach = (files: File[]): void => { if (files.length === 0) return @@ -179,7 +195,7 @@ function AskInput({ running, first, profiles, onSelectProfile, onAsk, onStop, on event.preventDefault() send() } - else if (event.key === 'Escape') { + else if (closable && event.key === 'Escape') { event.preventDefault() onClose() } @@ -211,9 +227,8 @@ function AskInput({ running, first, profiles, onSelectProfile, onAsk, onStop, on