✨ feat(vscode): 头脑风暴会话一键续聊,终端开着直接打进去、关了带提示词恢复
详情面板「头脑风暴会话id」行的 ▶ 改为续聊:规划终端还开着就把续聊提示词 打进去;终端关了但有会话 id 就 `claude --resume <id>` 带提示词恢复;没会话 才新开。属性行右侧按钮改为数组,续聊与关闭 tab 可并存。 Claude-Session: https://claude.ai/code/session_01KDcq9HcrVLs2c9tkRxasub
This commit is contained in:
1 parent
90600cf070
commit
ae875756a9
15 files changed
+307
-297
No files matched your search
@@ -16,9 +16,13 @@ export interface CcCommandOpts {
|
||||
profilePath: string
|
||||
/** e.g. 'high' → `--effort high`; omitted means no `--effort`. */
|
||||
effort?: string
|
||||
/** When set, append `--resume <id>`. Mutually exclusive with prompt; resume wins. */
|
||||
/** When set, append `--resume <id>` so cc restores that conversation. */
|
||||
resumeSessionId?: string
|
||||
/** When set (and no resume), append the prompt as a positional `'<prompt>'` arg. */
|
||||
/**
|
||||
* When set, append the prompt as a positional `'<prompt>'` arg. Combined
|
||||
* with `resumeSessionId` it is submitted as the next message of the
|
||||
* restored conversation.
|
||||
*/
|
||||
prompt?: string
|
||||
}
|
||||
|
||||
@@ -31,11 +35,9 @@ export function buildCcCommand(opts: CcCommandOpts): string {
|
||||
if (opts.effort)
|
||||
parts.push(`--effort ${opts.effort}`)
|
||||
parts.push('--dangerously-skip-permissions', `--settings '${opts.profilePath}'`)
|
||||
// resume and prompt are mutually exclusive; a resumed session restores its
|
||||
// own conversation, so a fresh prompt would be meaningless.
|
||||
if (opts.resumeSessionId)
|
||||
parts.push(`--resume ${opts.resumeSessionId}`)
|
||||
else if (opts.prompt)
|
||||
if (opts.prompt)
|
||||
parts.push(`'${opts.prompt}'`)
|
||||
return parts.join(' ')
|
||||
}
|
||||
@@ -562,8 +562,8 @@ export class KanbanWebviewPanel {
|
||||
void issues.handleCloseIssue(this, msg.issueNumber)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'brainstorm/start') {
|
||||
void sessions.handleStartBrainstormSession(this, msg.issueNumber)
|
||||
if (msg.type === 'brainstorm/continue') {
|
||||
void sessions.handleContinueBrainstorm(this, msg.issueNumber)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'profiles/get') {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 「续聊」按钮的三条路径。终端还活着就直接把提示词打进去(不管有没有
|
||||
* 记录到 sessionId,新建工单流程里 id 可能还没落盘);终端没了但有
|
||||
* sessionId 就 `--resume` 并带上提示词;两者都没有才新开会话。
|
||||
*/
|
||||
export type BrainstormContinuePath = 'inject' | 'resume' | 'fresh'
|
||||
|
||||
export function pickBrainstormContinuePath(opts: { liveTerminal: boolean, sessionId: string | undefined }): BrainstormContinuePath {
|
||||
if (opts.liveTerminal)
|
||||
return 'inject'
|
||||
if (opts.sessionId)
|
||||
return 'resume'
|
||||
return 'fresh'
|
||||
}
|
||||
@@ -22,8 +22,10 @@ import { logger } from '../../logging/logger'
|
||||
import { getSettings } from '../../settings/store'
|
||||
import { webhookCoordinator } from '../../webhook/coordinator'
|
||||
import { decideAutoReviewPath } from '../autoReviewDecision'
|
||||
import { pickBrainstormContinuePath } from '../brainstormContinue'
|
||||
import { makeNonce } from '../KanbanPanel'
|
||||
import { cleanupFeatureBranch } from './branchCleanup'
|
||||
import { submitToSessionTerminal } from './terminals'
|
||||
|
||||
export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: string, profilePath?: string, relCwd?: string, issueNumber?: number): Promise<void> {
|
||||
// kind 跟着 sessionRole 提前判定(原来散落在方法中部,提到入口是为了构造
|
||||
@@ -446,15 +448,7 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: {
|
||||
terminal.show(false)
|
||||
// codex TUI already running inside this tab; sending the full
|
||||
// startup command would be received as a long redundant user message.
|
||||
// Mirror the two-step submit pattern from injectIntoImplTerminal:
|
||||
// push the body first, then send a standalone \r 250ms later so codex
|
||||
// TUI registers it as Enter (single-shot \r packed with the body is
|
||||
// sometimes consumed as a newline, not a submit).
|
||||
const body = 'PR 更新了,再审一下'
|
||||
terminal.sendText(body, false)
|
||||
setTimeout(() => {
|
||||
terminal.sendText('\r', false)
|
||||
}, 250)
|
||||
submitToSessionTerminal(terminal, 'PR 更新了,再审一下')
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'terminal',
|
||||
@@ -493,12 +487,8 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: {
|
||||
// before the TUI accepts input — empirically much slower than the
|
||||
// 250ms gap the reuse path uses. 8s is a conservative guess that
|
||||
// covers a cold codex start without feeling laggy.
|
||||
const body = 'PR 更新了,再审一下'
|
||||
setTimeout(() => {
|
||||
terminal.sendText(body, false)
|
||||
setTimeout(() => {
|
||||
terminal.sendText('\r', false)
|
||||
}, 250)
|
||||
submitToSessionTerminal(terminal, 'PR 更新了,再审一下')
|
||||
}, 8000)
|
||||
logger.add({
|
||||
level: 'info',
|
||||
@@ -954,34 +944,13 @@ export async function handleImplement(
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a fresh "规划" (brainstorm continuation) cc tab for an existing
|
||||
* issue whose body has no sessionId yet. Used when the issue was created
|
||||
* outside the panel (e.g. user ran spx from another cc session, body
|
||||
* lacks the nonce marker so the webhook took the "external" branch and
|
||||
* didn't link a terminal). Click the Play button next to the empty
|
||||
* "头脑风暴会话 id" row to spawn a new cc session anchored to this issue.
|
||||
*
|
||||
* Differs from `handleImplement`:
|
||||
* - No worktree / no feature branch (this is discussion, not coding).
|
||||
* - cwd = workspaceRoot (not a worktree).
|
||||
* - Watches the workspace-root `~/.claude/projects/...` dir for the new
|
||||
* session jsonl and writes the captured id back as `sessionId`
|
||||
* (not `implementSessionId`).
|
||||
* 详情面板「▶ 续聊」入口:把续聊 prompt 送进这个工单的头脑风暴会话。
|
||||
* - 规划终端还开着 → 直接打进去(含新建工单流程留下的 issue-new-* tab)
|
||||
* - 终端关了但 state JSON 有 sessionId → `--resume <id>` 并带上 prompt
|
||||
* - 两者都没有 → 新开会话,捕获到的 id 写回 `sessionId`
|
||||
* 全程在 workspaceRoot,不建 worktree / 分支(这是讨论,不是编码)。
|
||||
*/
|
||||
export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
|
||||
const terminalName = `issue-${issueNumber}-规划`
|
||||
const existing = panel.findExistingTerminal(terminalName)
|
||||
if (existing) {
|
||||
panel.trackSessionTerminal(existing, issueNumber, 'brainstorm')
|
||||
existing.show(false)
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'brainstorm',
|
||||
message: `复用已有规划终端 #${issueNumber},跳过 cc 启动`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
export async function handleContinueBrainstorm(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
|
||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
if (!workspaceRoot) {
|
||||
void window.showErrorMessage('请先打开一个工作区文件夹')
|
||||
@@ -1000,10 +969,13 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is
|
||||
|
||||
// 头脑风暴只用 brainstormProfilePath;空 = DEFAULT,不回退实施 profile。
|
||||
let brainstormProfilePath: string | undefined
|
||||
let sessionId: string | undefined
|
||||
try {
|
||||
const stateObj = await panel.readIssueState(issueNumber)
|
||||
if (stateObj && typeof stateObj.brainstormProfilePath === 'string' && stateObj.brainstormProfilePath.length > 0)
|
||||
brainstormProfilePath = stateObj.brainstormProfilePath
|
||||
if (stateObj && typeof stateObj.sessionId === 'string' && stateObj.sessionId.length > 0)
|
||||
sessionId = stateObj.sessionId
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({
|
||||
@@ -1014,32 +986,50 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is
|
||||
})
|
||||
}
|
||||
|
||||
const prompt = getBrainstormContinuePrompt(panel.context, { issueNumber, issueRef: panel.refFor(issueNumber).externalId })
|
||||
const terminalName = `issue-${issueNumber}-规划`
|
||||
// 新建工单流程的 tab 叫 issue-new-{nonce}-规划,只能靠 newIssueTerminals 反查。
|
||||
const linked = panel.newIssueTerminals.get(issueNumber)
|
||||
const live = linked && linked.exitStatus === undefined ? linked : panel.findExistingTerminal(terminalName)
|
||||
const continuePath = pickBrainstormContinuePath({ liveTerminal: !!live, sessionId })
|
||||
|
||||
if (continuePath === 'inject' && live) {
|
||||
panel.trackSessionTerminal(live, issueNumber, 'brainstorm')
|
||||
live.show(false)
|
||||
submitToSessionTerminal(live, prompt)
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'brainstorm',
|
||||
message: `已把续聊 prompt 打进 #${issueNumber} 的规划终端`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const effectiveProfilePath = resolveBrainstormProfilePath(panel, brainstormProfilePath)
|
||||
if (effectiveProfilePath.includes('\'')) {
|
||||
void window.showErrorMessage(
|
||||
`启动规划失败:brainstormProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`,
|
||||
`续聊失败:brainstormProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const prompt = getBrainstormContinuePrompt(panel.context, { issueNumber, issueRef: panel.refFor(issueNumber).externalId })
|
||||
if (prompt.includes('\'')) {
|
||||
void window.showErrorMessage('启动规划失败:prompt 含单引号,拒绝执行')
|
||||
void window.showErrorMessage('续聊失败:prompt 含单引号,拒绝执行')
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure the projects dir exists so the watcher can't miss the create
|
||||
// event. Workspace root (no worktree here).
|
||||
const projDir = projectsDirFor(workspaceRoot)
|
||||
try {
|
||||
await fsp.mkdir(projDir, { recursive: true })
|
||||
// resume 沿用原 id,只有新开会话才要盯着 session jsonl 出现;
|
||||
// 监听要在 createTerminal 之前挂上,否则会漏掉创建事件。
|
||||
let watchPromise: ReturnType<typeof watchForNewSession> | undefined
|
||||
if (continuePath === 'fresh') {
|
||||
const projDir = projectsDirFor(workspaceRoot)
|
||||
try {
|
||||
await fsp.mkdir(projDir, { recursive: true })
|
||||
}
|
||||
catch (err) {
|
||||
console.warn('[superpowers] failed to mkdir claude projects dir:', err)
|
||||
}
|
||||
watchPromise = watchForNewSession({ projectsDir: projDir, timeoutMs: 120_000 })
|
||||
}
|
||||
catch (err) {
|
||||
console.warn('[superpowers] failed to mkdir claude projects dir:', err)
|
||||
}
|
||||
|
||||
// Kick off the watcher before spawning the terminal so we don't race.
|
||||
const watchPromise = watchForNewSession({ projectsDir: projDir, timeoutMs: 120_000 })
|
||||
|
||||
const { themeColor, iconUri } = await panel.resolveIssueIcon(issueNumber)
|
||||
const terminal = window.createTerminal({
|
||||
@@ -1049,6 +1039,8 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is
|
||||
iconPath: iconUri,
|
||||
color: themeColor,
|
||||
})
|
||||
if (continuePath === 'resume' && sessionId)
|
||||
panel.terminals.set(sessionId, terminal)
|
||||
panel.trackSessionTerminal(terminal, issueNumber, 'brainstorm')
|
||||
terminal.show(false)
|
||||
logger.add({
|
||||
@@ -1057,18 +1049,21 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is
|
||||
message: `已创建终端 "${terminal.name}"`,
|
||||
})
|
||||
|
||||
const cmd = buildCcCommand({ profilePath: effectiveProfilePath, prompt })
|
||||
terminal.sendText(cmd)
|
||||
terminal.sendText(buildCcCommand({
|
||||
profilePath: effectiveProfilePath,
|
||||
resumeSessionId: continuePath === 'resume' ? sessionId : undefined,
|
||||
prompt,
|
||||
}))
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'brainstorm',
|
||||
message: `已发送规划 prompt 到终端 #${issueNumber}`,
|
||||
message: continuePath === 'resume'
|
||||
? `已带续聊 prompt 恢复 #${issueNumber} 规划会话 ${sessionId}`
|
||||
: `已发送规划 prompt 到终端 #${issueNumber}`,
|
||||
})
|
||||
|
||||
// Fire-and-forget: when the session jsonl materializes, persist the id
|
||||
// as the issue's brainstorm `sessionId` so the X / Play button toggles
|
||||
// and the user can later resume via the existing UI.
|
||||
watchPromise.then(async (sid) => {
|
||||
// 拿到 session jsonl 后把 id 写回 state JSON,详情面板据此切到 resume 入口。
|
||||
watchPromise?.then(async (sid) => {
|
||||
if (!sid) {
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
@@ -1104,7 +1099,7 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is
|
||||
* 手动启动一个"测试"cc 会话:工单 PR 合并后,用户在详情面板点测试会话行的
|
||||
* 启动按钮,开一个 claude tab 让 cc 先了解代码、再告诉用户怎么测试。
|
||||
*
|
||||
* 与 handleStartBrainstormSession 的差异:
|
||||
* 与 handleContinueBrainstorm 的差异:
|
||||
* - 首条 prompt 依赖 state JSON 里的 `pr`(合并的 PR 号);没有 PR 直接 toast 拒绝。
|
||||
* - cwd 优先用 worktree(存在时),否则退回 workspaceRoot——测试本质要读真实代码,
|
||||
* profile 走 resolveTestProfilePath(testProfilePath > 默认,不回退实施)。
|
||||
|
||||
@@ -56,19 +56,25 @@ export function injectIntoImplTerminal(panel: KanbanWebviewPanel, issueNumber: n
|
||||
}
|
||||
if (!terminal)
|
||||
return false
|
||||
// cc 的 TUI 在 raw 模式下,LF (\n) 只算输入框内的换行,CR (\r)
|
||||
// 才会被识别为 Enter(提交消息)。实测把多行内容 + 末尾 \r 在同一次
|
||||
// sendText 里发出去时,cc 进入多行输入模式后并不会把紧跟的 \r 当成
|
||||
// 提交键,结果就是反馈只粘贴到输入框、没提交。
|
||||
// 拆两次发:先把内容完整推进输入框,250ms 后再独立发一个 \r 作为 Enter。
|
||||
const body = `\n[审查反馈]\n${text}`
|
||||
terminal.sendText(body, false)
|
||||
setTimeout(() => {
|
||||
terminal!.sendText('\r', false)
|
||||
}, 250)
|
||||
submitToSessionTerminal(terminal, `\n[审查反馈]\n${text}`)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 往正在跑 cc / codex TUI 的终端里提交一条消息。
|
||||
*
|
||||
* TUI 在 raw 模式下,LF (\n) 只算输入框内的换行,CR (\r) 才会被识别为
|
||||
* Enter。实测多行内容 + 末尾 \r 在同一次 sendText 里发出去时,TUI 进入
|
||||
* 多行输入模式后不会把紧跟的 \r 当成提交键,内容只粘贴到输入框、没提交。
|
||||
* 所以拆两次发:先把内容完整推进输入框,250ms 后再独立发一个 \r 作为 Enter。
|
||||
*/
|
||||
export function submitToSessionTerminal(terminal: Terminal, text: string): void {
|
||||
terminal.sendText(text, false)
|
||||
setTimeout(() => {
|
||||
terminal.sendText('\r', false)
|
||||
}, 250)
|
||||
}
|
||||
|
||||
/**
|
||||
* 本机是否开着该工单的存活终端(exitStatus undefined)——webhook 门禁对
|
||||
* 无 assignee 存量工单的兜底证据。不依赖 panel:看板关闭时实施终端
|
||||
|
||||
@@ -220,7 +220,7 @@ export type WebviewToExtension
|
||||
| { type: 'env-lock/toggle' }
|
||||
| { type: 'issue/delete', issueNumber: number }
|
||||
| { type: 'issue/close', issueNumber: number }
|
||||
| { type: 'brainstorm/start', issueNumber: number }
|
||||
| { type: 'brainstorm/continue', issueNumber: number }
|
||||
| { type: 'profiles/get' }
|
||||
| { type: 'profiles/save', data: ProfilesData }
|
||||
| { type: 'profiles/open', value: string }
|
||||
|
||||
Reference in new issue
Block a user