pi-firecode 0.8.1 → 1.0.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 (79) hide show
  1. package/README.md +35 -15
  2. package/activity.ts +92 -0
  3. package/busy.ts +188 -0
  4. package/config.example.jsonc +38 -47
  5. package/config.ts +198 -172
  6. package/deliver.ts +100 -13
  7. package/flame.ts +149 -0
  8. package/format.ts +84 -31
  9. package/header.ts +208 -82
  10. package/index.ts +17 -11
  11. package/master/actions.ts +293 -0
  12. package/master/activity-list.ts +281 -0
  13. package/master/event-card.ts +18 -37
  14. package/master/event-format.ts +71 -40
  15. package/master/guard.ts +46 -0
  16. package/master/index.ts +88 -1036
  17. package/master/list-view.ts +129 -0
  18. package/master/outbox.ts +188 -0
  19. package/master/prompts/master.zh.md +17 -12
  20. package/master/prompts/worker.zh.md +1 -1
  21. package/master/role.ts +6 -1
  22. package/master/run.ts +315 -0
  23. package/master/runtime.ts +259 -0
  24. package/master/spawn.ts +79 -38
  25. package/master/state.ts +30 -6
  26. package/master/worker-view.ts +517 -0
  27. package/package.json +1 -1
  28. package/provider/claude-sub.ts +35 -13
  29. package/provider/openai-native/src/compact-client.ts +2 -14
  30. package/provider/openai-native/src/options.ts +4 -2
  31. package/review/advisor.ts +0 -2
  32. package/review/card.ts +36 -23
  33. package/review/checkpoint.ts +2 -8
  34. package/review/evidence.ts +42 -44
  35. package/review/index.ts +271 -525
  36. package/review/occupancy.ts +25 -0
  37. package/review/outcome.ts +31 -7
  38. package/review/prompt.ts +7 -8
  39. package/review/prompts/review.en.md +7 -6
  40. package/review/prompts/review.zh.md +7 -6
  41. package/review/reviewer.ts +2 -5
  42. package/review/session.ts +11 -30
  43. package/review/state.ts +32 -60
  44. package/review/ui.ts +30 -469
  45. package/round-recorder.ts +18 -0
  46. package/session/herdr-display.ts +1 -1
  47. package/session/presets.ts +130 -87
  48. package/session/quota.ts +123 -0
  49. package/session/stats.ts +4 -1
  50. package/statusbar/index.ts +242 -104
  51. package/statusbar/render.ts +95 -163
  52. package/theme.ts +2 -41
  53. package/tools/actions.ts +53 -0
  54. package/tools/assistant-view.ts +64 -0
  55. package/tools/click-anchor.ts +56 -0
  56. package/tools/group-view.ts +420 -0
  57. package/tools/grouping.ts +146 -221
  58. package/tools/host.ts +369 -0
  59. package/tools/index.ts +108 -105
  60. package/tools/line.ts +95 -58
  61. package/tools/machine.ts +78 -0
  62. package/tools/parts.ts +2 -2
  63. package/tools/round.ts +95 -0
  64. package/tools/turn-clock.ts +41 -0
  65. package/tools/turn-summary.ts +109 -0
  66. package/truncated-write.ts +18 -0
  67. package/watcher/card.ts +28 -22
  68. package/watcher/index.ts +16 -44
  69. package/watcher/observer.ts +3 -3
  70. package/watcher/prompts/watch.zh.md +1 -1
  71. package/watcher/transcript.ts +4 -12
  72. package/flame-frames.ts +0 -460
  73. package/review/progress.ts +0 -324
  74. package/session/bark.ts +0 -157
  75. package/session/working-flame.ts +0 -116
  76. package/statusbar/quota-cache.ts +0 -60
  77. package/statusbar/quota-parse.ts +0 -85
  78. package/statusbar/quota.ts +0 -203
  79. package/statusbar/tps.ts +0 -87
package/tools/line.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /** 单条工具行的渲染:状态字形 + 标签 + 主体 + 右侧耗时/大小,展开时附完整结果。 */
2
2
  import { stripVTControlCharacters } from "node:util";
3
- import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
4
4
  import { type Component, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
5
5
  import { type ClipSide, clip, oneLine } from "../format.js";
6
6
  import {
@@ -14,6 +14,11 @@ import {
14
14
  import { takeDuration } from "./timing.js";
15
15
 
16
16
  export const RAIL = "▏ ";
17
+ /**
18
+ * 聊天行右侧留出的列:宿主全屏模式把滚动条画在最后一列,按列切掉它时会丢掉行尾的颜色复位,
19
+ * 所以行宽到倒数第二列为止,末尾再空一格与滚动条隔开。
20
+ */
21
+ export const CHAT_GUTTER = 2;
17
22
  /** 右侧列与主体之间至少留出的空隙,不够就先丢弃右侧列 */
18
23
  const RIGHT_GAP = 2;
19
24
  const MIN_VALUE_WIDTH = 8;
@@ -24,7 +29,10 @@ const STATUS = {
24
29
  run: { glyph: "●", color: "accent", bg: "toolPendingBg" },
25
30
  ok: { glyph: "✓", color: "success", bg: "toolSuccessBg" },
26
31
  err: { glyph: "✗", color: "error", bg: "toolErrorBg" },
27
- } as const;
32
+ } as const satisfies Record<string, Status>;
33
+
34
+ type Status = { glyph: string; color: ThemeColor; bg?: ThemeBg };
35
+ type ThemeBg = Parameters<Theme["bg"]>[0];
28
36
 
29
37
  /** renderCall / renderResult 之间共享的行状态。 */
30
38
  export type RowState = {
@@ -71,12 +79,12 @@ function sanitizeDisplayText(text: string): string {
71
79
  return output;
72
80
  }
73
81
 
74
- function resultText(result: ToolResult): { displayText: string; chars: number } {
82
+ export function resultText(result: ToolResult, includeText: boolean): { displayText: string; chars: number } {
75
83
  const blocks: string[] = [];
76
84
  let chars = 0;
77
85
  for (const item of result.content ?? []) {
78
86
  if (item.type !== "text" || typeof item.text !== "string") continue;
79
- blocks.push(sanitizeDisplayText(item.text));
87
+ if (includeText) blocks.push(sanitizeDisplayText(item.text));
80
88
  chars += item.text.length;
81
89
  }
82
90
  return { displayText: blocks.join("\n"), chars };
@@ -110,67 +118,96 @@ export function paintBgLine(line: string, width: number, bgFn?: (text: string) =
110
118
  return bgFn ? bgFn(padded) : padded;
111
119
  }
112
120
 
113
- export class ToolLine implements Component {
114
- constructor(private readonly options: ToolLineOptions) {}
121
+ /** 折叠摘要从工具行取当前动作词;未提供该能力的静态工具由通用参数摘要承担。 */
122
+ export interface ActionLine extends Component {
123
+ readonly actionWord: string;
124
+ /** 当前动作的简短目标(纯文本),摘要行跟在动作词后面。 */
125
+ readonly actionTarget?: string;
126
+ }
115
127
 
128
+ export class ToolLine implements ActionLine {
129
+ constructor(private readonly options: ToolLineOptions) {}
116
130
  invalidate(): void {}
117
131
 
132
+ get actionWord(): string {
133
+ return this.options.label;
134
+ }
135
+
136
+ get actionTarget(): string {
137
+ return this.options.value.map((part) => part.text).join("").trim();
138
+ }
139
+
118
140
  render(width: number): string[] {
119
141
  const { theme, ctx } = this.options;
120
142
  const state = ctx.state;
121
143
  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)];
144
+ const value = this.valueWithMeta();
145
+ return renderLine(theme, width, {
146
+ status,
147
+ label: { text: this.options.label, color: ctx.isError ? "error" : "toolTitle", bold: true },
148
+ value: ctx.isError ? value.map((part) => ({ ...part, color: "error" as const })) : value,
149
+ clip: this.options.clip,
150
+ error: ctx.isError && !ctx.expanded ? oneLine(state.errorText ?? "") : "",
151
+ right: [durationPart(state.durationMs), sizePart(state.chars)].filter((part): part is Part => !!part),
152
+ });
153
+ }
154
+
155
+ private valueWithMeta(): Part[] {
156
+ const { value, meta, ctx } = this.options;
157
+ return [...value, ...(meta ?? []), ...(ctx.state.meta ?? [])];
158
+ }
159
+ }
160
+
161
+ /** 一行的布局:头与尾固定,值可裁,错误摘要与值分摊,右列空间不足时先整列丢弃。 */
162
+ type LineSpec = {
163
+ status: Status;
164
+ label: Part;
165
+ value: Part[];
166
+ clip: ClipSide;
167
+ error?: string;
168
+ right: Part[];
169
+ };
170
+
171
+ function renderLine(theme: Theme, width: number, spec: LineSpec): string[] {
172
+ const { status } = spec;
173
+ const safeWidth = Math.max(1, width - CHAT_GUTTER);
174
+ const head: Part[] = [
175
+ { text: RAIL, color: "dim" },
176
+ { text: `${status.glyph} `, color: status.color, bold: true },
177
+ spec.label,
178
+ ...(partsWidth(spec.value) ? [{ text: " " }] : []),
179
+ ];
180
+ const fixedWidth = partsWidth(head);
181
+ const bg = status.bg;
182
+ const bgFn = bg ? (text: string) => theme.bg(bg, text) : undefined;
183
+ if (safeWidth <= fixedWidth) {
184
+ const clipped = paint(theme, clipParts(head, safeWidth, "end"));
185
+ return [paintBgLine(clipped, width, bgFn)];
186
+ }
187
+
188
+ const right = [...spec.right];
189
+ while (right.length && safeWidth - fixedWidth - rightContentWidth(right) - RIGHT_GAP < MIN_VALUE_WIDTH)
190
+ right.pop();
191
+ const rightVisible = rightContentWidth(right);
192
+ const freeWidth = safeWidth - fixedWidth - (right.length ? rightVisible + RIGHT_GAP : 0);
193
+ let value = spec.value;
194
+ let errorPart: Part | undefined;
195
+ if (spec.error) {
196
+ value = clipParts(value, Math.max(1, Math.floor(freeWidth * ERROR_VALUE_RATIO)), spec.clip);
197
+ const errorWidth = freeWidth - partsWidth(value) - 3;
198
+ if (errorWidth >= 1)
199
+ errorPart = { text: ` · ${clip(spec.error, errorWidth, "end")}`, color: "error" };
200
+ } else {
201
+ value = clipParts(value, freeWidth, spec.clip);
202
+ }
203
+
204
+ const body = [...head, ...value, ...(errorPart ? [errorPart] : [])];
205
+ let line = paint(theme, body);
206
+ if (right.length) {
207
+ const pad = Math.max(RIGHT_GAP, safeWidth - partsWidth(body) - rightVisible);
208
+ line += " ".repeat(pad) + right.map((part) => paint(theme, [part])).join(theme.fg("dim", " · "));
173
209
  }
210
+ return [paintBgLine(line, width, bgFn)];
174
211
  }
175
212
 
176
213
  /** 折叠时只回写行状态;展开时输出完整结果。 */
@@ -182,7 +219,7 @@ export function makeResultRenderer(sized: boolean) {
182
219
  context: RenderContext,
183
220
  ): Component => {
184
221
  const state = context.state;
185
- const { displayText, chars } = resultText(result);
222
+ const { displayText, chars } = resultText(result, options.expanded || context.isError);
186
223
  if (sized) state.chars = chars;
187
224
  const durationMs = takeDuration(context.toolCallId);
188
225
  if (durationMs !== undefined) state.durationMs = durationMs;
@@ -0,0 +1,78 @@
1
+ /** 机器消息(指挥官事件、观察员发言、审查卡)的一行投影:卡片与折叠展开态共用,数据只来自信封正文。 */
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import { type EnvelopeTag, parseEnvelopes } from "../deliver.js";
4
+ import { clip, firstSentence } from "../format.js";
5
+ import { CHAT_GUTTER } from "./line.js";
6
+
7
+ export interface MachineEntry {
8
+ /** 一行标题:Master 事件与审查卡是信封正文第一行原样,观察员固定为“观察员”。 */
9
+ title: string;
10
+ /** 正文首句,没有则为空。 */
11
+ preview: string;
12
+ /** 画失败色:只认生产端声明的事实——Master 事件带“错误:”分节,审查卡的 details.tone 为 warning/error。 */
13
+ alarm: boolean;
14
+ /** Master 事件才有:标题第一个空格前的子代理名。 */
15
+ worker?: string;
16
+ /** Master 事件正文带独占一行的“错误:”分节:子代理失败。 */
17
+ failed?: boolean;
18
+ /** 落定类 Master 事件的子代理本次运行时长;有它即触发摘要行的到达高亮。 */
19
+ duration?: string;
20
+ }
21
+
22
+ /** 正文分节标记独占一行(如“回复:”“错误:”);失败时预览取错误分节之后的正文,否则取首个分节之后的。 */
23
+ const SECTION = /^[^\s:]{1,8}:$/u;
24
+ const ERROR_SECTION = "错误:";
25
+ const RUN_TIME = /^耗时:本次运行 (\S+)/mu;
26
+ /** 审查卡生产端(review/card.ts)在 details.tone 里声明的失败色。 */
27
+ const ALARM_TONES = new Set(["warning", "error"]);
28
+ /** 审查卡正文里的发现标题(“## 发现 1:…”)与原因行(“原因:…”)。 */
29
+ const FINDING = /^#{1,6}\s*(?:发现|Finding)\s*[^::]*[::]\s*(.+)$/mu;
30
+ const REASON = /^(?:原因|Reason)[::]\s*(.+)$/mu;
31
+ /** 审查卡里不是结论的行:模型分节、模型清单、卡点、分隔线与用时脚注。 */
32
+ const REVIEW_NOISE = /^(?:\*\*(?:模型|Model)[ ·].*\*\*|(?:模型|Models)[::].*|(?:卡点|Blocker)[::].*|---|(?:用时|Elapsed)[::].*)$/u;
33
+
34
+ /** review/state.ts 多审查者通过汇总的一行:“• 模型短名:结论”。 */
35
+ const MODEL_BULLET = /^•\s*[^\s::]+[::]\s*/u;
36
+
37
+ /** details 是承载信封的 CustomMessage 的 details(审查卡带 tone);用户消息形态没有。 */
38
+ export function machineEntries(text: string, details?: unknown): MachineEntry[] | undefined {
39
+ return parseEnvelopes(text)?.map(({ tag, body }) => entryOf(tag, body, details));
40
+ }
41
+
42
+ function entryOf(tag: EnvelopeTag, body: string, details: unknown): MachineEntry {
43
+ const [heading = "", ...rest] = body.split("\n");
44
+ if (tag === "firecode_watcher") return { title: "观察员", preview: firstSentence(rest.join("\n")), alarm: false };
45
+ if (tag === "firecode_review")
46
+ return { title: heading, preview: reviewPreview(rest), alarm: ALARM_TONES.has((details as { tone?: string } | undefined)?.tone ?? "") };
47
+ // 失败只看是否有独占一行的“错误:”,不论它是第几个分节;失败时预览从错误分节取。
48
+ // 预览只取分节里给人看的正文:没有分节的事件(通知、被中断)正文是写给模型的指令,↳ 只显示标题。
49
+ const errorAt = rest.findIndex((line) => line.trim() === ERROR_SECTION);
50
+ const failed = errorAt >= 0;
51
+ const marker = failed ? errorAt : rest.findIndex((line) => SECTION.test(line.trim()));
52
+ const content = marker < 0 ? "" : rest.slice(marker + 1).filter((line) => !RUN_TIME.test(line)).join("\n");
53
+ const duration = RUN_TIME.exec(body)?.[1];
54
+ return {
55
+ title: heading,
56
+ preview: firstSentence(content),
57
+ alarm: failed,
58
+ worker: heading.split(" ", 1)[0],
59
+ failed,
60
+ ...(duration ? { duration } : {}),
61
+ };
62
+ }
63
+
64
+ /** 审查卡的预览是结论:首条发现标题,否则原因,否则第一句非模型名的正文(多审查者汇总行去掉“• 模型:”前缀)。 */
65
+ function reviewPreview(lines: string[]): string {
66
+ const text = lines.join("\n");
67
+ const pick = FINDING.exec(text)?.[1] ?? REASON.exec(text)?.[1];
68
+ const body = lines.filter((line) => !REVIEW_NOISE.test(line.trim())).map((line) => line.replace(MODEL_BULLET, ""));
69
+ return firstSentence(pick ?? body.join("\n"));
70
+ }
71
+
72
+ /** “↳ 标题 · 时长 首句”;不铺背景,可直接用 clip 截断。宿主在聊天区右缘画滚动条,留一列给它,截断才由 clip 加省略号。 */
73
+ export function machineLine(entry: MachineEntry, theme: Theme, width: number): string {
74
+ const head = `${theme.fg(entry.alarm ? "error" : "success", "↳")} ${theme.fg(entry.alarm ? "error" : "text", entry.title)}`;
75
+ const duration = entry.duration ? `${theme.fg("dim", " · ")}${theme.fg("muted", entry.duration)}` : "";
76
+ const preview = entry.preview ? ` ${theme.fg("muted", entry.preview)}` : "";
77
+ return clip(`${head}${duration}${preview}`, Math.max(1, width - CHAT_GUTTER));
78
+ }
package/tools/parts.ts CHANGED
@@ -26,8 +26,8 @@ export function paint(theme: Theme, parts: Part[]): string {
26
26
  return parts
27
27
  .map((part) => {
28
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);
29
+ if (part.color) text = theme.fg(part.color, text);
30
+ if (part.bg) text = theme.bg(part.bg, text);
31
31
  return text;
32
32
  })
33
33
  .join("");
package/tools/round.ts ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * 轮记录:一段会话进行中(一次人类输入到歇下,含等子代理)的事后事实——整段时长与终态。
3
+ * 歇下边沿写成官方 CustomEntry 持久化,自己不占行;过程组摘要行落定时读它,重载后依然有数。
4
+ */
5
+ import type { CustomEntry, EntryRenderer } from "@earendil-works/pi-coding-agent";
6
+ import type { Component } from "@earendil-works/pi-tui";
7
+ import type { SettledRound } from "../busy.js";
8
+ import { parseEnvelopes } from "../deliver.js";
9
+ import { textOf } from "../format.js";
10
+
11
+ export const ROUND_ENTRY = "firecode-round";
12
+ /** 轮记录写进会话之后在进程内总线上发布(无 payload):订阅方此刻读分支一定已含这条记录,不依赖歇下边沿的订阅顺序。 */
13
+ export const ROUND_RECORDED_CHANNEL = "firecode:round-recorded";
14
+
15
+ export interface Round extends SettledRound {
16
+ /** 歇下时刻(宿主记录的 entry 时间戳)。 */
17
+ at: number;
18
+ }
19
+
20
+ /** 零行标记组件:只把记录带进聊天树,供投影按能力识别。 */
21
+ interface RoundMarker extends Component {
22
+ round: Round;
23
+ }
24
+
25
+ /** 零行标记:投影按 roundOf 识别。会话里的轮记录经宿主渲染成它,没有轮记录的会话(子代理)也可按运行边界直接造。 */
26
+ export function roundMarker(round: Round): Component {
27
+ const marker: RoundMarker = { round, render: () => [], invalidate() {} };
28
+ return marker;
29
+ }
30
+
31
+ /** 本模块写的 entry 一定带 data;类型上的 data? 只是宿主给所有 CustomEntry 的通用形状。 */
32
+ export const renderRound: EntryRenderer<SettledRound> = (entry: CustomEntry<SettledRound>) =>
33
+ roundMarker({ ...(entry.data as SettledRound), at: Date.parse(entry.timestamp) });
34
+
35
+ /** 一轮里更早的非完成终态的短标记。 */
36
+ const EARLIER_TEXT = { aborted: "中断过", error: "请求失败过" } as const;
37
+
38
+ /**
39
+ * 一轮可能有多条记录(中断后又跑了一段,如 /fire-review 或命令触发的再次进行)。合成规则不丢信息:
40
+ * 耗时累加(用户关心这一轮总共花了多久),终态与落定时刻取最后一条(这一轮最终怎样),更早的中断/请求失败
41
+ * 以“中断过 N 次”追加;多段的均速没有请求墙钟无法合成,按“不出半截的数”不给。
42
+ */
43
+ export interface TurnRecord {
44
+ round: Round;
45
+ earlier: string[];
46
+ }
47
+
48
+ export function combineRounds(rounds: readonly Round[]): TurnRecord | undefined {
49
+ const last = rounds.at(-1);
50
+ if (!last) return undefined;
51
+ const elapsed = rounds.reduce((total, round) => total + round.elapsed, 0);
52
+ const round: Round = rounds.length === 1 ? last : { elapsed, outcome: last.outcome, at: last.at };
53
+ const earlier = (["aborted", "error"] as const).flatMap((outcome) => {
54
+ const count = rounds.slice(0, -1).filter((round) => round.outcome === outcome).length;
55
+ return count ? [`${EARLIER_TEXT[outcome]} ${count} 次`] : [];
56
+ });
57
+ return { round, earlier };
58
+ }
59
+
60
+ /** 会话分支条目里本模块读到的部分(宿主 SessionEntry 的结构子集)。 */
61
+ export type BranchEntry =
62
+ | { type: "custom"; customType: string; data?: unknown; timestamp: string }
63
+ | { type: "message"; message: { role: string; content?: unknown } }
64
+ | { type: string };
65
+
66
+ const isHumanEntry = (entry: BranchEntry) => entry.type === "message" && "message" in entry
67
+ && entry.message.role === "user" && !parseEnvelopes(textOf(entry.message.content));
68
+
69
+ /** 会话条目若是轮记录,给出它(带落定时刻);子代理全过程视图与 Master 的本次运行耗时都按它读子代理会话。 */
70
+ export function roundFromEntry(entry: unknown): Round | undefined {
71
+ const record = entry as { type?: unknown; customType?: unknown; data?: unknown; timestamp?: unknown };
72
+ if (record?.type !== "custom" || record.customType !== ROUND_ENTRY || typeof record.timestamp !== "string") return undefined;
73
+ return { ...(record.data as SettledRound), at: Date.parse(record.timestamp) };
74
+ }
75
+
76
+ /**
77
+ * 当前分支最近一轮的落定事实:最近一条人类消息之后的全部轮记录,按 combineRounds 合成。
78
+ * 输入框上边框落定态读它,与摘要行是同一份记录、同一条合成规则。
79
+ */
80
+ export function latestTurnRecord(branch: readonly BranchEntry[]): TurnRecord | undefined {
81
+ const rounds: Round[] = [];
82
+ for (let index = branch.length - 1; index >= 0; index--) {
83
+ const entry = branch[index];
84
+ if (isHumanEntry(entry)) break;
85
+ const round = roundFromEntry(entry);
86
+ if (round) rounds.unshift(round);
87
+ }
88
+ return combineRounds(rounds);
89
+ }
90
+
91
+ /** 宿主把 entry 包成 Container(Spacer + 渲染器组件);按能力找标记,不依赖类身份。 */
92
+ export function roundOf(component: Component): Round | undefined {
93
+ const children = (component as { children?: readonly Component[] }).children;
94
+ return children?.find((child): child is RoundMarker => "round" in child)?.round;
95
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 轮次时钟:把 busy.ts 的会话进行中事实投影到各轮摘要行——哪一轮开着、机器消息是否刚到。
3
+ * 落定后的耗时与终态不在这里,来自聊天树里的轮记录(round.ts)。
4
+ */
5
+ import { type BusyView, IDLE } from "../busy.js";
6
+
7
+ /** 子代理结果到达后摘要行高亮多久。 */
8
+ export const ARRIVAL_FLASH_MS = 2500;
9
+
10
+ export class TurnClock {
11
+ private busy: BusyView = IDLE;
12
+ private lastKey?: object;
13
+ private readonly arrivals = new WeakMap<object, number>();
14
+
15
+ constructor(readonly now: () => number = Date.now) {}
16
+
17
+ sync(view: BusyView): void {
18
+ this.busy = view;
19
+ }
20
+
21
+ /** 指挥官自己的回合在跑;否则这一段只是在等子代理,摘要行没有当前动作可说。 */
22
+ get agentRunning(): boolean {
23
+ return this.busy.agentRunning;
24
+ }
25
+
26
+ /** 投影每次渲染声明当前最后一轮。 */
27
+ track(key: object): void {
28
+ this.lastKey = key;
29
+ }
30
+
31
+ /** 会话正在进行且这是最后一轮。 */
32
+ live(key: object): boolean {
33
+ return this.busy.busy && key === this.lastKey;
34
+ }
35
+
36
+ /** 机器消息距首次出现多久;只有运行中出现的才算“新到达”,恢复的历史永远是旧的。 */
37
+ arrivalAge(item: object): number {
38
+ if (!this.arrivals.has(item)) this.arrivals.set(item, this.busy.busy ? this.now() : -Infinity);
39
+ return this.now() - this.arrivals.get(item)!;
40
+ }
41
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * 一轮的摘要行:只承担运行状态、事后记录与异常提醒。运行中是火苗 + 当前动作(实时计时只在边框),落定后是 ✓ 整段耗时 · 均速,
3
+ * 中断/请求失败是 ✗ 加终态字样;这一轮有子代理失败追加“N 个子代理失败”,宿主的 ⚠ 提示原文也追加。
4
+ * 工具失败是模型的工作过程,不计数、不影响标记。纯渲染,不碰宿主组件。
5
+ */
6
+ import type { Theme } from "@earendil-works/pi-coding-agent";
7
+ import { type Component, type TuiMouseEvent, visibleWidth } from "@earendil-works/pi-tui";
8
+ import { HEAT_COLORS, flame, mix, paint, settleMark } from "../flame.js";
9
+ import { OUTCOME_TEXT, roundTexts } from "../busy.js";
10
+ import { clip } from "../format.js";
11
+ import { CHAT_GUTTER } from "./line.js";
12
+ import type { Round } from "./round.js";
13
+ import { ARRIVAL_FLASH_MS } from "./turn-clock.js";
14
+
15
+ export interface SummaryView {
16
+ /** 折入段内的首条宿主提示原文(缓存、丢思考、压缩计费)。 */
17
+ notice?: string;
18
+ live: boolean;
19
+ /** 运行中的当前动作(动作词 + 简短目标);落定后或指挥官只在等子代理时没有,只画火苗。 */
20
+ action?: { word: string; target?: string };
21
+ /** 子代理结果刚到达:短暂替换当前动作。 */
22
+ arrival?: { text: string; failed: boolean; age: number };
23
+ /** 这一轮里失败的子代理数(按名字去重)。 */
24
+ failures?: number;
25
+ /** 落定后的轮记录:这一轮各段合成的耗时与终态(见 round.ts 的 combineRounds)。 */
26
+ round?: Round;
27
+ /** 这一轮更早几段的非完成终态短标记,如“中断过 1 次”。 */
28
+ earlier?: string[];
29
+ sinceEnd?: number;
30
+ /** 单轮展开/收起;全局展开时为空,点击无效。 */
31
+ toggle?: () => void;
32
+ }
33
+
34
+ /** 提示原文被目标挤压时至少保留的列数。 */
35
+ const NOTICE_MIN = 12;
36
+ /** 目标少于这个宽度就不显示,只留动作词。 */
37
+ const MIN_TARGET = 4;
38
+
39
+ export class TurnSummary implements Component {
40
+ constructor(private readonly view: SummaryView, private readonly theme: Theme) {}
41
+ invalidate(): void {}
42
+
43
+ render(full: number): string[] {
44
+ const width = full - CHAT_GUTTER;
45
+ const { theme, view } = this;
46
+ const sep = theme.fg("dim", " · ");
47
+ const outcome = this.outcomeText();
48
+ const word = view.live ? this.actionText() : outcome;
49
+ const target = view.live && view.action?.target && !this.flashing() ? view.action.target : undefined;
50
+ const glyph = view.live
51
+ ? flame(1, 0.05)
52
+ : settleMark(outcome ? "failed" : "done", view.sinceEnd ?? Infinity);
53
+ const head = word ? `${glyph} ${word}` : glyph;
54
+ const record = view.round === undefined ? [] : roundTexts(view.round).map((text) => theme.fg("muted", text));
55
+ // 失败数与更早的中断都是不能丢的固定标记,窄屏时与它们一起保留。
56
+ const failures = [
57
+ ...(view.earlier ?? []).map((text) => theme.fg("warning", text)),
58
+ ...(view.failures ? [theme.fg("error", `${view.failures} 个子代理失败`)] : []),
59
+ ];
60
+ const join = (lead: string, parts: string[]) => `${lead}${parts.map((part, index) => (index === 0 && !word ? " " : sep) + part).join("")}`;
61
+ const noticeNeed = view.notice ? visibleWidth(sep) + 2 + Math.min(visibleWidth(view.notice), NOTICE_MIN) : 0;
62
+ // 窄屏先裁目标(给提示原文留出最小空间),再裁提示原文,仍放不下再丢记录,最后丢提示原文;动作词与失败数是固定标记。
63
+ for (const base of [[...record, ...failures], failures]) {
64
+ const targetRoom = width - visibleWidth(join(head, base)) - noticeNeed - 1;
65
+ const lead = target && targetRoom >= MIN_TARGET ? `${head} ${theme.fg("muted", clip(target, targetRoom))}` : head;
66
+ const used = visibleWidth(join(lead, base));
67
+ if (!view.notice && used <= width) return [join(lead, base)];
68
+ const noticeRoom = width - used - visibleWidth(sep) - 2;
69
+ if (view.notice && noticeRoom >= 4) return [join(lead, [...base, theme.fg("warning", `⚠ ${clip(view.notice, noticeRoom)}`)])];
70
+ }
71
+ const marked = join(head, failures);
72
+ return [visibleWidth(marked) <= width ? marked : clip(head, Math.max(1, width))];
73
+ }
74
+
75
+ private flashing(): boolean {
76
+ return !!this.view.arrival && this.view.arrival.age < ARRIVAL_FLASH_MS;
77
+ }
78
+
79
+ private outcomeText(): string {
80
+ const text = OUTCOME_TEXT[this.view.round?.outcome ?? "complete"];
81
+ return text && this.theme.fg("error", text);
82
+ }
83
+
84
+ private actionText(): string {
85
+ const { theme, view } = this;
86
+ const { arrival } = view;
87
+ if (arrival && arrival.age < ARRIVAL_FLASH_MS) {
88
+ const fade = Math.min(1, arrival.age / ARRIVAL_FLASH_MS);
89
+ const settled = arrival.failed ? HEAT_COLORS.fail : HEAT_COLORS.gold;
90
+ return paint(mix(HEAT_COLORS.white, settled, fade), theme.bold(arrival.text));
91
+ }
92
+ return view.action ? theme.fg("text", view.action.word) : "";
93
+ }
94
+
95
+ handleMouse(event: TuiMouseEvent) {
96
+ if (!this.view.toggle || event.type !== "click" || event.button !== "left") return undefined;
97
+ this.view.toggle();
98
+ return { handled: true };
99
+ }
100
+ }
101
+
102
+ /** 单行文本,截断不加背景相关的 reset。 */
103
+ export class Line implements Component {
104
+ constructor(private readonly text: string) {}
105
+ invalidate(): void {}
106
+ render(width: number): string[] {
107
+ return [clip(this.text, Math.max(1, width - CHAT_GUTTER))];
108
+ }
109
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * 拦截把 read 截断结果整体写回的 write:宿主 read 截断时在正文末尾追加“…Use offset=N to continue.]”,
3
+ * codemode 脚本把它原样写回会把文件清成半截(出过两次,测试文件被截断还被提交)。不属于任何可关的功能,每个会话都注册。
4
+ */
5
+ import { isToolCallEventType, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+
7
+ /** 与宿主 core/tools/read.ts 的截断提示同形。 */
8
+ const READ_TRUNCATION = /\n\[(?:Showing lines \d+-\d+ of \d+[^\]\n]*|\d+ more lines in file)\. Use offset=\d+ to continue\.\]/;
9
+
10
+ export function registerTruncatedWriteGuard(pi: ExtensionAPI): void {
11
+ pi.on("tool_call", (event) => {
12
+ if (!isToolCallEventType("write", event) || !READ_TRUNCATION.test(event.input.content)) return;
13
+ return {
14
+ block: true,
15
+ reason: "写入内容带 read 截断提示,说明拿的是截断后的半截文件:改文件用 edit,或分段读完整再写",
16
+ };
17
+ });
18
+ }