pi-firecode 1.0.0 → 1.0.2
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/README.md +59 -41
- package/README.zh-CN.md +78 -0
- package/config.example.jsonc +21 -22
- package/dist/config.example.jsonc +90 -0
- package/dist/index.js +11443 -0
- package/dist/master/prompts/master.en.md +22 -0
- package/{master → dist/master}/prompts/master.zh.md +3 -3
- package/dist/master/prompts/worker.en.md +1 -0
- package/dist/watcher/prompts/watch.en.md +43 -0
- package/package.json +10 -27
- package/activity.ts +0 -92
- package/busy.ts +0 -188
- package/config.ts +0 -500
- package/deliver.ts +0 -119
- package/flame.ts +0 -149
- package/format.ts +0 -133
- package/header.ts +0 -218
- package/herdr-client.ts +0 -60
- package/index.ts +0 -68
- package/jsonc.ts +0 -34
- package/master/actions.ts +0 -293
- package/master/activity-list.ts +0 -281
- package/master/event-card.ts +0 -87
- package/master/event-format.ts +0 -83
- package/master/guard.ts +0 -46
- package/master/index.ts +0 -252
- package/master/list-view.ts +0 -129
- package/master/outbox.ts +0 -188
- package/master/prompt.ts +0 -26
- package/master/role.ts +0 -18
- package/master/run.ts +0 -315
- package/master/runtime.ts +0 -259
- package/master/spawn.ts +0 -234
- package/master/state.ts +0 -225
- package/master/worker-view.ts +0 -517
- package/provider/claude-sub.ts +0 -151
- package/provider/openai-native/index.ts +0 -8
- package/provider/openai-native/src/compact-client.ts +0 -350
- package/provider/openai-native/src/config.ts +0 -297
- package/provider/openai-native/src/extension.ts +0 -93
- package/provider/openai-native/src/native-compaction.ts +0 -151
- package/provider/openai-native/src/native-details.ts +0 -157
- package/provider/openai-native/src/native-replay.ts +0 -253
- package/provider/openai-native/src/native-runtime.ts +0 -144
- package/provider/openai-native/src/options.ts +0 -87
- package/provider/openai-native/src/request-pipeline.ts +0 -21
- package/provider/openai-native/src/responses-input.ts +0 -433
- package/review/advisor.ts +0 -110
- package/review/card.ts +0 -398
- package/review/checkpoint.ts +0 -378
- package/review/evidence.ts +0 -230
- package/review/index.ts +0 -992
- package/review/occupancy.ts +0 -25
- package/review/outcome.ts +0 -98
- package/review/prompt.ts +0 -209
- package/review/reviewer.ts +0 -353
- package/review/session.ts +0 -110
- package/review/state.ts +0 -877
- package/review/ui.ts +0 -89
- package/round-recorder.ts +0 -18
- package/session/herdr-display.ts +0 -87
- package/session/presets.ts +0 -300
- package/session/quota.ts +0 -123
- package/session/rename.ts +0 -46
- package/session/stats.ts +0 -313
- package/statusbar/index.ts +0 -256
- package/statusbar/render.ts +0 -113
- package/theme.ts +0 -45
- package/tools/actions.ts +0 -53
- package/tools/assistant-view.ts +0 -64
- package/tools/click-anchor.ts +0 -56
- package/tools/group-view.ts +0 -420
- package/tools/grouping.ts +0 -158
- package/tools/host.ts +0 -369
- package/tools/index.ts +0 -186
- package/tools/line.ts +0 -231
- package/tools/machine.ts +0 -78
- package/tools/parts.ts +0 -143
- package/tools/round.ts +0 -95
- package/tools/timing.ts +0 -22
- package/tools/turn-clock.ts +0 -41
- package/tools/turn-summary.ts +0 -109
- package/truncated-write.ts +0 -18
- package/watcher/card.ts +0 -91
- package/watcher/index.ts +0 -176
- package/watcher/observer.ts +0 -75
- package/watcher/transcript.ts +0 -56
- /package/{master → dist/master}/prompts/worker.zh.md +0 -0
- /package/{review → dist/review}/prompts/advisor.en.md +0 -0
- /package/{review → dist/review}/prompts/advisor.zh.md +0 -0
- /package/{review → dist/review}/prompts/review.en.md +0 -0
- /package/{review → dist/review}/prompts/review.zh.md +0 -0
- /package/{watcher → dist/watcher}/prompts/watch.zh.md +0 -0
package/master/actions.ts
DELETED
|
@@ -1,293 +0,0 @@
|
|
|
1
|
-
/** subagents 七个命令动作:每个动作一个处理函数,表驱动分发。 */
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
|
-
import { readFile, realpath } from "node:fs/promises";
|
|
4
|
-
import { isAbsolute } from "node:path";
|
|
5
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import type { MasterRole } from "../config.js";
|
|
7
|
-
import { textOf } from "../format.js";
|
|
8
|
-
import { readReviewOutcome } from "../review/outcome.js";
|
|
9
|
-
import { compactWorker } from "./list-view.js";
|
|
10
|
-
import {
|
|
11
|
-
monitorAndSettleReview, observeWorker, openWorkerSession, resumeCheckPrompt, reviewRunId, runWorker, spawnWorker,
|
|
12
|
-
} from "./run.js";
|
|
13
|
-
import type { MasterRuntime } from "./runtime.js";
|
|
14
|
-
import { preallocateWorkerSession } from "./spawn.js";
|
|
15
|
-
import { requireWorker, THINKING_LEVELS, type WorkerRef } from "./state.js";
|
|
16
|
-
|
|
17
|
-
export const ACTIONS = ["start", "send", "interrupt", "review", "tail", "ack", "kill"] as const;
|
|
18
|
-
export type Action = (typeof ACTIONS)[number];
|
|
19
|
-
type Params = Record<string, unknown>;
|
|
20
|
-
type ToolResult = { content: { type: "text"; text: string }[]; details: unknown };
|
|
21
|
-
type Handler = (active: MasterRuntime, params: Params, ctx: ExtensionContext) => Promise<ToolResult>;
|
|
22
|
-
|
|
23
|
-
/** 同时 working/reviewing 的 Worker 上限;超出直接拒绝,不排队。 */
|
|
24
|
-
const MAX_IN_FLIGHT = 15;
|
|
25
|
-
|
|
26
|
-
export const ACTION_HANDLERS: Record<Action, Handler> = { start, send, interrupt, review, tail, ack, kill };
|
|
27
|
-
|
|
28
|
-
async function kill(active: MasterRuntime, params: Params): Promise<ToolResult> {
|
|
29
|
-
const target = requireWorker(active.store.state, requiredString(params.worker, "worker"));
|
|
30
|
-
// 同步段内删档案与运行时事实,迟到的异步写回据此全部作废;随后等 session_shutdown 收口释放热会话。
|
|
31
|
-
active.remove(target.name);
|
|
32
|
-
await active.setup.pool.dispose(target.sessionPath);
|
|
33
|
-
return toolResult({ killed: true });
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async function tail(active: MasterRuntime, params: Params): Promise<ToolResult> {
|
|
37
|
-
const target = requireWorker(active.store.state, requiredString(params.worker, "worker"));
|
|
38
|
-
return { content: [{ type: "text", text: await readWorkerTrace(target) }], details: undefined };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** 动作名按模型先验取:曾叫 hold,被读成“暂停”而假成功。名字治误读,非 idle 报错治假成功。 */
|
|
42
|
-
async function ack(active: MasterRuntime, params: Params): Promise<ToolResult> {
|
|
43
|
-
const target = requireWorker(active.store.state, requiredString(params.worker, "worker"));
|
|
44
|
-
if (target.reviewNeeded) throw new Error(`${target.name} 此票有审查义务,完成 review 后才能 ack`);
|
|
45
|
-
if (target.status !== "idle") throw new Error(`${target.name} 正在 ${target.status},不能 ack`);
|
|
46
|
-
if (target.disposition) {
|
|
47
|
-
const { disposition: _disposition, ...rest } = target;
|
|
48
|
-
active.store.dispatch({ type: "UPSERT_WORKER", worker: rest });
|
|
49
|
-
}
|
|
50
|
-
// ack 发落失败与被中断的行;完成的留在“✓ N 个已完成”里直到 kill。
|
|
51
|
-
const live = active.live.get(target.name);
|
|
52
|
-
if (live?.outcome && live.outcome.kind !== "done") live.outcome = undefined;
|
|
53
|
-
active.render();
|
|
54
|
-
return toolResult({ acked: true });
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
async function review(active: MasterRuntime, params: Params): Promise<ToolResult> {
|
|
58
|
-
if (active.setup.reviewGate) throw new Error(active.setup.reviewGate);
|
|
59
|
-
const target = requireWorker(active.store.state, requiredString(params.worker, "worker"));
|
|
60
|
-
const live = active.liveOf(target.name);
|
|
61
|
-
if (target.status !== "idle" || live.transitioning) throw new Error(`${target.name} 正在处理其他动作,不能 review`);
|
|
62
|
-
live.transitioning = true;
|
|
63
|
-
try {
|
|
64
|
-
const session = await openWorkerSession(active, target);
|
|
65
|
-
await session.waitForIdle();
|
|
66
|
-
active.assertOpen();
|
|
67
|
-
observeWorker(active, target, session);
|
|
68
|
-
const previousRunId = reviewRunId(readReviewOutcome(target.sessionPath));
|
|
69
|
-
active.commit(target, ({ disposition: _disposition, interruptedAt: _interruptedAt, ...rest }) => ({ ...rest, status: "reviewing" }));
|
|
70
|
-
active.beginRun(target.name);
|
|
71
|
-
monitorAndSettleReview(active, target, session, previousRunId);
|
|
72
|
-
return toolResult({ reviewing: true });
|
|
73
|
-
} finally {
|
|
74
|
-
live.transitioning = undefined;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
async function interrupt(active: MasterRuntime, params: Params): Promise<ToolResult> {
|
|
79
|
-
const target = requireWorker(active.store.state, requiredString(params.worker, "worker"));
|
|
80
|
-
if (target.status !== "working") throw new Error(`${target.name} 当前是 ${target.status},不能 interrupt`);
|
|
81
|
-
const session = active.setup.pool.getSession(target.sessionPath);
|
|
82
|
-
if (!session) throw new Error(`${target.name} 的进程内会话已释放,无法 interrupt`);
|
|
83
|
-
const live = active.liveOf(target.name);
|
|
84
|
-
const run = live.run;
|
|
85
|
-
if (!run) throw new Error(`${target.name} 当前没有可中断的回合`);
|
|
86
|
-
live.interruptedRun = run;
|
|
87
|
-
try {
|
|
88
|
-
await session.abort();
|
|
89
|
-
active.assertOpen();
|
|
90
|
-
return toolResult({ interrupted: true });
|
|
91
|
-
} catch (error) {
|
|
92
|
-
if (live.interruptedRun === run) live.interruptedRun = undefined;
|
|
93
|
-
throw error;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
async function send(active: MasterRuntime, params: Params): Promise<ToolResult> {
|
|
98
|
-
const { reviewGate, pool, roster } = active.setup;
|
|
99
|
-
if (params.review === true && reviewGate) throw new Error(reviewGate);
|
|
100
|
-
const target = requireWorker(active.store.state, requiredString(params.worker, "worker"));
|
|
101
|
-
const live = active.liveOf(target.name);
|
|
102
|
-
if (live.transitioning) throw new Error(`${target.name} 正在切换,稍后再 send`);
|
|
103
|
-
const requestedRole = optionalString(params.role);
|
|
104
|
-
const requestedThinking = optionalString(params.thinking);
|
|
105
|
-
const requestedCwd = optionalString(params.cwd);
|
|
106
|
-
const prompt = requiredString(params.prompt, "prompt");
|
|
107
|
-
validateDelegationText(prompt);
|
|
108
|
-
// 子代理全过程视图的补话走同一入口,只多一个来源标记:视图起的运行指挥官不在等(见 RunOrigin),
|
|
109
|
-
// 落定事件注明是用户在视图里直接派的;视图补进别人起的运行只记原话,不改来源。
|
|
110
|
-
const fromView = params.origin === "view";
|
|
111
|
-
if (target.status === "working" && !requestedRole && !requestedThinking && !requestedCwd) {
|
|
112
|
-
const result = await steer(active, target, prompt, params.review === true);
|
|
113
|
-
if (fromView) live.viewPrompts.push(prompt);
|
|
114
|
-
else if (live.origin !== "master") {
|
|
115
|
-
live.origin = "master";
|
|
116
|
-
active.render();
|
|
117
|
-
}
|
|
118
|
-
return result;
|
|
119
|
-
}
|
|
120
|
-
if (target.status === "working") throw new Error(`${target.name} 正在工作;切换 role/thinking/cwd 需先 interrupt`);
|
|
121
|
-
if (target.status !== "idle") throw new Error(`${target.name} 正在审查,等落定再 send`);
|
|
122
|
-
const selection = requestedRole ? resolveRole(roster, requestedRole) : undefined;
|
|
123
|
-
const thinkingOverride = validThinking(requestedThinking);
|
|
124
|
-
live.transitioning = true;
|
|
125
|
-
try {
|
|
126
|
-
const cwd = await resolveSendCwd(target, requestedCwd);
|
|
127
|
-
if (cwd !== target.cwd) await pool.dispose(target.sessionPath);
|
|
128
|
-
const nextModel = selection ? await pool.resolveModel(selection.model) : undefined;
|
|
129
|
-
active.assertOpen();
|
|
130
|
-
const session = await openWorkerSession(active, { ...target, cwd });
|
|
131
|
-
await session.waitForIdle();
|
|
132
|
-
active.assertOpen();
|
|
133
|
-
let { role, model, thinking } = target;
|
|
134
|
-
if (selection && nextModel) {
|
|
135
|
-
await session.setModel(nextModel);
|
|
136
|
-
active.assertOpen();
|
|
137
|
-
({ role, model, thinking } = selection);
|
|
138
|
-
}
|
|
139
|
-
if (selection || thinkingOverride) {
|
|
140
|
-
thinking = thinkingOverride ?? thinking;
|
|
141
|
-
session.setThinkingLevel(thinking);
|
|
142
|
-
}
|
|
143
|
-
const interruptedAt = active.current(target).interruptedAt;
|
|
144
|
-
const working = active.commit(target, ({ disposition: _disposition, interruptedAt: _interrupted, ...rest }) => ({
|
|
145
|
-
...rest,
|
|
146
|
-
role,
|
|
147
|
-
model,
|
|
148
|
-
thinking,
|
|
149
|
-
cwd,
|
|
150
|
-
status: "working",
|
|
151
|
-
...(params.review === true || rest.reviewNeeded ? { reviewNeeded: true } : {}),
|
|
152
|
-
}));
|
|
153
|
-
active.beginRun(target.name, fromView ? "view" : "master");
|
|
154
|
-
if (fromView) live.viewPrompts.push(prompt);
|
|
155
|
-
await runWorker(active, working, session, interruptedAt ? `${resumeCheckPrompt()}\n\n${prompt}` : prompt);
|
|
156
|
-
return toolResult({ sent: true });
|
|
157
|
-
} finally {
|
|
158
|
-
live.transitioning = undefined;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* working Worker 的普通 send 经宿主 steer 在句缝送达,不打断。steer 会 await 子会话的 input 处理器:
|
|
164
|
-
* 期间可能落定或被 kill,写回只认重读后的档案;回合已结束时清掉滞留队列并报未送达。
|
|
165
|
-
*/
|
|
166
|
-
async function steer(active: MasterRuntime, target: WorkerRef, prompt: string, review: boolean): Promise<ToolResult> {
|
|
167
|
-
const session = active.setup.pool.getSession(target.sessionPath);
|
|
168
|
-
if (!session?.isStreaming) throw new Error(`${target.name} 回合正在收尾,稍后再 send`);
|
|
169
|
-
await session.steer(prompt);
|
|
170
|
-
if (active.current(target).status !== "working") {
|
|
171
|
-
session.clearQueue();
|
|
172
|
-
throw new Error(`${target.name} 的回合已结束,补充说明未送达,请重新 send`);
|
|
173
|
-
}
|
|
174
|
-
if (review) active.commit(target, (latest) => ({ ...latest, reviewNeeded: true }));
|
|
175
|
-
return toolResult({ steered: true });
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
async function start(active: MasterRuntime, params: Params, ctx: ExtensionContext): Promise<ToolResult> {
|
|
179
|
-
const { reviewGate, roster } = active.setup;
|
|
180
|
-
if (params.review === true && reviewGate) throw new Error(reviewGate);
|
|
181
|
-
if (typeof params.worker !== "string" || !params.worker.trim())
|
|
182
|
-
throw new Error("start 需要 worker:给子代理起个简短任务名(如 fix-auth、repo-scan)");
|
|
183
|
-
const name = params.worker.trim();
|
|
184
|
-
validateWorkerName(name);
|
|
185
|
-
const starting = [...active.live].flatMap(([candidate, live]) => (live.starting ? [candidate] : []));
|
|
186
|
-
if (active.store.state.workers.some((worker) => worker.name === name) || starting.includes(name))
|
|
187
|
-
throw new Error(`子代理已存在:${name}`);
|
|
188
|
-
const inFlight = active.store.state.workers.filter((worker) => worker.status === "working" || worker.status === "reviewing");
|
|
189
|
-
if (inFlight.length + starting.length >= MAX_IN_FLIGHT)
|
|
190
|
-
throw new Error(`Worker 并发上限 ${MAX_IN_FLIGHT},当前在飞:${[...inFlight.map((worker) => worker.name), ...starting].join("、")}`);
|
|
191
|
-
const prompt = requiredString(params.prompt, "prompt");
|
|
192
|
-
validateDelegationText(prompt);
|
|
193
|
-
const selectedRole = resolveRole(roster, requiredString(params.role, "start 必须指定 role"));
|
|
194
|
-
const thinking = validThinking(optionalString(params.thinking)) ?? selectedRole.thinking;
|
|
195
|
-
const { live, launch } = active.reserve(name);
|
|
196
|
-
try {
|
|
197
|
-
const cwd = await resolveWorkerCwd(optionalString(params.cwd) ?? ctx.cwd);
|
|
198
|
-
active.assertOpen();
|
|
199
|
-
const mainSessionPath = ctx.sessionManager.getSessionFile();
|
|
200
|
-
if (!mainSessionPath) throw new Error("主会话尚未落盘,无法创建子代理会话目录");
|
|
201
|
-
const worker: WorkerRef = {
|
|
202
|
-
name,
|
|
203
|
-
role: selectedRole.role,
|
|
204
|
-
model: selectedRole.model,
|
|
205
|
-
thinking,
|
|
206
|
-
status: "working",
|
|
207
|
-
sessionPath: preallocateWorkerSession(mainSessionPath, cwd),
|
|
208
|
-
cwd,
|
|
209
|
-
launch,
|
|
210
|
-
...(params.review === true ? { reviewNeeded: true } : {}),
|
|
211
|
-
};
|
|
212
|
-
active.store.dispatch({ type: "UPSERT_WORKER", worker });
|
|
213
|
-
live.starting = undefined;
|
|
214
|
-
active.beginRun(name);
|
|
215
|
-
const session = await spawnWorker(active, worker, false);
|
|
216
|
-
await runWorker(active, worker, session, prompt);
|
|
217
|
-
return toolResult({ started: true, worker: compactWorker(worker) });
|
|
218
|
-
} catch (error) {
|
|
219
|
-
// 只撤自己这一票:kill 后同名重开的新票不受影响。
|
|
220
|
-
if (!active.closed && active.live.get(name) === live) active.remove(name, live);
|
|
221
|
-
throw error;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
async function readWorkerTrace(worker: WorkerRef): Promise<string> {
|
|
226
|
-
let raw: string;
|
|
227
|
-
try {
|
|
228
|
-
raw = await readFile(worker.sessionPath, "utf8");
|
|
229
|
-
} catch (error) {
|
|
230
|
-
throw new Error(`无法读取子代理 ${worker.name} 会话:${error instanceof Error ? error.message : String(error)}`);
|
|
231
|
-
}
|
|
232
|
-
const lines: string[] = [];
|
|
233
|
-
for (const line of raw.split(/\r?\n/u)) {
|
|
234
|
-
if (!line) continue;
|
|
235
|
-
try {
|
|
236
|
-
const entry = JSON.parse(line) as { type?: string; message?: { role?: string; content?: unknown } };
|
|
237
|
-
if (entry.type !== "message" || !entry.message?.role) continue;
|
|
238
|
-
const text = textOf(entry.message.content);
|
|
239
|
-
if (text) lines.push(`${entry.message.role}: ${text}`);
|
|
240
|
-
} catch {
|
|
241
|
-
// 正在追加的尾行可暂时不完整;近况保留此前完整记录。
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
return `子代理 ${worker.name} 近况(${worker.status})\n${lines.join("\n").slice(-4_000)}`;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function toolResult(value: unknown): ToolResult {
|
|
248
|
-
return { content: [{ type: "text", text: JSON.stringify(value) }], details: value };
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function requiredString(value: unknown, field: string): string {
|
|
252
|
-
if (typeof value !== "string" || !value.trim()) throw new Error(`${field} 不能为空`);
|
|
253
|
-
return value.trim();
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function optionalString(value: unknown): string | undefined {
|
|
257
|
-
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function validThinking(value: string | undefined): WorkerRef["thinking"] | undefined {
|
|
261
|
-
if (value && !THINKING_LEVELS.includes(value as WorkerRef["thinking"])) throw new Error(`thinking 值无效:${value}`);
|
|
262
|
-
return value as WorkerRef["thinking"] | undefined;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/** 宿主已按 schema 枚举校验过 role,这里只查表。 */
|
|
266
|
-
function resolveRole(roles: MasterRole[], role: string): MasterRole {
|
|
267
|
-
return roles.find((candidate) => candidate.role === role)!;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function validateWorkerName(name: string): void {
|
|
271
|
-
if (!/^[a-z][a-z0-9_-]{0,31}$/u.test(name)) throw new Error("Worker name 必须匹配 [a-z][a-z0-9_-]{0,31}");
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function validateDelegationText(prompt: string): void {
|
|
275
|
-
const text = prompt.trimStart();
|
|
276
|
-
if (/^\/skills?:/u.test(text) && !text.startsWith("/skill:tdd ")) throw new Error("委派文本只允许 /skill:tdd 技能前缀");
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
async function resolveWorkerCwd(path: string): Promise<string> {
|
|
280
|
-
if (!isAbsolute(path)) throw new Error("cwd 必须是已存在的绝对目录");
|
|
281
|
-
try {
|
|
282
|
-
return await realpath(path);
|
|
283
|
-
} catch {
|
|
284
|
-
throw new Error(`cwd 不存在:${path}`);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
async function resolveSendCwd(worker: WorkerRef, requested: string | undefined): Promise<string | undefined> {
|
|
289
|
-
if (requested) return resolveWorkerCwd(requested);
|
|
290
|
-
if (worker.cwd && !existsSync(worker.cwd))
|
|
291
|
-
throw new Error(`${worker.name} 的 cwd 已不存在:${worker.cwd};send 请带 cwd 指向新检出`);
|
|
292
|
-
return worker.cwd;
|
|
293
|
-
}
|
package/master/activity-list.ts
DELETED
|
@@ -1,281 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 输入框上方的子代理活动列表:需要处理的(失败、被中断、卡住)置顶,然后在跑与审查,再合计已完成与空闲。
|
|
3
|
-
* 行布局在 activity.ts;这里只决定谁上榜、怎么排、哪些折叠、整表是否留角色,以及何时需要动画时钟;落定事实只在运行时,reload 后不展示历史。
|
|
4
|
-
*/
|
|
5
|
-
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
7
|
-
import { type ActivityRow as Row, nameWidthFor, renderActivityRow, roleFits } from "../activity.js";
|
|
8
|
-
import { flame, HEAT_COLORS, onFrame, paint, phaseOf, reviewMark } from "../flame.js";
|
|
9
|
-
import { clip, formatDuration } from "../format.js";
|
|
10
|
-
import { toolActionText } from "../tools/actions.js";
|
|
11
|
-
import type { ReviewProgress } from "../review/outcome.js";
|
|
12
|
-
import type { WorkerRef } from "./state.js";
|
|
13
|
-
|
|
14
|
-
/** 本次运行的落定事实:落定时刻、结局与行上的说明。失败与被中断留到 ack 或 kill,完成留到 kill。 */
|
|
15
|
-
export interface SettledFact {
|
|
16
|
-
at: number;
|
|
17
|
-
/** 被中断不是失败:会话与义务都在,等指挥官续派或收口。 */
|
|
18
|
-
kind: "done" | "failed" | "interrupted";
|
|
19
|
-
note?: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface ActivityFacts {
|
|
23
|
-
workers: readonly Pick<WorkerRef, "name" | "role" | "status" | "sessionPath" | "cwd" | "launch">[];
|
|
24
|
-
currentTools: ReadonlyMap<string, ReadonlyMap<string, { tool: string; args: unknown }>>;
|
|
25
|
-
reviewProgress: ReadonlyMap<string, ReviewProgress>;
|
|
26
|
-
runStartedAt: ReadonlyMap<string, number>;
|
|
27
|
-
/** 最近一次输出(工具事件或模型 token)的时刻;卡住按它与本次运行起点中较晚者计算。 */
|
|
28
|
-
lastOutputAt: ReadonlyMap<string, number>;
|
|
29
|
-
settled: ReadonlyMap<string, SettledFact>;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
type Toggle = "running" | "done" | "idle";
|
|
33
|
-
/** 一行输出:子代理行,或可点击的折叠行。 */
|
|
34
|
-
/** 折叠行:计数(可点)与收起时被折叠的名字。 */
|
|
35
|
-
type Line = { row: Row } | { label: string; mark: string; toggle: Toggle; names: readonly string[] };
|
|
36
|
-
/** 点击命中:折叠行翻转展开,子代理行打开它的全过程视图。 */
|
|
37
|
-
type Target = { toggle: Toggle } | { open: string };
|
|
38
|
-
|
|
39
|
-
/** working 子代理这么久没有任何输出(模型 token 或工具事件)就追加“N 分钟无输出”。 */
|
|
40
|
-
const STUCK_MS = 5 * 60_000;
|
|
41
|
-
const MINUTE_MS = 60_000;
|
|
42
|
-
const STUCK_GLYPH = "◌";
|
|
43
|
-
const INTERRUPTED_GLYPH = "‖";
|
|
44
|
-
/** 可见行数下限;终端每 6 行再容纳一条在跑的行。 */
|
|
45
|
-
const MIN_ROWS = 4;
|
|
46
|
-
const ROWS_PER_ACTIVITY = 6;
|
|
47
|
-
|
|
48
|
-
const duration = (ms: number) => formatDuration(Math.max(0, ms));
|
|
49
|
-
const DONE_MARK = paint(HEAT_COLORS.green, "✓");
|
|
50
|
-
const IDLE_GLYPH = "·";
|
|
51
|
-
|
|
52
|
-
/** 卡住提醒的全写法与窄屏短写法。 */
|
|
53
|
-
function silentNote(minutes: number): { full: string; short: string } {
|
|
54
|
-
return { full: `${minutes} 分钟无输出`, short: `${minutes}m 无输出` };
|
|
55
|
-
}
|
|
56
|
-
const FAILED_MARK = paint(HEAT_COLORS.fail, "✗");
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* 可见的在跑行数:随终端高度放宽;宿主拿不到高度时用下限。ctrl+o 是聊天区的全局展开,不展开活动列表——
|
|
60
|
-
* 它常驻在输入框上方,再展开只会挤掉正文视口;要看全部点折叠行。
|
|
61
|
-
*/
|
|
62
|
-
export function visibleRows(terminalRows: number | undefined): number {
|
|
63
|
-
return Math.max(MIN_ROWS, Math.floor((terminalRows ?? 0) / ROWS_PER_ACTIVITY));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
interface Groups {
|
|
67
|
-
failed: Row[];
|
|
68
|
-
interrupted: Row[];
|
|
69
|
-
stuck: Row[];
|
|
70
|
-
running: Row[];
|
|
71
|
-
done: Row[];
|
|
72
|
-
/** 空闲且本进程内没有落定结局的(resume 恢复的、ack 过的)。 */
|
|
73
|
-
idle: Row[];
|
|
74
|
-
/** 有行在动:在跑、审查、卡住(耗时仍在走),需要动画时钟;落定行静止。 */
|
|
75
|
-
animating: boolean;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** 按档案里的启动序。 */
|
|
79
|
-
function launchSorted(facts: ActivityFacts): number[] {
|
|
80
|
-
return facts.workers.map((_, index) => index).sort((a, b) => facts.workers[a].launch - facts.workers[b].launch);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** 子代理按启动序的名字:全过程视图按它换子代理,位置不随状态分组跳动。 */
|
|
84
|
-
export function launchOrder(facts: ActivityFacts): string[] {
|
|
85
|
-
return launchSorted(facts).map((index) => facts.workers[index].name);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** 一个子代理在活动列表里的分组(审查与在跑同组展示);全过程视图顶行读同一份。 */
|
|
89
|
-
export type RowKind = "failed" | "interrupted" | "stuck" | "running" | "review" | "done" | "idle";
|
|
90
|
-
|
|
91
|
-
/** 有行在动(耗时仍在走)的分组:需要动画时钟。 */
|
|
92
|
-
export const ANIMATING_KINDS: ReadonlySet<RowKind> = new Set(["stuck", "running", "review"]);
|
|
93
|
-
|
|
94
|
-
/** 一个子代理的行状态:所在分组与这一行(标记、动作、耗时)。index 是它在 facts.workers 里的位置(决定火苗相位)。 */
|
|
95
|
-
export function rowState(facts: ActivityFacts, index: number, now: number, theme: Theme): { kind: RowKind; row: Row } {
|
|
96
|
-
const worker = facts.workers[index];
|
|
97
|
-
const path = worker.sessionPath;
|
|
98
|
-
const phase = phaseOf(index);
|
|
99
|
-
const start = facts.runStartedAt.get(path);
|
|
100
|
-
const base = { name: worker.name, role: worker.role, elapsed: start === undefined ? "" : duration(now - start) };
|
|
101
|
-
if (worker.status === "working") {
|
|
102
|
-
const silent = now - Math.max(start ?? now, facts.lastOutputAt.get(path) ?? 0);
|
|
103
|
-
const tool = [...(facts.currentTools.get(path)?.values() ?? [])].at(-1);
|
|
104
|
-
const action = tool ? toolActionText(tool.tool, tool.args, worker.cwd ?? "") : "思考中";
|
|
105
|
-
// 卡住时动作照常显示(用户要知道卡在哪条命令上),只追加提醒;前台长命令同样按无输出计时。
|
|
106
|
-
if (silent >= STUCK_MS)
|
|
107
|
-
// 右侧不放总耗时:要看的是“多久没输出”,两个时长并排(“5m 无输出 5m13s”)只会看混。
|
|
108
|
-
return { kind: "stuck", row: { ...base, elapsed: "", mark: theme.fg("warning", STUCK_GLYPH), action, note: silentNote(Math.floor(silent / MINUTE_MS)) } };
|
|
109
|
-
return { kind: "running", row: { ...base, mark: flame(1, phase), action } };
|
|
110
|
-
}
|
|
111
|
-
if (worker.status === "reviewing") {
|
|
112
|
-
const progress = facts.reviewProgress.get(path);
|
|
113
|
-
const action = progress ? `审查第 ${progress.round} 轮 · ${progress.settled}/${progress.total} 通过` : "审查中";
|
|
114
|
-
return { kind: "review", row: { ...base, mark: reviewMark(phase), action, tone: "review" } };
|
|
115
|
-
}
|
|
116
|
-
const fact = facts.settled.get(path);
|
|
117
|
-
// 组名已经说了“空闲”,展开行只列谁。
|
|
118
|
-
if (!fact) return { kind: "idle", row: { ...base, elapsed: "", mark: theme.fg("dim", IDLE_GLYPH), action: "", settled: true } };
|
|
119
|
-
const settledRow = { ...base, elapsed: start === undefined ? "" : duration(fact.at - start), settled: true };
|
|
120
|
-
if (fact.kind === "done") return { kind: "done", row: { ...settledRow, mark: DONE_MARK, action: fact.note ?? "已返回" } };
|
|
121
|
-
if (fact.kind === "interrupted")
|
|
122
|
-
return { kind: "interrupted", row: { ...settledRow, mark: theme.fg("warning", INTERRUPTED_GLYPH), action: "被中断", tone: "warning" } };
|
|
123
|
-
return { kind: "failed", row: { ...settledRow, mark: FAILED_MARK, action: fact.note ?? "失败", tone: "failed" } };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function group(facts: ActivityFacts, now: number, theme: Theme): Groups {
|
|
127
|
-
const groups: Groups = { failed: [], interrupted: [], stuck: [], running: [], done: [], idle: [], animating: false };
|
|
128
|
-
for (const index of launchSorted(facts)) {
|
|
129
|
-
const { kind, row } = rowState(facts, index, now, theme);
|
|
130
|
-
groups.animating ||= ANIMATING_KINDS.has(kind);
|
|
131
|
-
groups[kind === "review" ? "running" : kind].push(row);
|
|
132
|
-
}
|
|
133
|
-
return groups;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
interface Folding {
|
|
137
|
-
limit: number;
|
|
138
|
-
showAllRunning: boolean;
|
|
139
|
-
showDone: boolean;
|
|
140
|
-
showIdle: boolean;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** 需要处理的永远可见、不计入上限;上限只约束在跑的行,超出折成可点击的“+N 个在跑”。 */
|
|
144
|
-
function layout({ failed, interrupted, stuck, running, done, idle }: Groups, folding: Folding, theme: Theme): Line[] {
|
|
145
|
-
const lines: Line[] = [...failed, ...interrupted, ...stuck].map((row) => ({ row }));
|
|
146
|
-
const idleMark = theme.fg("dim", IDLE_GLYPH);
|
|
147
|
-
const names = (rows: readonly Row[], open: boolean) => (open ? [] : rows.map((row) => row.name));
|
|
148
|
-
const overflow = running.length > folding.limit;
|
|
149
|
-
if (!overflow || folding.showAllRunning) {
|
|
150
|
-
lines.push(...running.map((row) => ({ row })));
|
|
151
|
-
if (overflow) lines.push({ label: "收起", mark: idleMark, toggle: "running", names: [] });
|
|
152
|
-
} else {
|
|
153
|
-
const shown = running.slice(0, folding.limit - 1);
|
|
154
|
-
const hidden = running.slice(shown.length);
|
|
155
|
-
lines.push(...shown.map((row) => ({ row })), { label: `+${hidden.length} 个在跑`, mark: idleMark, toggle: "running", names: names(hidden, false) });
|
|
156
|
-
}
|
|
157
|
-
if (done.length) {
|
|
158
|
-
lines.push({ label: `${done.length} 个已完成`, mark: DONE_MARK, toggle: "done", names: names(done, folding.showDone) });
|
|
159
|
-
if (folding.showDone) lines.push(...done.map((row) => ({ row })));
|
|
160
|
-
}
|
|
161
|
-
if (idle.length) {
|
|
162
|
-
lines.push({ label: `${idle.length} 个空闲`, mark: idleMark, toggle: "idle", names: names(idle, folding.showIdle) });
|
|
163
|
-
if (folding.showIdle) lines.push(...idle.map((row) => ({ row })));
|
|
164
|
-
}
|
|
165
|
-
return lines;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function renderLines(lines: Line[], width: number, theme: Theme): string[] {
|
|
169
|
-
const rows = lines.flatMap((line) => ("row" in line ? [line.row] : []));
|
|
170
|
-
const nameWidth = nameWidthFor(rows, width);
|
|
171
|
-
// 退让整表一致:任何一行放不下“角色 · 动作”就全表丢角色,列才对得齐。
|
|
172
|
-
const showRole = rows.every((row) => roleFits(row, width, nameWidth));
|
|
173
|
-
// 折叠行的名字预览列对齐:按最宽的计数补齐。
|
|
174
|
-
const labelWidth = Math.max(0, ...lines.map((line) => ("label" in line ? visibleWidth(line.label) : 0)));
|
|
175
|
-
return lines.map((line) => {
|
|
176
|
-
if ("row" in line) return renderActivityRow(line.row, width, nameWidth, theme, showRole);
|
|
177
|
-
const head = ` ${line.mark} ${chip(line.label, theme)}`;
|
|
178
|
-
const pad = labelWidth - visibleWidth(line.label) + 1;
|
|
179
|
-
const preview = fitNames(line.names, width - visibleWidth(head) - pad);
|
|
180
|
-
const text = preview ? `${head}${" ".repeat(pad)}${theme.fg("dim", preview)}` : head;
|
|
181
|
-
return clip(text, width, "end", "");
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* 折叠行计数的可点提示:两侧各一格空白、铺工具行同族的中性暗底。theme.bg 以背景关闭(49)收尾、不带全量重置,
|
|
187
|
-
* clip 在胶囊中间截断也保留这个关闭序列,底色不会被掐断或漏到后面。不用下划线:终端按字形画,数字粗、中文细。
|
|
188
|
-
*/
|
|
189
|
-
const chip = (label: string, theme: Theme) => theme.bg("toolPendingBg", theme.fg("muted", ` ${label} `));
|
|
190
|
-
|
|
191
|
-
/** 能放下几个名字列几个,只列整名,不在名字中间截断。 */
|
|
192
|
-
function fitNames(names: readonly string[], room: number): string {
|
|
193
|
-
let text = "";
|
|
194
|
-
for (const name of names) {
|
|
195
|
-
const next = text ? `${text} · ${name}` : name;
|
|
196
|
-
if (visibleWidth(next) > room) break;
|
|
197
|
-
text = next;
|
|
198
|
-
}
|
|
199
|
-
return text;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/** widget 组件:动画时钟只在有行在动时订阅,静止即取消;折叠行可点击展开/收起。 */
|
|
203
|
-
export class ActivityList {
|
|
204
|
-
private unsubscribe: (() => void) | undefined;
|
|
205
|
-
private showAllRunning = false;
|
|
206
|
-
private showDone = false;
|
|
207
|
-
private showIdle = false;
|
|
208
|
-
private moving = true;
|
|
209
|
-
/** 上一次渲染每行对应的点击目标。 */
|
|
210
|
-
private targets: (Target | undefined)[] = [];
|
|
211
|
-
|
|
212
|
-
constructor(
|
|
213
|
-
private readonly tui: { requestRender(): void },
|
|
214
|
-
private readonly theme: Theme,
|
|
215
|
-
private readonly facts: () => ActivityFacts,
|
|
216
|
-
private readonly limit: () => number,
|
|
217
|
-
private readonly open: (name: string) => void = () => {},
|
|
218
|
-
) {}
|
|
219
|
-
|
|
220
|
-
/** 事实变化后调用:对齐时钟订阅并重绘一次。 */
|
|
221
|
-
sync(): void {
|
|
222
|
-
const animating = group(this.facts(), Date.now(), this.theme).animating;
|
|
223
|
-
if (animating && !this.unsubscribe) this.unsubscribe = onFrame(() => this.onFrame());
|
|
224
|
-
if (!animating) this.release();
|
|
225
|
-
this.tui.requestRender();
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/** 每帧只重绘;是否还在动由上一次 render 的分组顺带给出,不再单独分组一遍。 */
|
|
229
|
-
private onFrame(): void {
|
|
230
|
-
if (!this.moving) this.release();
|
|
231
|
-
this.tui.requestRender();
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
private release(): void {
|
|
235
|
-
this.unsubscribe?.();
|
|
236
|
-
this.unsubscribe = undefined;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
invalidate(): void {}
|
|
240
|
-
|
|
241
|
-
render(width: number): string[] {
|
|
242
|
-
const limit = this.limit();
|
|
243
|
-
const groups = group(this.facts(), Date.now(), this.theme);
|
|
244
|
-
this.moving = groups.animating;
|
|
245
|
-
const lines = layout(groups, {
|
|
246
|
-
limit,
|
|
247
|
-
showAllRunning: this.showAllRunning,
|
|
248
|
-
showDone: this.showDone,
|
|
249
|
-
showIdle: this.showIdle,
|
|
250
|
-
}, this.theme);
|
|
251
|
-
this.targets = lines.map((line) => ("toggle" in line ? { toggle: line.toggle } : { open: line.row.name }));
|
|
252
|
-
return renderLines(lines, width, this.theme);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
handleMouse(event: TuiMouseEvent) {
|
|
256
|
-
if (event.type !== "click" || event.button !== "left") return undefined;
|
|
257
|
-
const target = this.targets[event.y];
|
|
258
|
-
if (!target) return undefined;
|
|
259
|
-
if ("open" in target) {
|
|
260
|
-
this.open(target.open);
|
|
261
|
-
return { handled: true };
|
|
262
|
-
}
|
|
263
|
-
const { toggle } = target;
|
|
264
|
-
if (toggle === "running") this.showAllRunning = !this.showAllRunning;
|
|
265
|
-
else if (toggle === "done") this.showDone = !this.showDone;
|
|
266
|
-
else this.showIdle = !this.showIdle;
|
|
267
|
-
this.tui.requestRender();
|
|
268
|
-
return { handled: true };
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/** 展开不常驻:下一轮人类输入时收起,免得一直占着输入框上方。 */
|
|
272
|
-
collapse(): void {
|
|
273
|
-
if (!this.showAllRunning && !this.showDone && !this.showIdle) return;
|
|
274
|
-
this.showAllRunning = this.showDone = this.showIdle = false;
|
|
275
|
-
this.tui.requestRender();
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
dispose(): void {
|
|
279
|
-
this.release();
|
|
280
|
-
}
|
|
281
|
-
}
|
package/master/event-card.ts
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Master 事件卡:默认紧凑(每事件一行标题行),ctrl+o 展开完整内容。
|
|
3
|
-
* 数据只来自消息正文里的信封(deliver.ts 是格式的唯一事实源);渲染器永不抛异常。
|
|
4
|
-
*/
|
|
5
|
-
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import { Box, type Component, Markdown, Text } from "@earendil-works/pi-tui";
|
|
8
|
-
import { clip, firstSentence, oneLine, textOf } from "../format.js";
|
|
9
|
-
import { parseEnvelopes } from "../deliver.js";
|
|
10
|
-
import { machineEntries } from "../tools/machine.js";
|
|
11
|
-
import { MASTER_EVENT_TYPE } from "./event-format.js";
|
|
12
|
-
|
|
13
|
-
export function registerMasterEventRenderer(pi: ExtensionAPI): void {
|
|
14
|
-
pi.registerMessageRenderer(
|
|
15
|
-
MASTER_EVENT_TYPE,
|
|
16
|
-
(message, options, theme) => new MasterEventCard(message.content, options.expanded, theme),
|
|
17
|
-
);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
class MasterEventCard implements Component {
|
|
21
|
-
private readonly card: Component;
|
|
22
|
-
private readonly fallback: Component;
|
|
23
|
-
|
|
24
|
-
constructor(content: string | (string | unknown)[], expanded: boolean, theme: Theme) {
|
|
25
|
-
const text = textOf(content);
|
|
26
|
-
this.fallback = new Text(text, 0, 0);
|
|
27
|
-
let card: Component | undefined;
|
|
28
|
-
try {
|
|
29
|
-
card = !expanded ? compactCard(text, theme) : fullCard(text, theme);
|
|
30
|
-
} catch {
|
|
31
|
-
card = undefined;
|
|
32
|
-
}
|
|
33
|
-
this.card = card ?? this.fallback;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
render(width: number): string[] {
|
|
37
|
-
try {
|
|
38
|
-
return this.card.render(Math.max(1, width));
|
|
39
|
-
} catch {
|
|
40
|
-
try {
|
|
41
|
-
return this.fallback.render(Math.max(1, width));
|
|
42
|
-
} catch {
|
|
43
|
-
return [];
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
invalidate(): void {
|
|
49
|
-
this.card.invalidate?.();
|
|
50
|
-
this.fallback.invalidate?.();
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** 每个事件一行:标题 + 正文首句;不是信封的旧消息退化为完整内容。 */
|
|
55
|
-
function compactCard(text: string, theme: Theme): Component {
|
|
56
|
-
const entries = machineEntries(text);
|
|
57
|
-
if (!entries) return fullCard(text, theme);
|
|
58
|
-
return card(theme, entries.map((entry) =>
|
|
59
|
-
new CompactTitle(`◆ ${entry.title}${entry.preview ? ` — ${firstSentence(entry.preview)}` : ""}`)));
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* 单行截断:不用 pi-tui 的 TruncatedText——它的省略号带 `\x1b[0m` 全量重置,
|
|
64
|
-
* 会在截断点把外层 Box 的背景色掠断(行尾露底色、右缘参差);
|
|
65
|
-
* clip 是纯文本截断,Box 补位后背景整行连续。
|
|
66
|
-
*/
|
|
67
|
-
class CompactTitle implements Component {
|
|
68
|
-
constructor(private readonly text: string) {}
|
|
69
|
-
|
|
70
|
-
invalidate(): void {}
|
|
71
|
-
|
|
72
|
-
render(width: number): string[] {
|
|
73
|
-
return [clip(oneLine(this.text), Math.max(1, width))];
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function fullCard(text: string, theme: Theme): Component {
|
|
78
|
-
const bodies = parseEnvelopes(text)?.map((envelope) => envelope.body).join("\n\n") ?? text;
|
|
79
|
-
return card(theme, [new Markdown(bodies, 0, 0, getMarkdownTheme())]);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function card(theme: Theme, children: Component[]): Component {
|
|
83
|
-
// 宿主 CustomMessageComponent 已自带一行消息间距,这里不再叠加;垂直内边距也为 0。
|
|
84
|
-
const box = new Box(1, 0, (text) => theme.bg("customMessageBg", text));
|
|
85
|
-
for (const child of children) box.addChild(child);
|
|
86
|
-
return box;
|
|
87
|
-
}
|