pi-firecode 0.8.1 → 1.0.1

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.
Files changed (79) hide show
  1. package/README.md +58 -24
  2. package/README.zh-CN.md +74 -0
  3. package/config.example.jsonc +47 -57
  4. package/dist/config.example.jsonc +90 -0
  5. package/dist/index.js +11464 -0
  6. package/dist/master/prompts/master.en.md +22 -0
  7. package/dist/master/prompts/master.zh.md +22 -0
  8. package/dist/master/prompts/worker.en.md +1 -0
  9. package/dist/master/prompts/worker.zh.md +1 -0
  10. package/{review → dist/review}/prompts/review.en.md +7 -6
  11. package/{review → dist/review}/prompts/review.zh.md +7 -6
  12. package/dist/watcher/prompts/watch.en.md +43 -0
  13. package/{watcher → dist/watcher}/prompts/watch.zh.md +1 -1
  14. package/package.json +10 -27
  15. package/config.ts +0 -474
  16. package/deliver.ts +0 -32
  17. package/flame-frames.ts +0 -460
  18. package/format.ts +0 -80
  19. package/header.ts +0 -92
  20. package/herdr-client.ts +0 -60
  21. package/index.ts +0 -62
  22. package/jsonc.ts +0 -34
  23. package/master/event-card.ts +0 -106
  24. package/master/event-format.ts +0 -52
  25. package/master/index.ts +0 -1200
  26. package/master/prompt.ts +0 -26
  27. package/master/prompts/master.zh.md +0 -17
  28. package/master/prompts/worker.zh.md +0 -1
  29. package/master/role.ts +0 -13
  30. package/master/spawn.ts +0 -193
  31. package/master/state.ts +0 -201
  32. package/provider/claude-sub.ts +0 -129
  33. package/provider/openai-native/index.ts +0 -8
  34. package/provider/openai-native/src/compact-client.ts +0 -362
  35. package/provider/openai-native/src/config.ts +0 -297
  36. package/provider/openai-native/src/extension.ts +0 -93
  37. package/provider/openai-native/src/native-compaction.ts +0 -151
  38. package/provider/openai-native/src/native-details.ts +0 -157
  39. package/provider/openai-native/src/native-replay.ts +0 -253
  40. package/provider/openai-native/src/native-runtime.ts +0 -144
  41. package/provider/openai-native/src/options.ts +0 -85
  42. package/provider/openai-native/src/request-pipeline.ts +0 -21
  43. package/provider/openai-native/src/responses-input.ts +0 -433
  44. package/review/advisor.ts +0 -112
  45. package/review/card.ts +0 -385
  46. package/review/checkpoint.ts +0 -384
  47. package/review/evidence.ts +0 -232
  48. package/review/index.ts +0 -1246
  49. package/review/outcome.ts +0 -74
  50. package/review/progress.ts +0 -324
  51. package/review/prompt.ts +0 -210
  52. package/review/reviewer.ts +0 -356
  53. package/review/session.ts +0 -129
  54. package/review/state.ts +0 -905
  55. package/review/ui.ts +0 -528
  56. package/session/bark.ts +0 -157
  57. package/session/herdr-display.ts +0 -87
  58. package/session/presets.ts +0 -257
  59. package/session/rename.ts +0 -46
  60. package/session/stats.ts +0 -310
  61. package/session/working-flame.ts +0 -116
  62. package/statusbar/index.ts +0 -118
  63. package/statusbar/quota-cache.ts +0 -60
  64. package/statusbar/quota-parse.ts +0 -85
  65. package/statusbar/quota.ts +0 -203
  66. package/statusbar/render.ts +0 -181
  67. package/statusbar/tps.ts +0 -87
  68. package/theme.ts +0 -84
  69. package/tools/grouping.ts +0 -233
  70. package/tools/index.ts +0 -183
  71. package/tools/line.ts +0 -194
  72. package/tools/parts.ts +0 -143
  73. package/tools/timing.ts +0 -22
  74. package/watcher/card.ts +0 -85
  75. package/watcher/index.ts +0 -204
  76. package/watcher/observer.ts +0 -75
  77. package/watcher/transcript.ts +0 -64
  78. /package/{review → dist/review}/prompts/advisor.en.md +0 -0
  79. /package/{review → dist/review}/prompts/advisor.zh.md +0 -0
@@ -1,181 +0,0 @@
1
- /** 状态栏的纯渲染与布局:给定数据和宽度产出字符串,不触碰会话状态。 */
2
- import type { ThemeColor } from "@earendil-works/pi-coding-agent";
3
- import { visibleWidth } from "@earendil-works/pi-tui";
4
- import { clip, formatDuration, formatTokens, oneLine } from "../format.js";
5
- import { cacheColor, contextColor, quotaColor } from "../theme.js";
6
- import type { QuotaStatus } from "./quota-parse.js";
7
- import type { TpsStatus } from "./tps.js";
8
-
9
- export type StatusLineParts = {
10
- model: string;
11
- modelCompact: string;
12
- quota: string;
13
- quotaCompact: string;
14
- context: string;
15
- contextCompact: string;
16
- cache: string;
17
- tps: string;
18
- };
19
-
20
- type ForegroundTheme = {
21
- fg(color: ThemeColor, text: string): string;
22
- };
23
-
24
- /** 最近一条助手消息的 cacheRead / (input + cacheRead + cacheWrite)。 */
25
- export function latestCacheHitPercent(
26
- entries: ReadonlyArray<{
27
- type?: string;
28
- message?: {
29
- role?: string;
30
- usage?: { input?: number; cacheRead?: number; cacheWrite?: number };
31
- };
32
- }>,
33
- ): number | undefined {
34
- for (let index = entries.length - 1; index >= 0; index--) {
35
- const entry = entries[index];
36
- if (entry?.type !== "message" || entry.message?.role !== "assistant") continue;
37
- const usage = entry.message.usage;
38
- if (!usage) continue;
39
- const input = usage.input ?? 0;
40
- const cacheRead = usage.cacheRead ?? 0;
41
- const cacheWrite = usage.cacheWrite ?? 0;
42
- const promptTokens = input + cacheRead + cacheWrite;
43
- if (promptTokens <= 0 || (cacheRead === 0 && cacheWrite === 0)) return undefined;
44
- return (cacheRead / promptTokens) * 100;
45
- }
46
- return undefined;
47
- }
48
-
49
- export function renderLocation(
50
- theme: ForegroundTheme,
51
- directory: string,
52
- branch: string | null | undefined,
53
- ): string {
54
- return [theme.fg("dim", `📍${directory}`), branch ? theme.fg("dim", `🌿 ${branch}`) : ""]
55
- .filter(Boolean)
56
- .join(" · ");
57
- }
58
-
59
- export function renderQuota(
60
- theme: ForegroundTheme,
61
- status: QuotaStatus,
62
- compact = false,
63
- ): string {
64
- if (status.state === "loading") return theme.fg("dim", "🔋 …");
65
- if (status.state === "unavailable") return theme.fg("dim", "🔋 —");
66
- const windows = compact
67
- ? [
68
- status.windows.reduce((tightest, window) =>
69
- window.remaining < tightest.remaining ? window : tightest,
70
- ),
71
- ]
72
- : status.windows;
73
- return `${theme.fg("dim", "🔋 ")}${windows
74
- .map((window) =>
75
- theme.fg(quotaColor(window.remaining), `${window.label} ${window.remaining}%`),
76
- )
77
- .join(theme.fg("dim", "/"))}`;
78
- }
79
-
80
- export function renderContext(
81
- theme: ForegroundTheme,
82
- percent: number | null | undefined,
83
- contextWindow: number,
84
- compact = false,
85
- ): string {
86
- const percentText = percent == null ? "?" : `${percent.toFixed(1)}%`;
87
- return `${theme.fg("dim", "📦 ")}${theme.fg(contextColor(percent), percentText)}${
88
- compact ? "" : theme.fg("dim", `/${formatTokens(contextWindow)}`)
89
- }`;
90
- }
91
-
92
- export function renderCache(theme: ForegroundTheme, percent: number | undefined): string {
93
- if (percent === undefined) return "";
94
- return `${theme.fg("dim", "♻️ ")}${theme.fg(cacheColor(percent), `${Math.round(percent)}%`)}`;
95
- }
96
-
97
- export function renderTps(theme: ForegroundTheme, status: TpsStatus | undefined): string {
98
- if (!status) return "";
99
- if (status.phase === "live") {
100
- return theme.fg(
101
- "success",
102
- `↗ ${status.tokensPerSecond ? `${status.tokensPerSecond}t/s` : "…"}`,
103
- );
104
- }
105
- const elapsed = formatDuration(status.elapsedSeconds * 1_000);
106
- const duration = theme.fg("dim", `⏱ ${elapsed}${status.tokensPerSecond ? " · " : ""}`);
107
- return status.tokensPerSecond
108
- ? `${duration}${theme.fg("success", `↗ ${status.tokensPerSecond}t/s`)}`
109
- : duration;
110
- }
111
-
112
- /** /fire-review 广播的审查进度,右对齐挂在首行末尾。 */
113
- export function reviewStatus(statuses: ReadonlyMap<string, string>): string {
114
- return oneLine(statuses.get("fire-review") ?? "");
115
- }
116
-
117
- /** 首行模块状态由各模块的 setStatus/onChange 驱动,状态栏只负责组合。 */
118
- export function statusBadges(statuses: ReadonlyMap<string, string>, separator: string): string {
119
- return [statuses.get("master"), statuses.get("watcher")].filter(Boolean).join(separator);
120
- }
121
-
122
- export function alignRight(left: string, right: string, width: number): string {
123
- if (!right) return left;
124
- const padding = width - visibleWidth(left) - visibleWidth(right);
125
- return padding >= 2 ? `${left}${" ".repeat(padding)}${right}` : left;
126
- }
127
-
128
- export function fitMetadataLine(
129
- location: string,
130
- title: string,
131
- width: number,
132
- separator: string,
133
- badge = "",
134
- ): string {
135
- if (width <= 0) return "";
136
- // badge(如指挥官态)整段取舍:放不下就丢,不截半个,再走会话名截断阶梯。
137
- if (badge) {
138
- const full = [location, title, badge].filter(Boolean).join(separator);
139
- if (visibleWidth(full) <= width) return full;
140
- }
141
- if (!title) return clip(location, width, "end", visibleWidth(location) > width ? "…" : "");
142
- const full = `${location}${separator}${title}`;
143
- if (visibleWidth(full) <= width) return full;
144
- const titleWidth = width - visibleWidth(location) - visibleWidth(separator);
145
- if (titleWidth > 0) {
146
- const fittedTitle = clip(title, titleWidth, "end");
147
- if (fittedTitle) return `${location}${separator}${fittedTitle}`;
148
- }
149
- return clip(location, width, "end");
150
- }
151
-
152
- const joinParts = (parts: string[], separator: string) => parts.filter(Boolean).join(separator);
153
-
154
- export function fitStatusLine(
155
- parts: StatusLineParts,
156
- width: number,
157
- separator: string,
158
- ): string {
159
- if (width <= 0) return "";
160
- // joinParts 会丢掉空的 quota/cache/tps。顺序:model → quota? → context → cache → tps。
161
- const candidates = [
162
- [parts.model, parts.quota, parts.context, parts.cache, parts.tps],
163
- [parts.model, parts.quota, parts.context, parts.cache],
164
- [parts.modelCompact, parts.quota, parts.context, parts.cache],
165
- [parts.modelCompact, parts.quotaCompact, parts.context, parts.cache],
166
- [parts.modelCompact, parts.context, parts.cache],
167
- [parts.modelCompact, parts.context],
168
- [parts.modelCompact, parts.contextCompact],
169
- ].map((candidate) => joinParts(candidate, separator));
170
- for (const candidate of candidates) {
171
- if (visibleWidth(candidate) <= width) return candidate;
172
- }
173
-
174
- const context = parts.contextCompact || parts.context;
175
- const contextWidth = visibleWidth(context);
176
- const modelBudget = width - contextWidth - visibleWidth(separator);
177
- if (modelBudget > 0 && contextWidth <= width) {
178
- return `${clip(parts.modelCompact, modelBudget, "end")}${separator}${context}`;
179
- }
180
- return clip(context, width, "end", "");
181
- }
package/statusbar/tps.ts DELETED
@@ -1,87 +0,0 @@
1
- /** Tracks one provider response from request start through final output usage. */
2
- import { performance } from "node:perf_hooks";
3
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
-
5
- const TOKEN_CHARS = 4;
6
- const MIN_SAMPLE_MS = 1_000;
7
- const UPDATE_INTERVAL_MS = 250;
8
-
9
- export type TpsStatus =
10
- | { phase: "live"; tokensPerSecond?: number }
11
- | { phase: "complete"; elapsedSeconds: number; tokensPerSecond?: number };
12
-
13
- function deltaText(event: { type: string; delta?: unknown }): string | undefined {
14
- if (event.type !== "text_delta" && event.type !== "thinking_delta" && event.type !== "toolcall_delta") {
15
- return undefined;
16
- }
17
- return typeof event.delta === "string" ? event.delta : undefined;
18
- }
19
-
20
- export function registerTps(
21
- pi: ExtensionAPI,
22
- update: (status?: TpsStatus) => void,
23
- now = (): number => performance.now(),
24
- ): void {
25
- let requestStartedAt: number | undefined;
26
- let firstDeltaAt: number | undefined;
27
- let lastDeltaAt: number | undefined;
28
- let lastUpdateAt: number | undefined;
29
- let estimatedTokens = 0;
30
-
31
- function resetMeasurement(): void {
32
- requestStartedAt = undefined;
33
- firstDeltaAt = undefined;
34
- lastDeltaAt = undefined;
35
- lastUpdateAt = undefined;
36
- estimatedTokens = 0;
37
- }
38
-
39
- function clear(): void {
40
- resetMeasurement();
41
- update();
42
- }
43
-
44
- pi.on("session_start", clear);
45
- pi.on("model_select", clear);
46
- pi.on("before_provider_request", () => {
47
- resetMeasurement();
48
- requestStartedAt = now();
49
- });
50
- pi.on("message_update", (event) => {
51
- if (event.message.role !== "assistant") return;
52
- const delta = deltaText(event.assistantMessageEvent);
53
- if (delta === undefined) return;
54
-
55
- const current = now();
56
- lastDeltaAt = current;
57
- estimatedTokens += Math.max(0, delta.length / TOKEN_CHARS);
58
- if (firstDeltaAt === undefined) {
59
- firstDeltaAt = current;
60
- lastUpdateAt = current;
61
- update({ phase: "live" });
62
- return;
63
- }
64
-
65
- const elapsed = current - firstDeltaAt;
66
- if (elapsed < MIN_SAMPLE_MS || current - (lastUpdateAt ?? 0) < UPDATE_INTERVAL_MS) return;
67
- lastUpdateAt = current;
68
- const officialTokens = event.message.usage.output;
69
- const tokens = officialTokens > 0 ? officialTokens : Math.round(estimatedTokens);
70
- update({ phase: "live", tokensPerSecond: Math.round((tokens * 1_000) / elapsed) });
71
- });
72
- pi.on("message_end", (event) => {
73
- if (event.message.role !== "assistant" || firstDeltaAt === undefined || lastDeltaAt === undefined) return;
74
- const current = now();
75
- const generationMs = lastDeltaAt - firstDeltaAt;
76
- const outputTokens = event.message.usage.output;
77
- update({
78
- phase: "complete",
79
- elapsedSeconds: Math.max(0, (current - (requestStartedAt ?? firstDeltaAt)) / 1_000),
80
- ...(generationMs > 0 && outputTokens > 0
81
- ? { tokensPerSecond: Math.round((outputTokens * 1_000) / generationMs) }
82
- : {}),
83
- });
84
- resetMeasurement();
85
- });
86
- pi.on("session_shutdown", clear);
87
- }
package/theme.ts DELETED
@@ -1,84 +0,0 @@
1
- /** FireCode 品牌色与数值分级配色:所有阈值到颜色的映射集中在此。 */
2
- import type { ExtensionAPI, ThemeColor } from "@earendil-works/pi-coding-agent";
3
-
4
- export const ANSI = {
5
- reset: "\x1b[0m",
6
- bold: "\x1b[1m",
7
- } as const;
8
-
9
- const rgb = (red: number, green: number, blue: number) =>
10
- `\x1b[38;2;${red};${green};${blue}m`;
11
-
12
- /** 火焰渐变:横幅按行取色,从底部暗红烧到芯部白金。 */
13
- export const FLAME = {
14
- red: rgb(255, 47, 32),
15
- orange: rgb(255, 119, 28),
16
- gold: rgb(255, 195, 61),
17
- white: rgb(255, 239, 184),
18
- textTop: rgb(255, 112, 38),
19
- textMid: rgb(255, 184, 62),
20
- textCore: rgb(255, 236, 170),
21
- } as const;
22
-
23
- /** 阈值表:首个满足 `value >= at` 的档位胜出,表按 at 降序书写。 */
24
- type Threshold = { at: number; color: ThemeColor };
25
-
26
- function pick(value: number, thresholds: readonly Threshold[]): ThemeColor {
27
- for (const threshold of thresholds) {
28
- if (value >= threshold.at) return threshold.color;
29
- }
30
- return thresholds[thresholds.length - 1].color;
31
- }
32
-
33
- const CONTEXT_FILL = [
34
- { at: 75, color: "error" },
35
- { at: 50, color: "warning" },
36
- { at: 0, color: "success" },
37
- ] as const satisfies readonly Threshold[];
38
-
39
- /** 缓存命中越高越好,与上下文填充方向相反。 */
40
- const CACHE_HIT = [
41
- { at: 90, color: "success" },
42
- { at: 50, color: "muted" },
43
- { at: 20, color: "warning" },
44
- { at: 0, color: "error" },
45
- ] as const satisfies readonly Threshold[];
46
-
47
- const QUOTA_REMAINING = [
48
- { at: 51, color: "success" },
49
- { at: 26, color: "warning" },
50
- { at: 0, color: "error" },
51
- ] as const satisfies readonly Threshold[];
52
-
53
- const RESULT_SIZE = [
54
- { at: 50_000, color: "error" },
55
- { at: 10_000, color: "warning" },
56
- { at: 0, color: "dim" },
57
- ] as const satisfies readonly Threshold[];
58
-
59
- export const contextColor = (percent: number | null | undefined): ThemeColor =>
60
- percent == null ? "muted" : pick(percent, CONTEXT_FILL);
61
-
62
- export const cacheColor = (percent: number): ThemeColor =>
63
- pick(percent, CACHE_HIT);
64
-
65
- export const quotaColor = (remaining: number): ThemeColor =>
66
- pick(remaining, QUOTA_REMAINING);
67
-
68
- export const sizeColor = (chars: number): ThemeColor =>
69
- pick(chars, RESULT_SIZE);
70
-
71
- type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
72
-
73
- const THINKING: Record<ThinkingLevel, ThemeColor> = {
74
- off: "thinkingOff",
75
- minimal: "thinkingMinimal",
76
- low: "thinkingLow",
77
- medium: "thinkingMedium",
78
- high: "thinkingHigh",
79
- xhigh: "thinkingXhigh",
80
- max: "thinkingMax",
81
- };
82
-
83
- export const thinkingColor = (level: ThinkingLevel): ThemeColor =>
84
- THINKING[level];
package/tools/grouping.ts DELETED
@@ -1,233 +0,0 @@
1
- /**
2
- * 连续工具行的视觉合并与第三方工具通用单行兜底:
3
- * 1. 原生内置或声明了 self 的工具:保留自渲染,去除相邻空行形成轨道;
4
- * 2. 未知第三方或默认宽框工具:自动收口为通用 ToolLine 单行紧凑轨道与全宽背景。
5
- */
6
- import type { ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
7
- import { ToolLine } from "./line.js";
8
- import { genericArgsParts } from "./parts.js";
9
-
10
- const GROUP_PATCH = Symbol.for("pi.firecode.group-patch");
11
- const REQUEST_RENDER_PATCH = Symbol.for("pi.firecode.request-render-patch");
12
- const GLOBAL_STATE = Symbol.for("pi.firecode.tools-state");
13
- const THEME_KEY = Symbol.for("@earendil-works/pi-coding-agent:theme");
14
-
15
- function getTheme(): Theme {
16
- const globalTheme = (globalThis as any)[THEME_KEY];
17
- if (globalTheme && typeof globalTheme.fg === "function") return globalTheme;
18
- return {
19
- fg: (_color: string, text: string) => text,
20
- bg: (_color: string, text: string) => text,
21
- bold: (text: string) => text,
22
- } as unknown as Theme;
23
- }
24
-
25
- type RenderFunction = (width: number) => string[];
26
- type RequestRenderFunction = (force?: boolean) => unknown;
27
- type ToolRow = { toolName: unknown; setExpanded: unknown; render: RenderFunction };
28
- type ToolRowPatch = { original: RenderFunction; patched: RenderFunction };
29
- type ToolRowPrototype = ToolRow & { [GROUP_PATCH]?: ToolRowPatch };
30
- type RequestRenderPatch = { original: RequestRenderFunction; patched: RequestRenderFunction };
31
- type ContainerLike = { children: unknown[] };
32
- type TuiLike = {
33
- requestRender: RequestRenderFunction;
34
- children?: unknown[];
35
- [REQUEST_RENDER_PATCH]?: RequestRenderPatch;
36
- };
37
- type GlobalState = {
38
- tui?: TuiLike;
39
- chat?: ContainerLike;
40
- indexedChildren?: unknown[];
41
- indexedLength?: number;
42
- joinedRows?: WeakSet<object>;
43
- patchedPrototype?: ToolRowPrototype;
44
- patchErrorReported?: boolean;
45
- decorated: Set<string>;
46
- };
47
-
48
- // 扩展热重载会重新执行模块,patch 状态必须挂在全局才能正确卸载。
49
- const runtimeGlobal = globalThis as typeof globalThis & { [GLOBAL_STATE]?: GlobalState };
50
- const globalState = (runtimeGlobal[GLOBAL_STATE] ??= { decorated: new Set<string>() });
51
-
52
- function isToolRow(value: unknown): value is ToolRow {
53
- if (!value || typeof value !== "object") return false;
54
- const row = value as ToolRow;
55
- return typeof row.render === "function" && typeof row.setExpanded === "function" && "toolName" in row;
56
- }
57
-
58
- function isDecoratedToolRow(value: unknown): value is ToolRow {
59
- return isToolRow(value);
60
- }
61
-
62
- export function renderFallbackToolRow(row: any, width: number, customTheme?: Theme): string[] {
63
- const isError = row.result?.isError ?? false;
64
- const isPartial = row.isPartial ?? false;
65
- const toolName = typeof row.toolName === "string" ? row.toolName : "tool";
66
- const activeTheme = customTheme ?? getTheme();
67
- const toolLine = new ToolLine({
68
- label: toolName,
69
- value: genericArgsParts(row.args),
70
- clip: "end",
71
- theme: activeTheme,
72
- ctx: {
73
- state: row.rendererState ?? {},
74
- cwd: row.cwd ?? process.cwd(),
75
- toolCallId: row.toolCallId ?? "",
76
- isPartial,
77
- isError,
78
- expanded: row.expanded ?? false,
79
- },
80
- });
81
- const lines = ["", ...toolLine.render(width)];
82
- if (row.expanded && row.result) {
83
- const textContent = Array.isArray(row.result.content)
84
- ? row.result.content
85
- .map((c: any) => (c?.type === "text" && typeof c.text === "string" ? c.text : ""))
86
- .filter(Boolean)
87
- .join("\n")
88
- : "";
89
- if (textContent) {
90
- const outputLines = textContent.split("\n").map((l: string) => activeTheme.fg("toolOutput", l));
91
- lines.push("", ...outputLines);
92
- }
93
- }
94
- return lines;
95
- }
96
-
97
- function findChatContainer(value: unknown, seen = new Set<unknown>()): ContainerLike | undefined {
98
- if (!value || typeof value !== "object" || seen.has(value)) return undefined;
99
- seen.add(value);
100
- const children = (value as ContainerLike).children;
101
- if (!Array.isArray(children)) return undefined;
102
- if (children.some(isToolRow)) return value as ContainerLike;
103
- for (const child of children) {
104
- const found = findChatContainer(child, seen);
105
- if (found) return found;
106
- }
107
- return undefined;
108
- }
109
-
110
- /** 增量标记"紧跟另一条装饰行"的行,聊天记录只增不改,可以从上次位置续算。 */
111
- function indexJoinedRows(children: unknown[]): void {
112
- const canAppend =
113
- globalState.indexedChildren === children &&
114
- globalState.indexedLength !== undefined &&
115
- globalState.indexedLength <= children.length &&
116
- globalState.joinedRows !== undefined;
117
- const joinedRows =
118
- canAppend && globalState.joinedRows ? globalState.joinedRows : new WeakSet<object>();
119
- const start = canAppend ? Math.max(1, globalState.indexedLength ?? 1) : 1;
120
- for (let index = start; index < children.length; index++) {
121
- const current = children[index];
122
- if (isDecoratedToolRow(current) && isDecoratedToolRow(children[index - 1]))
123
- joinedRows.add(current);
124
- }
125
- globalState.indexedChildren = children;
126
- globalState.indexedLength = children.length;
127
- globalState.joinedRows = joinedRows;
128
- }
129
-
130
- function followsToolRow(row: ToolRow): boolean {
131
- if (!globalState.chat) globalState.chat = findChatContainer(globalState.tui);
132
- const children = globalState.chat?.children;
133
- if (!children) return false;
134
- if (globalState.indexedChildren !== children || globalState.indexedLength !== children.length)
135
- indexJoinedRows(children);
136
- return globalState.joinedRows?.has(row) ?? false;
137
- }
138
-
139
- /** 走 pi 原生渲染(会消费工具自带 renderCall)的工具;其余走通用兜底行。
140
- * subagents 自带中文 ToolLine 渲染(master/index.ts),不列入会被兜底行遮掉。 */
141
- export const FIRECODE_TOOLS = new Set(["read", "bash", "edit", "write", "find", "grep", "ls", "subagents"]);
142
-
143
- function patchToolRows(): void {
144
- if (globalState.patchedPrototype) return;
145
- const chat = globalState.chat ?? findChatContainer(globalState.tui);
146
- if (!chat) return;
147
- globalState.chat = chat;
148
- const row = chat.children.find(isToolRow);
149
- if (!row) return;
150
- const prototype = Object.getPrototypeOf(row) as ToolRowPrototype;
151
- if (prototype[GROUP_PATCH]) {
152
- globalState.patchedPrototype = prototype;
153
- return;
154
- }
155
- const original = prototype.render;
156
- const patched: RenderFunction = function (this: ToolRow, width: number): string[] {
157
- const toolName = typeof this.toolName === "string" ? this.toolName : "";
158
- const isFirecodeTool = FIRECODE_TOOLS.has(toolName);
159
- const isExpanded = Boolean((this as any).expanded);
160
- const lines = isFirecodeTool || isExpanded
161
- ? original.call(this, width)
162
- : renderFallbackToolRow(this, width);
163
- return followsToolRow(this) && lines[0] === "" ? lines.slice(1) : lines;
164
- };
165
- Object.defineProperty(prototype, GROUP_PATCH, {
166
- configurable: true,
167
- value: { original, patched } satisfies ToolRowPatch,
168
- });
169
- prototype.render = patched;
170
- globalState.patchedPrototype = prototype;
171
- }
172
-
173
- export function installGroupPatch(ui: ExtensionUIContext, decorated: Set<string>): void {
174
- globalState.decorated = decorated;
175
- globalState.patchErrorReported = false;
176
- // 借一次 widget 生命周期拿到 tui 实例,拿到即注销。
177
- ui.setWidget("firecode-tui-capture", (tui) => {
178
- const patchableTui = tui as TuiLike;
179
- globalState.tui = patchableTui;
180
- globalState.chat = undefined;
181
- globalState.indexedChildren = undefined;
182
- globalState.joinedRows = undefined;
183
- if (!patchableTui[REQUEST_RENDER_PATCH]) {
184
- const original = patchableTui.requestRender;
185
- const patched: RequestRenderFunction = (force) => {
186
- try {
187
- patchToolRows();
188
- } catch (error) {
189
- if (!globalState.patchErrorReported) {
190
- globalState.patchErrorReported = true;
191
- ui.notify(
192
- `工具轨道连接失败:${error instanceof Error ? error.message : String(error)}`,
193
- "warning",
194
- );
195
- }
196
- }
197
- return original.call(patchableTui, force);
198
- };
199
- Object.defineProperty(patchableTui, REQUEST_RENDER_PATCH, {
200
- configurable: true,
201
- value: { original, patched } satisfies RequestRenderPatch,
202
- });
203
- patchableTui.requestRender = patched;
204
- }
205
- patchToolRows();
206
- return { render: () => [], invalidate: () => {} };
207
- });
208
- ui.setWidget("firecode-tui-capture", undefined);
209
- }
210
-
211
- export function uninstallGroupPatch(): void {
212
- const tui = globalState.tui;
213
- const requestPatch = tui?.[REQUEST_RENDER_PATCH];
214
- if (tui && requestPatch && tui.requestRender === requestPatch.patched) {
215
- tui.requestRender = requestPatch.original;
216
- delete tui[REQUEST_RENDER_PATCH];
217
- }
218
-
219
- const prototype = globalState.patchedPrototype;
220
- const rowPatch = prototype?.[GROUP_PATCH];
221
- if (prototype && rowPatch && prototype.render === rowPatch.patched) {
222
- prototype.render = rowPatch.original;
223
- delete prototype[GROUP_PATCH];
224
- }
225
-
226
- globalState.tui = undefined;
227
- globalState.chat = undefined;
228
- globalState.indexedChildren = undefined;
229
- globalState.indexedLength = undefined;
230
- globalState.joinedRows = undefined;
231
- globalState.patchedPrototype = undefined;
232
- globalState.patchErrorReported = false;
233
- }