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
@@ -1,118 +1,256 @@
1
- /** 底部两行状态栏:位置/会话名 + 模型/额度/上下文/缓存/速度。 */
2
- import { basename, join } from "node:path";
3
- import { type ExtensionAPI, getAgentDir } from "@earendil-works/pi-coding-agent";
4
- import { formatModelName } from "../format.js";
5
- import { thinkingColor } from "../theme.js";
6
- import { fileQuotaCache } from "./quota-cache.js";
7
- import { registerQuota } from "./quota.js";
8
- import type { QuotaStatus } from "./quota-parse.js";
1
+ /**
2
+ * 输入框外壳:状态画进编辑器的上下边框,不再有独立底栏。
3
+ * 上边框:回合火苗与计时、主会话审查进度 | 观察员、指挥官;下边框:会话标题 | 模型、上下文。
4
+ */
5
+ import { stripVTControlCharacters } from "node:util";
9
6
  import {
10
- alignRight,
11
- fitMetadataLine,
12
- fitStatusLine,
13
- reviewStatus,
14
- statusBadges,
15
- latestCacheHitPercent,
16
- renderCache,
17
- renderContext,
18
- renderLocation,
19
- renderQuota,
20
- renderTps,
21
- } from "./render.js";
22
- import { type TpsStatus, registerTps } from "./tps.js";
7
+ CustomEditor,
8
+ type ExtensionAPI,
9
+ type ExtensionContext,
10
+ type KeybindingsManager,
11
+ type MessageStartEvent,
12
+ type Theme,
13
+ } from "@earendil-works/pi-coding-agent";
14
+ import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
15
+ import { type BusyView, IDLE, OUTCOME_TEXT, roundTexts, watchBusy } from "../busy.js";
16
+ import { HEAT_COLORS, flame, onFrame, paint, phaseOf, reviewMark, settleMark, settling } from "../flame.js";
17
+ import { clip, firstSentence, formatDuration, formatModelName, formatTokens, oneLine } from "../format.js";
18
+ import { OCCUPANCY_CHANNEL, type OccupancyPayload, type ReviewProgress, type ReviewStage } from "../review/occupancy.js";
19
+ import { contextColor, thinkingColor } from "../theme.js";
20
+ import { type BranchEntry, latestTurnRecord, ROUND_RECORDED_CHANNEL, type TurnRecord } from "../tools/round.js";
21
+ import { type BottomParts, type TopParts, bottomBorder, topBorder } from "./render.js";
22
+
23
+ /** 展示标题的上限(列):实际宽度由下边框布局按终端宽度逐级裁。 */
24
+ const TITLE_MAX_WIDTH = 60;
25
+ /** 去掉终端控制序列与控制字符,保留换行供首句规则识别 Markdown 结构。 */
26
+ const sanitize = (text: string) => stripVTControlCharacters(text).replace(/[\x00-\x09\x0b-\x1f\x7f-\x9f]/g, " ");
27
+
28
+ function userTitle(message: MessageStartEvent["message"]): string | undefined {
29
+ if (message.role !== "user") return undefined;
30
+ const content = message.content;
31
+ const text = typeof content === "string" ? content : content
32
+ .filter((block) => block.type === "text").map((block) => block.text).join("\n");
33
+ return clip(firstSentence(sanitize(text)), TITLE_MAX_WIDTH) || undefined;
34
+ }
35
+
36
+ function displayTitle(ctx: ExtensionContext, incoming?: MessageStartEvent["message"]): string {
37
+ const name = ctx.sessionManager.getSessionName();
38
+ if (name) return oneLine(sanitize(name));
39
+ for (const entry of ctx.sessionManager.getBranch()) {
40
+ if (entry.type !== "message") continue;
41
+ const title = userTitle(entry.message);
42
+ if (title) return title;
43
+ }
44
+ return (incoming && userTitle(incoming)) || "新会话";
45
+ }
46
+
47
+ const FAST_STATUS = "pi-openai-native-fast";
48
+ /** session/presets.ts 发布的生效预设名(已着色)。 */
49
+ const PRESET_STATUS = "preset";
50
+ /** 落定后暖光渐隐的时长;落定结果本身一直留到下一轮开始。 */
51
+ const GLOW_FADE_MS = 1_000;
52
+
53
+ /** 外壳要展示的全部运行状态;事件写入,编辑器每次绘制只读。 */
54
+ class Shell {
55
+ title = "新会话";
56
+ /** busy.ts 的会话进行中快照:起点、指挥官是否在跑、在飞子代理数。 */
57
+ busy: BusyView = IDLE;
58
+ /**
59
+ * 最近一轮的落定事实(分支轮记录按与摘要行同一条规则合成)与落定时刻,留到下一轮开始。
60
+ * 只在轮记录写入、开会话、切分支时算一次,绘制只读它:读分支是整条回溯。at 为空是开会话时恢复的,不播落定过渡。
61
+ */
62
+ settled: { record: TurnRecord; at?: number } | undefined;
63
+ /** 审查占用期间的进度访问器(review 经占用频道发布);undefined 表示没有审查。 */
64
+ review: (() => ReviewProgress | undefined) | undefined;
65
+ statuses: () => ReadonlyMap<string, string> = () => new Map();
66
+ theme: Theme | undefined;
67
+ requestRender = () => {};
68
+ private stopClock: (() => void) | undefined;
69
+
70
+ /** 时钟只在有动效要播时订阅:回合进行、落定过渡或审查进行。 */
71
+ syncClock(): void {
72
+ const need = this.review !== undefined || this.busy.busy || (this.settled?.at !== undefined && settling(Date.now() - this.settled.at));
73
+ if (need && !this.stopClock) this.stopClock = onFrame(() => { this.syncClock(); this.requestRender(); });
74
+ if (!need && this.stopClock) { this.stopClock(); this.stopClock = undefined; }
75
+ }
76
+
77
+ dispose(): void {
78
+ this.stopClock?.();
79
+ this.stopClock = undefined;
80
+ this.requestRender = () => {};
81
+ }
82
+
83
+ sync(view: BusyView): void {
84
+ this.busy = view;
85
+ if (view.busy) this.settled = undefined;
86
+ }
87
+
88
+ showRecord(branch: readonly BranchEntry[], at?: number): void {
89
+ if (this.busy.busy) return;
90
+ const record = latestTurnRecord(branch);
91
+ this.settled = record && { record, at };
92
+ }
93
+
94
+ top(): TopParts {
95
+ const { busy, settled, review, theme } = this;
96
+ const status = (key: string) => this.statuses().get(key) ?? "";
97
+ const parts: TopParts = {
98
+ mark: "", word: "", elapsed: "", review: [], glow: 0,
99
+ watcher: status("watcher"), master: status("master"),
100
+ };
101
+ if (busy.since !== undefined) {
102
+ parts.mark = flame(3, phaseOf(0));
103
+ const word = activityWord(busy);
104
+ parts.word = word && (this.theme?.fg("text", word) ?? "");
105
+ parts.elapsed = this.theme?.fg("muted", formatDuration(Date.now() - busy.since)) ?? "";
106
+ parts.glow = 1;
107
+ } else if (settled && theme) {
108
+ const { record } = settled;
109
+ const since = settled.at === undefined ? Infinity : Date.now() - settled.at;
110
+ const text = OUTCOME_TEXT[record.round.outcome];
111
+ parts.mark = settleMark(text ? "failed" : "done", since);
112
+ // 与摘要行同一写法:终态字样、耗时、均速之间都是“ · ”;终态字样不随窄屏退让。
113
+ parts.elapsed = [
114
+ ...(text ? [theme.fg("error", text)] : []),
115
+ ...roundTexts(record.round).map((part) => theme.fg("muted", part)),
116
+ ...record.earlier.map((part) => theme.fg("warning", part)),
117
+ ].join(theme.fg("dim", " · "));
118
+ parts.glow = Math.max(0, 1 - since / GLOW_FADE_MS);
119
+ }
120
+ if (review && theme) parts.review = reviewTiers(review(), theme);
121
+ return parts;
122
+ }
123
+
124
+ bottom(ctx: ExtensionContext, thinking: string): BottomParts {
125
+ const theme = this.theme;
126
+ const fg = (color: Parameters<Theme["fg"]>[0], text: string) => theme?.fg(color, text) ?? text;
127
+ const model = ctx.model;
128
+ const usage = ctx.getContextUsage();
129
+ const window = usage?.contextWindow ?? model?.contextWindow ?? 0;
130
+ const percent = usage?.percent;
131
+ return {
132
+ title: fg("muted", this.title),
133
+ preset: this.statuses().get(PRESET_STATUS) ?? "",
134
+ model: fg("text", formatModelName(model?.id)),
135
+ think: model?.reasoning ? fg(thinkingColor(thinking as never), `/${thinking}`) : "",
136
+ fast: this.statuses().has(FAST_STATUS) ? fg("warning", "Fast") : "",
137
+ percent: fg(contextColor(percent), percent == null ? "?" : `${percent.toFixed(1)}%`),
138
+ capacity: fg("dim", `/${formatTokens(window)}`),
139
+ };
140
+ }
141
+ }
142
+
143
+ /** 进行中的那个词:主会话审查期间(等结论、修复、总结)一律由审查进度说明,不另写词;否则指挥官在跑是“处理中”,再否则是在等子代理。 */
144
+ function activityWord(busy: BusyView): string {
145
+ if (busy.review) return "";
146
+ return busy.agentRunning ? "处理中" : `等待 ${busy.inFlight} 个子代理`;
147
+ }
148
+
149
+ const STAGE_TEXT: Record<Exclude<ReviewStage, "reviewing">, string> = {
150
+ queued: "排队中", advisor: "顾问介入", fixing: "修复中", summarizing: "总结中",
151
+ };
152
+
153
+ /** 审查进度的退让档:`审查 第2轮 1/3 · 1 阻断` → 丢阻断数 → 丢票数或阶段,“审查 第N轮”留到最后;字形始终在。 */
154
+ function reviewTiers(progress: ReviewProgress | undefined, theme: Theme): string[] {
155
+ const gold = (text: string) => paint(HEAT_COLORS.gold, text);
156
+ const mark = reviewMark(phaseOf(2));
157
+ const head = `${mark} ${gold("审查")}`;
158
+ if (!progress) return [head];
159
+ const named = progress.round > 0 ? `${head}${gold(` 第${progress.round}轮`)}` : head;
160
+ const body = gold(progress.stage === "reviewing" ? `${progress.passed}/${progress.total}` : STAGE_TEXT[progress.stage]);
161
+ const blocked = progress.stage === "reviewing" && progress.blocked ? `${gold(" · ")}${theme.fg("error", `${progress.blocked} 阻断`)}` : "";
162
+ const tiers = [`${named} ${body}${blocked}`, `${named} ${body}`, named];
163
+ return tiers.filter((tier, index) => tier !== tiers[index - 1]);
164
+ }
165
+
166
+ class ShellEditor extends CustomEditor {
167
+ constructor(
168
+ tui: TUI,
169
+ theme: EditorTheme,
170
+ keybindings: KeybindingsManager,
171
+ private readonly shell: Shell,
172
+ private readonly bottomParts: () => BottomParts,
173
+ ) {
174
+ super(tui, theme, keybindings);
175
+ shell.requestRender = () => tui.requestRender();
176
+ }
177
+
178
+ // 输入过长滚动时让位给宿主的“↑ n more”提示。
179
+ protected override renderTopBorder(width: number, hiddenLineCount: number): string {
180
+ return hiddenLineCount > 0
181
+ ? super.renderTopBorder(width, hiddenLineCount)
182
+ : topBorder(width, this.shell.top(), this.borderColor);
183
+ }
184
+
185
+ protected override renderBottomBorder(width: number, hiddenLineCount: number): string {
186
+ return hiddenLineCount > 0
187
+ ? super.renderBottomBorder(width, hiddenLineCount)
188
+ : bottomBorder(width, this.bottomParts(), this.borderColor);
189
+ }
190
+ }
23
191
 
24
192
  export function registerStatusBar(pi: ExtensionAPI, subsession = false): void {
25
193
  if (subsession) return;
26
- let quota: QuotaStatus | undefined;
27
- let tpsStatus: TpsStatus | undefined;
28
- let requestRender = () => {};
29
-
30
- registerQuota(
31
- pi,
32
- (status) => {
33
- quota = status;
34
- requestRender();
194
+ const shell = new Shell();
195
+ const updateTitle = (ctx: ExtensionContext, incoming?: MessageStartEvent["message"]) => {
196
+ shell.title = displayTitle(ctx, incoming);
197
+ shell.requestRender();
198
+ };
199
+ pi.on("message_start", (event, ctx) => {
200
+ if (event.message.role === "user") updateTitle(ctx, event.message);
201
+ });
202
+ pi.on("session_info_changed", (_event, ctx) => updateTitle(ctx));
203
+ watchBusy(pi, {
204
+ onChange: (view) => {
205
+ shell.sync(view);
206
+ shell.syncClock();
207
+ shell.requestRender();
35
208
  },
36
- fileQuotaCache(join(getAgentDir(), "tmp")),
37
- );
38
- registerTps(pi, (status) => {
39
- tpsStatus = status;
40
- requestRender();
41
209
  });
42
-
43
- // 助手消息落定后缓存命中率才可算。
44
- pi.on("message_end", (event) => {
45
- if (event.message.role === "assistant") requestRender();
210
+ let branch: () => readonly BranchEntry[] = () => [];
211
+ /**
212
+ * 落定态只在三个时点算一次并存下,绘制只读(读分支是整条回溯,不能放进每次按键重绘):轮记录写入后的发布、
213
+ * session_start(重开会话直接显示上一轮,不播落定过渡)、session_tree。不取歇下边沿:那一刻记录未必已写进分支,订阅顺序不定。
214
+ */
215
+ const showRecord = (at?: number) => {
216
+ shell.showRecord(branch(), at);
217
+ shell.syncClock();
218
+ shell.requestRender();
219
+ };
220
+ pi.events.on(ROUND_RECORDED_CHANNEL, () => showRecord(Date.now()));
221
+ pi.on("session_tree", (_event, ctx) => {
222
+ updateTitle(ctx);
223
+ showRecord();
224
+ });
225
+ pi.events.on(OCCUPANCY_CHANNEL, (data) => {
226
+ const occupancy = data as OccupancyPayload;
227
+ shell.review = occupancy.active ? occupancy.progress : undefined;
228
+ shell.syncClock();
229
+ shell.requestRender();
46
230
  });
47
-
48
231
  pi.on("session_start", (_event, ctx) => {
49
- ctx.ui.setFooter((tui, theme, footerData) => {
50
- requestRender = () => tui.requestRender();
51
- const unsubscribe = footerData.onBranchChange(requestRender);
52
-
53
- return {
54
- dispose: unsubscribe,
55
- invalidate() {},
56
- render(width: number): string[] {
57
- const statuses = footerData.getExtensionStatuses();
58
- const review = reviewStatus(statuses);
59
- const model = ctx.model;
60
- const thinkingLevel = pi.getThinkingLevel();
61
- const modelCore = `${theme.fg("accent", `🧠 ${formatModelName(model?.id)}`)}${
62
- model?.reasoning
63
- ? theme.fg(thinkingColor(thinkingLevel), `/${thinkingLevel}`)
64
- : ""
65
- }`;
66
- const modelText = statuses.has("pi-openai-native-fast")
67
- ? `${modelCore}${theme.fg("warning", " · ⚡fast")}`
68
- : modelCore;
69
-
70
- const location = renderLocation(
71
- theme,
72
- basename(ctx.sessionManager.getCwd()) || "/",
73
- footerData.getGitBranch(),
74
- );
75
- const sessionName = ctx.sessionManager.getSessionName();
76
- const title = sessionName ? theme.fg("dim", `💬 ${sessionName}`) : "";
77
-
78
- const usage = ctx.getContextUsage();
79
- const percent = usage?.percent;
80
- const contextWindow = usage?.contextWindow ?? model?.contextWindow ?? 0;
81
- const separator = ` ${theme.fg("dim", "|")}`;
82
-
83
- const statusLine = fitStatusLine(
84
- {
85
- model: modelText,
86
- modelCompact: modelCore,
87
- quota: quota ? renderQuota(theme, quota) : "",
88
- quotaCompact: quota ? renderQuota(theme, quota, true) : "",
89
- context: renderContext(theme, percent, contextWindow),
90
- contextCompact: renderContext(theme, percent, contextWindow, true),
91
- cache: renderCache(
92
- theme,
93
- latestCacheHitPercent(ctx.sessionManager.getEntries()),
94
- ),
95
- tps: renderTps(theme, tpsStatus),
96
- },
97
- width,
98
- separator,
99
- );
100
- return [
101
- alignRight(
102
- fitMetadataLine(location, title, width, separator, statusBadges(statuses, separator)),
103
- review,
104
- width,
105
- ),
106
- statusLine,
107
- ];
108
- },
109
- };
232
+ updateTitle(ctx);
233
+ branch = () => ctx.sessionManager.getBranch();
234
+ showRecord();
235
+ // 宿主内嵌的 Working 指示由外壳的火苗取代,可见性只在这里管理。
236
+ ctx.ui.setWorkingVisible(false);
237
+ // 独立底栏 0 行;借它拿到状态订阅与主题。
238
+ ctx.ui.setFooter((_tui, theme, footerData) => {
239
+ shell.theme = theme;
240
+ shell.statuses = () => footerData.getExtensionStatuses();
241
+ return { dispose() {}, invalidate() {}, render: () => [] };
110
242
  });
243
+ ctx.ui.setEditorComponent((tui, theme, keybindings) =>
244
+ new ShellEditor(tui, theme, keybindings, shell, () => shell.bottom(ctx, pi.getThinkingLevel())));
111
245
  });
112
-
113
- pi.on("thinking_level_select", () => requestRender());
246
+ pi.on("thinking_level_select", () => shell.requestRender());
247
+ pi.on("model_select", () => shell.requestRender());
114
248
  pi.on("session_shutdown", (_event, ctx) => {
115
- requestRender = () => {};
249
+ shell.dispose();
250
+ shell.busy = IDLE;
251
+ shell.settled = undefined;
252
+ shell.review = undefined;
116
253
  ctx.ui.setFooter(undefined);
254
+ ctx.ui.setEditorComponent(undefined);
117
255
  });
118
256
  }
@@ -1,181 +1,113 @@
1
- /** 状态栏的纯渲染与布局:给定数据和宽度产出字符串,不触碰会话状态。 */
2
- import type { ThemeColor } from "@earendil-works/pi-coding-agent";
1
+ /**
2
+ * 输入框边框纯布局:状态嵌进上下边框横线。所有片段由调用方预先着色,
3
+ * 本文件只按显示宽度逐级退让,宽窄布局不保存状态。
4
+ */
3
5
  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";
6
+ import { clip } from "../format.js";
7
+ import { HEAT_COLORS, type Rgb, mix, paint } from "../flame.js";
8
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
- };
9
+ /** 回合进行时边框左端的暖光渐隐距离(列)。 */
10
+ const GLOW_SPAN = 18;
11
+ const LINE_BASE: Rgb = [78, 74, 70];
12
+ export const SEPARATOR = ` ${paint([88, 84, 79], "·")} `;
19
13
 
20
- type ForegroundTheme = {
21
- fg(color: ThemeColor, text: string): string;
22
- };
14
+ export type Line = (text: string) => string;
15
+ /** 一条边框的一档候选:左、右两段(已着色)。 */
16
+ export type BorderParts = readonly [left: string, right: string];
23
17
 
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;
18
+ /**
19
+ * 一条边框:`─ 左 ───── 右 ─`。左端按 glow 渐变成暖橙;横线放不下(填充不足两格)返回空串。
20
+ * 左右为空时对应空位并入横线。
21
+ */
22
+ function border(width: number, left: string, right: string, line: Line, glow: number): string {
23
+ const fill = width - 2 - (left ? visibleWidth(left) + 2 : 0) - (right ? visibleWidth(right) + 2 : 0);
24
+ if (fill < 2) return "";
25
+ const lit = (index: number) => glow * Math.max(0, 1 - index / GLOW_SPAN);
26
+ const dash = (index: number) => {
27
+ const k = lit(index);
28
+ return k > 0.02 ? paint(mix(LINE_BASE, HEAT_COLORS.orange, k), "─") : line("─");
29
+ };
30
+ let bar = "";
31
+ for (let index = 0; index < fill; index++) bar += dash(index + (left ? 0 : 1));
32
+ return `${dash(0)}${left ? ` ${left} ` : ""}${bar}${right ? ` ${right} ` : ""}${line("─")}`;
47
33
  }
48
34
 
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
- );
35
+ /**
36
+ * 边框布局:依次尝试由长到短的候选,取第一个放得下的;全都放不下就是一条纯横线。
37
+ * 主会话输入框与子代理视图输入区共用这一个函数,退让档由各自给出。
38
+ */
39
+ export function fitBorder(width: number, line: Line, glow: number, candidates: Iterable<BorderParts>): string {
40
+ for (const [left, right] of candidates) {
41
+ const text = border(width, left, right, line, glow);
42
+ if (text && visibleWidth(text) <= width) return text;
104
43
  }
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") ?? "");
44
+ return line("─").repeat(Math.max(0, width));
115
45
  }
116
46
 
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);
47
+ export interface TopParts {
48
+ /** 火苗或落定标记;空表示没有回合。 */
49
+ mark: string;
50
+ word: string;
51
+ elapsed: string;
52
+ /** 审查进度由长到短的退让档(已着色);空数组表示没有审查。 */
53
+ review: readonly string[];
54
+ watcher: string;
55
+ master: string;
56
+ /** 0–1:回合进行时边框左端的暖光强度。 */
57
+ glow: number;
120
58
  }
121
59
 
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;
60
+ /** 退让顺序:观察员 → 审查进度逐档缩短 → “处理中” → 审查进度最短档 → 指挥官。 */
61
+ export function topBorder(width: number, parts: TopParts, line: Line): string {
62
+ const left = (word: boolean, review: string) => {
63
+ const head = [parts.mark, word ? parts.word : "", parts.elapsed].filter(Boolean).join(" ");
64
+ return [head, review].filter(Boolean).join(SEPARATOR);
65
+ };
66
+ const right = (...items: string[]) => items.filter(Boolean).join(" ");
67
+ const at = (word: boolean, review: string, ...items: string[]): BorderParts => [left(word, review), right(...items)];
68
+ const [full = "", ...shorter] = parts.review;
69
+ return fitBorder(width, line, parts.glow, [
70
+ at(true, full, parts.watcher, parts.master),
71
+ ...[full, ...shorter].map((review) => at(true, review, parts.master)),
72
+ at(false, parts.review.at(-1) ?? "", parts.master),
73
+ at(false, "", parts.master),
74
+ at(false, ""),
75
+ ]);
126
76
  }
127
77
 
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");
78
+ export interface BottomParts {
79
+ title: string;
80
+ /** 生效中的预设名(已着色);没有预设为空。 */
81
+ preset: string;
82
+ model: string;
83
+ /** 含前导斜杠,如 `/high`;模型不支持思考档时为空。 */
84
+ think: string;
85
+ fast: string;
86
+ percent: string;
87
+ /** 含前导斜杠,如 `/1M`。 */
88
+ capacity: string;
150
89
  }
151
90
 
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
- }
91
+ /** 标题裁到这么窄就先去裁模型名:再窄的标题认不出会话。 */
92
+ const TITLE_MIN = 8;
173
93
 
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}`;
94
+ /** 退让顺序:容量 → 预设名 → 标题裁到 TITLE_MIN → 模型名逐列裁 → 丢标题;Fast 与百分比始终保留。 */
95
+ export function bottomBorder(width: number, parts: BottomParts, line: Line): string {
96
+ const right = (preset: string, model: string, capacity: string) => {
97
+ const name = [model + (model ? parts.think : ""), parts.fast].filter(Boolean).join(" ");
98
+ return [preset, name, `${parts.percent}${capacity}`].filter(Boolean).join(SEPARATOR);
99
+ };
100
+ const at = (title: string, preset: string, model: string, capacity: string): BorderParts => [title, right(preset, model, capacity)];
101
+ function* candidates() {
102
+ yield at(parts.title, parts.preset, parts.model, parts.capacity);
103
+ yield at(parts.title, parts.preset, parts.model, "");
104
+ const titleWidth = visibleWidth(parts.title);
105
+ for (let n = titleWidth; n >= Math.min(titleWidth, TITLE_MIN); n--) yield at(clip(parts.title, n), "", parts.model, "");
106
+ const short = clip(parts.title, TITLE_MIN);
107
+ for (let n = visibleWidth(parts.model) - 1; n >= 1; n--) yield at(short, "", clip(parts.model, n), "");
108
+ yield at("", "", parts.model, "");
109
+ for (let n = visibleWidth(parts.model) - 1; n >= 1; n--) yield at("", "", clip(parts.model, n), "");
110
+ yield at("", "", "", "");
179
111
  }
180
- return clip(context, width, "end", "");
112
+ return fitBorder(width, line, 0, candidates());
181
113
  }