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/flame.ts ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * 火焰动效:全局唯一动画时钟、盲文火苗与落定过渡。凡是“正在运行”的东西都用这里的火苗;
3
+ * 所有动效由同一个时钟驱动,没有订阅者时计时器停止,帧号取绝对时间,多处火苗天然同步。
4
+ */
5
+ export type Rgb = readonly [number, number, number];
6
+
7
+ /** 火焰色板:heat 从余烬烧到白金;ash 是横幅副标题淡入的终色灰。 */
8
+ export const HEAT_COLORS = {
9
+ ember: [110, 30, 22],
10
+ red: [255, 47, 32],
11
+ orange: [255, 119, 28],
12
+ gold: [255, 195, 61],
13
+ white: [255, 239, 184],
14
+ ash: [128, 123, 115],
15
+ green: [124, 196, 120],
16
+ fail: [226, 78, 66],
17
+ } as const satisfies Record<string, Rgb>;
18
+
19
+ const FPS = 12;
20
+ const FRAME_MS = 1000 / FPS;
21
+ const SETTLE_MS = 400;
22
+ /** 一格火苗与三格火苗的火舌高度轮廓(每格两个点列)。 */
23
+ const PROFILE = { 1: [0.75, 1], 3: [0.3, 0.6, 0.9, 1, 0.7, 0.35] } as const;
24
+ // 盲文点位:左列自上而下 1 2 3 7,右列 4 5 6 8。
25
+ const DOT = [
26
+ [0x01, 0x02, 0x04, 0x40],
27
+ [0x08, 0x10, 0x20, 0x80],
28
+ ] as const;
29
+ const HEAT_STOPS: readonly [number, Rgb][] = [
30
+ [0, HEAT_COLORS.ember],
31
+ [0.3, HEAT_COLORS.red],
32
+ [0.55, HEAT_COLORS.orange],
33
+ [0.8, HEAT_COLORS.gold],
34
+ [1, HEAT_COLORS.white],
35
+ ];
36
+
37
+ const listeners = new Set<() => void>();
38
+ let timer: ReturnType<typeof setInterval> | undefined;
39
+
40
+ /** 订阅动画帧;返回取消函数。最后一个订阅者离开时计时器随之停止。 */
41
+ export function onFrame(listener: () => void): () => void {
42
+ listeners.add(listener);
43
+ if (!timer) {
44
+ timer = setInterval(() => {
45
+ for (const notify of listeners) notify();
46
+ }, FRAME_MS);
47
+ timer.unref?.();
48
+ }
49
+ return () => {
50
+ listeners.delete(listener);
51
+ if (listeners.size || !timer) return;
52
+ clearInterval(timer);
53
+ timer = undefined;
54
+ };
55
+ }
56
+
57
+ /** 按帧量化的绝对秒数;所有火苗共用同一相位基准。 */
58
+ const frameSeconds = () => Math.floor(Date.now() / FRAME_MS) / FPS;
59
+
60
+ const clamp = (value: number) => Math.min(1, Math.max(0, value));
61
+ const easeOut = (k: number) => 1 - (1 - clamp(k)) ** 3;
62
+
63
+ export function mix(from: Rgb, to: Rgb, k: number): Rgb {
64
+ const t = clamp(k);
65
+ return [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t, from[2] + (to[2] - from[2]) * t];
66
+ }
67
+
68
+ export function heat(value: number): Rgb {
69
+ const x = clamp(value);
70
+ for (let index = 1; index < HEAT_STOPS.length; index++) {
71
+ const [at, color] = HEAT_STOPS[index];
72
+ const [fromAt, from] = HEAT_STOPS[index - 1];
73
+ if (x <= at) return mix(from, color, (x - fromAt) / (at - fromAt));
74
+ }
75
+ return HEAT_COLORS.white;
76
+ }
77
+
78
+ /** 只设置/复位前景色,不注入全量 reset,可安全嵌进带背景的行。 */
79
+ export function paint(color: Rgb, text: string): string {
80
+ return text ? `\x1b[38;2;${color[0] | 0};${color[1] | 0};${color[2] | 0}m${text}\x1b[39m` : "";
81
+ }
82
+
83
+ /** 三个不可公约频率叠加的火势,0.1–1 之间起伏。 */
84
+ export function flicker(phase: number, t = frameSeconds()): number {
85
+ const tau = Math.PI * 2;
86
+ return 0.55
87
+ + 0.2 * Math.sin(tau * (1.1 * t + phase))
88
+ + 0.15 * Math.sin(tau * (2.7 * t + 1.7 * phase) + 1.3)
89
+ + 0.1 * Math.sin(tau * (5.9 * t + 3.1 * phase) + 0.4);
90
+ }
91
+
92
+ /**
93
+ * 盲文火苗:每个点列是一根随时钟起伏的火舌,火尖随风摆,顶行只留一个点保持尖顶。
94
+ * phase 让并列的火苗错开。
95
+ */
96
+ export function flame(cells: 1 | 3, phase: number): string {
97
+ const profile = PROFILE[cells];
98
+ const columns = cells * 2;
99
+ const t = frameSeconds();
100
+ const sway = Math.round(Math.sin(Math.PI * 2 * (0.7 * t + phase)) * 0.6);
101
+ const heights = Array.from({ length: columns }, (_, index) => {
102
+ const base = profile[Math.min(columns - 1, Math.max(0, index - sway))];
103
+ return Math.round(clamp(base * (0.7 + 0.45 * flicker(phase + index * 0.37, t))) * 4);
104
+ });
105
+ const peak = Math.max(...heights);
106
+ let topSeen = false;
107
+ let out = "";
108
+ for (let cell = 0; cell < cells; cell++) {
109
+ let bits = 0;
110
+ for (let side = 0; side < 2; side++) {
111
+ let height = heights[cell * 2 + side];
112
+ if (height === peak && height > 1) {
113
+ if (topSeen) height -= 1;
114
+ topSeen = true;
115
+ }
116
+ for (let row = 0; row < height; row++) bits |= DOT[side][3 - row];
117
+ }
118
+ const core = cells === 1 ? 0.5 : 1 - Math.abs(cell - (cells - 1) / 2) / cells;
119
+ const color = heat(0.35 + 0.45 * core + 0.2 * flicker(phase + cell, t));
120
+ out += paint(color, String.fromCodePoint(0x2800 + bits));
121
+ }
122
+ return out;
123
+ }
124
+
125
+ export type Settle = "done" | "failed";
126
+
127
+ /**
128
+ * 落定标记:歇下那一刻就是 ✓/✗(与定格文字同帧出现,不留冷却中的火苗残帧),颜色在 0.4 秒内从火焰橙转到终色。
129
+ */
130
+ export function settleMark(kind: Settle, sinceMs: number): string {
131
+ const k = easeOut(sinceMs / SETTLE_MS);
132
+ return kind === "done"
133
+ ? paint(mix(HEAT_COLORS.orange, HEAT_COLORS.green, k), "✓")
134
+ : paint(mix(HEAT_COLORS.orange, HEAT_COLORS.fail, k), "✗");
135
+ }
136
+
137
+ /** 落定过渡是否仍在播放;调用方据此决定是否继续订阅时钟。 */
138
+ export const settling = (sinceMs: number) => sinceMs < SETTLE_MS;
139
+
140
+ const SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
141
+
142
+ /** 审查:金色盲文转圈点,与火苗同一时钟、同一字符族,靠金色与火焰橙区分;phase 错开并列的转圈。 */
143
+ export function reviewMark(phase = 0): string {
144
+ const frame = Math.floor(Date.now() / FRAME_MS + phase * SPINNER.length);
145
+ return paint(HEAT_COLORS.gold, SPINNER[frame % SPINNER.length]);
146
+ }
147
+
148
+ /** 并列火苗的相位:按序号错开,避免一排火苗同起同落。 */
149
+ export const phaseOf = (index: number) => index * 0.23 + 0.11;
package/format.ts CHANGED
@@ -2,9 +2,22 @@
2
2
  import { visibleWidth } from "@earendil-works/pi-tui";
3
3
 
4
4
  const ELLIPSIS = "…";
5
+ const ANSI_SEQUENCE = /(\x1b(?:\[[0-?]*[ -/]*[@-~]|\][\s\S]*?(?:\x07|\x1b\\)))/g;
5
6
  const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
6
7
 
7
8
  /** 压平换行与连续空白,用于把任意文本塞进单行 UI。 */
9
+ /** 消息 content 里的正文:字符串原样;块数组只取 text 块,按行拼接。全插件只此一处。 */
10
+ export function textOf(content: unknown): string {
11
+ if (typeof content === "string") return content;
12
+ if (!Array.isArray(content)) return "";
13
+ return content
14
+ .filter((part): part is { type: "text"; text: string } =>
15
+ typeof part === "object" && part !== null && (part as { type?: unknown }).type === "text"
16
+ && typeof (part as { text?: unknown }).text === "string")
17
+ .map((part) => part.text)
18
+ .join("\n");
19
+ }
20
+
8
21
  export function oneLine(value = ""): string {
9
22
  return value
10
23
  .replace(/[\r\n\t]+/g, " ")
@@ -25,50 +38,90 @@ export function clip(
25
38
  ellipsis: string = ELLIPSIS,
26
39
  ): string {
27
40
  if (width <= 0) return "";
28
- if (visibleWidth(text) <= width) return text;
29
- const target = Math.max(0, width - visibleWidth(ellipsis));
30
- const segments = [...segmenter.segment(text)].map((entry) => entry.segment);
31
- let output = "";
32
- let used = 0;
33
- if (from === "start") {
34
- for (let index = segments.length - 1; index >= 0; index--) {
35
- const segmentWidth = visibleWidth(segments[index]);
36
- if (used + segmentWidth > target) break;
37
- output = segments[index] + output;
38
- used += segmentWidth;
41
+ const textWidth = visibleWidth(text);
42
+ if (textWidth <= width) return text;
43
+ const ellipsisWidth = visibleWidth(ellipsis);
44
+ if (ellipsisWidth > width) return clip(ellipsis, width, "end", "");
45
+ const target = width - ellipsisWidth;
46
+ let output = from === "start" ? ellipsis : "";
47
+ let column = 0;
48
+ let clipped = false;
49
+ const chunks = text.split(ANSI_SEQUENCE);
50
+ for (let index = 0; index < chunks.length; index++) {
51
+ if (index % 2) {
52
+ // 连被裁掉文字后的颜色关闭/链接关闭也保留,避免样式泄漏;不注入全量 reset。
53
+ output += chunks[index];
54
+ continue;
55
+ }
56
+ for (const { segment } of segmenter.segment(chunks[index])) {
57
+ const columns = visibleWidth(segment);
58
+ if (from === "start") {
59
+ if (column >= textWidth - target) output += segment;
60
+ } else if (!clipped) {
61
+ if (column + columns <= target) output += segment;
62
+ else { output += ellipsis; clipped = true; }
63
+ }
64
+ column += columns;
39
65
  }
40
- return ellipsis + output;
41
- }
42
- for (const segment of segments) {
43
- const segmentWidth = visibleWidth(segment);
44
- if (used + segmentWidth > target) break;
45
- output += segment;
46
- used += segmentWidth;
47
66
  }
48
- return output + ellipsis;
67
+ return output;
49
68
  }
50
69
 
51
- /** 1234 → 1.2k,1_500_000 → 1.5M;0 与 undefined 显示为 ?。 */
70
+ /** 1234 → 1.2k,1_500_000 → 1.5M,整数不带小数(1M、200k);0 与 undefined 显示为 ?。 */
52
71
  export function formatTokens(tokens: number): string {
53
- if (tokens >= 1_000_000) {
54
- const value = tokens / 1_000_000;
55
- return value >= 10 ? `${Math.round(value)}M` : `${value.toFixed(1)}M`;
56
- }
57
- if (tokens >= 1_000) {
58
- const value = tokens / 1_000;
59
- return value >= 10 ? `${Math.round(value)}k` : `${value.toFixed(1)}k`;
60
- }
72
+ if (tokens >= 1_000_000) return `${scaled(tokens / 1_000_000)}M`;
73
+ if (tokens >= 1_000) return `${scaled(tokens / 1_000)}k`;
61
74
  return tokens ? `${tokens}` : "?";
62
75
  }
63
76
 
64
- /** 900 → 0.9s,12_400 → 12s,93_000 → 1m33s。 */
77
+ const scaled = (value: number) => (value >= 10 ? Math.round(value) : Number(value.toFixed(1)));
78
+
79
+ const FENCE = /^\s*```/u;
80
+ const HEADING = /^\s*#{1,6}\s+/u;
81
+ const TABLE_ROW = /^\s*\|.*\|\s*$/u;
82
+ const RULE = /^\s*([-*_])(?:\s*\1){2,}\s*$/u;
83
+ /** 块级标记:引用符、列表符、序号(含任务框)。 */
84
+ const BLOCK_MARK = /^\s*(?:>\s*)*(?:(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?)?/u;
85
+ /** 句末:中文句末标点,或后接空白/行尾的英文句末标点——但缩写的点不算。 */
86
+ const SENTENCE_END = /[。!?]|(?<!\b(?:e\.g|i\.e|etc|vs|cf|Mr|Mrs|Ms|Dr|St|No))[.!?](?=\s|$)/u;
87
+
88
+ /**
89
+ * 一行预览用的首句(机器消息行、中间回复、Master 事件卡与会话标题共用):认 Markdown 结构,
90
+ * 跳过标题、围栏、表格行与分隔线,剥掉引用符、列表符与序号,去掉行内标记;只有标题没有正文时才用标题文字。
91
+ * 首句以冒号结尾(如“标准输出:”)本身没有信息,并入下一有效行的首句。
92
+ */
93
+ export function firstSentence(text: string): string {
94
+ const lines = text.split("\n").filter((line) => !FENCE.test(line) && !TABLE_ROW.test(line) && !RULE.test(line));
95
+ const body = lines.filter((line) => !HEADING.test(line));
96
+ const source = body.some((line) => line.trim()) ? body : lines.map((line) => line.replace(HEADING, ""));
97
+ return sentenceOf(source.map((line) => oneLine(inline(line.replace(BLOCK_MARK, "")))).filter(Boolean));
98
+ }
99
+
100
+ /** 预览是纯文本:链接只留文字,去掉粗体与行内代码标记。 */
101
+ function inline(line: string): string {
102
+ return line
103
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/gu, "$1")
104
+ .replace(/(\*\*|__)(.+?)\1/gu, "$2")
105
+ .replace(/`([^`]+)`/gu, "$1");
106
+ }
107
+
108
+ function sentenceOf([head = "", ...rest]: string[]): string {
109
+ const end = SENTENCE_END.exec(head);
110
+ const sentence = end ? head.slice(0, end.index + 1) : head;
111
+ const colon = /[::]$/u.exec(sentence)?.[0];
112
+ if (!colon || rest.length === 0) return sentence;
113
+ return `${sentence}${colon === ":" ? " " : ""}${sentenceOf(rest)}`;
114
+ }
115
+
116
+ /** 紧凑耗时:十秒以内保留一位小数,长耗时按小时、分钟、秒进位。 */
65
117
  export function formatDuration(milliseconds: number): string {
66
118
  if (milliseconds < 10_000) return `${(milliseconds / 1_000).toFixed(1)}s`;
67
119
  const totalSeconds = Math.round(milliseconds / 1_000);
68
120
  if (totalSeconds < 60) return `${totalSeconds}s`;
69
- const minutes = Math.floor(totalSeconds / 60);
121
+ const hours = Math.floor(totalSeconds / 3_600);
122
+ const minutes = Math.floor(totalSeconds / 60) % 60;
70
123
  const seconds = totalSeconds % 60;
71
- return seconds ? `${minutes}m${seconds}s` : `${minutes}m`;
124
+ return `${hours ? `${hours}h` : ""}${minutes ? `${minutes}m` : ""}${seconds ? `${seconds}s` : ""}`;
72
125
  }
73
126
 
74
127
  /** 去掉模型 id 的 provider 前缀与日期后缀。 */
package/header.ts CHANGED
@@ -1,92 +1,218 @@
1
- /** 会话启动横幅:终端够宽显示火焰 + FireCode 字标,否则退化成一行。 */
2
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ /**
2
+ * 会话启动横幅:半格方块像素火焰 + 字标,按终端宽度分三档,三档都带同一行副标题(pi 版本 · 工作目录,
3
+ * 放不下只留目录、从开头按整段省略)。启动时字标自左向右点亮、扫光一道,约 1.5 秒后定格并退订动画时钟——
4
+ * 横幅会滚出视口,定格后不再触发重绘。
5
+ */
6
+ import { homedir } from "node:os";
7
+ import { type ExtensionAPI, VERSION } from "@earendil-works/pi-coding-agent";
8
+ import type { TUI } from "@earendil-works/pi-tui";
3
9
  import { visibleWidth } from "@earendil-works/pi-tui";
10
+ import { flame, flicker, HEAT_COLORS, heat, mix, onFrame, paint, type Rgb } from "./flame.js";
4
11
  import { clip } from "./format.js";
5
- import { ANSI, FLAME } from "./theme.js";
6
-
7
- const GAP = " ";
8
- const EXTRA_RIGHT_PAD = " ";
9
- /** 字标相对火焰顶部下沉的行数 */
10
- const TEXT_TOP_PAD = 4;
11
-
12
- const FLAME_ART = [
13
- "⠀⠀⠀⠀⠀⠀⢱⣆⠀⠀⠀⠀⠀⠀",
14
- "⠀⠀⠀⠀⠀⠀⠈⣿⣷⡀⠀⠀⠀⠀",
15
- "⠀⠀⠀⠀⠀⠀⢸⣿⣿⣷⣧⠀⠀⠀",
16
- "⠀⠀⠀⠀⡀⢠⣿⡟⣿⣿⣿⡇⠀⠀",
17
- "⠀⠀⠀⠀⣳⣼⣿⡏⢸⣿⣿⣿⢀⠀",
18
- "⠀⠀⠀⣰⣿⣿⡿⠁⢸⣿⣿⡟⣼⡆",
19
- "⢰⢀⣾⣿⣿⠟⠀⠀⣾⢿⣿⣿⣿⣿",
20
- "⢸⣿⣿⣿⡏⠀⠀⠀⠃⠸⣿⣿⣿⡿",
21
- "⢳⣿⣿⣿⠀⠀⠀⠀⠀⠀⢹⣿⡿⡁",
22
- "⠀⠹⣿⣿⡄⠀⠀⠀⠀⠀⢠⣿⡞⠁",
23
- "⠀⠀⠈⠛⢿⣄⠀⠀⠀⣠⠞⠋⠀⠀",
24
- ];
25
- const WORDMARK = [
26
- "___________.__ _________ .___ ",
27
- "\\_ _____/|__|______ ____ \\_ ___ \\ ____ __| _/____ ",
28
- " | __) | \\_ __ \\_/ __ \\ / \\ \\/ / _ \\ / __ |/ __ \\ ",
29
- " | \\ | || | \\/\\ ___/ \\ \\___( <_> ) /_/ \\ ___/ ",
30
- " \\___ / |__||__| \\___ > \\______ /\\____/\\____ |\\___ >",
31
- " \\/ \\/ \\/ \\/ \\/ ",
32
- ];
33
- const FLAME_COLORS = [
34
- FLAME.red,
35
- FLAME.red,
36
- FLAME.orange,
37
- FLAME.orange,
38
- FLAME.gold,
39
- FLAME.white,
40
- FLAME.white,
41
- FLAME.gold,
42
- FLAME.orange,
43
- FLAME.red,
44
- FLAME.red,
45
- ];
46
- const TEXT_COLORS = [
47
- FLAME.textTop,
48
- FLAME.textTop,
49
- FLAME.textMid,
50
- FLAME.textCore,
51
- FLAME.gold,
52
- FLAME.gold,
53
- ];
54
-
55
- function padToWidth(line: string, width: number): string {
56
- return line + " ".repeat(Math.max(0, width - visibleWidth(line)));
12
+
13
+ const LARGE_MIN_WIDTH = 83;
14
+ const MID_MIN_WIDTH = 52;
15
+ const REVEAL_END = 0.7;
16
+ const SWEEP_END = 1.5;
17
+ const TINY_FLAME_PHASE = 0.4;
18
+ /** 副标题从这一深底色淡入成灰;终端底色不可知,取深色主题的近似底色。 */
19
+ const FADE_FROM: Rgb = [20, 19, 18];
20
+ const RULE: Rgb = [60, 57, 53];
21
+ /** 单行档副标题至少留这么宽才显示(省略号加几个字),右侧横线至少留这么长。 */
22
+ const TINY_SUBTITLE_MIN = 8;
23
+ const TINY_RULE_MIN = 3;
24
+
25
+ const clamp = (value: number) => Math.min(1, Math.max(0, value));
26
+ const ease = (k: number) => 1 - (1 - clamp(k)) ** 3;
27
+
28
+ /** 像素画:每个终端格承载上下两个方形像素,▀ 的前景画上半、背景画下半。 */
29
+ type Pixels = (Rgb | undefined)[][];
30
+
31
+ const background = (color: Rgb) => `\x1b[48;2;${color[0] | 0};${color[1] | 0};${color[2] | 0}m`;
32
+
33
+ function halfBlocks(pixels: Pixels): string[] {
34
+ const lines: string[] = [];
35
+ for (let y = 0; y < pixels.length; y += 2) {
36
+ let line = "";
37
+ pixels[y].forEach((top, x) => {
38
+ const bottom = pixels[y + 1]?.[x];
39
+ if (top && bottom) line += `${background(bottom)}${paint(top, "▀")}\x1b[49m`;
40
+ else if (top) line += paint(top, "▀");
41
+ else if (bottom) line += paint(bottom, "▄");
42
+ else line += " ";
43
+ });
44
+ lines.push(line);
45
+ }
46
+ return lines;
47
+ }
48
+
49
+ /** 水滴火焰:下部半圆、上部按幂曲线收成火尖,火尖随时间摆动;离焰芯越近越接近白金。 */
50
+ function roundFlame(widthPx: number, heightPx: number, t: number): Pixels {
51
+ const radius = widthPx / 2;
52
+ const belly = 0.36;
53
+ const tall = 0.9 + 0.1 * flicker(0.2, t);
54
+ return Array.from({ length: heightPx }, (_, y) => {
55
+ const row: (Rgb | undefined)[] = Array(widthPx).fill(undefined);
56
+ const v = (heightPx - 0.5 - y) / heightPx / tall;
57
+ if (v > 1) return row;
58
+ const half = v < belly
59
+ ? radius * Math.sqrt(1 - ((belly - v) / belly) ** 2)
60
+ : radius * ((1 - v) / (1 - belly)) ** 1.25;
61
+ const sway = 1.3 * v * v * Math.sin(Math.PI * 2 * 0.6 * t) + 0.4 * v * Math.sin(Math.PI * 2 * (1.7 * t + v * 2));
62
+ const reach = half + 0.25 * flicker(v * 3.1, t) * v;
63
+ for (let x = 0; x < widthPx; x++) {
64
+ const distance = Math.abs(x + 0.5 - radius - sway);
65
+ if (distance > reach) continue;
66
+ const core = 1 - distance / (reach + 0.01);
67
+ row[x] = heat(0.12 + 0.55 * core * (1 - v * 0.6) + 0.45 * (1 - v) ** 2);
68
+ }
69
+ return row;
70
+ });
71
+ }
72
+
73
+ /** 8 像素高的细笔画字形:1 像素笔画,转角缺一角做出圆角。 */
74
+ const FONT: Record<string, string[]> = {
75
+ F: ["#####", "#....", "#....", "####.", "#....", "#....", "#....", "#...."],
76
+ i: ["#", ".", "#", "#", "#", "#", "#", "#"],
77
+ r: ["....", "....", ".###", "#...", "#...", "#...", "#...", "#..."],
78
+ e: [".....", ".....", ".###.", "#...#", "#####", "#....", "#....", ".####"],
79
+ C: [".####", "#....", "#....", "#....", "#....", "#....", "#....", ".####"],
80
+ o: [".....", ".....", ".###.", "#...#", "#...#", "#...#", "#...#", ".###."],
81
+ d: ["....#", "....#", ".####", "#...#", "#...#", "#...#", "#...#", ".####"],
82
+ };
83
+ const WORD = "FireCode";
84
+
85
+ const wordBits = (gap: number): boolean[][] =>
86
+ FONT.F.map((_, y) => [...WORD].flatMap((char, index) => [
87
+ ...Array<boolean>(index ? gap : 0).fill(false),
88
+ ...[...FONT[char][y]].map((pixel) => pixel === "#"),
89
+ ]));
90
+ const WORD_BITS = { 1: wordBits(1), 2: wordBits(2) } as const;
91
+
92
+ /** 字标:横向红→橙→金;入场时自左向右点亮(亮边白热),随后一道斜向扫光。 */
93
+ function wordmark(gap: 1 | 2, t: number): Pixels {
94
+ const bits = WORD_BITS[gap];
95
+ const width = bits[0].length;
96
+ const edge = (t / REVEAL_END) * (width + 8);
97
+ const sweep = ((t - REVEAL_END - 0.1) / (SWEEP_END - REVEAL_END)) * (width + 16) - 8;
98
+ return bits.map((row, y) => row.map((on, x) => {
99
+ if (!on || x > edge) return undefined;
100
+ const half = width / 2;
101
+ const base = x < half
102
+ ? mix(HEAT_COLORS.red, HEAT_COLORS.orange, x / half)
103
+ : mix(HEAT_COLORS.orange, HEAT_COLORS.gold, (x - half) / half);
104
+ const hotEdge = t < REVEAL_END ? clamp(1 - (edge - x) / 6) : 0;
105
+ const shine = t > REVEAL_END && t < SWEEP_END ? clamp(1 - Math.abs(x - y * 0.6 - sweep) / 4.5) : 0;
106
+ return mix(base, HEAT_COLORS.white, Math.max(hotEdge, shine * 0.9));
107
+ }));
108
+ }
109
+
110
+ function sideBySide(left: string[], right: string[], gap: number, rightTop = 0): string[] {
111
+ const leftWidth = Math.max(...left.map(visibleWidth));
112
+ return Array.from({ length: Math.max(left.length, right.length + rightTop) }, (_, index) => {
113
+ const cell = left[index] ?? "";
114
+ return `${cell}${" ".repeat(leftWidth - visibleWidth(cell) + gap)}${right[index - rightTop] ?? ""}`;
115
+ });
116
+ }
117
+
118
+ /** 副标题的来源:三档共用同一份。 */
119
+ interface Subtitle {
120
+ version: string;
121
+ cwd: string;
122
+ }
123
+
124
+ /** 放得下就是“pi 版本 · 工作目录”;放不下只留目录,从开头按整段省略(…/末尾几段),连末段都放不下才裁字。 */
125
+ function fitSubtitle({ version, cwd }: Subtitle, width: number): string {
126
+ const full = `pi ${version} · ${cwd}`;
127
+ if (visibleWidth(full) <= width) return full;
128
+ const segments = cwd.split("/");
129
+ for (let index = 1; index < segments.length; index++) {
130
+ const tail = `…/${segments.slice(index).join("/")}`;
131
+ if (visibleWidth(tail) <= width) return tail;
132
+ }
133
+ return clip(segments.at(-1) ?? cwd, width, "start");
134
+ }
135
+
136
+ /** 副标题:从深底色淡入成灰。 */
137
+ function subtitleLine(subtitle: Subtitle, width: number, t: number): string {
138
+ return paint(mix(FADE_FROM, HEAT_COLORS.ash, ease((t - 0.4) / 0.6)), fitSubtitle(subtitle, width));
139
+ }
140
+
141
+ function large(t: number, subtitle: Subtitle): string[] {
142
+ const word = halfBlocks(wordmark(2, t));
143
+ return sideBySide(halfBlocks(roundFlame(10, 16, t)), [...word, "", subtitleLine(subtitle, visibleWidth(word[0]), t)], 3, 2);
144
+ }
145
+
146
+ function mid(t: number, subtitle: Subtitle): string[] {
147
+ const word = halfBlocks(wordmark(1, t));
148
+ return sideBySide(halfBlocks(roundFlame(6, 8, t)), [...word, subtitleLine(subtitle, visibleWidth(word[0]), t)], 2);
149
+ }
150
+
151
+ /** 一行档:火苗 + 渐变字标逐字亮起 + 副标题 + 向右渐隐的横线;太窄时只留横线。 */
152
+ function tiny(width: number, t: number, glyph: string, subtitle: Subtitle): string {
153
+ const sweep = ((t - REVEAL_END) / (SWEEP_END - REVEAL_END)) * 14 - 3;
154
+ const letters = [...WORD].map((char, index) => {
155
+ if (t <= (index / WORD.length) * REVEAL_END) return " ";
156
+ const shine = t > REVEAL_END && t < SWEEP_END ? clamp(1 - Math.abs(index - sweep) / 2) : 0;
157
+ return paint(mix(mix(HEAT_COLORS.orange, HEAT_COLORS.gold, index / (WORD.length - 1)), HEAT_COLORS.white, shine), char);
158
+ }).join("");
159
+ const word = `${glyph} \x1b[1m${letters}\x1b[22m `;
160
+ const room = width - visibleWidth(word) - 1 - TINY_RULE_MIN;
161
+ const head = room >= TINY_SUBTITLE_MIN ? `${word}${subtitleLine(subtitle, room, t)} ` : word;
162
+ const length = width - visibleWidth(head);
163
+ const reveal = clamp((t - 0.2) / REVEAL_END);
164
+ let rule = "";
165
+ for (let index = 0; index < length; index++)
166
+ rule += index / length <= reveal
167
+ ? paint(mix(HEAT_COLORS.gold, RULE, ease(index / Math.max(8, length * 0.7))), "─")
168
+ : " ";
169
+ return clip(head + rule, width, "end", "");
170
+ }
171
+
172
+ function center(lines: string[], width: number): string[] {
173
+ const pad = " ".repeat(Math.max(0, Math.floor((width - Math.max(...lines.map(visibleWidth))) / 2)));
174
+ return lines.map((line) => clip(pad + line, width, "end", ""));
175
+ }
176
+
177
+ function banner(width: number, t: number, glyph: string, subtitle: Subtitle): string[] {
178
+ if (width >= LARGE_MIN_WIDTH) return ["", ...center(large(t, subtitle), width), ""];
179
+ if (width >= MID_MIN_WIDTH) return ["", ...center(mid(t, subtitle), width), ""];
180
+ return [tiny(width, t, glyph, subtitle), ""];
57
181
  }
58
182
 
59
- const FLAME_WIDTH = Math.max(...FLAME_ART.map(visibleWidth));
60
- const FULL_WIDTH = Math.max(
61
- ...FLAME_ART.map((flame, index) => {
62
- const text = WORDMARK[index - TEXT_TOP_PAD] ?? "";
63
- return visibleWidth(`${padToWidth(flame, FLAME_WIDTH)}${GAP}${text}${EXTRA_RIGHT_PAD}`);
64
- }),
65
- );
66
- const FULL = FLAME_ART.map((art, index) => {
67
- const textIndex = index - TEXT_TOP_PAD;
68
- const wordmark = WORDMARK[textIndex] ?? "";
69
- const flame = `${FLAME_COLORS[index]}${padToWidth(art, FLAME_WIDTH)}${ANSI.reset}`;
70
- const title = wordmark
71
- ? `${ANSI.bold}${TEXT_COLORS[textIndex]}${wordmark}${ANSI.reset}`
72
- : "";
73
- return padToWidth(`${flame}${GAP}${title}`, FULL_WIDTH);
74
- });
75
- const TINY = [`${FLAME.orange}🔥 Fire${ANSI.reset} ${FLAME.textCore}Code${ANSI.reset}`];
76
-
77
- function center(line: string, width: number): string {
78
- const clipped = clip(line, width, "end", "");
79
- return " ".repeat(Math.max(0, Math.floor((width - visibleWidth(clipped)) / 2))) + clipped;
183
+ function createBanner(tui: TUI, subtitle: Subtitle) {
184
+ const start = Date.now();
185
+ const elapsed = () => Math.min(SWEEP_END, (Date.now() - start) / 1000);
186
+ let unsubscribe: (() => void) | undefined;
187
+ const dispose = () => {
188
+ unsubscribe?.();
189
+ unsubscribe = undefined;
190
+ };
191
+ unsubscribe = onFrame(() => {
192
+ tui.requestRender();
193
+ if (elapsed() >= SWEEP_END) dispose();
194
+ });
195
+ let frozenGlyph: string | undefined;
196
+ let frozen: { width: number; lines: string[] } | undefined;
197
+ return {
198
+ invalidate() {},
199
+ dispose,
200
+ render(width: number): string[] {
201
+ const t = elapsed();
202
+ if (t < SWEEP_END) return banner(width, t, flame(3, TINY_FLAME_PHASE), subtitle);
203
+ if (frozen?.width !== width) {
204
+ frozenGlyph ??= flame(3, TINY_FLAME_PHASE);
205
+ frozen = { width, lines: banner(width, t, frozenGlyph, subtitle) };
206
+ }
207
+ return frozen.lines;
208
+ },
209
+ };
80
210
  }
81
211
 
82
212
  export function registerHeader(pi: ExtensionAPI): void {
83
213
  pi.on("session_start", (_event, ctx) => {
84
- ctx.ui.setHeader(() => ({
85
- invalidate() {},
86
- render(width: number): string[] {
87
- const lines = width >= FULL_WIDTH ? FULL : TINY;
88
- return ["", ...lines.map((line) => center(line, width)), ""];
89
- },
90
- }));
214
+ const home = homedir();
215
+ const cwd = ctx.cwd.startsWith(home) ? `~${ctx.cwd.slice(home.length)}` : ctx.cwd;
216
+ ctx.ui.setHeader((tui) => createBanner(tui, { version: VERSION, cwd }));
91
217
  });
92
218
  }