详情面板「头脑风暴会话id」行的 ▶ 改为续聊:规划终端还开着就把续聊提示词 打进去;终端关了但有会话 id 就 `claude --resume <id>` 带提示词恢复;没会话 才新开。属性行右侧按钮改为数组,续聊与关闭 tab 可并存。 Claude-Session: https://claude.ai/code/session_01KDcq9HcrVLs2c9tkRxasub
387 lines
15 KiB
TypeScript
387 lines
15 KiB
TypeScript
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')
|
||
}
|