@zhushanwen/pi-subagent-workflow 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/context-builder.md +17 -0
- package/agents/general-purpose.md +16 -0
- package/agents/oracle.md +17 -0
- package/agents/planner.md +17 -0
- package/agents/researcher.md +17 -0
- package/agents/reviewer.md +17 -0
- package/agents/scout.md +17 -0
- package/agents/worker.md +16 -0
- package/examples/README.md +43 -0
- package/examples/chain.example.js +92 -0
- package/examples/map-reduce.example.js +99 -0
- package/examples/parallel.example.js +82 -0
- package/examples/scatter-gather.example.js +106 -0
- package/index.ts +1 -0
- package/package.json +66 -0
- package/skills/workflow-script-format/SKILL.md +328 -0
- package/src/execution/__tests__/agent-registry.test.ts +164 -0
- package/src/execution/__tests__/agent-result-mapper.test.ts +128 -0
- package/src/execution/__tests__/alive-store.test.ts +147 -0
- package/src/execution/__tests__/bg-notify-render.test.ts +256 -0
- package/src/execution/__tests__/concurrency-pool.test.ts +217 -0
- package/src/execution/__tests__/config.test.ts +110 -0
- package/src/execution/__tests__/crash-recovery.test.ts +311 -0
- package/src/execution/__tests__/execute-nesting.test.ts +359 -0
- package/src/execution/__tests__/execute-options-mapper.test.ts +138 -0
- package/src/execution/__tests__/execution-record.test.ts +959 -0
- package/src/execution/__tests__/finalized-marker.test.ts +82 -0
- package/src/execution/__tests__/format-schema-instruction.test.ts +135 -0
- package/src/execution/__tests__/format.test.ts +320 -0
- package/src/execution/__tests__/helpers/mock-extension-api.ts +30 -0
- package/src/execution/__tests__/list-component.test.ts +347 -0
- package/src/execution/__tests__/model-resolver.test.ts +356 -0
- package/src/execution/__tests__/output-collector.test.ts +61 -0
- package/src/execution/__tests__/path-encoding.test.ts +75 -0
- package/src/execution/__tests__/pi-invocation.test.ts +73 -0
- package/src/execution/__tests__/record-store.test.ts +545 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +439 -0
- package/src/execution/__tests__/run-spawn-integration.test.ts +897 -0
- package/src/execution/__tests__/sdk-contract.test.ts +272 -0
- package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
- package/src/execution/__tests__/session-file-gc.test.ts +247 -0
- package/src/execution/__tests__/session-reconstructor.test.ts +359 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +314 -0
- package/src/execution/__tests__/session-start-reaper.test.ts +227 -0
- package/src/execution/__tests__/spawn-args.test.ts +244 -0
- package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
- package/src/execution/__tests__/subagent-service.test.ts +678 -0
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +389 -0
- package/src/execution/__tests__/temp-prompt.test.ts +53 -0
- package/src/execution/__tests__/timeout-integration.test.ts +381 -0
- package/src/execution/__tests__/tombstone-store.test.ts +73 -0
- package/src/execution/__tests__/tool-action.test.ts +330 -0
- package/src/execution/__tests__/turn-limiter.test.ts +65 -0
- package/src/execution/__tests__/worktree-manager.test.ts +423 -0
- package/src/execution/__tests__/worktree-registry.test.ts +161 -0
- package/src/execution/agent-registry.ts +252 -0
- package/src/execution/agent-result-mapper.ts +84 -0
- package/src/execution/alive-store.ts +92 -0
- package/src/execution/best-effort.ts +30 -0
- package/src/execution/concurrency-pool.ts +84 -0
- package/src/execution/config.ts +73 -0
- package/src/execution/execute-options-mapper.ts +86 -0
- package/src/execution/execution-record.ts +778 -0
- package/src/execution/finalized-marker.ts +51 -0
- package/src/execution/model-config-service.ts +225 -0
- package/src/execution/model-resolver.ts +247 -0
- package/src/execution/notifier.ts +168 -0
- package/src/execution/output-collector.ts +88 -0
- package/src/execution/path-encoding.ts +34 -0
- package/src/execution/pi-invocation.ts +70 -0
- package/src/execution/record-store.ts +350 -0
- package/src/execution/session-context-resolver.ts +64 -0
- package/src/execution/session-file-gc.ts +98 -0
- package/src/execution/session-reconstructor.ts +450 -0
- package/src/execution/session-runner.ts +725 -0
- package/src/execution/spawn-event-adapter.ts +150 -0
- package/src/execution/subagent-service.ts +973 -0
- package/src/execution/subprocess-agent-runner.ts +108 -0
- package/src/execution/temp-prompt.ts +57 -0
- package/src/execution/tombstone-store.ts +72 -0
- package/src/execution/turn-limiter.ts +88 -0
- package/src/execution/types.ts +634 -0
- package/src/execution/worktree-manager.ts +285 -0
- package/src/execution/worktree-registry.ts +144 -0
- package/src/index.ts +454 -0
- package/src/interface/bg-notify-render.ts +286 -0
- package/src/interface/commands.ts +157 -0
- package/src/interface/format.ts +501 -0
- package/src/interface/gui-adapter.ts +136 -0
- package/src/interface/helpers.ts +110 -0
- package/src/interface/list-component.ts +643 -0
- package/src/interface/list-shared.ts +84 -0
- package/src/interface/list-view.ts +373 -0
- package/src/interface/reentry-guard.ts +30 -0
- package/src/interface/subagent-actions.ts +294 -0
- package/src/interface/subagent-tool.ts +294 -0
- package/src/interface/subagents.ts +30 -0
- package/src/interface/tool-render.ts +333 -0
- package/src/interface/tool-workflow-script.ts +351 -0
- package/src/interface/tool-workflow.ts +485 -0
- package/src/interface/views/WorkflowsView.ts +944 -0
- package/src/interface/views/detail-content.ts +298 -0
- package/src/interface/views/format.ts +320 -0
- package/src/orchestration/__tests__/concurrency-gate.test.ts +125 -0
- package/src/orchestration/__tests__/config-loader.test.ts +381 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +332 -0
- package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +248 -0
- package/src/orchestration/__tests__/lifecycle.test.ts +385 -0
- package/src/orchestration/__tests__/script-lint.test.ts +347 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +42 -0
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +319 -0
- package/src/orchestration/agent-opts-resolver.ts +128 -0
- package/src/orchestration/concurrency-gate.ts +69 -0
- package/src/orchestration/config-loader.ts +313 -0
- package/src/orchestration/error-recovery.ts +578 -0
- package/src/orchestration/execute-agent-call.ts +174 -0
- package/src/orchestration/jsonl-run-store.ts +292 -0
- package/src/orchestration/launcher.ts +368 -0
- package/src/orchestration/lifecycle.ts +373 -0
- package/src/orchestration/models/__tests__/budget.test.ts +367 -0
- package/src/orchestration/models/agent-call.ts +76 -0
- package/src/orchestration/models/budget.ts +148 -0
- package/src/orchestration/models/ports.ts +165 -0
- package/src/orchestration/models/run-runtime.ts +91 -0
- package/src/orchestration/models/run-spec.ts +54 -0
- package/src/orchestration/models/run-state.ts +44 -0
- package/src/orchestration/models/trace.ts +102 -0
- package/src/orchestration/models/types.ts +242 -0
- package/src/orchestration/models/workflow-run.ts +275 -0
- package/src/orchestration/models/workflow-script-registry.ts +32 -0
- package/src/orchestration/models/workflow-script.ts +90 -0
- package/src/orchestration/node-ops.ts +192 -0
- package/src/orchestration/script-lint.ts +387 -0
- package/src/orchestration/skill-discovery.ts +60 -0
- package/src/orchestration/worker-handle.ts +115 -0
- package/src/orchestration/worker-host.ts +93 -0
- package/src/orchestration/worker-script-builder.ts +281 -0
- package/src/orchestration/workflow-files.ts +85 -0
- package/src/orchestration/workflow-script-registry-impl.ts +128 -0
- package/src/shared/__tests__/resource-discovery.test.ts +226 -0
- package/src/shared/agent-event.ts +13 -0
- package/src/shared/resource-discovery.ts +535 -0
|
@@ -0,0 +1,944 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Fullscreen TUI View — Three-level navigation.
|
|
3
|
+
*
|
|
4
|
+
* Level 0 (Phase): 左 phase list,右 agent overview
|
|
5
|
+
* Level 1 (Agent): 左 agent list,右 agent summary
|
|
6
|
+
* Level 2 (Detail): 完整 agent 执行详情
|
|
7
|
+
*
|
|
8
|
+
* 读 WorkflowRun 聚合根;移除 restart(D-9)。新 engine 无 orchestrator 事件层
|
|
9
|
+
* (AC-3),view 改用内部轮询(setInterval TICK_MS)从 run.state.trace 实时读 +
|
|
10
|
+
* requestRender。
|
|
11
|
+
*
|
|
12
|
+
* SDK 集成:ctx.ui.custom factory 返回 Component{render(width), handleInput(data),
|
|
13
|
+
* invalidate},第二参数 `{overlay:true, overlayOptions}`(全屏 overlay,对齐 main +
|
|
14
|
+
* subagents 扩展 + docs/pi-tui-development-guide.md §3.2)。按键经
|
|
15
|
+
* matchesKey(data, KeyId) 解析(兼容 xterm/iTerm/kitty 转义序列差异)。
|
|
16
|
+
* escape/ctrl+c 在 keybindings 同映射到 exit。
|
|
17
|
+
*
|
|
18
|
+
* 关键实现点:(a) overlay 第二参数必须传,否则 view 不全屏;(b) trace 必须 per-render
|
|
19
|
+
* 重读(run.state.trace.toArray 返回内部数组引用,trace.append 后下次 render 可见),
|
|
20
|
+
* 配 1s tick invalidate + requestRender 保证运行中刷新;(c) 's' save 模式无条件可用
|
|
21
|
+
* (saveWorkflow 内部对非 tmp workflow 返回错误消息);(d) pause/resume/abort 失败时
|
|
22
|
+
* notify 反馈,不静默吞。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { promises as fsPromises } from "node:fs";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { join as pathJoin } from "node:path";
|
|
28
|
+
|
|
29
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
30
|
+
import { Key, matchesKey } from "@mariozechner/pi-tui";
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
getAllToolCalls,
|
|
34
|
+
projectLiveProgress,
|
|
35
|
+
} from "../../execution/execution-record.ts";
|
|
36
|
+
import type { ExecutionTraceNode } from "../../orchestration/models/types.ts";
|
|
37
|
+
import type { WorkflowRun } from "../../orchestration/models/workflow-run.ts";
|
|
38
|
+
import { saveWorkflow } from "../../orchestration/workflow-files.ts";
|
|
39
|
+
import {
|
|
40
|
+
BOX_BORDER_CHARS,
|
|
41
|
+
BUDGET_TOKENS_DIVISOR,
|
|
42
|
+
buildPhaseGroups,
|
|
43
|
+
ELLIPSIS,
|
|
44
|
+
formatActivityLine,
|
|
45
|
+
formatAgentOneLiner,
|
|
46
|
+
formatElapsed,
|
|
47
|
+
formatElapsedSeconds,
|
|
48
|
+
formatPhaseLine,
|
|
49
|
+
formatStatusBadge,
|
|
50
|
+
padVisible,
|
|
51
|
+
SIDEBAR_WIDTH,
|
|
52
|
+
statusDotStr,
|
|
53
|
+
TERM_ROWS_FALLBACK,
|
|
54
|
+
type ThemeLike,
|
|
55
|
+
visibleLen,
|
|
56
|
+
} from "./format.ts";
|
|
57
|
+
|
|
58
|
+
// L2 详情内容构建 + 滚动按键(纯函数)抽到 detail-content.ts;此处 re-export 保持
|
|
59
|
+
// view 的对外 API 不变(测试仍从 WorkflowsView 导入)。
|
|
60
|
+
export {
|
|
61
|
+
buildDetailContent,
|
|
62
|
+
detailContentLength,
|
|
63
|
+
type DetailKeyResult,
|
|
64
|
+
type DetailScrollContext,
|
|
65
|
+
processDetailKey,
|
|
66
|
+
} from "./detail-content.ts";
|
|
67
|
+
import { buildDetailContent, detailContentLength, type DetailScrollContext,processDetailKey } from "./detail-content.ts";
|
|
68
|
+
|
|
69
|
+
// ── TUI layout constants ──────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
const NAV_LEVEL_DETAIL = 2;
|
|
72
|
+
type NavLevel = 0 | 1 | typeof NAV_LEVEL_DETAIL;
|
|
73
|
+
const MIN_BODY_LINES = 3;
|
|
74
|
+
const BODY_HEIGHT_NUMERATOR = 2;
|
|
75
|
+
const BODY_HEIGHT_DENOMINATOR = 3;
|
|
76
|
+
/** save overlay 居中计算的除数(÷2 居中)。 */
|
|
77
|
+
const OVERLAY_CENTER_DIVISOR = 2;
|
|
78
|
+
|
|
79
|
+
/** 轮询间隔:engine 无事件推送,view 自轮询 trace 变化。
|
|
80
|
+
* 200ms 对齐 subagents spinner 节奏,保证 agent 运行中 live 进度(tool calls/activity)流式可见。 */
|
|
81
|
+
const TICK_MS = 200;
|
|
82
|
+
|
|
83
|
+
/** 可打印 ASCII 字符下限(用于 save overlay 输入过滤)。 */
|
|
84
|
+
const PRINTABLE_CHAR_MIN = 32;
|
|
85
|
+
|
|
86
|
+
// ── 边框着色 helper(统一 borderMuted,避 ANSI 嵌套失色)──────────
|
|
87
|
+
// 对齐 subagents list-component.ts 的 b/dash/dashes/titleBorder/plainBorder/walled。
|
|
88
|
+
// 所有 ╭╮╰╯├┤┬┴─│ 统一走 borderMuted token,保证边框颜色一致。
|
|
89
|
+
|
|
90
|
+
/** 着色单个框线字符(borderMuted)。 */
|
|
91
|
+
function b(theme: ThemeLike, s: string): string {
|
|
92
|
+
return theme.fg("borderMuted", s);
|
|
93
|
+
}
|
|
94
|
+
/** 着色单字符填充用的 ─(供 segFillColored 的 fillStyled)。 */
|
|
95
|
+
function dash(theme: ThemeLike): string {
|
|
96
|
+
return theme.fg("borderMuted", "─");
|
|
97
|
+
}
|
|
98
|
+
/** 满宽 ─ 填充串(borderMuted)。n 次单字符着色,ANSI 自然延续。 */
|
|
99
|
+
function dashes(theme: ThemeLike, n: number): string {
|
|
100
|
+
return dash(theme).repeat(Math.max(0, n));
|
|
101
|
+
}
|
|
102
|
+
/** 纯线顶/底框(无标题):左角 + ─×W + 右角。 */
|
|
103
|
+
function plainBorder(theme: ThemeLike, left: string, right: string, contentWidth: number): string {
|
|
104
|
+
return b(theme, left) + dashes(theme, contentWidth) + b(theme, right);
|
|
105
|
+
}
|
|
106
|
+
/** 内容行墙:│ + 内容(pad 到 contentWidth) + │,墙字符 borderMuted。 */
|
|
107
|
+
function walled(theme: ThemeLike, content: string, contentWidth: number): string {
|
|
108
|
+
return `${b(theme, "│")}${padVisible(content, contentWidth)}${b(theme, "│")}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Minimal TUI duck-types(避免直接 import TUI/KeybindingsManager 类型 ──
|
|
112
|
+
// 共享类型 fallback shared/types/mariozechner/index.d.ts 不导出 TUI 类,
|
|
113
|
+
// workspace 跨包 typecheck 会报 "no exported member 'TUI'"。
|
|
114
|
+
// 此处用结构化接口替代——只声明 view 实际用到的成员(requestRender + terminal)。
|
|
115
|
+
|
|
116
|
+
interface TuiLike {
|
|
117
|
+
terminal: { columns: number; rows: number };
|
|
118
|
+
requestRender(): void;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── View actions ──────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* view 可触发的 lifecycle 操作(由调用方注入,避免 view 直接依赖 Engine 函数)。
|
|
125
|
+
* 每个 action 接收 runId;调用方绑到 pauseRun/resumeRun/abortRun。
|
|
126
|
+
*/
|
|
127
|
+
export interface ViewActions {
|
|
128
|
+
pause: (runId: string) => Promise<void>;
|
|
129
|
+
resume: (runId: string) => Promise<void>;
|
|
130
|
+
abort: (runId: string) => Promise<void>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── View state ────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
interface ViewState {
|
|
136
|
+
level: NavLevel;
|
|
137
|
+
phaseIdx: number;
|
|
138
|
+
agentIdx: number;
|
|
139
|
+
promptExpanded: boolean;
|
|
140
|
+
disposed: boolean;
|
|
141
|
+
// ── Save mode ──
|
|
142
|
+
saveMode: boolean;
|
|
143
|
+
saveInputValue: string;
|
|
144
|
+
saveMessage: string;
|
|
145
|
+
saveMsgOk: boolean;
|
|
146
|
+
// ── L0/L1 列表滚动 ──
|
|
147
|
+
/** 左侧 phase list 滚动 offset。 */
|
|
148
|
+
phaseScrollOffset: number;
|
|
149
|
+
/** 右侧 agent list 滚动 offset。 */
|
|
150
|
+
agentScrollOffset: number;
|
|
151
|
+
// ── L2 详情滚动 ──
|
|
152
|
+
/** 右侧 detail 当前滚动 offset(render 路径 clamp 收敛)。 */
|
|
153
|
+
detailScrollOffset: number;
|
|
154
|
+
/** running 态是否自动钉底部(用户 PgUp 后置 false,End/PgDn 到底恢复 true)。 */
|
|
155
|
+
followTail: boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function createInitialState(): ViewState {
|
|
159
|
+
return {
|
|
160
|
+
level: 0,
|
|
161
|
+
phaseIdx: 0,
|
|
162
|
+
agentIdx: 0,
|
|
163
|
+
promptExpanded: false,
|
|
164
|
+
disposed: false,
|
|
165
|
+
saveMode: false,
|
|
166
|
+
saveInputValue: "",
|
|
167
|
+
saveMessage: "",
|
|
168
|
+
saveMsgOk: false,
|
|
169
|
+
phaseScrollOffset: 0,
|
|
170
|
+
agentScrollOffset: 0,
|
|
171
|
+
detailScrollOffset: 0,
|
|
172
|
+
followTail: true, // 默认钉底(对齐 subagents 进详情即底部对齐)
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── View factory ──────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 创建 workflow fullscreen view。
|
|
180
|
+
*
|
|
181
|
+
* @param run WorkflowRun 聚合根(读 state.status/spec/trace/meta)
|
|
182
|
+
* @param theme ThemeLike(避免直接 import Pi runtime)
|
|
183
|
+
* @param ctx ExtensionContext(调 ui.custom 渲染 + ui.notify 错误反馈)
|
|
184
|
+
* @param actions lifecycle 操作(pause/resume/abort),由调用方注入
|
|
185
|
+
*/
|
|
186
|
+
export function createWorkflowsView(
|
|
187
|
+
run: WorkflowRun,
|
|
188
|
+
theme: ThemeLike,
|
|
189
|
+
ctx: ExtensionContext,
|
|
190
|
+
actions: ViewActions,
|
|
191
|
+
): Promise<void> {
|
|
192
|
+
return ctx.ui.custom<void>((_tui: unknown, _t: unknown, _kb: unknown, done: (result: void) => void) => {
|
|
193
|
+
const state = createInitialState();
|
|
194
|
+
const tui = _tui as TuiLike;
|
|
195
|
+
|
|
196
|
+
function currentPhaseAgents() {
|
|
197
|
+
const live = buildPhaseGroups([...run.state.trace.toArray()]);
|
|
198
|
+
const pg = live[state.phaseIdx];
|
|
199
|
+
return pg ? pg.nodes : [];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function clampSelections() {
|
|
203
|
+
const live = buildPhaseGroups([...run.state.trace.toArray()]);
|
|
204
|
+
if (state.phaseIdx >= live.length) state.phaseIdx = Math.max(0, live.length - 1);
|
|
205
|
+
const agents = currentPhaseAgents();
|
|
206
|
+
if (state.agentIdx >= agents.length) state.agentIdx = Math.max(0, agents.length - 1);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 渲染缓存:缓存 key = width×rows,终端 resize 改高度时缓存失效(防行数不匹配终端)
|
|
210
|
+
const cache = { key: undefined as string | undefined, lines: undefined as string[] | undefined };
|
|
211
|
+
const requestRender = () => tui.requestRender();
|
|
212
|
+
|
|
213
|
+
// ── 轮询 tick:engine 无事件推送,view 自轮询 trace 变化 ──
|
|
214
|
+
// 每 200ms 重绘,保证 header 动态数据(elapsed/tokens)实时更新。
|
|
215
|
+
// 行数固定后,diff-redraw 引擎能正确逐行对比,不会出现残影。
|
|
216
|
+
const tick = setInterval(() => {
|
|
217
|
+
if (state.disposed) return;
|
|
218
|
+
cache.key = undefined;
|
|
219
|
+
cache.lines = undefined;
|
|
220
|
+
requestRender();
|
|
221
|
+
}, TICK_MS);
|
|
222
|
+
|
|
223
|
+
const wrappedDone = () => {
|
|
224
|
+
if (state.disposed) return;
|
|
225
|
+
state.disposed = true;
|
|
226
|
+
clearInterval(tick);
|
|
227
|
+
done();
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// ── L2 详情滚动辅助 ──
|
|
231
|
+
/** 安全读 terminal.rows(duck-type 失败兜底,对齐 subagents termRows)。 */
|
|
232
|
+
function termRows(): number {
|
|
233
|
+
const rows = tui.terminal?.rows;
|
|
234
|
+
return typeof rows === "number" && rows > 0 ? rows : TERM_ROWS_FALLBACK;
|
|
235
|
+
}
|
|
236
|
+
/** L2 右侧 detail viewport 高度(与 renderLayout 的 viewH 同源)。 */
|
|
237
|
+
function detailViewportHeight(): number {
|
|
238
|
+
return Math.max(MIN_BODY_LINES, bodyHeight(termRows()));
|
|
239
|
+
}
|
|
240
|
+
/** 重置 detail 滚动到底部对齐(切 agent / 进 L2 时调用)。 */
|
|
241
|
+
function resetDetailScroll(): void {
|
|
242
|
+
state.detailScrollOffset = Number.MAX_SAFE_INTEGER; // render clamp 收敛到 max
|
|
243
|
+
state.followTail = true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Key handling(SDK Component.handleInput 模式,对齐 main) ──
|
|
247
|
+
// matchesKey(data, KeyId) 处理终端转义序列差异(xterm/iTerm/kitty),
|
|
248
|
+
// 优于手写 \x1b[A 等原始序列。escape/ctrl+c 在 keybindings 里同映射到 exit。
|
|
249
|
+
function handleInput(data: string): void {
|
|
250
|
+
if (state.disposed) return;
|
|
251
|
+
|
|
252
|
+
// ── Save mode 拦截(缺陷 #3 恢复)── save overlay 活跃时,所有键走 save 流程
|
|
253
|
+
if (state.saveMode) {
|
|
254
|
+
handleSaveModeInput(data);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Escape / ctrl+c: level back or exit
|
|
259
|
+
if (matchesKey(data, Key.escape)) {
|
|
260
|
+
if (state.level === 0) {
|
|
261
|
+
wrappedDone();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
state.level = (state.level - 1) as NavLevel;
|
|
265
|
+
state.promptExpanded = false;
|
|
266
|
+
cache.key = undefined;
|
|
267
|
+
requestRender();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── L2 详情滚动(PgUp/PgDn/Home/End,对齐 subagents processKey 阶段 2) ──
|
|
272
|
+
// up/down 在 L2 用于切 agent,故滚动键独立于此;未命中则落到下面的 up/down/enter。
|
|
273
|
+
if (state.level === NAV_LEVEL_DETAIL) {
|
|
274
|
+
const node = currentPhaseAgents()[state.agentIdx];
|
|
275
|
+
if (node) {
|
|
276
|
+
const detailCtx: DetailScrollContext = {
|
|
277
|
+
viewportHeight: detailViewportHeight(),
|
|
278
|
+
contentLines: detailContentLength(node, state, run, theme),
|
|
279
|
+
isRunning: node.status === "running",
|
|
280
|
+
};
|
|
281
|
+
const r = processDetailKey(
|
|
282
|
+
data,
|
|
283
|
+
{ scrollOffset: state.detailScrollOffset, followTail: state.followTail },
|
|
284
|
+
detailCtx,
|
|
285
|
+
);
|
|
286
|
+
if (r.handled) {
|
|
287
|
+
state.detailScrollOffset = r.scrollOffset;
|
|
288
|
+
state.followTail = r.followTail;
|
|
289
|
+
cache.key = undefined;
|
|
290
|
+
requestRender();
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Navigation: up/down
|
|
297
|
+
if (matchesKey(data, Key.up)) {
|
|
298
|
+
if (state.level === 0 && state.phaseIdx > 0) {
|
|
299
|
+
state.phaseIdx--;
|
|
300
|
+
state.agentIdx = 0;
|
|
301
|
+
state.phaseScrollOffset = 0; // 切 phase → 重置滚动
|
|
302
|
+
state.agentScrollOffset = 0; // 切 phase → 重置滚动
|
|
303
|
+
} else if (state.level === 1 && state.agentIdx > 0) {
|
|
304
|
+
state.agentIdx--;
|
|
305
|
+
// agentScrollOffset 由 renderLevel1 自动调整
|
|
306
|
+
} else if (state.level === NAV_LEVEL_DETAIL && state.agentIdx > 0) {
|
|
307
|
+
state.agentIdx--;
|
|
308
|
+
state.promptExpanded = false;
|
|
309
|
+
resetDetailScroll(); // 切 agent → 底部对齐(对齐 subagents 进详情即钉底)
|
|
310
|
+
}
|
|
311
|
+
cache.key = undefined;
|
|
312
|
+
requestRender();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (matchesKey(data, Key.down)) {
|
|
316
|
+
if (state.level === 0) {
|
|
317
|
+
const live = buildPhaseGroups([...run.state.trace.toArray()]);
|
|
318
|
+
if (state.phaseIdx < live.length - 1) {
|
|
319
|
+
state.phaseIdx++;
|
|
320
|
+
state.agentIdx = 0;
|
|
321
|
+
state.phaseScrollOffset = 0; // 切 phase → 重置滚动
|
|
322
|
+
state.agentScrollOffset = 0; // 切 phase → 重置滚动
|
|
323
|
+
}
|
|
324
|
+
} else if (state.level === 1) {
|
|
325
|
+
const agents = currentPhaseAgents();
|
|
326
|
+
if (state.agentIdx < agents.length - 1) state.agentIdx++;
|
|
327
|
+
// agentScrollOffset 由 renderLevel1 自动调整
|
|
328
|
+
} else if (state.level === NAV_LEVEL_DETAIL) {
|
|
329
|
+
const agents = currentPhaseAgents();
|
|
330
|
+
if (state.agentIdx < agents.length - 1) {
|
|
331
|
+
state.agentIdx++;
|
|
332
|
+
state.promptExpanded = false;
|
|
333
|
+
resetDetailScroll(); // 切 agent → 底部对齐
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
cache.key = undefined;
|
|
337
|
+
requestRender();
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Enter: drill down (L0→L1→L2) or toggle prompt (L2)
|
|
342
|
+
if (matchesKey(data, Key.enter)) {
|
|
343
|
+
if (state.level === 0 && currentPhaseAgents().length > 0) {
|
|
344
|
+
state.level = 1;
|
|
345
|
+
state.agentIdx = 0;
|
|
346
|
+
state.agentScrollOffset = 0; // 进 L1 → 重置滚动
|
|
347
|
+
} else if (state.level === 1) {
|
|
348
|
+
state.level = NAV_LEVEL_DETAIL;
|
|
349
|
+
state.promptExpanded = false;
|
|
350
|
+
resetDetailScroll(); // 进 L2 → 底部对齐
|
|
351
|
+
} else if (state.level === NAV_LEVEL_DETAIL) {
|
|
352
|
+
state.promptExpanded = !state.promptExpanded;
|
|
353
|
+
// 展开/折叠改变内容长度,render 路径 clamp 收敛;保持当前锚点语义
|
|
354
|
+
}
|
|
355
|
+
cache.key = undefined;
|
|
356
|
+
requestRender();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── Lifecycle shortcuts (no restart per D-9) ──
|
|
361
|
+
if (data === "p") {
|
|
362
|
+
if (run.state.status === "running") {
|
|
363
|
+
void actions.pause(run.runId)
|
|
364
|
+
.then(() => { cache.key = undefined; requestRender(); })
|
|
365
|
+
.catch((err: Error) => ctx.ui.notify(`Pause failed: ${err.message}`, "error"));
|
|
366
|
+
} else if (run.state.status === "paused") {
|
|
367
|
+
void actions.resume(run.runId)
|
|
368
|
+
.then(() => { cache.key = undefined; requestRender(); })
|
|
369
|
+
.catch((err: Error) => ctx.ui.notify(`Resume failed: ${err.message}`, "error"));
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (data === "a") {
|
|
374
|
+
if (run.state.status === "running" || run.state.status === "paused") {
|
|
375
|
+
void actions.abort(run.runId)
|
|
376
|
+
.then(() => { cache.key = undefined; requestRender(); })
|
|
377
|
+
.catch((err: Error) => ctx.ui.notify(`Abort failed: ${err.message}`, "error"));
|
|
378
|
+
}
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ── Save shortcut(对齐 main:总是进入 save mode,非 tmp 时 saveWorkflow 报错) ──
|
|
383
|
+
if (data === "s") {
|
|
384
|
+
state.saveMode = true;
|
|
385
|
+
state.saveInputValue = run.spec.scriptName;
|
|
386
|
+
state.saveMessage = "";
|
|
387
|
+
state.saveMsgOk = false;
|
|
388
|
+
cache.key = undefined;
|
|
389
|
+
requestRender();
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ── Trace export(对齐 main 的 S 键):导出完整 trace 到 Markdown 文件 ──
|
|
394
|
+
if (data === "S") {
|
|
395
|
+
saveTraceToFile(run, ctx);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** save overlay 内的按键处理(esc 取消 / enter 保存 / backspace 删除 / 可打印追加)。 */
|
|
401
|
+
function handleSaveModeInput(data: string): void {
|
|
402
|
+
// Escape → 退出 save 模式
|
|
403
|
+
if (matchesKey(data, Key.escape)) {
|
|
404
|
+
state.saveMode = false;
|
|
405
|
+
cache.key = undefined;
|
|
406
|
+
requestRender();
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
// Enter → 保存
|
|
410
|
+
if (data === "\r" || data === "\n") {
|
|
411
|
+
const name = state.saveInputValue.trim();
|
|
412
|
+
if (!name) {
|
|
413
|
+
state.saveMessage = "Please enter a name";
|
|
414
|
+
state.saveMsgOk = false;
|
|
415
|
+
cache.key = undefined;
|
|
416
|
+
requestRender();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
void saveWorkflow(run.spec.scriptName, name)
|
|
420
|
+
.then((msg) => {
|
|
421
|
+
state.saveMessage = msg;
|
|
422
|
+
state.saveMsgOk = true;
|
|
423
|
+
state.saveMode = false;
|
|
424
|
+
cache.key = undefined;
|
|
425
|
+
requestRender();
|
|
426
|
+
})
|
|
427
|
+
.catch((err: Error) => {
|
|
428
|
+
state.saveMessage = err.message;
|
|
429
|
+
state.saveMsgOk = false;
|
|
430
|
+
cache.key = undefined;
|
|
431
|
+
requestRender();
|
|
432
|
+
});
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
// Backspace → 删除最后一个字符
|
|
436
|
+
if (data === "\x7f" || data === "\b") {
|
|
437
|
+
state.saveMessage = "";
|
|
438
|
+
if (state.saveInputValue.length > 0) {
|
|
439
|
+
state.saveInputValue = state.saveInputValue.slice(0, -1);
|
|
440
|
+
}
|
|
441
|
+
cache.key = undefined;
|
|
442
|
+
requestRender();
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
// 可打印字符 → 追加
|
|
446
|
+
if (data.length === 1 && data.charCodeAt(0) >= PRINTABLE_CHAR_MIN) {
|
|
447
|
+
state.saveMessage = "";
|
|
448
|
+
state.saveInputValue += data;
|
|
449
|
+
cache.key = undefined;
|
|
450
|
+
requestRender();
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
// 屏蔽其他键(↑↓ 等)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ── Component(SDK 规范:render(width) → string[] + handleInput + invalidate) ──
|
|
457
|
+
return {
|
|
458
|
+
invalidate(): void {
|
|
459
|
+
cache.key = undefined;
|
|
460
|
+
cache.lines = undefined;
|
|
461
|
+
},
|
|
462
|
+
render(width: number): string[] {
|
|
463
|
+
const height = tui.terminal.rows;
|
|
464
|
+
const key = `${width}x${height}`;
|
|
465
|
+
if (cache.lines && cache.key === key) return cache.lines;
|
|
466
|
+
clampSelections();
|
|
467
|
+
// 缺陷 #1 修复:每次 render 从 run.state.trace 实时读(toArray 返回内部数组引用,
|
|
468
|
+
// 后续 trace.append 会反映到 view),不再用 factory 时的冻结快照。
|
|
469
|
+
const liveGroups = buildPhaseGroups([...run.state.trace.toArray()]);
|
|
470
|
+
const raw = renderLayout(run, state, liveGroups, theme, width, height);
|
|
471
|
+
// Pad to terminal height so the overlay fills the screen (matches main 行为)
|
|
472
|
+
const lines = raw.length < height
|
|
473
|
+
? [...raw, ...Array.from({ length: height - raw.length }, () => "")]
|
|
474
|
+
: raw;
|
|
475
|
+
cache.key = key;
|
|
476
|
+
cache.lines = lines;
|
|
477
|
+
return lines;
|
|
478
|
+
},
|
|
479
|
+
handleInput,
|
|
480
|
+
};
|
|
481
|
+
}, {
|
|
482
|
+
// 缺陷 #2 修复:overlay 第二参数(对齐 main + subagents + pi-tui guide §3.2)
|
|
483
|
+
overlay: true,
|
|
484
|
+
overlayOptions: { anchor: "center" as const, width: "100%", maxHeight: "100%", margin: 0 },
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ── Layout rendering(box-drawing 框架,对齐 main)──────────────
|
|
489
|
+
//
|
|
490
|
+
// 视觉结构(与 main 一致):
|
|
491
|
+
// ╭───────────────────────────────────────────────────╮
|
|
492
|
+
// │ name (bold) ● status · x/y · Ns │ ← header
|
|
493
|
+
// ├───────────────────────────────────────────────────┤
|
|
494
|
+
// │ Phases │ title · N agents │
|
|
495
|
+
// │ ──────────────── │ ────────────────── │ ← body
|
|
496
|
+
// │ ❯ ● 1 build 0/2 │ ● builder model ... │
|
|
497
|
+
// │ ● 2 deploy 0/1 │ ● tester model ... │
|
|
498
|
+
// │ (pad) │ (pad) │
|
|
499
|
+
// ╰───────────────────────────────────────────────────╯
|
|
500
|
+
// ↑↓ phase · ⏎ enter · p pause · a abort · s save · esc back ← footer (框外)
|
|
501
|
+
//
|
|
502
|
+
// body = sidebar(SIDEBAR_WIDTH) │ main(rest)
|
|
503
|
+
// save overlay 活跃时居中覆盖 body。
|
|
504
|
+
|
|
505
|
+
function bodyHeight(screenHeight: number): number {
|
|
506
|
+
// header(2: name+desc/blank) + border(1) + body + border(1) + footer(1)
|
|
507
|
+
const HEADER_FOOTER_LINES = 6;
|
|
508
|
+
return Math.max(MIN_BODY_LINES, Math.floor((screenHeight * BODY_HEIGHT_NUMERATOR) / BODY_HEIGHT_DENOMINATOR) - HEADER_FOOTER_LINES);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const BUDGET_COST_DECIMALS = 4;
|
|
512
|
+
|
|
513
|
+
function renderLayout(
|
|
514
|
+
run: WorkflowRun,
|
|
515
|
+
state: ViewState,
|
|
516
|
+
phaseGroups: ReturnType<typeof buildPhaseGroups>,
|
|
517
|
+
theme: ThemeLike,
|
|
518
|
+
screenWidth: number,
|
|
519
|
+
screenHeight: number,
|
|
520
|
+
): string[] {
|
|
521
|
+
const lines: string[] = [];
|
|
522
|
+
const contentWidth = screenWidth - BOX_BORDER_CHARS;
|
|
523
|
+
const mainWidth = contentWidth - SIDEBAR_WIDTH - 1; // -1 for the │ divider
|
|
524
|
+
|
|
525
|
+
renderHeader(lines, run, theme, contentWidth);
|
|
526
|
+
|
|
527
|
+
const phase = phaseGroups[state.phaseIdx] ?? phaseGroups[0];
|
|
528
|
+
const agents = phase?.nodes ?? [];
|
|
529
|
+
const now = Date.now();
|
|
530
|
+
|
|
531
|
+
// 固定 body 高度,确保 renderLayout 始终返回固定行数
|
|
532
|
+
const viewH = Math.max(MIN_BODY_LINES, bodyHeight(screenHeight));
|
|
533
|
+
|
|
534
|
+
const bodyStart = lines.length;
|
|
535
|
+
if (state.level === 0) {
|
|
536
|
+
renderLevel0(lines, run, phaseGroups, state, theme, mainWidth, now, viewH);
|
|
537
|
+
} else if (state.level === 1) {
|
|
538
|
+
renderLevel1(lines, run, phaseGroups, state, theme, mainWidth, now, viewH);
|
|
539
|
+
} else {
|
|
540
|
+
// L2 详情走固定高度 viewport(右侧滚动),高度 = minBody(与 L0/L1 最小一致)
|
|
541
|
+
renderLevel2(lines, run, agents, state, theme, mainWidth, now, viewH);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Padding 已在各 renderLevel 内部处理,无需额外 padding
|
|
545
|
+
|
|
546
|
+
// Wrap body lines with │ borders + sidebar divider(统一 borderMuted)
|
|
547
|
+
for (let i = bodyStart; i < lines.length; i++) {
|
|
548
|
+
lines[i] = walled(theme, lines[i], contentWidth);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
lines.push(plainBorder(theme, "╰", "╯", contentWidth));
|
|
552
|
+
|
|
553
|
+
// Save overlay(缺陷 #3):居中覆盖 body
|
|
554
|
+
if (state.saveMode) {
|
|
555
|
+
const overlayLines = renderSaveOverlay(state, theme, screenWidth);
|
|
556
|
+
const overlayStart = Math.max(bodyStart, bodyStart + Math.floor((lines.length - bodyStart - overlayLines.length) / OVERLAY_CENTER_DIVISOR));
|
|
557
|
+
for (let i = 0; i < overlayLines.length && overlayStart + i < lines.length; i++) {
|
|
558
|
+
lines[overlayStart + i] = overlayLines[i];
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Footer(框外,对齐 main renderFooter)
|
|
563
|
+
renderFooter(lines, run, state, theme);
|
|
564
|
+
return lines;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Header:╭─╮ + name(bold) + 右侧 status/agents/elapsed/budget。 */
|
|
568
|
+
function renderHeader(
|
|
569
|
+
lines: string[],
|
|
570
|
+
run: WorkflowRun,
|
|
571
|
+
theme: ThemeLike,
|
|
572
|
+
contentWidth: number,
|
|
573
|
+
): void {
|
|
574
|
+
const traceArr = run.state.trace.toArray();
|
|
575
|
+
const completed = traceArr.filter((n) => n.status === "completed").length;
|
|
576
|
+
const total = traceArr.length;
|
|
577
|
+
const elapsed = formatElapsed(run.meta.startedAt);
|
|
578
|
+
const headerRight = `${formatStatusBadge(run.state.status, theme)} · ${completed}/${total} agents · ${elapsed}`;
|
|
579
|
+
const budget = run.state.budget;
|
|
580
|
+
const budgetStr = `${Math.round(budget.usedTokens / BUDGET_TOKENS_DIVISOR)}k/${budget.maxTokens ? `${Math.round(budget.maxTokens / BUDGET_TOKENS_DIVISOR)}k` : "∞"} tok · $${budget.usedCost.toFixed(BUDGET_COST_DECIMALS)}`;
|
|
581
|
+
|
|
582
|
+
const nameLine = theme.bold(run.spec.scriptName);
|
|
583
|
+
const rightPart = theme.fg("muted", `${headerRight} · ${budgetStr}`);
|
|
584
|
+
|
|
585
|
+
lines.push(plainBorder(theme, "╭", "╮", contentWidth));
|
|
586
|
+
lines.push(walled(theme, nameLine, contentWidth));
|
|
587
|
+
|
|
588
|
+
if (run.spec.description) {
|
|
589
|
+
const maxDesc = contentWidth - visibleLen(rightPart) - 1;
|
|
590
|
+
const descText = run.spec.description.length > maxDesc
|
|
591
|
+
? run.spec.description.slice(0, maxDesc - 1) + ELLIPSIS
|
|
592
|
+
: run.spec.description;
|
|
593
|
+
const descPart = theme.fg("dim", descText);
|
|
594
|
+
const padLen = Math.max(0, contentWidth - visibleLen(descPart) - visibleLen(rightPart));
|
|
595
|
+
lines.push(`${b(theme, "│")}${descPart}${" ".repeat(padLen)}${rightPart}${b(theme, "│")}`);
|
|
596
|
+
} else {
|
|
597
|
+
lines.push(walled(theme, rightPart, contentWidth));
|
|
598
|
+
}
|
|
599
|
+
lines.push(plainBorder(theme, "├", "┤", contentWidth));
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Footer(框外):nav hint + lifecycle shortcuts + esc/ctrl+c。 */
|
|
603
|
+
function renderFooter(
|
|
604
|
+
lines: string[],
|
|
605
|
+
run: WorkflowRun,
|
|
606
|
+
state: ViewState,
|
|
607
|
+
theme: ThemeLike,
|
|
608
|
+
): void {
|
|
609
|
+
const navPart = state.level === 0
|
|
610
|
+
? "↑↓ phase · ⏎ enter"
|
|
611
|
+
: state.level === 1
|
|
612
|
+
? "↑↓ agent · ⏎ detail"
|
|
613
|
+
: "↑↓ agent · ⏎ prompt · PgUp/PgDn scroll";
|
|
614
|
+
const actionParts: string[] = [];
|
|
615
|
+
const status = run.state.status;
|
|
616
|
+
if (status === "running" || status === "paused") {
|
|
617
|
+
actionParts.push("a abort");
|
|
618
|
+
actionParts.push(status === "paused" ? "p resume" : "p pause");
|
|
619
|
+
}
|
|
620
|
+
actionParts.push("s save");
|
|
621
|
+
actionParts.push("S trace");
|
|
622
|
+
actionParts.push("esc back");
|
|
623
|
+
const footer = `${navPart} · ${actionParts.join(" · ")}`;
|
|
624
|
+
lines.push("");
|
|
625
|
+
lines.push(theme.fg("muted", footer));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** mergeBody:左侧 sidebar + │ divider + 右侧 main,拼成 body 行(divider 着色 borderMuted)。 */
|
|
629
|
+
function mergeBody(
|
|
630
|
+
lines: string[],
|
|
631
|
+
leftLines: string[],
|
|
632
|
+
rightLines: string[],
|
|
633
|
+
theme: ThemeLike,
|
|
634
|
+
): void {
|
|
635
|
+
const bodyHeightVal = Math.max(leftLines.length, rightLines.length);
|
|
636
|
+
for (let i = 0; i < bodyHeightVal; i++) {
|
|
637
|
+
const left = padVisible(leftLines[i] ?? "", SIDEBAR_WIDTH);
|
|
638
|
+
lines.push(left + b(theme, "│") + (rightLines[i] ?? ""));
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// 分屏视图固定头部行数(title + separator = 2)。
|
|
643
|
+
const SPLIT_HEADER_LINES = 2;
|
|
644
|
+
|
|
645
|
+
/** 计算滚动视口起始 index,确保选中项可见(居中策略,参考 subagents renderLeftColumn)。
|
|
646
|
+
* 返回 [startIdx, viewportH]:startIdx 是内容区第一个可见项的 index,viewportH 是内容区可显示行数。 */
|
|
647
|
+
function computeViewport(
|
|
648
|
+
totalCount: number,
|
|
649
|
+
selectedIdx: number,
|
|
650
|
+
bodyH: number,
|
|
651
|
+
): { startIdx: number; viewportH: number } {
|
|
652
|
+
const viewportH = Math.max(0, bodyH - SPLIT_HEADER_LINES);
|
|
653
|
+
if (viewportH <= 0) return { startIdx: 0, viewportH: 0 };
|
|
654
|
+
if (totalCount <= viewportH) return { startIdx: 0, viewportH };
|
|
655
|
+
// 选中项居中,到列表顶/底贴边
|
|
656
|
+
const maxStart = Math.max(0, totalCount - viewportH);
|
|
657
|
+
const center = Math.floor(selectedIdx - viewportH / OVERLAY_CENTER_DIVISOR);
|
|
658
|
+
return { startIdx: Math.max(0, Math.min(center, maxStart)), viewportH };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ── Level 0: Phase selection ──────────────────────────────────
|
|
662
|
+
|
|
663
|
+
function renderLevel0(
|
|
664
|
+
lines: string[],
|
|
665
|
+
run: WorkflowRun,
|
|
666
|
+
phases: ReturnType<typeof buildPhaseGroups>,
|
|
667
|
+
state: ViewState,
|
|
668
|
+
theme: ThemeLike,
|
|
669
|
+
mainWidth: number,
|
|
670
|
+
now: number,
|
|
671
|
+
bodyH: number,
|
|
672
|
+
): void {
|
|
673
|
+
const leftLines: string[] = [];
|
|
674
|
+
const rightLines: string[] = [];
|
|
675
|
+
|
|
676
|
+
// Left: sidebar title + phase list(固定高度 viewport + 滚动,只构建可见行)
|
|
677
|
+
leftLines.push(theme.fg("muted", "Phases"));
|
|
678
|
+
leftLines.push(dashes(theme, SIDEBAR_WIDTH));
|
|
679
|
+
const { startIdx: phaseStart, viewportH: phaseViewportH } = computeViewport(phases.length, state.phaseIdx, bodyH);
|
|
680
|
+
for (let i = phaseStart; i < phaseStart + phaseViewportH && i < phases.length; i++) {
|
|
681
|
+
leftLines.push(formatPhaseLine(phases[i], i, i === state.phaseIdx, theme, SIDEBAR_WIDTH));
|
|
682
|
+
}
|
|
683
|
+
// 回写滚动偏移(clamp,防止状态脏值)
|
|
684
|
+
state.phaseScrollOffset = phaseStart;
|
|
685
|
+
// padding 到固定高度
|
|
686
|
+
while (leftLines.length < bodyH) leftLines.push("");
|
|
687
|
+
leftLines.length = bodyH;
|
|
688
|
+
|
|
689
|
+
// Right: agents in the currently selected phase only(固定高度 viewport + 滚动)
|
|
690
|
+
const selectedPhase = phases[state.phaseIdx] ?? phases[0];
|
|
691
|
+
rightLines.push(theme.fg("muted", selectedPhase
|
|
692
|
+
? (selectedPhase.name
|
|
693
|
+
? `${selectedPhase.name} · ${selectedPhase.nodes.length} agents · ${formatElapsed(run.meta.startedAt, now)}`
|
|
694
|
+
: `${selectedPhase.nodes.length} agents · ${formatElapsed(run.meta.startedAt, now)}`)
|
|
695
|
+
: "(no phase)"));
|
|
696
|
+
rightLines.push(dashes(theme, mainWidth));
|
|
697
|
+
if (selectedPhase) {
|
|
698
|
+
const { startIdx: agentStart, viewportH: agentViewportH } = computeViewport(selectedPhase.nodes.length, state.agentIdx, bodyH);
|
|
699
|
+
for (let i = agentStart; i < agentStart + agentViewportH && i < selectedPhase.nodes.length; i++) {
|
|
700
|
+
rightLines.push(formatAgentOneLiner(selectedPhase.nodes[i], theme));
|
|
701
|
+
}
|
|
702
|
+
state.agentScrollOffset = agentStart;
|
|
703
|
+
}
|
|
704
|
+
while (rightLines.length < bodyH) rightLines.push("");
|
|
705
|
+
rightLines.length = bodyH;
|
|
706
|
+
|
|
707
|
+
mergeBody(lines, leftLines, rightLines, theme);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// ── Level 1: Agent selection ──────────────────────────────────
|
|
711
|
+
|
|
712
|
+
function renderLevel1(
|
|
713
|
+
lines: string[],
|
|
714
|
+
_run: WorkflowRun,
|
|
715
|
+
phases: ReturnType<typeof buildPhaseGroups>,
|
|
716
|
+
state: ViewState,
|
|
717
|
+
theme: ThemeLike,
|
|
718
|
+
mainWidth: number,
|
|
719
|
+
now: number,
|
|
720
|
+
bodyH: number,
|
|
721
|
+
): void {
|
|
722
|
+
const leftLines: string[] = [];
|
|
723
|
+
const rightLines: string[] = [];
|
|
724
|
+
|
|
725
|
+
// Left: sidebar title + phase list(固定高度 viewport + 滚动,只构建可见行)
|
|
726
|
+
leftLines.push(theme.fg("muted", "Phases"));
|
|
727
|
+
leftLines.push(dashes(theme, SIDEBAR_WIDTH));
|
|
728
|
+
const { startIdx: phaseStart, viewportH: phaseViewportH } = computeViewport(phases.length, state.phaseIdx, bodyH);
|
|
729
|
+
for (let i = phaseStart; i < phaseStart + phaseViewportH && i < phases.length; i++) {
|
|
730
|
+
leftLines.push(formatPhaseLine(phases[i], i, i === state.phaseIdx, theme, SIDEBAR_WIDTH));
|
|
731
|
+
}
|
|
732
|
+
state.phaseScrollOffset = phaseStart;
|
|
733
|
+
while (leftLines.length < bodyH) leftLines.push("");
|
|
734
|
+
leftLines.length = bodyH;
|
|
735
|
+
|
|
736
|
+
// Right: agent list(固定高度 viewport + 滚动)
|
|
737
|
+
const currentPhase = phases[state.phaseIdx];
|
|
738
|
+
const agents = currentPhase?.nodes ?? [];
|
|
739
|
+
if (currentPhase) {
|
|
740
|
+
rightLines.push(theme.fg("muted", currentPhase.name
|
|
741
|
+
? `${currentPhase.name} · ${currentPhase.nodes.length} agents`
|
|
742
|
+
: `${currentPhase.nodes.length} agents`));
|
|
743
|
+
rightLines.push(dashes(theme, mainWidth));
|
|
744
|
+
} else {
|
|
745
|
+
rightLines.push(theme.fg("muted", "(no phase)"));
|
|
746
|
+
rightLines.push(dashes(theme, mainWidth));
|
|
747
|
+
}
|
|
748
|
+
const { startIdx: agentStart, viewportH: agentViewportH } = computeViewport(agents.length, state.agentIdx, bodyH);
|
|
749
|
+
for (let i = agentStart; i < agentStart + agentViewportH && i < agents.length; i++) {
|
|
750
|
+
const node = agents[i];
|
|
751
|
+
const pointer = i === state.agentIdx ? "❯ " : " ";
|
|
752
|
+
const dot = statusDotStr(node.status, theme);
|
|
753
|
+
// Live 路径优先:运行中从 node.live 读实时 token/tool 计数 + elapsed。
|
|
754
|
+
if (node.live) {
|
|
755
|
+
const live = projectLiveProgress(node.live);
|
|
756
|
+
const tokStr = live.totalTokens > 0 ? `${Math.round(live.totalTokens / BUDGET_TOKENS_DIVISOR)}k tok` : "";
|
|
757
|
+
const tcCount = getAllToolCalls(node.live).length;
|
|
758
|
+
const elapsed = formatElapsedSeconds(live.elapsedSeconds);
|
|
759
|
+
rightLines.push(`${pointer}${dot} ${node.agent} ${node.model} ${tokStr} · ${tcCount} tools · ${elapsed}`);
|
|
760
|
+
} else {
|
|
761
|
+
const elapsed = formatElapsed(
|
|
762
|
+
node.startedAt,
|
|
763
|
+
node.completedAt ? new Date(node.completedAt).getTime() : now,
|
|
764
|
+
);
|
|
765
|
+
const tok = node.result?.usage;
|
|
766
|
+
const tokStr = tok ? `${Math.round((tok.input + tok.output) / BUDGET_TOKENS_DIVISOR)}k tok` : "";
|
|
767
|
+
const tcCount = node.result?.toolCalls?.length ?? 0;
|
|
768
|
+
rightLines.push(`${pointer}${dot} ${node.agent} ${node.model} ${tokStr} · ${tcCount} tools · ${elapsed}`);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
state.agentScrollOffset = agentStart;
|
|
772
|
+
while (rightLines.length < bodyH) rightLines.push("");
|
|
773
|
+
rightLines.length = bodyH;
|
|
774
|
+
|
|
775
|
+
mergeBody(lines, leftLines, rightLines, theme);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// ── Level 2: Execution detail ─────────────────────────────────
|
|
779
|
+
// 详情内容构建 + 滚动按键已抽到 detail-content.ts(纯函数,可单测,且控制本文件行数)。
|
|
780
|
+
|
|
781
|
+
function renderLevel2(
|
|
782
|
+
lines: string[],
|
|
783
|
+
run: WorkflowRun,
|
|
784
|
+
agents: ExecutionTraceNode[],
|
|
785
|
+
state: ViewState,
|
|
786
|
+
theme: ThemeLike,
|
|
787
|
+
mainWidth: number,
|
|
788
|
+
now: number,
|
|
789
|
+
viewH: number,
|
|
790
|
+
): void {
|
|
791
|
+
const leftLines: string[] = [];
|
|
792
|
+
const rightLines: string[] = [];
|
|
793
|
+
|
|
794
|
+
// Left: agents title + agent names
|
|
795
|
+
leftLines.push(theme.fg("muted", "Agents"));
|
|
796
|
+
leftLines.push(dashes(theme, SIDEBAR_WIDTH));
|
|
797
|
+
const AGENT_NAME_BUDGET = 4; // pointer(2) + spacing(2)
|
|
798
|
+
for (let i = 0; i < agents.length; i++) {
|
|
799
|
+
const a = agents[i];
|
|
800
|
+
const pointer = i === state.agentIdx ? "❯ " : " ";
|
|
801
|
+
const maxNameWidth = SIDEBAR_WIDTH - AGENT_NAME_BUDGET;
|
|
802
|
+
const agentName = visibleLen(a.agent) > maxNameWidth
|
|
803
|
+
? a.agent.slice(0, maxNameWidth - 1) + ELLIPSIS
|
|
804
|
+
: a.agent;
|
|
805
|
+
leftLines.push(`${pointer}${agentName}`);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Right: full detail(viewport 截断 + 滚动,对齐 subagents renderRightDetail)
|
|
809
|
+
const node = agents[state.agentIdx];
|
|
810
|
+
if (node) {
|
|
811
|
+
const content = buildDetailContent(node, state, run, theme, mainWidth, now);
|
|
812
|
+
const maxOff = Math.max(0, content.length - viewH);
|
|
813
|
+
// running 且 followTail → 钉底部(最新输出始终可见,用户 PgUp 后停止跟随)
|
|
814
|
+
if (node.status === "running" && state.followTail) {
|
|
815
|
+
state.detailScrollOffset = maxOff;
|
|
816
|
+
}
|
|
817
|
+
// clamp 收敛(切 agent / End 越界后下次 render 归位)
|
|
818
|
+
if (state.detailScrollOffset > maxOff) state.detailScrollOffset = maxOff;
|
|
819
|
+
const start = state.detailScrollOffset;
|
|
820
|
+
const visible = content.slice(start, start + viewH);
|
|
821
|
+
while (visible.length < viewH) visible.push(""); // pad 填满视口
|
|
822
|
+
rightLines.push(...visible);
|
|
823
|
+
|
|
824
|
+
// 位置指示(仅内容超一屏时,对齐 subagents detailScrollInfo)
|
|
825
|
+
if (content.length > viewH) {
|
|
826
|
+
const end = Math.min(start + viewH, content.length);
|
|
827
|
+
// 覆盖第一行标题,拼上 (start+1-end/total)
|
|
828
|
+
const titleBase = "Detail";
|
|
829
|
+
const indicator = ` (${start + 1}-${end}/${content.length})`;
|
|
830
|
+
rightLines[0] = theme.fg("muted", titleBase + indicator);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// body 固定高度 = viewH(左右都 pad/截到 viewH,避免内容撑高溢出)
|
|
835
|
+
const bodyH = viewH;
|
|
836
|
+
const leftPadded: string[] = [];
|
|
837
|
+
for (let i = 0; i < bodyH; i++) leftPadded.push(leftLines[i] ?? "");
|
|
838
|
+
const rightPadded: string[] = [];
|
|
839
|
+
for (let i = 0; i < bodyH; i++) rightPadded.push(rightLines[i] ?? "");
|
|
840
|
+
mergeBody(lines, leftPadded, rightPadded, theme);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// ── Trace export(S 键,对齐 main saveTraceToFile)─────────────
|
|
844
|
+
|
|
845
|
+
/** trace 导出文件每节点 outcome 截断长度。 */
|
|
846
|
+
const TRACE_OUTCOME_SLICE = 2000;
|
|
847
|
+
/** trace 导出文件 activity 行宽。 */
|
|
848
|
+
const TRACE_ACTIVITY_WIDTH = 80;
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* 导出完整 workflow trace 到 Markdown 文件。
|
|
852
|
+
* 路径:~/.pi/agent/workflow-traces/{runId}.md
|
|
853
|
+
* 对齐 main 的 saveTraceToFile(WorkflowsView.ts:365-396)。
|
|
854
|
+
*/
|
|
855
|
+
function saveTraceToFile(run: WorkflowRun, ctx: ExtensionContext): void {
|
|
856
|
+
const dir = pathJoin(homedir(), ".pi", "agent", "workflow-traces");
|
|
857
|
+
const filePath = pathJoin(dir, `${run.runId}.md`);
|
|
858
|
+
const lines: string[] = [];
|
|
859
|
+
lines.push(`# Workflow Trace: ${run.spec.scriptName} (${run.runId})`, "");
|
|
860
|
+
lines.push(`Status: ${run.state.status} | Started: ${run.meta.startedAt ?? "-"} | Duration: ${formatElapsed(run.meta.startedAt)}`);
|
|
861
|
+
const budget = run.state.budget;
|
|
862
|
+
lines.push(`Budget: ${budget.usedTokens}/${budget.maxTokens ?? "unlimited"} tokens, $${budget.usedCost.toFixed(BUDGET_COST_DECIMALS)}`, "");
|
|
863
|
+
const phases = buildPhaseGroups([...run.state.trace.toArray()]);
|
|
864
|
+
for (const pg of phases) {
|
|
865
|
+
lines.push(`## Phase: ${pg.name || "(unnamed)"}`, "");
|
|
866
|
+
for (const node of pg.nodes) {
|
|
867
|
+
lines.push(`### [#${node.stepIndex}] ${node.agent} — ${node.status}`);
|
|
868
|
+
lines.push(`- Model: ${node.model}`);
|
|
869
|
+
lines.push(`- Duration: ${formatElapsed(node.startedAt, node.completedAt ? new Date(node.completedAt).getTime() : Date.now())}`, "");
|
|
870
|
+
lines.push("**Prompt:**", node.task, "");
|
|
871
|
+
const toolCalls = node.result?.toolCalls ?? [];
|
|
872
|
+
if (toolCalls.length > 0) {
|
|
873
|
+
lines.push("**Activity:**");
|
|
874
|
+
for (const tc of toolCalls) lines.push(`- ${formatActivityLine(tc, TRACE_ACTIVITY_WIDTH)}`);
|
|
875
|
+
lines.push("");
|
|
876
|
+
}
|
|
877
|
+
lines.push("**Outcome:**");
|
|
878
|
+
if (node.status === "running") lines.push("Still running...");
|
|
879
|
+
else if (node.result?.error) lines.push(node.result.error);
|
|
880
|
+
else if (node.result?.content) lines.push(node.result.content.slice(0, TRACE_OUTCOME_SLICE));
|
|
881
|
+
lines.push("");
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
fsPromises.mkdir(dir, { recursive: true })
|
|
885
|
+
.then(() => fsPromises.writeFile(filePath, lines.join("\n"), "utf8"))
|
|
886
|
+
.then(() => ctx.ui.notify(`Trace saved: ${filePath}`, "info"))
|
|
887
|
+
.catch((err: Error) => ctx.ui.notify(`Save failed: ${err.message}`, "error"));
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// ── Save overlay(缺陷 #3 恢复,从 main 移植简化版)────────────
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* save overlay——名称输入框 + 状态消息。
|
|
894
|
+
* refactor 的 saveWorkflow 仅支持 project scope(workflow-files.ts 统一为 rename),
|
|
895
|
+
* 故去掉 main 的 scope 切换(Tab),只保留名称输入。
|
|
896
|
+
*/
|
|
897
|
+
function renderSaveOverlay(
|
|
898
|
+
state: ViewState,
|
|
899
|
+
theme: ThemeLike,
|
|
900
|
+
width: number,
|
|
901
|
+
): string[] {
|
|
902
|
+
const contentWidth = width - BOX_BORDER_CHARS;
|
|
903
|
+
const lines: string[] = [];
|
|
904
|
+
|
|
905
|
+
lines.push(plainBorder(theme, "╭", "╮", contentWidth));
|
|
906
|
+
|
|
907
|
+
// Title
|
|
908
|
+
lines.push(walled(theme, theme.bold(" Save dynamic workflow"), contentWidth));
|
|
909
|
+
|
|
910
|
+
// Destination preview
|
|
911
|
+
const destName = state.saveInputValue || "(name)";
|
|
912
|
+
const destLine = `.pi/workflows/${destName}.js`;
|
|
913
|
+
lines.push(walled(theme, theme.fg("dim", destLine), contentWidth));
|
|
914
|
+
|
|
915
|
+
// Empty line
|
|
916
|
+
lines.push(walled(theme, "", contentWidth));
|
|
917
|
+
|
|
918
|
+
// Label
|
|
919
|
+
lines.push(walled(theme, "Save as:", contentWidth));
|
|
920
|
+
|
|
921
|
+
// Input line with cursor block
|
|
922
|
+
const inputLine = ` > ${state.saveInputValue}\u2588`;
|
|
923
|
+
lines.push(walled(theme, inputLine, contentWidth));
|
|
924
|
+
|
|
925
|
+
// Empty line
|
|
926
|
+
lines.push(walled(theme, "", contentWidth));
|
|
927
|
+
|
|
928
|
+
// Inline message (error or success)
|
|
929
|
+
if (state.saveMessage) {
|
|
930
|
+
const msgStyle = state.saveMsgOk ? "success" : "error";
|
|
931
|
+
const msgLine = ` ${state.saveMessage}`;
|
|
932
|
+
lines.push(walled(theme, theme.fg(msgStyle, msgLine), contentWidth));
|
|
933
|
+
} else {
|
|
934
|
+
lines.push(walled(theme, "", contentWidth));
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Hint
|
|
938
|
+
const hint = "Enter to save · Esc to cancel";
|
|
939
|
+
lines.push(walled(theme, theme.fg("muted", hint), contentWidth));
|
|
940
|
+
|
|
941
|
+
lines.push(plainBorder(theme, "╰", "╯", contentWidth));
|
|
942
|
+
|
|
943
|
+
return lines;
|
|
944
|
+
}
|