@buyi1net/pi-toolkit 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/assembler.ts +127 -0
  4. package/config.ts +201 -0
  5. package/i18n.ts +693 -0
  6. package/index.ts +99 -0
  7. package/menu/items.ts +264 -0
  8. package/menu/panels.ts +91 -0
  9. package/menu/settings-list.ts +84 -0
  10. package/menu/theme.ts +31 -0
  11. package/menu/toolkit-menu.ts +139 -0
  12. package/module.ts +156 -0
  13. package/modules/eyes/chain.ts +173 -0
  14. package/modules/eyes/config.ts +319 -0
  15. package/modules/eyes/index.ts +161 -0
  16. package/modules/eyes/menu.ts +828 -0
  17. package/modules/eyes/pi-model-backend.ts +245 -0
  18. package/modules/eyes/resilience.ts +161 -0
  19. package/modules/eyes/vision-bridge.ts +232 -0
  20. package/modules/eyes/vision-cache.ts +86 -0
  21. package/modules/eyes/vision-json.ts +19 -0
  22. package/modules/eyes/vision-preprocess.ts +192 -0
  23. package/modules/eyes/vision-probe.ts +21 -0
  24. package/modules/eyes/vision-prompt.ts +90 -0
  25. package/modules/eyes/vision-tool.ts +59 -0
  26. package/modules/eyes/vision-types.ts +33 -0
  27. package/modules/index.ts +10 -0
  28. package/modules/subagents/agents/researcher.md +51 -0
  29. package/modules/subagents/agents/scout.md +40 -0
  30. package/modules/subagents/agents/worker.md +79 -0
  31. package/modules/subagents/config.json.example +6 -0
  32. package/modules/subagents/config.ts +144 -0
  33. package/modules/subagents/index.ts +91 -0
  34. package/modules/subagents/menu.ts +388 -0
  35. package/modules/subagents/src/activity.ts +511 -0
  36. package/modules/subagents/src/agents.ts +126 -0
  37. package/modules/subagents/src/command.ts +37 -0
  38. package/modules/subagents/src/dependencies.ts +246 -0
  39. package/modules/subagents/src/diagnostics.ts +13 -0
  40. package/modules/subagents/src/display.ts +94 -0
  41. package/modules/subagents/src/headless.ts +342 -0
  42. package/modules/subagents/src/herdr.ts +203 -0
  43. package/modules/subagents/src/index.ts +2798 -0
  44. package/modules/subagents/src/inspect-tool.ts +839 -0
  45. package/modules/subagents/src/launch-config.ts +196 -0
  46. package/modules/subagents/src/layout-budget.ts +26 -0
  47. package/modules/subagents/src/list-tool.ts +292 -0
  48. package/modules/subagents/src/message-tool.ts +808 -0
  49. package/modules/subagents/src/names.ts +17 -0
  50. package/modules/subagents/src/pane-layout.ts +49 -0
  51. package/modules/subagents/src/params.ts +145 -0
  52. package/modules/subagents/src/renderers.ts +181 -0
  53. package/modules/subagents/src/result.ts +43 -0
  54. package/modules/subagents/src/retention.ts +114 -0
  55. package/modules/subagents/src/route-error.ts +212 -0
  56. package/modules/subagents/src/routing.ts +235 -0
  57. package/modules/subagents/src/runtime-registry.ts +159 -0
  58. package/modules/subagents/src/session.ts +801 -0
  59. package/modules/subagents/src/status.ts +513 -0
  60. package/modules/subagents/src/stop-tool.ts +235 -0
  61. package/modules/subagents/src/subagent-done.ts +590 -0
  62. package/modules/subagents/src/subagent-tool.ts +1155 -0
  63. package/modules/subagents/src/surface.ts +355 -0
  64. package/modules/subagents/src/team-dispatch-tool.ts +219 -0
  65. package/modules/subagents/src/team.ts +232 -0
  66. package/modules/subagents/src/tmux.ts +210 -0
  67. package/modules/subagents/src/tools/safe-bash.ts +72 -0
  68. package/modules/subagents/src/types.ts +131 -0
  69. package/modules/tui/adapter/provider-usage.ts +143 -0
  70. package/modules/tui/config.ts +50 -0
  71. package/modules/tui/index.ts +120 -0
  72. package/modules/tui/kernel/pkg/shared/grok-subscription.ts +169 -0
  73. package/modules/tui/kernel/pkg/shared/official-subscription.ts +237 -0
  74. package/modules/tui/kernel/pkg/shared/provider-catalog.ts +367 -0
  75. package/modules/tui/kernel/pkg/shared/provider-contracts.ts +150 -0
  76. package/modules/tui/kernel/pkg/shared/provider-display.ts +55 -0
  77. package/modules/tui/kernel/pkg/shared/provider-parsers.ts +171 -0
  78. package/modules/tui/kernel/pkg/shared/volcengine.ts +191 -0
  79. package/modules/tui/kernel/pkg/shared/zhipu.ts +149 -0
  80. package/modules/tui/kernel/pkg/usage-core/index.ts +335 -0
  81. package/modules/tui/kernel/pkg/usage-core/provider-routes.ts +187 -0
  82. package/modules/tui/kernel/pkg/usage-node/index.ts +635 -0
  83. package/modules/tui/kernel/pkg/usage-node/provider-usage.ts +388 -0
  84. package/modules/tui/kernel/usage-core.ts +2 -0
  85. package/modules/tui/kernel/usage-node.ts +2 -0
  86. package/modules/tui/menu.ts +418 -0
  87. package/modules/tui/plugin/editor.ts +396 -0
  88. package/modules/tui/plugin/footer.ts +161 -0
  89. package/modules/tui/plugin/index.ts +30 -0
  90. package/modules/tui/plugin/lifecycle.ts +637 -0
  91. package/modules/tui/plugin/package-order.ts +169 -0
  92. package/modules/tui/plugin/screen-transition.ts +203 -0
  93. package/modules/tui/plugin/settings-config.ts +297 -0
  94. package/modules/tui/plugin/status-sources.ts +49 -0
  95. package/modules/tui/plugin/transition-gate.ts +261 -0
  96. package/modules/tui/renderer/custom-header.ts +157 -0
  97. package/modules/tui/renderer/editor.ts +70 -0
  98. package/modules/tui/renderer/header.ts +72 -0
  99. package/modules/tui/renderer/icons.ts +149 -0
  100. package/modules/tui/renderer/pi-installer-logo.ts +194 -0
  101. package/modules/tui/status/auto-compaction.ts +67 -0
  102. package/modules/tui/status/project-status.ts +655 -0
  103. package/modules/tui/status/provider-status.ts +120 -0
  104. package/modules/tui/status/runtime-status.ts +307 -0
  105. package/modules/tui/status/session-status.ts +325 -0
  106. package/modules/tui/status/status-config.ts +95 -0
  107. package/modules/tui/status/status-segments.ts +263 -0
  108. package/modules/tui/status/turn-telemetry.ts +466 -0
  109. package/modules/tui/themes/LICENSE.pi-themes-bundle +21 -0
  110. package/modules/tui/themes/UPSTREAM.md +7 -0
  111. package/modules/tui/themes/catppuccin-latte.json +80 -0
  112. package/modules/tui/themes/catppuccin-mocha.json +79 -0
  113. package/modules/tui/themes/crimson-noir.json +85 -0
  114. package/modules/tui/themes/dracula.json +79 -0
  115. package/modules/tui/themes/everforest-dark.json +85 -0
  116. package/modules/tui/themes/gruvbox-dark.json +85 -0
  117. package/modules/tui/themes/gruvbox-light.json +85 -0
  118. package/modules/tui/themes/matrix.json +85 -0
  119. package/modules/tui/themes/nord.json +85 -0
  120. package/modules/tui/themes/one-dark.json +85 -0
  121. package/modules/tui/themes/rose-pine-dawn.json +85 -0
  122. package/modules/tui/themes/rose-pine.json +85 -0
  123. package/modules/tui/themes/solarized-dark.json +85 -0
  124. package/modules/tui/themes/solarized-light.json +85 -0
  125. package/modules/tui/themes/tokyo-night-storm.json +79 -0
  126. package/modules/tui/themes/tokyo-night.json +79 -0
  127. package/package.json +28 -0
  128. package/services.ts +38 -0
  129. package/toolkit.ts +147 -0
@@ -0,0 +1,49 @@
1
+ import type { ContextUsage } from "@earendil-works/pi-coding-agent";
2
+ import type { UsageRuntimeState } from "../kernel/usage-node.ts";
3
+ import type { IconGlyphs } from "../renderer/icons.ts";
4
+ import type { ProjectStatusSnapshot } from "../status/project-status.ts";
5
+ import type { ResolvedStatusSettings } from "../status/status-config.ts";
6
+ import type { RuntimeStatusSnapshot } from "../status/runtime-status.ts";
7
+ import type { SessionStatusSnapshot } from "../status/session-status.ts";
8
+ import type { TurnTimerSnapshot } from "../status/status-segments.ts";
9
+
10
+ // 视图数据源分组契约:Footer 与 Editor 只按数据源接收分组接口,装配
11
+ // (lifecycle.ts)负责把状态控制器与宿主上下文接成这些组。新增一个状态
12
+ // 数据段只扩展或实现对应分组,不再改动视图构造器签名。
13
+
14
+ /** 会话源:本轮计时器与会话遥测(token/cache、上下文用量、自动压缩)。 */
15
+ export interface SessionStatusSource {
16
+ getTimer?: () => TurnTimerSnapshot;
17
+ getSessionStatus?: () => SessionStatusSnapshot;
18
+ getContextUsage?: () => ContextUsage | undefined;
19
+ getContextWindow?: () => number | undefined;
20
+ getAutoCompactionEnabled?: () => boolean;
21
+ }
22
+
23
+ /** 供应商源:用量快照查询;刷新请求由装配层接给控制器,不进视图。 */
24
+ export interface ProviderUsageSource {
25
+ getState?: () => UsageRuntimeState | undefined;
26
+ }
27
+
28
+ /** 项目环境源:Git 状态、运行时状态与工作目录,供 Footer 项目行。 */
29
+ export interface ProjectEnvironmentSource {
30
+ getProjectStatus?: () => Readonly<ProjectStatusSnapshot>;
31
+ getRuntimeStatus?: () => Readonly<RuntimeStatusSnapshot> | null;
32
+ cwd?: string;
33
+ }
34
+
35
+ /** 布局源(Footer 侧):高度回报。 */
36
+ export interface FooterLayoutSource {
37
+ reportHeight?: (height: number) => void;
38
+ }
39
+
40
+ /** 布局源(Editor 侧):Footer 高度查询,供补全 Overlay 定位。 */
41
+ export interface EditorLayoutSource {
42
+ getFooterHeight?: () => number;
43
+ }
44
+
45
+ /** 外观配置:图标解析与状态段设置,随数据源分组一起注入两端视图。 */
46
+ export interface StatusAppearance {
47
+ getGlyphs?: () => IconGlyphs;
48
+ settings?: ResolvedStatusSettings;
49
+ }
@@ -0,0 +1,261 @@
1
+ import type {
2
+ TUI,
3
+ TuiMainScreenRenderState,
4
+ } from "@earendil-works/pi-tui";
5
+
6
+ const CLEAR_VISIBLE_SCREEN = "\x1b[2J\x1b[H";
7
+ const BEGIN_SYNCHRONIZED_OUTPUT = "\x1b[?2026h";
8
+ const END_SYNCHRONIZED_OUTPUT = "\x1b[?2026l";
9
+ const MAX_HELD_OUTPUT_BYTES = 2 * 1024 * 1024;
10
+ const FAIL_OPEN_AFTER_MS = 10_000;
11
+ const GUARD_CLEAR_INTERVAL_MS = 16;
12
+ const GLOBAL_GATE_KEY = "__piTuiTerminalTransitionGateV1";
13
+
14
+ type TerminalWriteTarget = {
15
+ write(data: string): void;
16
+ };
17
+
18
+ type TransitionOutput = {
19
+ isTTY?: boolean;
20
+ write(data: string): unknown;
21
+ };
22
+
23
+ type MainScreenTui = TUI & {
24
+ captureRenderState(): TuiMainScreenRenderState;
25
+ restoreRenderState(state: TuiMainScreenRenderState): void;
26
+ };
27
+
28
+ type HeldWrite = {
29
+ terminal: TerminalWriteTarget;
30
+ data: string;
31
+ };
32
+
33
+ export interface TransitionHoldOptions {
34
+ clearVisibleScreen?: boolean;
35
+ }
36
+
37
+ function isMainScreenTui(tui: TUI): tui is MainScreenTui {
38
+ return (
39
+ tui.mode === "regular" &&
40
+ typeof (tui as Partial<MainScreenTui>).captureRenderState === "function" &&
41
+ typeof (tui as Partial<MainScreenTui>).restoreRenderState === "function"
42
+ );
43
+ }
44
+
45
+ function canRedrawVisibleViewport(tui: TUI, state: TuiMainScreenRenderState): boolean {
46
+ const width = tui.terminal.columns;
47
+ const height = tui.terminal.rows;
48
+ if (!Number.isSafeInteger(width) || width <= 0 || !Number.isSafeInteger(height) || height <= 0) {
49
+ return false;
50
+ }
51
+ if (!Array.isArray(state.previousLines) || state.previousLines.length === 0) return false;
52
+ if (state.previousLines.some((line) => line.includes("\x1b_G") || line.includes("\x1b]1337;File="))) {
53
+ return false;
54
+ }
55
+ return Number.isSafeInteger(state.previousViewportTop) && state.previousViewportTop >= 0;
56
+ }
57
+
58
+ export class TerminalTransitionGate {
59
+ private readonly output: TransitionOutput;
60
+ private holding = false;
61
+ private heldWrites: HeldWrite[] = [];
62
+ private heldOutputBytes = 0;
63
+ private failOpenTimer: ReturnType<typeof setTimeout> | undefined;
64
+ private hasVisibleFrame = false;
65
+
66
+ // 实例级拦截:宿主真实 terminal(jiti 双副本下 prototype patch 拦不到宿主)。
67
+ private hookedTerminal: TerminalWriteTarget | undefined;
68
+ private instanceOriginalWrite: ((data: string) => void) | undefined;
69
+ // 拦截挂接前的看门狗:holding 且 hook 未挂期间宿主仍在渲染原生帧(真实配置
70
+ // 下扩展初始化可达数秒),以约一帧的间隔清屏,落屏帧最多存活 16ms——
71
+ // 低于单行文字的感知阈值;空屏上的清屏是视觉 no-op,不会闪烁。
72
+ private guardTimer: ReturnType<typeof setInterval> | undefined;
73
+
74
+ constructor(output: TransitionOutput = process.stdout) {
75
+ this.output = output;
76
+ }
77
+
78
+ /** 同一实例重复挂接不叠加拦截;换绑时先恢复旧实例,避免旧终端继续持闸。 */
79
+ hookTerminal(terminal: TerminalWriteTarget): void {
80
+ if (this.hookedTerminal === terminal) return;
81
+ this.stopGuardTimer();
82
+ if (this.hookedTerminal && this.instanceOriginalWrite) {
83
+ this.hookedTerminal.write = this.instanceOriginalWrite;
84
+ }
85
+ const originalWrite = terminal.write;
86
+ this.hookedTerminal = terminal;
87
+ this.instanceOriginalWrite = originalWrite;
88
+ const gate = this;
89
+ terminal.write = (data: string): void => {
90
+ if (!gate.holding) {
91
+ originalWrite.call(terminal, data);
92
+ return;
93
+ }
94
+ gate.captureWrite(terminal, data);
95
+ };
96
+ }
97
+
98
+ /** 清屏和揭示必须绕过拦截,否则控制序列也会被持闸。 */
99
+ private writeThrough(data: string): void {
100
+ if (this.hookedTerminal && this.instanceOriginalWrite) {
101
+ this.instanceOriginalWrite.call(this.hookedTerminal, data);
102
+ return;
103
+ }
104
+ this.output.write(data);
105
+ }
106
+
107
+ isHolding(): boolean {
108
+ return this.holding;
109
+ }
110
+
111
+ hold(tui?: TUI, options: TransitionHoldOptions = {}): boolean {
112
+ if (!this.output.isTTY) return false;
113
+ if (this.holding) {
114
+ if (options.clearVisibleScreen) this.clearVisibleScreen(tui);
115
+ return true;
116
+ }
117
+
118
+ // 首次启动时隐藏宿主的未装饰帧;reload 时保留当前画面,最终帧会原位覆盖,
119
+ // 避免 Windows Terminal 把整屏清除前的内容留进回滚历史形成重复残影。
120
+ if (!this.hasVisibleFrame || options.clearVisibleScreen) {
121
+ this.writeThrough(CLEAR_VISIBLE_SCREEN);
122
+ }
123
+
124
+ this.holding = true;
125
+ this.heldWrites = [];
126
+ this.heldOutputBytes = 0;
127
+ this.armFailOpen();
128
+ this.startGuardTimer();
129
+ return true;
130
+ }
131
+
132
+ private startGuardTimer(): void {
133
+ if (this.guardTimer || this.hookedTerminal) return;
134
+ this.guardTimer = setInterval(() => {
135
+ if (!this.holding || this.hookedTerminal) {
136
+ this.stopGuardTimer();
137
+ return;
138
+ }
139
+ // 经 output 直写清屏:此时 hook 未挂,宿主 write 不经闸门。
140
+ this.output.write(CLEAR_VISIBLE_SCREEN);
141
+ }, GUARD_CLEAR_INTERVAL_MS);
142
+ this.guardTimer.unref?.();
143
+ }
144
+
145
+ private stopGuardTimer(): void {
146
+ if (!this.guardTimer) return;
147
+ clearInterval(this.guardTimer);
148
+ this.guardTimer = undefined;
149
+ }
150
+
151
+ private clearVisibleScreen(_tui?: TUI): void {
152
+ this.writeThrough(CLEAR_VISIBLE_SCREEN);
153
+ }
154
+
155
+ reveal(tui: TUI): boolean {
156
+ if (!this.holding) return false;
157
+
158
+ try {
159
+ // 闸门关闭期间仍让 Pi 完成一次内部渲染,随后直接揭示准备好的可见区。
160
+ tui.renderNow(false);
161
+ if (isMainScreenTui(tui)) {
162
+ const state = tui.captureRenderState();
163
+ if (canRedrawVisibleViewport(tui, state)) {
164
+ const height = tui.terminal.rows;
165
+ const maxViewportTop = Math.max(0, state.previousLines.length - height);
166
+ const top = Math.min(state.previousViewportTop, maxViewportTop);
167
+ const bottom = Math.min(state.previousLines.length, top + height);
168
+ tui.restoreRenderState({
169
+ ...state,
170
+ hardwareCursorRow: Math.max(top, bottom - 1),
171
+ });
172
+ this.release(false);
173
+ const visibleLines = state.previousLines.slice(top, bottom);
174
+ this.writeThrough(
175
+ `${BEGIN_SYNCHRONIZED_OUTPUT}\x1b[H${visibleLines.join("\r\n")}\x1b[J${END_SYNCHRONIZED_OUTPUT}`,
176
+ );
177
+ this.hasVisibleFrame = true;
178
+ // 状态已经与最终帧一致;这一轮只让 Pi 恢复 IME 硬件光标。
179
+ tui.renderNow(false);
180
+ return true;
181
+ }
182
+ }
183
+
184
+ this.release(false);
185
+ tui.renderNow(true);
186
+ this.hasVisibleFrame = true;
187
+ return true;
188
+ } catch {
189
+ this.release(true);
190
+ try {
191
+ tui.renderNow(true);
192
+ } catch {
193
+ // 已回放被拦截的输出,不能再安全恢复时保持宿主当前终端状态。
194
+ }
195
+ return false;
196
+ }
197
+ }
198
+
199
+ release(replayHeldOutput = false): void {
200
+ if (!this.holding) return;
201
+ this.stopGuardTimer();
202
+ this.holding = false;
203
+ this.clearFailOpenTimer();
204
+ const heldWrites = this.heldWrites;
205
+ this.heldWrites = [];
206
+ this.heldOutputBytes = 0;
207
+ if (!replayHeldOutput) return;
208
+ // 已解除持闸,按捕获来源回放;换绑后的当前直写通道不再属于旧终端。
209
+ for (const held of heldWrites) {
210
+ held.terminal.write(held.data);
211
+ }
212
+ }
213
+
214
+ private captureWrite(terminal: TerminalWriteTarget, data: string): void {
215
+ this.heldWrites.push({ terminal, data });
216
+ this.heldOutputBytes += Buffer.byteLength(data);
217
+ if (this.heldOutputBytes > MAX_HELD_OUTPUT_BYTES) this.release(true);
218
+ }
219
+
220
+ private armFailOpen(): void {
221
+ this.clearFailOpenTimer();
222
+ this.failOpenTimer = setTimeout(() => this.release(true), FAIL_OPEN_AFTER_MS);
223
+ this.failOpenTimer.unref?.();
224
+ }
225
+
226
+ private clearFailOpenTimer(): void {
227
+ if (!this.failOpenTimer) return;
228
+ clearTimeout(this.failOpenTimer);
229
+ this.failOpenTimer = undefined;
230
+ }
231
+ }
232
+
233
+ export function getTerminalTransitionGate(
234
+ output: TransitionOutput = process.stdout,
235
+ ): TerminalTransitionGate {
236
+ const globalState = globalThis as typeof globalThis & Record<string, unknown>;
237
+ const existing = globalState[GLOBAL_GATE_KEY];
238
+ if (
239
+ existing !== null &&
240
+ typeof existing === "object" &&
241
+ "hold" in existing &&
242
+ typeof existing.hold === "function" &&
243
+ "reveal" in existing &&
244
+ typeof existing.reveal === "function" &&
245
+ "release" in existing &&
246
+ typeof existing.release === "function"
247
+ ) {
248
+ const gate = existing as TerminalTransitionGate;
249
+ // jiti 会在 /reload 时载入新的模块实例,但全局闸门必须沿用同一份拦截状态。
250
+ // 把旧实例升级到当前原型,避免同一 Pi 进程一直执行上一个版本的方法。
251
+ Object.setPrototypeOf(gate, TerminalTransitionGate.prototype);
252
+ // 能复用全局实例说明当前进程已经展示过上一版插件界面;旧实例没有该字段。
253
+ if (typeof (gate as unknown as { hasVisibleFrame?: unknown }).hasVisibleFrame !== "boolean") {
254
+ (gate as unknown as { hasVisibleFrame: boolean }).hasVisibleFrame = true;
255
+ }
256
+ return gate;
257
+ }
258
+ const gate = new TerminalTransitionGate(output);
259
+ globalState[GLOBAL_GATE_KEY] = gate;
260
+ return gate;
261
+ }
@@ -0,0 +1,157 @@
1
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+ import { formatProjectPath } from "../status/project-status.ts";
4
+ import type { IconGlyphs } from "./icons.ts";
5
+ import {
6
+ PI_INSTALLER_LOGO_DURATIONS_MS,
7
+ PI_INSTALLER_LOGO_FRAMES,
8
+ PI_INSTALLER_LOGO_WIDTH,
9
+ type PiInstallerLogoFrame,
10
+ type PiLogoColor,
11
+ } from "./pi-installer-logo.ts";
12
+
13
+ /**
14
+ * 基于 amosblomqvist/pi-config 的 custom-header.ts 思路改造:
15
+ * 用 Pi 公开的 Header 组件绘制启动横幅,并保留本项目的宽度与主题适配。
16
+ * 参考:https://github.com/amosblomqvist/pi-config/blob/main/extensions/custom-header.ts
17
+ */
18
+
19
+ const BANNER_MIN_WIDTH = 32;
20
+ const BANNER_GAP = 1;
21
+ const BANNER_PADDING_X = 1;
22
+ export const CUSTOM_HEADER_LOGO_FRAMES = PI_INSTALLER_LOGO_FRAMES;
23
+ export const CUSTOM_HEADER_ANIMATION_DURATIONS_MS = PI_INSTALLER_LOGO_DURATIONS_MS;
24
+
25
+ const LOGO_THEME_COLORS: Record<PiLogoColor, ThemeColor | undefined> = {
26
+ panel: undefined,
27
+ cyan: "accent",
28
+ red: "error",
29
+ green: "success",
30
+ orange: "warning",
31
+ white: "text",
32
+ flash: "warning",
33
+ };
34
+
35
+ export interface CustomHeaderSnapshot {
36
+ version: string;
37
+ model: string;
38
+ thinking: string;
39
+ cwd: string;
40
+ }
41
+
42
+ function joinSides(left: string, right: string, width: number): string {
43
+ if (!right) return truncateToWidth(left, width);
44
+ const gap = width - visibleWidth(left) - visibleWidth(right);
45
+ if (gap >= 1) return `${left}${" ".repeat(gap)}${right}`;
46
+ return truncateToWidth(`${left} · ${right}`, width);
47
+ }
48
+
49
+ function joinColumns(
50
+ left: string,
51
+ right: string,
52
+ width: number,
53
+ leftColumnWidth = visibleWidth(left),
54
+ ): string {
55
+ const leftWidth = visibleWidth(left);
56
+ const availableRightWidth = width - leftColumnWidth - BANNER_GAP;
57
+ if (availableRightWidth < 1) return truncateToWidth(left, width);
58
+ const paddedLeft = `${left}${" ".repeat(Math.max(0, leftColumnWidth - leftWidth))}`;
59
+ return `${paddedLeft}${" ".repeat(BANNER_GAP)}${truncateToWidth(right, availableRightWidth)}`;
60
+ }
61
+
62
+ function renderLogoFrame(frame: PiInstallerLogoFrame, theme: Theme): string[] {
63
+ return frame.map((row) => {
64
+ let line = "";
65
+ let run = "";
66
+ let runColor: ThemeColor | undefined;
67
+
68
+ const flush = (): void => {
69
+ if (!run) return;
70
+ line += runColor ? theme.fg(runColor, run) : run;
71
+ run = "";
72
+ };
73
+
74
+ for (const cell of row) {
75
+ const color = LOGO_THEME_COLORS[cell];
76
+ if (color !== runColor && run) flush();
77
+ runColor = color;
78
+ // 对应官方安装器的 panel_cell=" " 与各色 cell="██"。
79
+ run += cell === "panel" ? " " : "██";
80
+ }
81
+ flush();
82
+ return line.trimEnd();
83
+ });
84
+ }
85
+
86
+ function formatHeaderModel(model: string): string {
87
+ const separator = model.lastIndexOf("/");
88
+ return separator >= 0 ? model.slice(separator + 1) : model;
89
+ }
90
+
91
+ function alignRowsToBottom(rows: readonly string[], height: number): string[] {
92
+ const visibleRows = rows.slice(-height);
93
+ const leadingEmptyRows = height - visibleRows.length;
94
+ return Array.from({ length: height }, (_, index) => visibleRows[index - leadingEmptyRows] ?? "");
95
+ }
96
+
97
+ function renderCompactHeader(
98
+ snapshot: CustomHeaderSnapshot,
99
+ width: number,
100
+ theme: Theme,
101
+ glyphs: IconGlyphs,
102
+ ): string[] {
103
+ const brand = theme.bold(theme.fg("border", `${glyphs.brand} Pi Tui`));
104
+ const version = theme.fg("muted", `Pi v${snapshot.version}`);
105
+ const first = joinSides(brand, version, width);
106
+ if (width < 32) return [first];
107
+
108
+ const model = theme.fg("accent", `${glyphs.model} ${snapshot.model}`);
109
+ const thinking = snapshot.thinking
110
+ ? theme.fg("muted", `${glyphs.thinking} ${snapshot.thinking}`)
111
+ : "";
112
+ const left = thinking ? `${model} · ${thinking}` : model;
113
+ const path = `${theme.fg("accent", glyphs.project)}${theme.fg(
114
+ "dim",
115
+ ` ${formatProjectPath(snapshot.cwd)}`,
116
+ )}`;
117
+ return [first, joinSides(left, path, width)];
118
+ }
119
+
120
+ export function renderCustomHeader(
121
+ snapshot: CustomHeaderSnapshot,
122
+ width: number,
123
+ theme: Theme,
124
+ glyphs: IconGlyphs,
125
+ logoFrame: PiInstallerLogoFrame = CUSTOM_HEADER_LOGO_FRAMES.at(-1)!,
126
+ ): string[] {
127
+ if (width <= 0) return [];
128
+ const paddingX = width >= BANNER_PADDING_X * 2 + 1 ? BANNER_PADDING_X : 0;
129
+ const contentWidth = Math.max(1, width - paddingX * 2);
130
+ if (width < BANNER_MIN_WIDTH) {
131
+ const compact = renderCompactHeader(snapshot, contentWidth, theme, glyphs);
132
+ return compact.map((line) => `${" ".repeat(paddingX)}${line}`);
133
+ }
134
+
135
+ const logo = renderLogoFrame(logoFrame, theme).map((line) => theme.bold(line));
136
+ const logoWidth = PI_INSTALLER_LOGO_WIDTH;
137
+ const title = `${theme.bold(theme.fg("border", "Pi"))}${theme.fg("dim", ` v${snapshot.version}`)}`;
138
+ const modelName = formatHeaderModel(snapshot.model);
139
+ const modelDetails = snapshot.thinking ? `${modelName} · ${snapshot.thinking}` : modelName;
140
+ const model = theme.fg("dim", modelDetails);
141
+ const path = theme.fg("dim", formatProjectPath(snapshot.cwd));
142
+ const shortcuts = theme.fg(
143
+ "dim",
144
+ "Escape interrupt · Ctrl+C/Ctrl+D clear/exit · / commands · ! bash · Ctrl+O more",
145
+ );
146
+ const details = [
147
+ title,
148
+ model,
149
+ path,
150
+ shortcuts,
151
+ ];
152
+ const alignedDetails = alignRowsToBottom(details, logo.length);
153
+
154
+ return logo.map(
155
+ (line, index) => `${" ".repeat(paddingX)}${joinColumns(line, alignedDetails[index] ?? "", contentWidth, logoWidth)}`,
156
+ );
157
+ }
@@ -0,0 +1,70 @@
1
+ import { stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
2
+
3
+ function plainLine(line: string): string {
4
+ return stripTerminalSequences(line);
5
+ }
6
+
7
+ function isEditorBorder(line: string): boolean {
8
+ const plain = plainLine(line);
9
+ return /^─+$/.test(plain) || /^─── [↑↓] \d+ more (?:─+|\.{1,3})$/.test(plain);
10
+ }
11
+
12
+ function scrollLabel(line: string): string | undefined {
13
+ return plainLine(line).match(/[↑↓] \d+ more/)?.[0];
14
+ }
15
+
16
+ export function hasTruncatedScrollBorder(lines: readonly string[]): boolean {
17
+ return lines.some((line, index) => {
18
+ if (index !== 0 && index < 2) return false;
19
+ const plain = plainLine(line);
20
+ return plain.endsWith("...") && (/^─/.test(plain) || /[↑↓]/.test(plain));
21
+ });
22
+ }
23
+
24
+ function findBottomBorder(lines: readonly string[]): number {
25
+ for (let index = lines.length - 1; index >= 2; index -= 1) {
26
+ if (isEditorBorder(lines[index] ?? "")) return index;
27
+ }
28
+ return -1;
29
+ }
30
+
31
+ export function splitNativeEditorRender(lines: readonly string[]): {
32
+ editor: string[];
33
+ autocomplete: string[];
34
+ } {
35
+ const bottomBorderIndex = findBottomBorder(lines);
36
+ if (bottomBorderIndex < 2) return { editor: [...lines], autocomplete: [] };
37
+ return {
38
+ editor: lines.slice(0, bottomBorderIndex + 1),
39
+ autocomplete: lines.slice(bottomBorderIndex + 1),
40
+ };
41
+ }
42
+
43
+ export interface TopBorderStatusOptions {
44
+ left: string;
45
+ borderColor: (text: string) => string;
46
+ }
47
+
48
+ /**
49
+ * 轻插入:Pi 原生上下横线边框保持原样,只在顶边左端嵌入状态文本。
50
+ * 顶边不是纯横线(滚动标签等原生动态形态)时原样返回,不与原生行为竞争。
51
+ */
52
+ export function insertTopBorderStatus(
53
+ baseLines: readonly string[],
54
+ options: TopBorderStatusOptions,
55
+ ): string[] {
56
+ if (baseLines.length === 0) return [...baseLines];
57
+ const top = baseLines[0] ?? "";
58
+ const plain = plainLine(top);
59
+ if (!/^─+$/.test(plain)) return [...baseLines];
60
+
61
+ const segment = options.left ? ` ${options.left} ` : "";
62
+ const segmentWidth = visibleWidth(segment);
63
+ const lineWidth = visibleWidth(plain);
64
+ // 两侧至少各留两列横线;放不下时保持原生顶边
65
+ if (segmentWidth === 0 || segmentWidth > lineWidth - 4) return [...baseLines];
66
+
67
+ const fill = "─".repeat(lineWidth - segmentWidth - 2);
68
+ const decorated = `${options.borderColor("──")}${segment}${options.borderColor(fill)}`;
69
+ return [decorated, ...baseLines.slice(1)];
70
+ }
@@ -0,0 +1,72 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { Component } from "@earendil-works/pi-tui";
3
+ import {
4
+ CUSTOM_HEADER_ANIMATION_DURATIONS_MS,
5
+ CUSTOM_HEADER_LOGO_FRAMES,
6
+ renderCustomHeader,
7
+ type CustomHeaderSnapshot,
8
+ } from "./custom-header.ts";
9
+ import type { IconGlyphs } from "./icons.ts";
10
+
11
+ export class PiTuiHeader implements Component {
12
+ private readonly getSnapshot: () => CustomHeaderSnapshot;
13
+ private readonly getTheme: () => Theme;
14
+ private readonly getGlyphs: () => IconGlyphs;
15
+ private readonly requestRender: () => void;
16
+ private animationFrame = 0;
17
+ private animationStopped = false;
18
+ private cancelAnimation: (() => void) | undefined;
19
+ private readonly scheduleTimeout: (callback: () => void, delayMs: number) => () => void;
20
+
21
+ constructor(
22
+ getSnapshot: () => CustomHeaderSnapshot,
23
+ getTheme: () => Theme,
24
+ getGlyphs: () => IconGlyphs,
25
+ requestRender: () => void = () => {},
26
+ // 为保持与 setTimeout 一致,注入调度器也须延后调用,并返回取消函数。
27
+ scheduleTimeout = (callback: () => void, delayMs: number): (() => void) => {
28
+ const timer = setTimeout(callback, delayMs);
29
+ return () => clearTimeout(timer);
30
+ },
31
+ ) {
32
+ this.getSnapshot = getSnapshot;
33
+ this.getTheme = getTheme;
34
+ this.getGlyphs = getGlyphs;
35
+ this.requestRender = requestRender;
36
+ this.scheduleTimeout = scheduleTimeout;
37
+ this.scheduleNextAnimationFrame();
38
+ }
39
+
40
+ private scheduleNextAnimationFrame(): void {
41
+ if (this.animationStopped || this.animationFrame >= CUSTOM_HEADER_LOGO_FRAMES.length - 1) return;
42
+ this.cancelAnimation = this.scheduleTimeout(() => {
43
+ this.cancelAnimation = undefined;
44
+ this.animationFrame += 1;
45
+ this.requestRender();
46
+ this.scheduleNextAnimationFrame();
47
+ }, CUSTOM_HEADER_ANIMATION_DURATIONS_MS[this.animationFrame] ?? 80);
48
+ }
49
+
50
+ private stopAnimation(): void {
51
+ // 重绘回调可能同步 dispose,此时尚未安排下一帧,单靠取消定时器无法阻止续播。
52
+ this.animationStopped = true;
53
+ this.cancelAnimation?.();
54
+ this.cancelAnimation = undefined;
55
+ }
56
+
57
+ invalidate(): void {}
58
+
59
+ render(width: number): string[] {
60
+ return renderCustomHeader(
61
+ this.getSnapshot(),
62
+ width,
63
+ this.getTheme(),
64
+ this.getGlyphs(),
65
+ CUSTOM_HEADER_LOGO_FRAMES[this.animationFrame],
66
+ );
67
+ }
68
+
69
+ dispose(): void {
70
+ this.stopAnimation();
71
+ }
72
+ }