From 416861fc75574bdb16776111c66da34bc3eb9b74 Mon Sep 17 00:00:00 2001 From: cruldra Date: Thu, 20 Aug 2026 13:53:12 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20URI=20handler=20?= =?UTF-8?q?=E5=A4=96=E9=83=A8=E8=A7=A6=E5=8F=91=E5=BC=80=20claude=20?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/package.json | 3 +- vscode/src/extension.ts | 4 + vscode/src/panel/handlers/managedSessions.ts | 92 ++++++++++++------- vscode/src/panel/handlers/terminals.ts | 2 +- vscode/src/uri/createSessionUriHandler.ts | 94 ++++++++++++++++++++ 5 files changed, 163 insertions(+), 32 deletions(-) create mode 100644 vscode/src/uri/createSessionUriHandler.ts diff --git a/vscode/package.json b/vscode/package.json index 2ba9274..42e38b8 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -32,7 +32,8 @@ "activationEvents": [ "onView:superpowers.kanban", "onCommand:superpowers.openKanban", - "onCommand:superpowers.setGiteaToken" + "onCommand:superpowers.setGiteaToken", + "onUri" ], "contributes": { "viewsContainers": { diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 8745438..4f56b85 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -9,6 +9,7 @@ import { initIdentity } from './auth/identity' import { setProfilesDirOverride } from './cc/profiles' import { KanbanWebviewPanel } from './panel/KanbanPanel' import { getSettings } from './settings/store' +import { registerCreateSessionUriHandler } from './uri/createSessionUriHandler' import { webhookCoordinator } from './webhook/coordinator' /** @@ -40,6 +41,9 @@ export function activate(context: ExtensionContext): void { // 状态栏常驻展示当前 Gitea 身份;token 用错人(同步/复制)时第一眼能看出来。 void initIdentity(context) + // vscode://clurdra.superpowers-vscode-clurdra/create-session 外部触发开 cc 会话 tab。 + registerCreateSessionUriHandler(context) + const treeView = window.createTreeView('superpowers.kanban', { treeDataProvider: new EmptyTreeProvider(), showCollapseAll: false, diff --git a/vscode/src/panel/handlers/managedSessions.ts b/vscode/src/panel/handlers/managedSessions.ts index 74bfa75..f1d6203 100644 --- a/vscode/src/panel/handlers/managedSessions.ts +++ b/vscode/src/panel/handlers/managedSessions.ts @@ -8,6 +8,7 @@ import { listImportableNamedSessions } from '../../sessions/importableNamedSessi import { readManagedSessions, writeManagedSessions } from '../../sessions/managedStore' import { inferProfilePathForSession } from '../../sessions/sessionProfileInference' import { resolveProfilePath } from '../../cc/profiles' +import { resolveTerminalLocation } from './terminals' /** 默认会话名:有 prompt 取前 20 字符,否则用短 id(前 8 位)。 */ function defaultSessionName(sessionId: string, prompt?: string): string { @@ -53,42 +54,51 @@ export async function handleManagedSessionsGet(panel: KanbanWebviewPanel): Promi await pushManagedSessions(panel) } +export interface CreateCcSessionTabOptions { + workspaceRoot: string + /** 传给 resolveProfilePath 的原始 profile 路径(空串走默认 profile)。 */ + profilePath: string + name?: string + prompt?: string + /** 终端 cwd;缺省 workspaceRoot(会话 jsonl 落在该目录对应的 projects 子目录)。 */ + cwd?: string + /** 有 panel 时登记存活终端并推全量列表;panel 外触发(如 URI handler)传 null。 */ + panel: KanbanWebviewPanel | null +} + /** - * 从会话管理 tab 创建一个新的 cc 会话: - * - cwd 用项目根(workspaceRoot,非 worktree)。 + * 创建 cc 会话终端 tab 并登记 managed store(核心逻辑,不要求 panel): * - 启动命令统一由 buildCcCommand 构建(claude --dangerously-skip-permissions * --settings ''),prompt 非空时作为位置参数传入。 * - 用 pollForNewSession 捕获新 sessionId(共享 projects 目录用轮询更可靠), - * 落进 .spx/session-names.json 后推全量列表。 + * 落进 workspaceRoot 的 .spx/session-names.json;有 panel 时再登记终端并推全量列表。 + * + * 校验失败时 showErrorMessage 并返回 undefined;成功返回终端名。 */ -export async function handleManagedSessionsCreate(panel: KanbanWebviewPanel, profilePath: string, name?: string, prompt?: string): Promise { - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) { - void window.showErrorMessage('请先打开一个工作区文件夹') - return - } +export async function createCcSessionTab(opts: CreateCcSessionTabOptions): Promise<{ terminalName: string } | undefined> { + const { workspaceRoot, panel } = opts const effectiveProfilePath = resolveProfilePath( - profilePath && profilePath.trim() !== '' ? profilePath : undefined, + opts.profilePath && opts.profilePath.trim() !== '' ? opts.profilePath : undefined, ) // 单引号会破坏下面的 shell 单引号包裹,防御性拒绝(与现有 handler 一致)。 if (effectiveProfilePath.includes('\'')) { void window.showErrorMessage( `创建会话失败:profilePath 含单引号,拒绝执行 (${effectiveProfilePath})`, ) - return + return undefined } - const trimmedName = (name ?? '').trim() + const trimmedName = (opts.name ?? '').trim() if (trimmedName.includes('\'')) { void window.showErrorMessage('创建会话失败:会话名字含单引号,拒绝执行') - return + return undefined } - const trimmedPrompt = (prompt ?? '').trim() + const trimmedPrompt = (opts.prompt ?? '').trim() if (trimmedPrompt.includes('\'')) { void window.showErrorMessage('创建会话失败:首个提示词含单引号,拒绝执行') - return + return undefined } // 首个提示词:填了 prompt 用 prompt;否则填了 name 用 /rename ;否则交互式无提示词。 @@ -98,8 +108,9 @@ export async function handleManagedSessionsCreate(panel: KanbanWebviewPanel, pro else if (trimmedName) effectivePrompt = `/rename ${trimmedName}` - // 项目根的 claude projects 子目录;先 mkdir 让 watcher 不会错过 create 事件。 - const projDir = projectsDirFor(workspaceRoot) + // cc 按进程 cwd 决定 projects 子目录;先 mkdir 让 watcher 不会错过 create 事件。 + const effectiveCwd = opts.cwd ?? workspaceRoot + const projDir = projectsDirFor(effectiveCwd) try { await fsp.mkdir(projDir, { recursive: true }) } @@ -114,8 +125,8 @@ export async function handleManagedSessionsCreate(panel: KanbanWebviewPanel, pro // (VS Code 终端创建后不能改名,所以只在 createTerminal 时设定。) const terminal = window.createTerminal({ name: trimmedName || 'cc-会话', - cwd: workspaceRoot, - location: panel.resolveTerminalLocation(false), + cwd: effectiveCwd, + location: resolveTerminalLocation(panel, false), }) terminal.show(false) logger.add({ @@ -126,15 +137,8 @@ export async function handleManagedSessionsCreate(panel: KanbanWebviewPanel, pro const cmd = buildCcCommand({ profilePath: effectiveProfilePath, prompt: effectivePrompt || undefined }) terminal.sendText(cmd) - logger.add({ - level: 'info', - source: 'panel', - message: '已从会话管理 tab 启动 cc 会话', - }) - void window.showInformationMessage('已创建 cc 会话') - - // Fire-and-forget:会话 jsonl 出现后写进 store、登记终端并推全量列表。 + // Fire-and-forget:会话 jsonl 出现后写进 store;有 panel 再登记终端并推全量列表。 watchPromise.then(async (sid) => { if (!sid) { logger.add({ @@ -158,9 +162,11 @@ export async function handleManagedSessionsCreate(panel: KanbanWebviewPanel, pro createdAt: Date.now(), }) await writeManagedSessions(workspaceRoot, data) - // 捕获到 sid 后登记终端,再推 show,让列表的 tabOpen 立即为 true。 - panel.managedTerminals.set(sid, terminal) - await pushManagedSessions(panel) + if (panel) { + // 捕获到 sid 后登记终端,再推 show,让列表的 tabOpen 立即为 true。 + panel.managedTerminals.set(sid, terminal) + await pushManagedSessions(panel) + } } catch (err) { console.warn('[superpowers] failed to persist managed session:', err) @@ -168,6 +174,32 @@ export async function handleManagedSessionsCreate(panel: KanbanWebviewPanel, pro }).catch((err) => { console.warn('[superpowers] managed session watch failed:', err) }) + + return { terminalName: terminal.name } +} + +/** + * 从会话管理 tab 创建一个新的 cc 会话: + * - cwd 用项目根(workspaceRoot,非 worktree)。 + * - 其余逻辑(组命令 / 捕获 sessionId / store 登记)在 createCcSessionTab。 + */ +export async function handleManagedSessionsCreate(panel: KanbanWebviewPanel, profilePath: string, name?: string, prompt?: string): Promise { + const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath + if (!workspaceRoot) { + void window.showErrorMessage('请先打开一个工作区文件夹') + return + } + + const created = await createCcSessionTab({ workspaceRoot, profilePath, name, prompt, panel }) + if (!created) + return + + logger.add({ + level: 'info', + source: 'panel', + message: '已从会话管理 tab 启动 cc 会话', + }) + void window.showInformationMessage('已创建 cc 会话') } /** diff --git a/vscode/src/panel/handlers/terminals.ts b/vscode/src/panel/handlers/terminals.ts index e74ce93..838ea92 100644 --- a/vscode/src/panel/handlers/terminals.ts +++ b/vscode/src/panel/handlers/terminals.ts @@ -7,7 +7,7 @@ import type { KanbanWebviewPanel } from '../KanbanPanel' import { ViewColumn, window } from 'vscode' import { logger } from '../../logging/logger' -export function resolveTerminalLocation(panel: KanbanWebviewPanel, preserveFocus: boolean): TerminalEditorLocationOptions { +export function resolveTerminalLocation(panel: KanbanWebviewPanel | null, preserveFocus: boolean): TerminalEditorLocationOptions { void panel // Pin all plugin-managed terminals to editor group 2 (right side of the // kanban panel in column 1). VS Code creates the group on demand if it diff --git a/vscode/src/uri/createSessionUriHandler.ts b/vscode/src/uri/createSessionUriHandler.ts new file mode 100644 index 0000000..6eb2024 --- /dev/null +++ b/vscode/src/uri/createSessionUriHandler.ts @@ -0,0 +1,94 @@ +import type { ExtensionContext, Uri } from 'vscode' +import { promises as fsp } from 'node:fs' +import { window, workspace } from 'vscode' +import { listClaudeProfiles } from '../cc/profiles' +import { logger } from '../logging/logger' +import { createCcSessionTab } from '../panel/handlers/managedSessions' + +/** + * 外部触发开 cc 会话 tab 的 URI 入口(供 delegate-fix skill 等外部方调用): + * + * vscode://clurdra.superpowers-vscode-clurdra/create-session + * ?profile= + * &name=<终端 tab 名,如 fix:fable5> + * &prompt=<初始 prompt> + * &cwd=<绝对路径,可选,缺省用 workspaceRoot> + * + * 编码契约:VS Code 把外部 URI 交给扩展前会对 query 整体做一次 percent-decode + * (vs/base/common/uri.ts 的 URI.parse),这里的 URLSearchParams 是第二次 decode。 + * 因此发送方对参数值必须做两层 urlencode,值里的 & = % + 等字符才能安全通过。 + */ +export function registerCreateSessionUriHandler(context: ExtensionContext): void { + context.subscriptions.push(window.registerUriHandler({ + handleUri: (uri: Uri): void => { + void handleCreateSessionUri(uri).catch((err) => { + void window.showErrorMessage(`处理 create-session URI 失败:${err instanceof Error ? err.message : String(err)}`) + }) + }, + })) +} + +async function handleCreateSessionUri(uri: Uri): Promise { + if (uri.path !== '/create-session') { + void window.showErrorMessage(`未知的 superpowers URI path: ${uri.path}`) + return + } + + const params = new URLSearchParams(uri.query) + const profileName = (params.get('profile') ?? '').trim() + const name = (params.get('name') ?? '').trim() + const prompt = (params.get('prompt') ?? '').trim() + const cwdParam = (params.get('cwd') ?? '').trim() + + if (!profileName) { + void window.showErrorMessage('create-session URI 缺少 profile 参数') + return + } + if (!prompt) { + void window.showErrorMessage('create-session URI 缺少 prompt 参数') + return + } + + const profiles = await listClaudeProfiles() + const profile = profiles.find(p => p.name === profileName) + if (!profile) { + void window.showErrorMessage( + `create-session URI:找不到 profile "${profileName}"(可用:${profiles.map(p => p.name).join(', ') || '无'})`, + ) + return + } + + const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath + if (!workspaceRoot) { + void window.showErrorMessage('请先打开一个工作区文件夹') + return + } + + let cwd = workspaceRoot + if (cwdParam) { + const stat = await fsp.stat(cwdParam).catch(() => undefined) + if (!stat?.isDirectory()) { + void window.showErrorMessage(`create-session URI:cwd 不存在或不是目录 (${cwdParam})`) + return + } + cwd = cwdParam + } + + const created = await createCcSessionTab({ + workspaceRoot, + profilePath: profile.path, + name, + prompt, + cwd, + panel: null, + }) + if (!created) + return + + logger.add({ + level: 'info', + source: 'uri', + message: `URI handler 已启动 cc 会话 tab "${created.terminalName}" (profile=${profileName})`, + }) + void window.showInformationMessage(`已开 cc 会话 tab "${created.terminalName}"`) +}