Files
superwork/vscode/src/panel/handlers/terminals.ts
T
cruldra ae875756a9 ✨ feat(vscode): 头脑风暴会话一键续聊,终端开着直接打进去、关了带提示词恢复
详情面板「头脑风暴会话id」行的 ▶ 改为续聊:规划终端还开着就把续聊提示词
打进去;终端关了但有会话 id 就 `claude --resume <id>` 带提示词恢复;没会话
才新开。属性行右侧按钮改为数组,续聊与关闭 tab 可并存。

Claude-Session: https://claude.ai/code/session_01KDcq9HcrVLs2c9tkRxasub
2026-09-03 01:16:41 +08:00

387 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
Terminal,
TerminalEditorLocationOptions,
} from 'vscode'
import type { Issue } from '../../gitea/types'
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { window } from 'vscode'
import { logger } from '../../logging/logger'
import { getSettings } from '../../settings/store'
import { terminalEditorColumn } from '../terminalColumn'
export function resolveTerminalLocation(panel: KanbanWebviewPanel | null, preserveFocus: boolean): TerminalEditorLocationOptions {
const split = panel ? getSettings(panel.context).splitNewTerminals : true
return { viewColumn: terminalEditorColumn(split), preserveFocus }
}
/**
* Inject review feedback into the implementation terminal for `issueNumber`.
* Called from the webhook coordinator's auto-review path. Returns `true`
* when a terminal was found and `sendText` was called; `false` otherwise
* (so the caller can log a warning).
*
* Falls back to looking up by terminal name when the in-memory map miss
* happens — e.g. after a window reload, the impl terminal might still be
* present in `window.terminals` but absent from `this.implTerminals`.
*/
export function injectIntoImplTerminal(panel: KanbanWebviewPanel, issueNumber: number, text: string, isFirstReview: boolean): boolean {
// `isFirstReview` 参数保留只为兼容签名(调用方仍按 first/再审 区分),
// 方法体本身不再消费它——合并改由用户拖工单到"完成"列时插件 API 触发,
// cc 不允许自行合并。
void isFirstReview
let terminal = panel.implTerminals.get(issueNumber)
if (!terminal) {
// Match by prefix — shell OSC title escapes can append a git branch
// suffix to terminal.name (e.g. "issue-48-实施 5f56026c").
const wantedPrefix = `issue-${issueNumber}-实施`
for (const t of window.terminals) {
if (t.name.startsWith(wantedPrefix)) {
terminal = t
panel.implTerminals.set(issueNumber, t)
trackSessionTerminal(panel, t, issueNumber, 'implement')
break
}
}
}
if (!terminal) {
// 实施会话未开但测试会话开着时,审查反馈交给测试会话。
// 临时使用,不写进 implTerminals(那是实施终端专属的 map)。
const testPrefix = `issue-${issueNumber}-测试`
for (const t of window.terminals) {
if (t.exitStatus === undefined && t.name.startsWith(testPrefix)) {
terminal = t
break
}
}
}
if (!terminal)
return false
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:看板关闭时实施终端
* 仍活着,归属判定不能因此失明。前缀匹配策略与 injectIntoImplTerminal
* 一致(shell OSC 会在名字后追加分支后缀)。
*/
export function hasLiveIssueSessionTerminal(
issueNumber: number,
kinds: readonly string[] = ['实施', '测试'],
): boolean {
const prefixes = kinds.map(k => `issue-${issueNumber}-${k}`)
return window.terminals.some(t =>
t.exitStatus === undefined && prefixes.some(p => t.name.startsWith(p)),
)
}
/**
* Scan `vscode.window.terminals` for an existing terminal whose name
* matches `expectedName`. Matches exact, or `startsWith(expectedName + ' ')`
* so a shell that appended a git branch suffix (e.g. "issue-48-实施
* 5f56026c") still counts. Skips terminals whose process already exited
* (`exitStatus !== undefined`) — those are zombie tabs and shouldn't block
* a fresh spawn.
*
* Why this exists: every session entry (`handleResumeSession`,
* `handleResumeReviewSession`, `handleImplement`) used to dedupe via its
* own in-memory Map (sessionId → Terminal). Panel reload / webview rebuild
* wipes those Maps, but the terminal stays alive in `window.terminals`,
* so re-clicking the link spawned a duplicate tab. Scanning the live
* terminal list survives reloads and prevents cross-map blind spots.
*/
export function findExistingTerminal(panel: KanbanWebviewPanel, expectedName: string): Terminal | undefined {
void panel
return window.terminals.find(
t =>
t.exitStatus === undefined
&& (t.name === expectedName || t.name.startsWith(`${expectedName} `)),
)
}
/**
* Focus an already-open terminal for `sessionId` without stealing focus
* from the kanban. Called when the webview's selection changes via arrow
* keys / clicks — if there's no terminal for this session yet, this is a
* no-op (user has to press Enter to spawn one).
*/
export function handleSessionFocus(panel: KanbanWebviewPanel, issueNumber: number): void {
// 如果这次 session/focus 是终端 tab 切换反选触发的回路
// (而不是用户主动点卡片切换工单),跳过优先级跳转,否则会把用户刚刚
// 点的"审查 tab"弹回到优先级更高的"实施 tab"。
const REVERSE_LOOP_WINDOW_MS = 200
if (
panel.lastReverseSelectIssueNumber === issueNumber
&& Date.now() - panel.lastReverseSelectAt < REVERSE_LOOP_WINDOW_MS
) {
return
}
// Priority 0: new-issue flow terminal whose name is `issue-new-{nonce}-规划`,
// not `issue-${N}-规划`. The webhook coordinator stitches issueNumber →
// terminal into `newIssueTerminals` via `linkPendingTerminalToIssue`.
// We can't rename the terminal tab (VS Code API limitation), so this
// side-map is the only way to find it by issueNumber.
const newIssueTerm = panel.newIssueTerminals.get(issueNumber)
if (newIssueTerm && newIssueTerm.exitStatus === undefined) {
if (newIssueTerm !== window.activeTerminal)
newIssueTerm.show(true)
return
}
// Priority 1-4: 实施 > 规划 > 审查 > 测试. Match by terminal.name since we know
// the convention (issue-${N}-实施 / issue-${N}-规划 / issue-${N}-审查 / issue-${N}-测试).
const namePriority = [
`issue-${issueNumber}-实施`,
`issue-${issueNumber}-规划`,
`issue-${issueNumber}-审查`,
`issue-${issueNumber}-测试`,
]
for (const name of namePriority) {
const term = findExistingTerminal(panel, name)
if (term) {
// preserveFocus 只在分组内切 tab、不激活分组,因此不会被当成
// 用户切到终端而反向选中;已是 active terminal 则无需重复 show。
if (term !== window.activeTerminal)
term.show(true)
return
}
}
// 找不到不报错,静默 return(用户没开过终端,正常)。
}
/**
* 反向选中:用户把焦点切到 column 2 的某个 terminal tab 时,从 terminal.name
* 解析 issueNumber 并通知 webview 选中对应工单。去重与回声抑制由调用方的
* `FocusedTerminalTracker` 负责,这里每次调用都视为一次真实切换。
*
* 终端名按 `issue-${N}-(规划|实施|审查|测试)` 命名;`issue-new-${nonce}-...`
* 是新建工单流程的占位 tab,没有 issue number,跳过。
*/
export function handleActiveTerminalChanged(panel: KanbanWebviewPanel, terminal: Terminal): void {
// Primary: `issue-${N}-(规划|实施|审查|测试)` — the steady-state naming.
const m = terminal.name.match(/^issue-(\d+)-(规划|实施|审查|测试)/)
if (m) {
const issueNumber = Number.parseInt(m[1], 10)
if (Number.isFinite(issueNumber)) {
panel.lastReverseSelectAt = Date.now()
panel.lastReverseSelectIssueNumber = issueNumber
panel.postMessage({ type: 'issue/select-by-number', issueNumber })
}
return
}
// Fallback: `issue-new-${shortNonce}-规划` — created via the new-issue flow
// before we knew the issueNumber. Reverse-scan `newIssueTerminals` (which
// was populated by the webhook coordinator via `linkPendingTerminalToIssue`).
if (terminal.name.startsWith('issue-new-')) {
for (const [num, term] of panel.newIssueTerminals) {
if (term === terminal) {
panel.lastReverseSelectAt = Date.now()
panel.lastReverseSelectIssueNumber = num
panel.postMessage({ type: 'issue/select-by-number', issueNumber: num })
return
}
}
}
}
/**
* 将当前 VS Code 里仍存活的会话终端同步回 issue 列表。
*
* `issues/update` 会用 Gitea 里持久化的 state JSON 整体替换 webview
* 状态;tabOpen 是本地运行态,不在 state JSON 中,因此发送列表前要按
* live terminal 重新补齐,同时登记 terminalOrigin,确保详情面板的关闭
* 按钮能找到对应 terminal。
*/
export function withLiveTerminalTabState(panel: KanbanWebviewPanel, issues: Issue[]): Issue[] {
const liveByIssue = new Map<number, Partial<Pick<Issue, 'brainstormTabOpen' | 'implementTabOpen' | 'reviewTabOpen' | 'testTabOpen'>>>()
const mark = (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test'): void => {
const patch = liveByIssue.get(issueNumber) ?? {}
if (kind === 'brainstorm')
patch.brainstormTabOpen = true
else if (kind === 'implement')
patch.implementTabOpen = true
else if (kind === 'review')
patch.reviewTabOpen = true
else
patch.testTabOpen = true
liveByIssue.set(issueNumber, patch)
}
for (const [terminal, origin] of panel.terminalOrigin) {
if (terminal.exitStatus === undefined)
mark(origin.issueNumber, origin.kind)
}
for (const terminal of window.terminals) {
if (terminal.exitStatus !== undefined)
continue
const match = terminal.name.match(/^issue-(\d+)-(规划|实施|审查|测试)(?:\s|$)/)
if (!match)
continue
const issueNumber = Number.parseInt(match[1], 10)
if (!Number.isFinite(issueNumber))
continue
const kind = match[2] === '规划'
? 'brainstorm'
: match[2] === '实施'
? 'implement'
: match[2] === '审查'
? 'review'
: 'test'
panel.terminalOrigin.set(terminal, { issueNumber, kind })
mark(issueNumber, kind)
}
if (liveByIssue.size === 0)
return issues
return issues.map((issue) => {
const patch = liveByIssue.get(issue.number)
return patch ? { ...issue, ...patch } : issue
})
}
/**
* 登记一个新创建/复用的会话终端,并主动推一条 issue/patch 把对应 tab 打开
* 标志置 true。详情面板侧据此在三行会话 id 右侧渲染关闭按钮。
*
* 复用场景(map 命中、existingByName 命中)也会再调一次,是幂等的:
* 重复设置 terminalOrigin 不影响,重复推 true 在 webview 端的
* `mergeIssuePatch` 也是 no-op。
*/
export function trackSessionTerminal(
panel: KanbanWebviewPanel,
terminal: Terminal,
issueNumber: number,
kind: 'brainstorm' | 'implement' | 'review' | 'test',
): void {
panel.terminalOrigin.set(terminal, { issueNumber, kind })
panel.postMessage({
type: 'issue/patch',
issueNumber,
patch:
kind === 'brainstorm'
? { brainstormTabOpen: true }
: kind === 'implement'
? { implementTabOpen: true }
: kind === 'review'
? { reviewTabOpen: true }
: { testTabOpen: true },
})
}
/**
* onDidCloseTerminal 触发后调用:反查 `terminalOrigin` 找到该 terminal
* 对应的工单 + 会话类型,并推 false 让详情面板隐藏关闭按钮。
*
* 同时从 `terminalOrigin` 删除条目;四个 issue-aware Map 的清理仍由
* 现有 onDidCloseTerminal 循环负责,本方法只关心 webview 通知。
*/
export function untrackClosedTerminal(panel: KanbanWebviewPanel, closed: Terminal): void {
const origin = panel.terminalOrigin.get(closed)
if (!origin)
return
panel.terminalOrigin.delete(closed)
panel.postMessage({
type: 'issue/patch',
issueNumber: origin.issueNumber,
patch:
origin.kind === 'brainstorm'
? { brainstormTabOpen: false }
: origin.kind === 'implement'
? { implementTabOpen: false }
: origin.kind === 'review'
? { reviewTabOpen: false }
: { testTabOpen: false },
})
}
/**
* webview 端"关闭 tab"按钮入口:直接扫 `terminalOrigin` 找匹配
* (issueNumber, kind) 的 terminal 并 `dispose()`。VS Code 随后会发
* onDidCloseTerminal,由统一回调清理四个 issue-aware Map 和推 flag=false
* ——本方法不直接改任何 Map,避免双重清理。
*
* 用 terminalOrigin 而不是分别查四个 Map:审查 tab 在
* `triggerAutoReviewTab` 路径里不会进 `reviewTerminals`(那个 Map 按
* thread_id 索引,此路径还没 thread_id),但会被登记到 terminalOrigin,
* 所以用它做单一真相源最稳。
*/
export function handleCloseSessionTab(panel: KanbanWebviewPanel, issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test'): void {
let terminal: Terminal | undefined
for (const [t, origin] of panel.terminalOrigin) {
if (origin.issueNumber === issueNumber && origin.kind === kind) {
terminal = t
break
}
}
if (!terminal) {
logger.add({
level: 'warn',
source: 'panel',
message: `关闭 ${kind} tab 失败 #${issueNumber}:未找到对应终端`,
})
return
}
terminal.dispose()
}
/**
* Removes and returns the {@link pendingIssueCreations} entry for the
* given nonce, if any. Called by the webhook coordinator when a matching
* `issues opened` payload arrives. Returning `undefined` (and leaving the
* map untouched) signals "no match — treat as external issue creation".
*/
export function takePendingIssueCreation(panel: KanbanWebviewPanel, nonce: string): {
sessionId?: string
brainstormProfilePath?: string
color: string
workspaceRoot: string
inboxDir: string
terminalName: string
terminal: Terminal
createdAt: number
} | undefined {
const entry = panel.pendingIssueCreations.get(nonce)
if (entry)
panel.pendingIssueCreations.delete(nonce)
return entry
}
/**
* Called by the webhook coordinator after it parses a `spx:nonce=...` token
* out of `issue.body` and matches it to an entry in `pendingIssueCreations`.
* Promotes the in-flight brainstorm terminal into the issueNumber-keyed
* `newIssueTerminals` map so subsequent `handleSessionFocus(issueNumber)`
* and `handleActiveTerminalChanged` calls can find it.
*
* Also drops the terminal into `terminals` keyed by sessionId (when known)
* so `handleResumeSession` reuses the same tab instead of spawning a new
* one. Does NOT remove the pending entry — `takePendingIssueCreation` is
* still responsible for cleanup on its own path.
*/
export function linkPendingTerminalToIssue(panel: KanbanWebviewPanel, nonce: string, issueNumber: number): void {
const pending = panel.pendingIssueCreations.get(nonce)
if (!pending)
return
panel.newIssueTerminals.set(issueNumber, pending.terminal)
if (pending.sessionId && pending.sessionId.length > 0)
panel.terminals.set(pending.sessionId, pending.terminal)
trackSessionTerminal(panel, pending.terminal, issueNumber, 'brainstorm')
}