✨ feat(vscode): URI handler 外部触发开 claude 会话 tab
This commit is contained in:
+2
-1
@@ -32,7 +32,8 @@
|
|||||||
"activationEvents": [
|
"activationEvents": [
|
||||||
"onView:superpowers.kanban",
|
"onView:superpowers.kanban",
|
||||||
"onCommand:superpowers.openKanban",
|
"onCommand:superpowers.openKanban",
|
||||||
"onCommand:superpowers.setGiteaToken"
|
"onCommand:superpowers.setGiteaToken",
|
||||||
|
"onUri"
|
||||||
],
|
],
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"viewsContainers": {
|
"viewsContainers": {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { initIdentity } from './auth/identity'
|
|||||||
import { setProfilesDirOverride } from './cc/profiles'
|
import { setProfilesDirOverride } from './cc/profiles'
|
||||||
import { KanbanWebviewPanel } from './panel/KanbanPanel'
|
import { KanbanWebviewPanel } from './panel/KanbanPanel'
|
||||||
import { getSettings } from './settings/store'
|
import { getSettings } from './settings/store'
|
||||||
|
import { registerCreateSessionUriHandler } from './uri/createSessionUriHandler'
|
||||||
import { webhookCoordinator } from './webhook/coordinator'
|
import { webhookCoordinator } from './webhook/coordinator'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,6 +41,9 @@ export function activate(context: ExtensionContext): void {
|
|||||||
// 状态栏常驻展示当前 Gitea 身份;token 用错人(同步/复制)时第一眼能看出来。
|
// 状态栏常驻展示当前 Gitea 身份;token 用错人(同步/复制)时第一眼能看出来。
|
||||||
void initIdentity(context)
|
void initIdentity(context)
|
||||||
|
|
||||||
|
// vscode://clurdra.superpowers-vscode-clurdra/create-session 外部触发开 cc 会话 tab。
|
||||||
|
registerCreateSessionUriHandler(context)
|
||||||
|
|
||||||
const treeView = window.createTreeView('superpowers.kanban', {
|
const treeView = window.createTreeView('superpowers.kanban', {
|
||||||
treeDataProvider: new EmptyTreeProvider(),
|
treeDataProvider: new EmptyTreeProvider(),
|
||||||
showCollapseAll: false,
|
showCollapseAll: false,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { listImportableNamedSessions } from '../../sessions/importableNamedSessi
|
|||||||
import { readManagedSessions, writeManagedSessions } from '../../sessions/managedStore'
|
import { readManagedSessions, writeManagedSessions } from '../../sessions/managedStore'
|
||||||
import { inferProfilePathForSession } from '../../sessions/sessionProfileInference'
|
import { inferProfilePathForSession } from '../../sessions/sessionProfileInference'
|
||||||
import { resolveProfilePath } from '../../cc/profiles'
|
import { resolveProfilePath } from '../../cc/profiles'
|
||||||
|
import { resolveTerminalLocation } from './terminals'
|
||||||
|
|
||||||
/** 默认会话名:有 prompt 取前 20 字符,否则用短 id(前 8 位)。 */
|
/** 默认会话名:有 prompt 取前 20 字符,否则用短 id(前 8 位)。 */
|
||||||
function defaultSessionName(sessionId: string, prompt?: string): string {
|
function defaultSessionName(sessionId: string, prompt?: string): string {
|
||||||
@@ -53,42 +54,51 @@ export async function handleManagedSessionsGet(panel: KanbanWebviewPanel): Promi
|
|||||||
await pushManagedSessions(panel)
|
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 会话:
|
* 创建 cc 会话终端 tab 并登记 managed store(核心逻辑,不要求 panel):
|
||||||
* - cwd 用项目根(workspaceRoot,非 worktree)。
|
|
||||||
* - 启动命令统一由 buildCcCommand 构建(claude --dangerously-skip-permissions
|
* - 启动命令统一由 buildCcCommand 构建(claude --dangerously-skip-permissions
|
||||||
* --settings '<profilePath>'),prompt 非空时作为位置参数传入。
|
* --settings '<profilePath>'),prompt 非空时作为位置参数传入。
|
||||||
* - 用 pollForNewSession 捕获新 sessionId(共享 projects 目录用轮询更可靠),
|
* - 用 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<void> {
|
export async function createCcSessionTab(opts: CreateCcSessionTabOptions): Promise<{ terminalName: string } | undefined> {
|
||||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
const { workspaceRoot, panel } = opts
|
||||||
if (!workspaceRoot) {
|
|
||||||
void window.showErrorMessage('请先打开一个工作区文件夹')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const effectiveProfilePath = resolveProfilePath(
|
const effectiveProfilePath = resolveProfilePath(
|
||||||
profilePath && profilePath.trim() !== '' ? profilePath : undefined,
|
opts.profilePath && opts.profilePath.trim() !== '' ? opts.profilePath : undefined,
|
||||||
)
|
)
|
||||||
// 单引号会破坏下面的 shell 单引号包裹,防御性拒绝(与现有 handler 一致)。
|
// 单引号会破坏下面的 shell 单引号包裹,防御性拒绝(与现有 handler 一致)。
|
||||||
if (effectiveProfilePath.includes('\'')) {
|
if (effectiveProfilePath.includes('\'')) {
|
||||||
void window.showErrorMessage(
|
void window.showErrorMessage(
|
||||||
`创建会话失败:profilePath 含单引号,拒绝执行 (${effectiveProfilePath})`,
|
`创建会话失败:profilePath 含单引号,拒绝执行 (${effectiveProfilePath})`,
|
||||||
)
|
)
|
||||||
return
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmedName = (name ?? '').trim()
|
const trimmedName = (opts.name ?? '').trim()
|
||||||
if (trimmedName.includes('\'')) {
|
if (trimmedName.includes('\'')) {
|
||||||
void window.showErrorMessage('创建会话失败:会话名字含单引号,拒绝执行')
|
void window.showErrorMessage('创建会话失败:会话名字含单引号,拒绝执行')
|
||||||
return
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmedPrompt = (prompt ?? '').trim()
|
const trimmedPrompt = (opts.prompt ?? '').trim()
|
||||||
if (trimmedPrompt.includes('\'')) {
|
if (trimmedPrompt.includes('\'')) {
|
||||||
void window.showErrorMessage('创建会话失败:首个提示词含单引号,拒绝执行')
|
void window.showErrorMessage('创建会话失败:首个提示词含单引号,拒绝执行')
|
||||||
return
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// 首个提示词:填了 prompt 用 prompt;否则填了 name 用 /rename <name>;否则交互式无提示词。
|
// 首个提示词:填了 prompt 用 prompt;否则填了 name 用 /rename <name>;否则交互式无提示词。
|
||||||
@@ -98,8 +108,9 @@ export async function handleManagedSessionsCreate(panel: KanbanWebviewPanel, pro
|
|||||||
else if (trimmedName)
|
else if (trimmedName)
|
||||||
effectivePrompt = `/rename ${trimmedName}`
|
effectivePrompt = `/rename ${trimmedName}`
|
||||||
|
|
||||||
// 项目根的 claude projects 子目录;先 mkdir 让 watcher 不会错过 create 事件。
|
// cc 按进程 cwd 决定 projects 子目录;先 mkdir 让 watcher 不会错过 create 事件。
|
||||||
const projDir = projectsDirFor(workspaceRoot)
|
const effectiveCwd = opts.cwd ?? workspaceRoot
|
||||||
|
const projDir = projectsDirFor(effectiveCwd)
|
||||||
try {
|
try {
|
||||||
await fsp.mkdir(projDir, { recursive: true })
|
await fsp.mkdir(projDir, { recursive: true })
|
||||||
}
|
}
|
||||||
@@ -114,8 +125,8 @@ export async function handleManagedSessionsCreate(panel: KanbanWebviewPanel, pro
|
|||||||
// (VS Code 终端创建后不能改名,所以只在 createTerminal 时设定。)
|
// (VS Code 终端创建后不能改名,所以只在 createTerminal 时设定。)
|
||||||
const terminal = window.createTerminal({
|
const terminal = window.createTerminal({
|
||||||
name: trimmedName || 'cc-会话',
|
name: trimmedName || 'cc-会话',
|
||||||
cwd: workspaceRoot,
|
cwd: effectiveCwd,
|
||||||
location: panel.resolveTerminalLocation(false),
|
location: resolveTerminalLocation(panel, false),
|
||||||
})
|
})
|
||||||
terminal.show(false)
|
terminal.show(false)
|
||||||
logger.add({
|
logger.add({
|
||||||
@@ -126,15 +137,8 @@ export async function handleManagedSessionsCreate(panel: KanbanWebviewPanel, pro
|
|||||||
|
|
||||||
const cmd = buildCcCommand({ profilePath: effectiveProfilePath, prompt: effectivePrompt || undefined })
|
const cmd = buildCcCommand({ profilePath: effectiveProfilePath, prompt: effectivePrompt || undefined })
|
||||||
terminal.sendText(cmd)
|
terminal.sendText(cmd)
|
||||||
logger.add({
|
|
||||||
level: 'info',
|
|
||||||
source: 'panel',
|
|
||||||
message: '已从会话管理 tab 启动 cc 会话',
|
|
||||||
})
|
|
||||||
|
|
||||||
void window.showInformationMessage('已创建 cc 会话')
|
// Fire-and-forget:会话 jsonl 出现后写进 store;有 panel 再登记终端并推全量列表。
|
||||||
|
|
||||||
// Fire-and-forget:会话 jsonl 出现后写进 store、登记终端并推全量列表。
|
|
||||||
watchPromise.then(async (sid) => {
|
watchPromise.then(async (sid) => {
|
||||||
if (!sid) {
|
if (!sid) {
|
||||||
logger.add({
|
logger.add({
|
||||||
@@ -158,16 +162,44 @@ export async function handleManagedSessionsCreate(panel: KanbanWebviewPanel, pro
|
|||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
})
|
})
|
||||||
await writeManagedSessions(workspaceRoot, data)
|
await writeManagedSessions(workspaceRoot, data)
|
||||||
|
if (panel) {
|
||||||
// 捕获到 sid 后登记终端,再推 show,让列表的 tabOpen 立即为 true。
|
// 捕获到 sid 后登记终端,再推 show,让列表的 tabOpen 立即为 true。
|
||||||
panel.managedTerminals.set(sid, terminal)
|
panel.managedTerminals.set(sid, terminal)
|
||||||
await pushManagedSessions(panel)
|
await pushManagedSessions(panel)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.warn('[superpowers] failed to persist managed session:', err)
|
console.warn('[superpowers] failed to persist managed session:', err)
|
||||||
}
|
}
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
console.warn('[superpowers] managed session watch failed:', 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<void> {
|
||||||
|
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 会话')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { KanbanWebviewPanel } from '../KanbanPanel'
|
|||||||
import { ViewColumn, window } from 'vscode'
|
import { ViewColumn, window } from 'vscode'
|
||||||
import { logger } from '../../logging/logger'
|
import { logger } from '../../logging/logger'
|
||||||
|
|
||||||
export function resolveTerminalLocation(panel: KanbanWebviewPanel, preserveFocus: boolean): TerminalEditorLocationOptions {
|
export function resolveTerminalLocation(panel: KanbanWebviewPanel | null, preserveFocus: boolean): TerminalEditorLocationOptions {
|
||||||
void panel
|
void panel
|
||||||
// Pin all plugin-managed terminals to editor group 2 (right side of the
|
// 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
|
// kanban panel in column 1). VS Code creates the group on demand if it
|
||||||
|
|||||||
@@ -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=<profile 名,不带 .json>
|
||||||
|
* &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<void> {
|
||||||
|
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}"`)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user