import type { Theme } from "@earendil-works/pi-coding-agent"; import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type TUI } from "@earendil-works/pi-tui"; import { fitContentLines, historyNavigationTarget, historySections, scrollbarGeometry } from "./dashboard-core.ts"; import { formatDuration } from "./duration.ts"; import { aggregateUsage, clusterUsage, formatCacheHitRate, formatTokenCount } from "./usage.ts"; import { hasCompleteExecutionData, type ClusterLevel, type ClusterState, type TaskRuntime, type UsageStats, type UserDecision, type WorkerHistoryMessage, type WorkerResult } from "./types.ts"; export interface DashboardActions { cancel(): void; togglePause(): void; decide(taskId: string, action: UserDecision["action"]): void; deleteHistory(runId: string): Promise; close(): void; } const levelLabel: Record = { low: "低", medium: "中", high: "高" }; const statusLabel: Record = { running: "运行中", paused: "已暂停", completed: "完成", failed: "失败", cancelled: "已取消", queued: "排队", reviewing: "审核中", paused_for_user: "等用户决策", timed_out: "已超时", blocked: "阻塞", }; type ViewMode = "clusters" | "overview" | "detail" | "history"; export interface DashboardClusterReference { state: ClusterState; active: boolean; } type ThemeColor = "accent" | "success" | "error" | "warning" | "muted" | "dim" | "toolOutput" | "border"; type AttemptHistoryCache = { attempt: TaskRuntime["attempts"][number]; worker: TaskRuntime["attempts"][number]["worker"]; workerHistory?: WorkerHistoryMessage[]; workerOutput?: string; workerUsage?: UsageStats; workerApiRetries?: WorkerResult["apiRetries"]; workerModel?: string; workerStopReason?: string; workerErrorMessage?: string; workerStderr?: string; workerFingerprint?: string; reviewer: TaskRuntime["attempts"][number]["reviewer"]; reviewerHistory?: WorkerHistoryMessage[]; reviewerOutput?: string; reviewerUsage?: UsageStats; reviewerApiRetries?: WorkerResult["apiRetries"]; reviewerModel?: string; reviewerStopReason?: string; reviewerErrorMessage?: string; reviewerStderr?: string; reviewerFingerprint?: string; reviewFingerprint?: string; review: TaskRuntime["attempts"][number]["review"]; }; type HistoryCache = { width: number; attempts: AttemptHistoryCache[]; lines: string[]; workerOffsets: number[]; }; type HistoryViewCache = { width: number; task: string; status: TaskRuntime["status"]; model: string; duration: string; usage: string; completeness: string; historyLines: string[]; lines: string[]; }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } function isLiveCluster(state: ClusterState): boolean { return state.status === "running" || state.status === "paused"; } function stringifyValue(value: unknown): string { if (typeof value === "string") return value; if (value === undefined) return ""; try { return JSON.stringify(value); } catch { return String(value); } } function resultFingerprint(result?: WorkerResult): string { if (!result) return ""; return stringifyValue({ output: result.output, stderr: result.stderr, model: result.model, stopReason: result.stopReason, errorMessage: result.errorMessage, toolCalls: result.toolCalls, usage: result.usage, history: result.history, apiRetries: result.apiRetries, }); } function messageRole(message: WorkerHistoryMessage): string { if (message.role === "assistant") return "assistant"; if (message.role === "toolResult" || message.role === "tool") return `tool${message.toolName ? ` ${message.toolName}` : ""}`; if (message.role === "user") return "user"; return message.role || "message"; } function contentLines(content: unknown): string[] { if (typeof content === "string") return content.split("\n"); if (!Array.isArray(content)) return content === undefined ? [] : [stringifyValue(content)]; const lines: string[] = []; for (const part of content) { if (!isRecord(part)) { lines.push(stringifyValue(part)); continue; } if (part.type === "text" || part.type === "thinking") { lines.push(...String(part.text ?? part.thinking ?? "").split("\n")); continue; } if (part.type === "toolCall") { const name = String(part.name ?? "tool"); lines.push(`→ ${name} ${stringifyValue(part.arguments ?? {})}`.trimEnd()); continue; } if (part.type === "image") { lines.push("[图片内容]"); continue; } lines.push(stringifyValue(part)); } return lines; } function assistantReplyLines(content: unknown): string[] { return contentLines(content); } function statusIcon(task: TaskRuntime): string { if (task.status === "completed") return "✓"; if (task.status === "failed" || task.status === "blocked" || task.status === "timed_out") return "✗"; if (task.status === "paused_for_user") return "!"; if (task.status === "running" || task.status === "reviewing") return "●"; return "○"; } function taskDuration(state: ClusterState, task: TaskRuntime): string { const duration = formatDuration(task.startedAt, task.finishedAt, state.pausePeriods); return task.status === "paused_for_user" ? `等待 ${duration}` : duration; } function usageText(usage: UsageStats): string { const totalTokens = usage.input + usage.output + usage.cacheRead + usage.cacheWrite; return `${formatTokenCount(totalTokens)} tokens · 输入 ${formatTokenCount(usage.input)} · 输出 ${formatTokenCount(usage.output)} · ${usage.turns} turns`; } function completenessText(state: ClusterState): string { return hasCompleteExecutionData(state) ? "统计/历史完整" : "统计不完整,缺少审核器数据"; } function usageBreakdown(task: TaskRuntime): { total: UsageStats; worker: UsageStats; reviewer: UsageStats } { const worker = aggregateUsage(task.attempts.map((attempt) => attempt.worker?.usage)); const reviewer = aggregateUsage(task.attempts.map((attempt) => attempt.reviewer?.usage)); return { total: aggregateUsage([worker, reviewer]), worker, reviewer }; } export class ClusterDashboard implements Component { private selectedClusterIndex = 0; private selectedIndex = 0; private mode: ViewMode = "clusters"; private scroll = 0; private maxScroll = 0; private viewport = 18; private historyContentWidth = 1; private historyWorkerIndex: number | undefined; private historyNavigationNotice: string | undefined; private deletingHistory = false; private readonly clusters: DashboardClusterReference[]; private readonly historyCache = new WeakMap(); private readonly historyViewCache = new WeakMap(); constructor( private readonly tui: TUI, private readonly theme: Theme, clusters: readonly DashboardClusterReference[], private readonly actions: DashboardActions, ) { this.clusters = [...clusters]; } private get selectedCluster(): DashboardClusterReference | undefined { return this.clusters[this.selectedClusterIndex]; } private get state(): ClusterState { const cluster = this.selectedCluster; if (!cluster) throw new Error("Dashboard 没有可查看的集群"); return cluster.state; } private isSelectedClusterActive(): boolean { return this.selectedCluster?.active === true; } private refreshClusterActivity(): void { for (const cluster of this.clusters) { if (cluster.active && !isLiveCluster(cluster.state)) cluster.active = false; } } private resetHistoryNavigation(): void { this.historyWorkerIndex = undefined; this.historyNavigationNotice = undefined; } handleInput(data: string): void { this.refreshClusterActivity(); if (matchesKey(data, Key.ctrl("c"))) { if (this.isSelectedClusterActive()) this.actions.cancel(); return; } if (data === "q") { this.actions.close(); return; } if (matchesKey(data, Key.escape)) { if (this.mode === "history") { this.mode = "detail"; this.resetHistoryNavigation(); } else if (this.mode === "detail") this.mode = "overview"; else if (this.mode === "overview") this.mode = "clusters"; else this.actions.close(); this.scroll = 0; this.tui.requestRender(); return; } if (this.mode === "clusters") { this.handleClusterListInput(data); return; } if (this.mode === "history") { if (this.handleHistoryNavigation(data)) return; this.handleScroll(data); return; } if (this.mode === "detail") { if (matchesKey(data, Key.enter)) { this.mode = "history"; this.scroll = 0; this.resetHistoryNavigation(); this.tui.requestRender(); } return; } if (matchesKey(data, Key.up) || data === "k") { this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.scroll = 0; this.tui.requestRender(); return; } if (matchesKey(data, Key.down) || data === "j") { this.selectedIndex = Math.min(Math.max(0, this.state.tasks.length - 1), this.selectedIndex + 1); this.scroll = 0; this.tui.requestRender(); return; } if (matchesKey(data, Key.enter)) { this.mode = "detail"; this.scroll = 0; this.tui.requestRender(); return; } if (!this.isSelectedClusterActive()) return; if (data === "p") this.actions.togglePause(); const task = this.state.tasks[this.selectedIndex]; if (!task) return; if (data === "r") this.actions.decide(task.id, "retry"); if (data === "e" && task.level !== "high") this.actions.decide(task.id, "escalate"); if (data === "a") this.actions.decide(task.id, "accept"); if (data === "x") this.actions.decide(task.id, "abandon"); } private handleClusterListInput(data: string): void { if (matchesKey(data, Key.up) || data === "k") { this.selectedClusterIndex = Math.max(0, this.selectedClusterIndex - 1); this.tui.requestRender(); return; } if (matchesKey(data, Key.down) || data === "j") { this.selectedClusterIndex = Math.min(Math.max(0, this.clusters.length - 1), this.selectedClusterIndex + 1); this.tui.requestRender(); return; } const selectedCluster = this.selectedCluster; if ((data === "d" || matchesKey(data, Key.delete)) && selectedCluster && !selectedCluster.active && !this.deletingHistory) { void this.deleteSelectedHistory(selectedCluster); return; } if (matchesKey(data, Key.enter) && selectedCluster) { this.selectedIndex = 0; this.mode = "overview"; this.scroll = 0; this.tui.requestRender(); } } private async deleteSelectedHistory(cluster: DashboardClusterReference): Promise { this.deletingHistory = true; try { if (!await this.actions.deleteHistory(cluster.state.runId)) return; const index = this.clusters.indexOf(cluster); if (index < 0) return; this.clusters.splice(index, 1); if (this.clusters.length === 0) { this.actions.close(); return; } this.selectedClusterIndex = Math.min(index, this.clusters.length - 1); this.selectedIndex = 0; this.scroll = 0; } finally { this.deletingHistory = false; this.tui.requestRender(); } } private handleHistoryNavigation(data: string): boolean { const direction = data === "n" ? "next" : data === "p" ? "previous" : undefined; if (!direction) return false; const task = this.state.tasks[this.selectedIndex]; if (!task) return true; const historyLines = this.historyLines(task, this.historyContentWidth); const workerOffsets = this.historyCache.get(task)?.workerOffsets ?? []; const target = historyNavigationTarget(workerOffsets, this.scroll, this.historyWorkerIndex, direction); if (target === undefined) { this.historyNavigationNotice = workerOffsets.length === 0 ? "暂无 worker 记录" : direction === "next" ? "已是最后一个 worker" : "已是第一个 worker"; } else { this.historyWorkerIndex = target; this.maxScroll = Math.max(0, historyLines.length - this.viewport); this.scroll = Math.min(this.maxScroll, workerOffsets[target] ?? 0); this.historyNavigationNotice = undefined; } this.tui.requestRender(); return true; } private handleScroll(data: string): void { let handled = true; if (matchesKey(data, Key.up) || data === "k") this.scroll = Math.max(0, this.scroll - 1); else if (matchesKey(data, Key.down) || data === "j") this.scroll = Math.min(this.maxScroll, this.scroll + 1); else if (matchesKey(data, Key.pageUp)) this.scroll = Math.max(0, this.scroll - this.viewport); else if (matchesKey(data, Key.pageDown)) this.scroll = Math.min(this.maxScroll, this.scroll + this.viewport); else if (matchesKey(data, Key.home) || data === "g") this.scroll = 0; else if (matchesKey(data, Key.end) || data === "G") this.scroll = this.maxScroll; else handled = false; if (!handled) return; this.resetHistoryNavigation(); this.tui.requestRender(); } render(width: number): string[] { this.refreshClusterActivity(); if (this.mode !== "clusters") this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.state.tasks.length - 1)); const contentWidth = Math.max(1, width - 2); const border = "─".repeat(contentWidth); const title = "Subagent Cluster"; const titleWidth = visibleWidth(title); const topBorder = contentWidth >= titleWidth + 4 ? this.theme.fg("border", "╭─ ") + this.theme.fg("accent", title) + this.theme.fg("border", ` ${"─".repeat(contentWidth - titleWidth - 3)}╮`) : this.theme.fg("border", `╭${border}╮`); const frame = (content: string[]): string[] => { if (width < 3) return content.map((line) => truncateToWidth(line, width, "", false)); return [ topBorder, ...content.map((line) => this.theme.fg("border", "│") + truncateToWidth(line, contentWidth, "", true) + this.theme.fg("border", "│")), this.theme.fg("border", `╰${border}╯`), ]; }; const terminalRows = this.tui.terminal?.rows ?? 24; const overlayRows = Math.max(4, Math.floor(terminalRows * 0.8)); const maxContentRows = Math.max(2, overlayRows - 4); this.viewport = Math.max(1, maxContentRows - 2); const content = this.mode === "clusters" ? this.renderClusterList(contentWidth) : this.mode === "overview" ? this.renderOverview(contentWidth, maxContentRows) : this.mode === "detail" ? this.renderDetail(contentWidth, maxContentRows) : this.renderHistory(contentWidth); const fittedContent = this.mode === "history" ? content : fitContentLines(content, maxContentRows, this.mode === "detail" ? 6 : this.mode === "overview" ? 8 : 1); return frame(fittedContent); } private renderClusterList(width: number): string[] { const lines = [ this.theme.fg("muted", `全部历史集群 · ${this.clusters.length} 个`), this.theme.fg("border", "─".repeat(width)), ]; if (this.clusters.length === 0) { lines.push(this.theme.fg("dim", "暂无集群记录")); } else { for (const [index, cluster] of this.clusters.entries()) { const state = cluster.state; const selected = index === this.selectedClusterIndex; const active = cluster.active; const icon = active ? "●" : state.status === "completed" ? "✓" : state.status === "failed" ? "✗" : "○"; const iconColor: ThemeColor = active ? "success" : state.status === "failed" ? "error" : state.status === "completed" ? "muted" : "dim"; const prefix = selected ? this.theme.fg("accent", "▸ ") : " "; const marker = active ? "当前" : "历史"; const completed = state.tasks.filter((task) => task.status === "completed").length; const usage = clusterUsage(state.tasks); const line = `${prefix}${this.theme.fg(iconColor, icon)} ${marker} · ${state.runId} · ${statusLabel[state.status]} · ${completed}/${state.tasks.length} agents · ${formatTokenCount(usage.totalTokens)} tokens · 命中率 ${formatCacheHitRate(usage)} · ${completenessText(state)} · 运行 ${formatDuration(state.startedAt, state.finishedAt, state.pausePeriods)} · ${state.goal}`; lines.push(truncateToWidth(selected ? this.theme.fg("accent", line) : line, width, "", true)); } } lines.push(this.theme.fg("border", "─".repeat(width))); const footer = ["↑/↓ 选择", "Enter 查看任务"]; if (this.isSelectedClusterActive()) footer.push("Ctrl+C 取消"); else if (this.selectedCluster) footer.push("d/Delete 删除"); footer.push("Esc 返回", "q 退出"); lines.push(this.theme.fg("dim", footer.join(" · "))); return lines; } private renderOverview(width: number, maxContentRows: number): string[] { const completed = this.state.tasks.filter((task) => task.status === "completed").length; const usage = clusterUsage(this.state.tasks); const availableWidth = Math.max(1, width - 1); const leftWidth = width >= 80 ? Math.min(56, Math.max(36, Math.floor(availableWidth * 0.27))) : Math.max(1, Math.floor(availableWidth * 0.4)); const rightWidth = Math.max(1, width - leftWidth - 1); const left = this.renderTaskList(leftWidth); const right = this.renderTaskSummary(rightWidth, maxContentRows); const sectionHeader = (sectionWidth: number, title: string): string => { const label = ` ${title} `; const remaining = sectionWidth - visibleWidth(label); if (remaining < 2) return this.theme.fg("border", "─".repeat(sectionWidth)); const leftBorderWidth = Math.floor(remaining / 2); return this.theme.fg("border", "─".repeat(leftBorderWidth)) + this.theme.fg("accent", label) + this.theme.fg("border", "─".repeat(remaining - leftBorderWidth)); }; const leftHeader = sectionHeader(leftWidth, "任务列表"); const rightHeader = sectionHeader(rightWidth, "当前 subagent"); const lines = [ `${this.theme.fg("muted", statusLabel[this.state.status])} ${completed}/${this.state.tasks.length} agents ${this.theme.fg("dim", `运行 ${formatDuration(this.state.startedAt, this.state.finishedAt, this.state.pausePeriods)}`)}`, this.theme.fg("dim", `${usageText(usage)} · 缓存命中率 ${formatCacheHitRate(usage)} · ${completenessText(this.state)}`), this.theme.fg("dim", this.state.goal), leftHeader + this.theme.fg("border", "┬") + rightHeader, ]; const rowCount = Math.max(left.length, right.length); for (let index = 0; index < rowCount; index++) { const leftLine = truncateToWidth(left[index] ?? "", leftWidth, "", true); const rightLine = truncateToWidth(right[index] ?? "", rightWidth, "", true); lines.push(leftLine + this.theme.fg("border", "│") + rightLine); } lines.push(this.theme.fg("border", "─".repeat(leftWidth)) + this.theme.fg("border", "┴") + this.theme.fg("border", "─".repeat(rightWidth))); lines.push(this.theme.fg("dim", this.overviewFooterHint())); return lines; } private overviewFooterHint(): string { const parts = ["↑/↓ 选择", "Enter 打开", "Esc 返回集群列表"]; if (!this.isSelectedClusterActive()) { parts.push("q 退出"); return parts.join(" · "); } const selected = this.state.tasks[this.selectedIndex]; const waitingForUser = this.state.tasks.some((task) => task.status === "paused_for_user"); if (this.state.status === "running") { parts.push("p 暂停", "Ctrl+C 取消"); } else if (this.state.status === "paused" && waitingForUser) { if (selected?.status === "paused_for_user") { parts.push("r 重试"); if (selected.level !== "high") parts.push("e 升级"); parts.push("a 接受", "x 放弃"); } else parts.push("选择等待决策任务"); parts.push("Ctrl+C 取消"); } else if (this.state.status === "paused") { parts.push("p 继续", "Ctrl+C 取消"); } parts.push("q 退出"); return parts.join(" · "); } private renderTaskList(width: number): string[] { const statusWidth = Math.max(1, ...this.state.tasks.map((task) => visibleWidth(statusLabel[task.status]))); const levelWidth = Math.max(1, ...this.state.tasks.map((task) => visibleWidth(levelLabel[task.level]))); const attemptsWidth = Math.max(1, ...this.state.tasks.map((task) => visibleWidth(`${task.attempts.length}次`))); const durationWidth = Math.max(1, ...this.state.tasks.map((task) => visibleWidth(taskDuration(this.state, task)))); const metaWidth = statusWidth + levelWidth + attemptsWidth + durationWidth + 9; const titleWidth = Math.max(1, width - metaWidth - 4); return this.state.tasks.map((task, index) => { const selected = index === this.selectedIndex; const icon = statusIcon(task); const iconColor: ThemeColor = task.status === "completed" ? "success" : task.status === "failed" || task.status === "blocked" || task.status === "timed_out" ? "error" : task.status === "paused_for_user" ? "warning" : "muted"; const prefix = selected ? this.theme.fg("accent", "▸ ") : " "; const titleText = truncateToWidth(`${task.id} ${task.title}`, titleWidth, "", true); const title = selected ? this.theme.fg("accent", titleText) : titleText; const status = truncateToWidth(statusLabel[task.status], statusWidth, "", true); const level = truncateToWidth(levelLabel[task.level], levelWidth, "", true); const attempts = truncateToWidth(`${task.attempts.length}次`, attemptsWidth, "", true); const duration = truncateToWidth(taskDuration(this.state, task), durationWidth, "", true); const meta = this.theme.fg("dim", `${status} · ${level} · ${attempts} · ${duration}`); return truncateToWidth(`${prefix}${this.theme.fg(iconColor, icon)} ${title}${meta}`, width, "", true); }); } private taskUsageLines(task: TaskRuntime, width: number): string[] { const usage = usageBreakdown(task); return [ ...this.wrap(`Token 总量:${usageText(usage.total)}`, width, "dim"), ...this.wrap(`worker:${usageText(usage.worker)}`, width, "dim"), ...this.wrap(`审核器:${usageText(usage.reviewer)}`, width, "dim"), ]; } private renderTaskSummary(width: number, maxContentRows: number): string[] { const task = this.state.tasks[this.selectedIndex]; if (!task) return [this.theme.fg("dim", "暂无任务")]; const latest = task.attempts[task.attempts.length - 1]; const model = latest?.model || latest?.worker?.model || `${levelLabel[task.level]}级模型`; const lines = [ this.theme.fg("accent", this.theme.bold(`${task.id} ${task.title}`)), this.theme.fg("dim", `Status: ${statusLabel[task.status]}`), this.theme.fg("dim", `任务类型: ${task.taskType}`), this.theme.fg("dim", `等级: 请求 ${levelLabel[task.requestedLevel]} → 实际初始 ${levelLabel[task.initialLevel]} → 当前 ${levelLabel[task.level]}`), ...this.wrap(`${task.requestedLevel === task.initialLevel ? "等级选择说明" : "等级调整原因"}:${task.levelSelectionReason}`, width, "dim"), this.theme.fg("dim", `运行时长: ${taskDuration(this.state, task)}`), this.theme.fg("dim", `Model: ${model}`), ...this.taskUsageLines(task, width), "", this.theme.fg("accent", "Prompt:"), ...this.wrap(task.task, width), "", this.theme.fg("accent", "Recent activity:"), ]; const recentLimit = Math.max(0, maxContentRows - 5 - lines.length); const activity = this.recentActivityLines(task, width, false); if (recentLimit > 0) lines.push(...activity.slice(-recentLimit)); else if (task.review) lines.push(...this.reviewLines(task.review, width, true)); return lines.map((line) => truncateToWidth(line, width, "", false)); } private renderDetail(width: number, maxContentRows: number): string[] { const task = this.state.tasks[this.selectedIndex]; if (!task) return [this.theme.fg("dim", "暂无任务")]; const latest = task.attempts[task.attempts.length - 1]; const model = latest?.model || latest?.worker?.model || `${levelLabel[task.level]}级模型`; const lines = [ this.theme.fg("accent", this.theme.bold(`${task.id} ${task.title}`)), this.theme.fg("dim", `Status: ${statusLabel[task.status]}`), this.theme.fg("dim", `任务类型: ${task.taskType}`), this.theme.fg("dim", `请求等级: ${levelLabel[task.requestedLevel]}`), this.theme.fg("dim", `实际初始等级: ${levelLabel[task.initialLevel]}`), ...this.wrap(`${task.requestedLevel === task.initialLevel ? "等级选择说明" : "等级调整原因"}:${task.levelSelectionReason}`, width, "dim"), this.theme.fg("dim", `当前等级: ${levelLabel[task.level]}`), this.theme.fg("dim", `运行时长: ${taskDuration(this.state, task)}`), this.theme.fg("dim", `Model: ${model}`), ...this.taskUsageLines(task, width), "", this.theme.fg("accent", "Prompt:"), ...this.wrap(task.task, width), ...task.acceptanceCriteria.map((criterion, index) => this.theme.fg("dim", `${index + 1}. ${criterion}`)), "", this.theme.fg("accent", "Recent activity:"), ]; const footer = [ this.theme.fg("border", "─".repeat(width)), this.theme.fg("dim", "Enter 打开完整历史 · Esc 返回概览"), ]; const recentLimit = Math.max(0, maxContentRows - lines.length - footer.length); const activity = this.recentActivityLines(task, width, true); if (recentLimit > 0) lines.push(...activity.slice(-recentLimit)); lines.push(...footer); return lines.map((line) => truncateToWidth(line, width, "", false)); } private renderHistory(width: number): string[] { const task = this.state.tasks[this.selectedIndex]; if (!task) return [this.theme.fg("dim", "暂无任务")]; const scrollbarWidth = width > 1 ? 1 : 0; const contentWidth = Math.max(1, width - scrollbarWidth); this.historyContentWidth = contentWidth; const latest = task.attempts[task.attempts.length - 1]; const model = latest?.model || latest?.worker?.model || `${levelLabel[task.level]}级模型`; const duration = taskDuration(this.state, task); const usage = usageBreakdown(task); const usageKey = `${usageText(usage.total)}\n${usageText(usage.worker)}\n${usageText(usage.reviewer)}`; const completeness = completenessText(this.state); const incompleteHistoryLines = hasCompleteExecutionData(this.state) ? [] : [ this.theme.fg("warning", "统计不完整,缺少审核器数据"), this.theme.fg("warning", "完整审核器历史未记录"), ]; const historyLines = this.historyLines(task, contentWidth); const cached = this.historyViewCache.get(task); let allLines: string[]; if ( cached && cached.width === contentWidth && cached.task === task.task && cached.status === task.status && cached.model === model && cached.duration === duration && cached.usage === usageKey && cached.completeness === completeness && cached.historyLines === historyLines ) { allLines = cached.lines; } else { allLines = [ this.theme.fg("accent", this.theme.bold(`${task.id} ${task.title}`)), this.theme.fg("dim", `Status: ${statusLabel[task.status]}`), this.theme.fg("dim", `运行时长: ${duration}`), this.theme.fg("dim", `Model: ${model}`), ...this.taskUsageLines(task, contentWidth), ...(hasCompleteExecutionData(this.state) ? [this.theme.fg("dim", completeness)] : incompleteHistoryLines), "", this.theme.fg("accent", "Prompt:"), ...this.wrap(task.task, contentWidth), "", this.theme.fg("accent", "History:"), ...historyLines, ]; this.historyViewCache.set(task, { width: contentWidth, task: task.task, status: task.status, model, duration, usage: usageKey, completeness, historyLines, lines: allLines }); } this.maxScroll = Math.max(0, allLines.length - this.viewport); this.scroll = Math.min(this.scroll, this.maxScroll); const scrollbar = scrollbarGeometry(allLines.length, this.viewport, this.scroll); const visibleLines = allLines.slice(this.scroll, this.scroll + this.viewport).map((line, index) => { const content = truncateToWidth(line, contentWidth, "", true); if (scrollbarWidth === 0) return content; if (!scrollbar) return `${content} `; const isThumb = index >= scrollbar.thumbStart && index < scrollbar.thumbStart + scrollbar.thumbSize; return content + this.theme.fg(isThumb ? "accent" : "border", isThumb ? "█" : "│"); }); const navigationHint = "↑/↓ 逐行 · PgUp/PgDn 翻页 · g/G 首尾 · n 下一个 worker · p 上一个 worker · Esc 返回详情"; const footer = this.historyNavigationNotice ? this.theme.fg("dim", navigationHint) + " · " + this.theme.fg("warning", this.historyNavigationNotice) : this.theme.fg("dim", navigationHint); return visibleLines.concat( this.theme.fg("border", "─".repeat(width)), footer, ); } private recentActivityLines(task: TaskRuntime, width: number, includeReviewReason: boolean): string[] { const lines: string[] = []; const message = this.latestAssistantMessage(task); if (message) { for (const line of assistantReplyLines(message.content)) lines.push(...this.wrap(line, width, "toolOutput")); } lines.push(...this.reviewLines(task.review, width, includeReviewReason)); return lines; } private latestAssistantMessage(task: TaskRuntime): WorkerHistoryMessage | undefined { for (let attemptIndex = task.attempts.length - 1; attemptIndex >= 0; attemptIndex--) { const attempt = task.attempts[attemptIndex]; for (const result of [attempt.reviewer, attempt.worker]) { const history = this.executionHistory(result); for (let messageIndex = history.length - 1; messageIndex >= 0; messageIndex--) { const message = history[messageIndex]; if (message.role === "assistant") return message; } } } return undefined; } private historyLines(task: TaskRuntime, width: number): string[] { const cached = this.historyCache.get(task); if (cached && cached.width === width && cached.attempts.length === task.attempts.length) { const unchanged = cached.attempts.every((entry, index) => { const attempt = task.attempts[index]; return ( entry.attempt === attempt && entry.worker === attempt.worker && entry.workerHistory === attempt.worker?.history && entry.workerOutput === attempt.worker?.output && entry.workerUsage === attempt.worker?.usage && entry.workerApiRetries === attempt.worker?.apiRetries && entry.workerModel === attempt.worker?.model && entry.workerStopReason === attempt.worker?.stopReason && entry.workerErrorMessage === attempt.worker?.errorMessage && entry.workerStderr === attempt.worker?.stderr && entry.workerFingerprint === resultFingerprint(attempt.worker) && entry.reviewer === attempt.reviewer && entry.reviewerHistory === attempt.reviewer?.history && entry.reviewerOutput === attempt.reviewer?.output && entry.reviewerUsage === attempt.reviewer?.usage && entry.reviewerApiRetries === attempt.reviewer?.apiRetries && entry.reviewerModel === attempt.reviewer?.model && entry.reviewerStopReason === attempt.reviewer?.stopReason && entry.reviewerErrorMessage === attempt.reviewer?.errorMessage && entry.reviewerStderr === attempt.reviewer?.stderr && entry.reviewerFingerprint === resultFingerprint(attempt.reviewer) && entry.reviewFingerprint === stringifyValue(attempt.review) && entry.review === attempt.review ); }); if (unchanged) return cached.lines; } const lines: string[] = []; const workerOffsets: number[] = []; for (const section of historySections(task.attempts)) { if (section.kind === "review") { lines.push(...this.reviewLines(section.review, width)); continue; } const attempt = section.attempt; const result = section.result; const model = result?.model || (section.kind === "worker" ? attempt.model : undefined) || `${levelLabel[attempt.level]}级模型`; const title = section.kind === "worker" ? `${levelLabel[attempt.level]}级 worker · 第 ${attempt.attempt} 次` : `审核器 · 第 ${attempt.attempt} 次 worker 对应审核`; if (section.kind === "worker") { workerOffsets.push(lines.length); lines.push(this.theme.fg("border", "─".repeat(width))); } const header = `── ${title} · Model: ${model} `; lines.push(this.theme.fg("border", header + "─".repeat(Math.max(0, width - visibleWidth(header))))); if (result) { lines.push(this.theme.fg("dim", `退出码: ${result.exitCode} · 停止原因: ${result.stopReason ?? "无"}`)); lines.push(...this.wrap(`Usage: ${usageText(result.usage)}`, width, "dim")); for (const retry of result.apiRetries ?? []) { lines.push(...this.wrap(`API 重试 #${retry.attempt} · 延迟 ${retry.delayMs}ms · ${retry.errorSummary}`, width, "warning")); } } const history = this.executionHistory(result); if (history.length === 0) { if (result?.output) lines.push(...this.wrap(result.output, width, "toolOutput")); for (const tool of result?.toolCalls ?? []) lines.push(...this.wrap(`工具调用:${tool.name} ${stringifyValue(tool.args)}`, width, "toolOutput")); if (!result?.output && (result?.toolCalls?.length ?? 0) === 0) lines.push(this.theme.fg("dim", "暂无对话记录")); } else { for (const message of history) { const role = messageRole(message); const roleColor: ThemeColor = role.startsWith("tool") ? "muted" : role === "assistant" ? "accent" : "dim"; lines.push(this.theme.fg(roleColor, `▌ ${role}`)); for (const line of contentLines(message.content)) lines.push(...this.wrap(line, width, role === "assistant" ? "toolOutput" : "dim")); } } if (result?.errorMessage) lines.push(...this.wrap(`错误:${result.errorMessage}`, width, "error")); if (result?.stderr) lines.push(...this.wrap(`stderr:${result.stderr.slice(-4_000)}`, width, "error")); } this.historyCache.set(task, { width, attempts: task.attempts.map((attempt) => ({ attempt, worker: attempt.worker, workerHistory: attempt.worker?.history, workerOutput: attempt.worker?.output, workerUsage: attempt.worker?.usage, workerApiRetries: attempt.worker?.apiRetries, workerModel: attempt.worker?.model, workerStopReason: attempt.worker?.stopReason, workerErrorMessage: attempt.worker?.errorMessage, workerStderr: attempt.worker?.stderr, workerFingerprint: resultFingerprint(attempt.worker), reviewer: attempt.reviewer, reviewerHistory: attempt.reviewer?.history, reviewerOutput: attempt.reviewer?.output, reviewerUsage: attempt.reviewer?.usage, reviewerApiRetries: attempt.reviewer?.apiRetries, reviewerModel: attempt.reviewer?.model, reviewerStopReason: attempt.reviewer?.stopReason, reviewerErrorMessage: attempt.reviewer?.errorMessage, reviewerStderr: attempt.reviewer?.stderr, reviewerFingerprint: resultFingerprint(attempt.reviewer), reviewFingerprint: stringifyValue(attempt.review), review: attempt.review, })), lines, workerOffsets, }); return lines; } private reviewLines(review: TaskRuntime["review"], width: number, includeReason = true): string[] { if (!review) return []; const decisionLabel = { pass: "通过", retry: "重试", escalate: "升级", ask_user: "需用户决策", timeout: "超时", error: "审核失败" }[review.decision]; if (!includeReason) return ["", this.theme.fg("warning", `审核结论:${decisionLabel}`)]; return [ "", this.theme.fg("warning", `审核结论:${decisionLabel}`), ...this.labeledWrap("原因", review.reason || "无", width), ...this.labeledWrap("缺失", review.missingCriteria.length > 0 ? review.missingCriteria.join(";") : "无", width), ...this.labeledWrap("后续", review.nextInstruction || "无", width), ]; } private labeledWrap(label: string, value: string, width: number): string[] { const prefix = `${label}:`; const contentWidth = Math.max(1, width - 2 - visibleWidth(prefix)); const wrapped = wrapTextWithAnsi(this.theme.fg("warning", value), contentWidth); return wrapped.map((line, index) => ` ${this.theme.fg("warning", index === 0 ? prefix : " ".repeat(visibleWidth(prefix)))}${line}`); } private executionHistory(result?: WorkerResult): WorkerHistoryMessage[] { if (result?.history && result.history.length > 0) return result.history; const legacyResult = result as (WorkerResult & { messages?: WorkerHistoryMessage[] }) | undefined; return legacyResult?.messages ?? []; } private wrap(text: string, width: number, color: ThemeColor = "toolOutput"): string[] { return wrapTextWithAnsi(this.theme.fg(color, text || " "), Math.max(1, width - 2)).map((line) => ` ${line}`); } invalidate(): void {} }