pi-firecode 0.8.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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/config.example.jsonc +100 -0
  4. package/config.ts +474 -0
  5. package/deliver.ts +32 -0
  6. package/flame-frames.ts +460 -0
  7. package/format.ts +80 -0
  8. package/header.ts +92 -0
  9. package/herdr-client.ts +60 -0
  10. package/index.ts +62 -0
  11. package/jsonc.ts +34 -0
  12. package/master/event-card.ts +106 -0
  13. package/master/event-format.ts +52 -0
  14. package/master/index.ts +1200 -0
  15. package/master/prompt.ts +26 -0
  16. package/master/prompts/master.zh.md +17 -0
  17. package/master/prompts/worker.zh.md +1 -0
  18. package/master/role.ts +13 -0
  19. package/master/spawn.ts +193 -0
  20. package/master/state.ts +201 -0
  21. package/package.json +59 -0
  22. package/provider/claude-sub.ts +129 -0
  23. package/provider/openai-native/index.ts +8 -0
  24. package/provider/openai-native/src/compact-client.ts +362 -0
  25. package/provider/openai-native/src/config.ts +297 -0
  26. package/provider/openai-native/src/extension.ts +93 -0
  27. package/provider/openai-native/src/native-compaction.ts +151 -0
  28. package/provider/openai-native/src/native-details.ts +157 -0
  29. package/provider/openai-native/src/native-replay.ts +253 -0
  30. package/provider/openai-native/src/native-runtime.ts +144 -0
  31. package/provider/openai-native/src/options.ts +85 -0
  32. package/provider/openai-native/src/request-pipeline.ts +21 -0
  33. package/provider/openai-native/src/responses-input.ts +433 -0
  34. package/review/advisor.ts +112 -0
  35. package/review/card.ts +385 -0
  36. package/review/checkpoint.ts +384 -0
  37. package/review/evidence.ts +232 -0
  38. package/review/index.ts +1246 -0
  39. package/review/outcome.ts +74 -0
  40. package/review/progress.ts +324 -0
  41. package/review/prompt.ts +210 -0
  42. package/review/prompts/advisor.en.md +41 -0
  43. package/review/prompts/advisor.zh.md +42 -0
  44. package/review/prompts/review.en.md +75 -0
  45. package/review/prompts/review.zh.md +75 -0
  46. package/review/reviewer.ts +356 -0
  47. package/review/session.ts +129 -0
  48. package/review/state.ts +905 -0
  49. package/review/ui.ts +528 -0
  50. package/session/bark.ts +157 -0
  51. package/session/herdr-display.ts +87 -0
  52. package/session/presets.ts +257 -0
  53. package/session/rename.ts +46 -0
  54. package/session/stats.ts +310 -0
  55. package/session/working-flame.ts +116 -0
  56. package/statusbar/index.ts +118 -0
  57. package/statusbar/quota-cache.ts +60 -0
  58. package/statusbar/quota-parse.ts +85 -0
  59. package/statusbar/quota.ts +203 -0
  60. package/statusbar/render.ts +181 -0
  61. package/statusbar/tps.ts +87 -0
  62. package/theme.ts +84 -0
  63. package/tools/grouping.ts +233 -0
  64. package/tools/index.ts +183 -0
  65. package/tools/line.ts +194 -0
  66. package/tools/parts.ts +143 -0
  67. package/tools/timing.ts +22 -0
  68. package/watcher/card.ts +85 -0
  69. package/watcher/index.ts +204 -0
  70. package/watcher/observer.ts +75 -0
  71. package/watcher/prompts/watch.zh.md +46 -0
  72. package/watcher/transcript.ts +64 -0
package/tools/index.ts ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * 接管默认 4 工具(read/bash/edit/write)的展示:单行摘要 + 耗时/大小列 + 连续行轨道。
3
+ *
4
+ * 只包装默认激活的工具:原版 pi 的 registerTool 是注册即激活(会话构建与 reload
5
+ * 固定 includeAllExtensionTools),给 grep/find/ls 挂渲染包装会把它们在所有会话
6
+ * 强制打开——渲染接管不得改变工具集,因此那三个用宿主默认渲染。
7
+ */
8
+ import {
9
+ createBashTool,
10
+ createEditTool,
11
+ createReadTool,
12
+ createWriteTool,
13
+ type ExtensionAPI,
14
+ } from "@earendil-works/pi-coding-agent";
15
+ import { installGroupPatch, uninstallGroupPatch } from "./grouping.js";
16
+ import { ToolLine, makeResultRenderer } from "./line.js";
17
+ import { commandParts, diffMeta, pathValue } from "./parts.js";
18
+ import { clearDurations, executeTimed } from "./timing.js";
19
+
20
+ const LABEL = { read: "读取", bash: "操作", edit: "修改", write: "写入" } as const;
21
+
22
+ type ToolArgs = {
23
+ path?: string;
24
+ file_path?: string;
25
+ offset?: number;
26
+ limit?: number;
27
+ };
28
+
29
+ type ToolMap = {
30
+ read: ReturnType<typeof createReadTool>;
31
+ bash: ReturnType<typeof createBashTool>;
32
+ edit: ReturnType<typeof createEditTool>;
33
+ write: ReturnType<typeof createWriteTool>;
34
+ };
35
+
36
+ const cache = new Map<string, ToolMap>();
37
+
38
+ function createTools(cwd: string): ToolMap {
39
+ return {
40
+ read: createReadTool(cwd),
41
+ bash: createBashTool(cwd),
42
+ edit: createEditTool(cwd),
43
+ write: createWriteTool(cwd),
44
+ };
45
+ }
46
+
47
+ /** 工具实例按 cwd 复用:同一会话内 cwd 不变,切目录也不必重建全部工具。 */
48
+ function tools(cwd: string): ToolMap {
49
+ let value = cache.get(cwd);
50
+ if (!value) {
51
+ value = createTools(cwd);
52
+ cache.set(cwd, value);
53
+ }
54
+ return value;
55
+ }
56
+
57
+ const argPath = (args: ToolArgs): string => args?.file_path ?? args?.path ?? "";
58
+
59
+ /** read 的 offset/limit → `:12-40`、`:12+`。 */
60
+ function rangeSuffix(args: ToolArgs): string {
61
+ if (args.offset === undefined && args.limit === undefined) return "";
62
+ const start = args.offset ?? 1;
63
+ if (args.limit === undefined) return `:${start}+`;
64
+ return `:${start}-${start + args.limit - 1}`;
65
+ }
66
+
67
+ function lineCount(text: string): number {
68
+ if (text === "") return 0;
69
+ const lines = text.split("\n").length;
70
+ return text.endsWith("\n") ? lines - 1 : lines;
71
+ }
72
+
73
+ function invoke<T extends (...args: never[]) => unknown>(
74
+ execute: T,
75
+ args: unknown[],
76
+ ): ReturnType<T> {
77
+ return Reflect.apply(execute, undefined, args) as ReturnType<T>;
78
+ }
79
+
80
+ export function registerToolRendering(pi: ExtensionAPI): void {
81
+ const initial = tools(process.cwd());
82
+ const decorated = new Set<string>(Object.keys(LABEL));
83
+
84
+ pi.on("session_start", async (_event, ctx) => {
85
+ clearDurations();
86
+ for (const tool of pi.getAllTools()) {
87
+ decorated.add(tool.name);
88
+ }
89
+ installGroupPatch(ctx.ui, decorated);
90
+ });
91
+ pi.on("session_shutdown", async () => {
92
+ clearDurations();
93
+ uninstallGroupPatch();
94
+ });
95
+
96
+ pi.registerTool({
97
+ ...initial.read,
98
+ label: LABEL.read,
99
+ renderShell: "self",
100
+ execute: (id, params, signal, update, ctx) =>
101
+ executeTimed(id, () => invoke(tools(ctx.cwd).read.execute, [id, params, signal, update, ctx])),
102
+ renderCall: (args, theme, ctx) =>
103
+ new ToolLine({
104
+ label: LABEL.read,
105
+ value: pathValue(argPath(args), ctx.cwd, rangeSuffix(args)),
106
+ clip: "start",
107
+ theme,
108
+ ctx,
109
+ }),
110
+ renderResult: makeResultRenderer(true),
111
+ });
112
+
113
+ pi.registerTool({
114
+ ...initial.bash,
115
+ label: LABEL.bash,
116
+ renderShell: "self",
117
+ execute: (id, params, signal, update, ctx) =>
118
+ executeTimed(id, () => invoke(tools(ctx.cwd).bash.execute, [id, params, signal, update, ctx])),
119
+ renderCall: (args, theme, ctx) =>
120
+ new ToolLine({
121
+ label: LABEL.bash,
122
+ value: commandParts(args.command),
123
+ clip: "end",
124
+ theme,
125
+ ctx,
126
+ }),
127
+ renderResult: makeResultRenderer(true),
128
+ });
129
+
130
+ const editResult = makeResultRenderer(false);
131
+ pi.registerTool({
132
+ ...initial.edit,
133
+ label: LABEL.edit,
134
+ renderShell: "self",
135
+ execute: (id, params, signal, update, ctx) =>
136
+ executeTimed(id, () => invoke(tools(ctx.cwd).edit.execute, [id, params, signal, update, ctx])),
137
+ renderCall: (args, theme, ctx) =>
138
+ new ToolLine({
139
+ label: LABEL.edit,
140
+ value: pathValue(argPath(args), ctx.cwd),
141
+ clip: "start",
142
+ theme,
143
+ ctx,
144
+ }),
145
+ renderResult(result, options, theme, ctx) {
146
+ const details = result.details as { diff?: unknown } | undefined;
147
+ const diff = !ctx.isError && typeof details?.diff === "string" ? details.diff : undefined;
148
+ ctx.state.meta = diff ? diffMeta(diff) : undefined;
149
+ return editResult(result, options, theme, ctx);
150
+ },
151
+ });
152
+
153
+ pi.registerTool({
154
+ ...initial.write,
155
+ label: LABEL.write,
156
+ renderShell: "self",
157
+ execute: (id, params, signal, update, ctx) =>
158
+ executeTimed(id, () => invoke(tools(ctx.cwd).write.execute, [id, params, signal, update, ctx])),
159
+ renderCall: (args, theme, ctx) =>
160
+ new ToolLine({
161
+ label: LABEL.write,
162
+ value: pathValue(argPath(args), ctx.cwd),
163
+ clip: "start",
164
+ meta: [{ text: ` +${lineCount(args.content ?? "")}`, color: "toolDiffAdded" }],
165
+ theme,
166
+ ctx,
167
+ }),
168
+ renderResult: makeResultRenderer(false),
169
+ });
170
+
171
+ pi.registerCommand("tool-status", {
172
+ description: "显示当前已加载/启用工具",
173
+ handler: async (_args, ctx) => {
174
+ ctx.ui.notify(
175
+ `active: ${pi.getActiveTools().join(", ")}\nall: ${pi
176
+ .getAllTools()
177
+ .map((tool) => tool.name)
178
+ .join(", ")}`,
179
+ "info",
180
+ );
181
+ },
182
+ });
183
+ }
package/tools/line.ts ADDED
@@ -0,0 +1,194 @@
1
+ /** 单条工具行的渲染:状态字形 + 标签 + 主体 + 右侧耗时/大小,展开时附完整结果。 */
2
+ import { stripVTControlCharacters } from "node:util";
3
+ import type { Theme } from "@earendil-works/pi-coding-agent";
4
+ import { type Component, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
5
+ import { type ClipSide, clip, oneLine } from "../format.js";
6
+ import {
7
+ type Part,
8
+ clipParts,
9
+ durationPart,
10
+ paint,
11
+ partsWidth,
12
+ sizePart,
13
+ } from "./parts.js";
14
+ import { takeDuration } from "./timing.js";
15
+
16
+ export const RAIL = "▏ ";
17
+ /** 右侧列与主体之间至少留出的空隙,不够就先丢弃右侧列 */
18
+ const RIGHT_GAP = 2;
19
+ const MIN_VALUE_WIDTH = 8;
20
+ /** 有错误摘要时,主体最多占可用宽度的比例 */
21
+ const ERROR_VALUE_RATIO = 0.4;
22
+
23
+ const STATUS = {
24
+ run: { glyph: "●", color: "accent", bg: "toolPendingBg" },
25
+ ok: { glyph: "✓", color: "success", bg: "toolSuccessBg" },
26
+ err: { glyph: "✗", color: "error", bg: "toolErrorBg" },
27
+ } as const;
28
+
29
+ /** renderCall / renderResult 之间共享的行状态。 */
30
+ export type RowState = {
31
+ /** 结果字符数(read/bash),驱动右对齐大小列 */
32
+ chars?: number;
33
+ durationMs?: number;
34
+ /** 结果产生的动态后缀(edit 的 ±diff) */
35
+ meta?: Part[];
36
+ /** 失败时的一行摘要 */
37
+ errorText?: string;
38
+ };
39
+
40
+ export type RenderContext = {
41
+ state: RowState;
42
+ cwd: string;
43
+ toolCallId: string;
44
+ isPartial: boolean;
45
+ isError: boolean;
46
+ expanded: boolean;
47
+ };
48
+
49
+ type ResultContent = { type: string; text?: string };
50
+ export type ToolResult = { content?: ResultContent[]; details?: unknown };
51
+
52
+ export type ToolLineOptions = {
53
+ label: string;
54
+ value: Part[];
55
+ /** 溢出时从哪端截断:路径保尾部,命令保头部 */
56
+ clip: ClipSide;
57
+ /** 紧跟 value 的左侧后缀(edit ±diff、write +N) */
58
+ meta?: Part[];
59
+ theme: Theme;
60
+ ctx: RenderContext;
61
+ };
62
+
63
+ function sanitizeDisplayText(text: string): string {
64
+ let output = "";
65
+ for (const char of stripVTControlCharacters(text)) {
66
+ const code = char.codePointAt(0);
67
+ if (code === undefined || code === 0x0d) continue;
68
+ if (code === 0x09 || code === 0x0a) output += char;
69
+ else if (code > 0x1f && (code < 0xfff9 || code > 0xfffb)) output += char;
70
+ }
71
+ return output;
72
+ }
73
+
74
+ function resultText(result: ToolResult): { displayText: string; chars: number } {
75
+ const blocks: string[] = [];
76
+ let chars = 0;
77
+ for (const item of result.content ?? []) {
78
+ if (item.type !== "text" || typeof item.text !== "string") continue;
79
+ blocks.push(sanitizeDisplayText(item.text));
80
+ chars += item.text.length;
81
+ }
82
+ return { displayText: blocks.join("\n"), chars };
83
+ }
84
+
85
+ /** 右侧列之间用 " · " 分隔。 */
86
+ function rightContentWidth(parts: Part[]): number {
87
+ return partsWidth(parts) + Math.max(0, parts.length - 1) * 3;
88
+ }
89
+
90
+ const EMPTY_RESULT: Component = { render: () => [], invalidate() {} };
91
+
92
+ class ExpandedResult implements Component {
93
+ constructor(
94
+ private readonly text: string,
95
+ private readonly theme: Theme,
96
+ ) {}
97
+
98
+ invalidate(): void {}
99
+
100
+ render(width: number): string[] {
101
+ const lines = wrapTextWithAnsi(this.text, Math.max(1, width));
102
+ return ["", ...lines.map((line) => this.theme.fg("toolOutput", line))];
103
+ }
104
+ }
105
+
106
+ export function paintBgLine(line: string, width: number, bgFn?: (text: string) => string): string {
107
+ const visLen = visibleWidth(line);
108
+ const padNeeded = Math.max(0, width - visLen);
109
+ const padded = line + " ".repeat(padNeeded);
110
+ return bgFn ? bgFn(padded) : padded;
111
+ }
112
+
113
+ export class ToolLine implements Component {
114
+ constructor(private readonly options: ToolLineOptions) {}
115
+
116
+ invalidate(): void {}
117
+
118
+ render(width: number): string[] {
119
+ const { theme, ctx } = this.options;
120
+ const state = ctx.state;
121
+ const status = ctx.isError ? STATUS.err : ctx.isPartial ? STATUS.run : STATUS.ok;
122
+ const safeWidth = Math.max(1, width - 2);
123
+ const head: Part[] = [
124
+ { text: RAIL, color: "dim" },
125
+ { text: `${status.glyph} `, color: status.color, bold: true },
126
+ {
127
+ text: `${this.options.label} `,
128
+ color: ctx.isError ? "error" : "toolTitle",
129
+ bold: true,
130
+ },
131
+ ];
132
+ const headWidth = partsWidth(head);
133
+ const bgFn = typeof theme.bg === "function" ? (text: string) => theme.bg(status.bg, text) : undefined;
134
+ if (safeWidth <= headWidth) {
135
+ const clipped = paint(theme, clipParts(head, safeWidth, "end"));
136
+ return [paintBgLine(clipped, width, bgFn)];
137
+ }
138
+
139
+ const meta = [...(this.options.meta ?? []), ...(state.meta ?? [])];
140
+ const right = [durationPart(state.durationMs), sizePart(state.chars)].filter(
141
+ (part): part is Part => !!part,
142
+ );
143
+ while (
144
+ right.length &&
145
+ safeWidth - headWidth - rightContentWidth(right) - RIGHT_GAP < MIN_VALUE_WIDTH
146
+ )
147
+ right.pop();
148
+ const rightVisible = rightContentWidth(right);
149
+ const freeWidth = safeWidth - headWidth - (right.length ? rightVisible + RIGHT_GAP : 0);
150
+ const errorText = ctx.isError && !ctx.expanded ? oneLine(state.errorText ?? "") : "";
151
+ let value = [...this.options.value, ...meta];
152
+ if (ctx.isError) value = value.map((part) => ({ ...part, color: "error" as const }));
153
+
154
+ let errorPart: Part | undefined;
155
+ if (errorText) {
156
+ value = clipParts(value, Math.max(1, Math.floor(freeWidth * ERROR_VALUE_RATIO)), this.options.clip);
157
+ const errorWidth = freeWidth - partsWidth(value) - 3;
158
+ if (errorWidth >= 1)
159
+ errorPart = { text: ` · ${clip(errorText, errorWidth, "end")}`, color: "error" };
160
+ } else {
161
+ value = clipParts(value, freeWidth, this.options.clip);
162
+ }
163
+
164
+ const body = [...head, ...value, ...(errorPart ? [errorPart] : [])];
165
+ let line = paint(theme, body);
166
+ if (right.length) {
167
+ const pad = Math.max(RIGHT_GAP, safeWidth - partsWidth(body) - rightVisible);
168
+ line +=
169
+ " ".repeat(pad) +
170
+ right.map((part) => paint(theme, [part])).join(theme.fg("dim", " · "));
171
+ }
172
+ return [paintBgLine(line, width, bgFn)];
173
+ }
174
+ }
175
+
176
+ /** 折叠时只回写行状态;展开时输出完整结果。 */
177
+ export function makeResultRenderer(sized: boolean) {
178
+ return (
179
+ result: ToolResult,
180
+ options: { expanded: boolean },
181
+ theme: Theme,
182
+ context: RenderContext,
183
+ ): Component => {
184
+ const state = context.state;
185
+ const { displayText, chars } = resultText(result);
186
+ if (sized) state.chars = chars;
187
+ const durationMs = takeDuration(context.toolCallId);
188
+ if (durationMs !== undefined) state.durationMs = durationMs;
189
+ state.errorText = context.isError ? displayText : "";
190
+ return options.expanded && displayText !== ""
191
+ ? new ExpandedResult(displayText, theme)
192
+ : EMPTY_RESULT;
193
+ };
194
+ }
package/tools/parts.ts ADDED
@@ -0,0 +1,143 @@
1
+ /** 工具行的着色片段:路径、命令、大小、耗时、diff 统计。 */
2
+ import { homedir } from "node:os";
3
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
4
+ import { visibleWidth } from "@earendil-works/pi-tui";
5
+ import { type ClipSide, clip, formatDuration, oneLine } from "../format.js";
6
+ import { sizeColor } from "../theme.js";
7
+
8
+ /** pi 公共出口未导出 ThemeBg:从 Theme.bg 的参数派生,避免依赖内部路径。 */
9
+ type ThemeBg = Parameters<Theme["bg"]>[0];
10
+
11
+ const HOME = homedir();
12
+ const ELLIPSIS = "…";
13
+ /** 结果小于此字符数不显示大小列(噪音阈值) */
14
+ const SIZE_MIN_CHARS = 100;
15
+ /** 快过此耗时不值得占一列 */
16
+ const DURATION_MIN_MS = 1_000;
17
+
18
+ /** 一段带颜色的文本片段;不给 color 则用终端默认前景色。 */
19
+ export type Part = { text: string; color?: ThemeColor; bg?: ThemeBg; bold?: boolean };
20
+
21
+ export function partsWidth(parts: Part[]): number {
22
+ return parts.reduce((total, part) => total + visibleWidth(part.text), 0);
23
+ }
24
+
25
+ export function paint(theme: Theme, parts: Part[]): string {
26
+ return parts
27
+ .map((part) => {
28
+ let text = part.bold ? theme.bold(part.text) : part.text;
29
+ if (part.color && typeof theme.fg === "function") text = theme.fg(part.color, text);
30
+ if (part.bg && typeof theme.bg === "function") text = theme.bg(part.bg, text);
31
+ return text;
32
+ })
33
+ .join("");
34
+ }
35
+
36
+ /** 按方向截断片段序列:start 保留尾部片段(basename 优先),end 保留头部。 */
37
+ export function clipParts(parts: Part[], width: number, from: ClipSide): Part[] {
38
+ if (partsWidth(parts) <= width) return parts;
39
+ const ordered = from === "start" ? [...parts].reverse() : parts;
40
+ const kept: Part[] = [];
41
+ let used = 0;
42
+ for (const part of ordered) {
43
+ const partWidth = visibleWidth(part.text);
44
+ if (used + partWidth <= width) {
45
+ kept.push(part);
46
+ used += partWidth;
47
+ continue;
48
+ }
49
+ const room = width - used;
50
+ if (room >= 1) kept.push({ ...part, text: clip(part.text, room, from) });
51
+ break;
52
+ }
53
+ return from === "start" ? kept.reverse() : kept;
54
+ }
55
+
56
+ /** 路径显示:cwd 内 → ./,家目录 → ~,目录前缀暗色、basename 亮色。 */
57
+ export function pathValue(path: string, cwd: string, suffix = ""): Part[] {
58
+ let display = path || ELLIPSIS;
59
+ if (cwd && display.startsWith(`${cwd}/`)) display = `./${display.slice(cwd.length + 1)}`;
60
+ else if (HOME && display.startsWith(HOME)) display = `~${display.slice(HOME.length)}`;
61
+ const cut = display.lastIndexOf("/") + 1;
62
+ const parts: Part[] = [];
63
+ if (cut > 0) parts.push({ text: display.slice(0, cut), color: "muted" });
64
+ parts.push({ text: display.slice(cut), color: "accent" });
65
+ if (suffix) parts.push({ text: suffix, color: "muted" });
66
+ return parts;
67
+ }
68
+
69
+ const CONNECTOR = /^(&&|\|\||\||;|↵)$/;
70
+ const REDIRECT = /^\d*(>>?|<)/;
71
+ const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/;
72
+
73
+ function appendPart(parts: Part[], text: string, color?: ThemeColor): void {
74
+ const last = parts[parts.length - 1];
75
+ if (last && last.color === color) last.text += text;
76
+ else parts.push({ text, color });
77
+ }
78
+
79
+ /** 命令分层着色:命令词 accent,参数默认色,管道/重定向/连接符/环境赋值暗色。 */
80
+ export function commandParts(command: string): Part[] {
81
+ const display = oneLine((command || "").replace(/\r?\n/g, " ↵ ")).replaceAll(HOME, "~");
82
+ if (!display) return [{ text: "$ ", color: "muted" }, { text: ELLIPSIS, color: "accent" }];
83
+ const parts: Part[] = [{ text: "$ ", color: "muted" }];
84
+ let expectCommand = true;
85
+ for (const token of display.split(" ")) {
86
+ let color: ThemeColor | undefined;
87
+ if (CONNECTOR.test(token)) {
88
+ color = "muted";
89
+ expectCommand = true;
90
+ } else if (REDIRECT.test(token)) {
91
+ color = "muted";
92
+ } else if (expectCommand && ENV_ASSIGN.test(token)) {
93
+ color = "muted";
94
+ } else if (expectCommand) {
95
+ color = "accent";
96
+ expectCommand = false;
97
+ }
98
+ if (token.endsWith(";")) expectCommand = true;
99
+ appendPart(parts, `${token} `, color);
100
+ }
101
+ const last = parts[parts.length - 1];
102
+ last.text = last.text.trimEnd();
103
+ return parts;
104
+ }
105
+
106
+ export function sizePart(chars: number | undefined): Part | undefined {
107
+ if (chars === undefined || chars < SIZE_MIN_CHARS) return undefined;
108
+ return { text: `${(chars / 1000).toFixed(1)}k`, color: sizeColor(chars) };
109
+ }
110
+
111
+ export function durationPart(durationMs: number | undefined): Part | undefined {
112
+ if (durationMs === undefined || durationMs < DURATION_MIN_MS) return undefined;
113
+ return { text: formatDuration(durationMs), color: "dim" };
114
+ }
115
+
116
+ export function diffMeta(diff: string): Part[] {
117
+ let added = 0;
118
+ let removed = 0;
119
+ for (const line of diff.split("\n")) {
120
+ if (line.startsWith("+")) added++;
121
+ else if (line.startsWith("-")) removed++;
122
+ }
123
+ const parts: Part[] = [];
124
+ if (added) parts.push({ text: ` +${added}`, color: "toolDiffAdded" });
125
+ if (removed) parts.push({ text: ` -${removed}`, color: "toolDiffRemoved" });
126
+ return parts;
127
+ }
128
+
129
+ export function genericArgsParts(args: unknown): Part[] {
130
+ if (!args || typeof args !== "object") {
131
+ return [{ text: String(args ?? ""), color: "accent" }];
132
+ }
133
+ const entries = Object.entries(args as Record<string, unknown>);
134
+ if (entries.length === 0) return [];
135
+ const preferredKey = ["prompt", "task", "query", "path", "command", "name", "message", "url"]
136
+ .find((k) => k in (args as object));
137
+ const [key, value] = preferredKey
138
+ ? [preferredKey, (args as Record<string, unknown>)[preferredKey]]
139
+ : entries[0];
140
+ const valStr = typeof value === "string" ? value : JSON.stringify(value);
141
+ const text = oneLine(valStr.length > 60 ? `${valStr.slice(0, 57)}…` : valStr);
142
+ return [{ text: `${key}: `, color: "muted" }, { text, color: "accent" }];
143
+ }
@@ -0,0 +1,22 @@
1
+ /** 工具真实执行耗时:execute 侧写入,渲染侧按 toolCallId 取走。 */
2
+ const durations = new Map<string, number>();
3
+
4
+ export async function executeTimed<T>(id: string, execute: () => Promise<T>): Promise<T> {
5
+ const startedAt = performance.now();
6
+ try {
7
+ return await execute();
8
+ } finally {
9
+ durations.set(id, performance.now() - startedAt);
10
+ }
11
+ }
12
+
13
+ /** 取一次即释放,避免长会话里堆积。 */
14
+ export function takeDuration(id: string): number | undefined {
15
+ const duration = durations.get(id);
16
+ if (duration !== undefined) durations.delete(id);
17
+ return duration;
18
+ }
19
+
20
+ export function clearDurations(): void {
21
+ durations.clear();
22
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * 观察员建议的展示:单通道单一样式,随投递消息一起渲染。
3
+ * 渲染器永不抛异常;校验失败降级纯文本。
4
+ */
5
+ import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
6
+ import { Text, type Component } from "@earendil-works/pi-tui";
7
+
8
+ import { clip, oneLine } from "../format.js";
9
+ import { RAIL, paintBgLine } from "../tools/line.js";
10
+
11
+ export const WATCHER_MESSAGE_TYPE = "firecode-watcher-note";
12
+
13
+ /** 继承 OMP 的 weigh don't blindly obey:投递给模型的正文自带权衡包装。 */
14
+ const WEIGH_NOTICE = "这是观察员供你权衡的第二意见,不是指令:与你掌握的上下文冲突时按你的判断继续。";
15
+ const LABEL = "👓 观察员";
16
+
17
+ export function adviceMessage(card: WatcherCard): string {
18
+ return `<firecode_watcher>\n${adviceHeadline(card)}\n${card.note}\n${WEIGH_NOTICE}\n</firecode_watcher>`;
19
+ }
20
+
21
+ export interface WatcherCard {
22
+ note: string;
23
+ turnIndex: number;
24
+ }
25
+
26
+ export function adviceHeadline(card: WatcherCard): string {
27
+ return `${LABEL}(${timeMark(card.turnIndex)})`;
28
+ }
29
+
30
+ /** 建议自带时点标记:投递时主会话可能已经走远,读的人要知道它看的是哪一刻。 */
31
+ export function timeMark(turnIndex: number): string {
32
+ return `基于第 ${turnIndex} 回合前的观察`;
33
+ }
34
+
35
+ export function registerWatcherCardRenderer(pi: ExtensionAPI): void {
36
+ pi.registerMessageRenderer<WatcherCard>(WATCHER_MESSAGE_TYPE, (message, options, theme) =>
37
+ isValidCard(message.details)
38
+ ? new AdviceLine(message.details, options.expanded, theme)
39
+ : new Text(String(message.content), 0, 0));
40
+ }
41
+
42
+ class AdviceLine implements Component {
43
+ private readonly fallback: Component;
44
+
45
+ constructor(
46
+ private readonly card: WatcherCard,
47
+ private readonly expanded: boolean,
48
+ private readonly theme: Theme,
49
+ ) {
50
+ this.fallback = new Text(`${adviceHeadline(card)} ${card.note}`, 0, 0);
51
+ }
52
+
53
+ render(width: number): string[] {
54
+ const columns = Math.max(1, width);
55
+ try {
56
+ const bgFn = typeof this.theme.bg === "function"
57
+ ? (text: string) => this.theme.bg("toolPendingBg", text)
58
+ : undefined;
59
+ if (this.expanded) {
60
+ const headline = this.theme.fg("warning", clip(oneLine(adviceHeadline(this.card)), columns));
61
+ const body = new Text(this.theme.fg("dim", ` ${this.card.note}\n (供权衡,勿盲从)`), 0, 0);
62
+ return [paintBgLine(headline, columns, bgFn), ...body.render(columns)];
63
+ }
64
+ // 收起与工具行同构:单行 + 背景条;时点标记只在展开态显示。
65
+ const firstLine = oneLine(this.card.note.split(/\r?\n/u, 1)[0] ?? "");
66
+ const line = clip(
67
+ `${this.theme.fg("dim", RAIL)}${this.theme.fg("warning", LABEL)}${this.theme.fg("dim", ` — ${firstLine}`)}`,
68
+ columns,
69
+ );
70
+ return [paintBgLine(line, columns, bgFn)];
71
+ } catch {
72
+ return this.fallback.render(columns);
73
+ }
74
+ }
75
+
76
+ invalidate(): void {
77
+ this.fallback.invalidate?.();
78
+ }
79
+ }
80
+
81
+ function isValidCard(value: unknown): value is WatcherCard {
82
+ if (!value || typeof value !== "object") return false;
83
+ const card = value as Record<string, unknown>;
84
+ return typeof card.note === "string" && typeof card.turnIndex === "number";
85
+ }