@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,169 @@
1
+ // 包顺序自调:Pi 的 packages 按数组顺序串行加载,且 pi install 固定 append 到
2
+ // 末尾——本包含 TUI 外观类扩展,需要尽早加载(闸门/看门狗越早挂,宿主与其
3
+ // 它扩展的初始化输出越少暴露)。检测到不在首位时调整一次并写回 + 通知。
4
+ // 幂等且可退出:已在首位零动作;settings 里 piToolkitKeepPackageOrder=true 或
5
+ // 环境变量 PI_TOOLKIT_KEEP_PACKAGE_ORDER=1 时永不调整;写回失败静默放弃。
6
+ //
7
+ // 迁入 pi-toolkit 后按新包名适配(保留行为,只换标识):原 pi-tui 的
8
+ // `piTuiKeepPackageOrder` / `PI_TUI_KEEP_PACKAGE_ORDER` 不再识别;识别目标
9
+ // 也从 pi-tui 包换成声明 `pi.extensions` 的本包(pi-toolkit)。
10
+
11
+ import { readFileSync, writeFileSync, renameSync, existsSync } from "node:fs";
12
+ import { dirname, isAbsolute, join, normalize } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ interface GitRepositoryIdentity {
16
+ host: string;
17
+ path: string;
18
+ }
19
+
20
+ interface SelfPackageIdentity {
21
+ dir: string;
22
+ name: string;
23
+ repository?: GitRepositoryIdentity;
24
+ }
25
+
26
+ function entrySource(entry: unknown): string | undefined {
27
+ if (typeof entry === "string") return entry;
28
+ if (!entry || typeof entry !== "object" || !("source" in entry)) return undefined;
29
+ const source = (entry as { source: unknown }).source;
30
+ return typeof source === "string" ? source : undefined;
31
+ }
32
+
33
+ function npmPackageName(source: string): string | undefined {
34
+ if (!source.startsWith("npm:")) return undefined;
35
+ const spec = source.slice(4).trim();
36
+ if (!spec) return undefined;
37
+ if (!spec.startsWith("@")) return spec.split("@", 1)[0] || undefined;
38
+ const slash = spec.indexOf("/");
39
+ if (slash < 0) return undefined;
40
+ const version = spec.indexOf("@", slash);
41
+ return version < 0 ? spec : spec.slice(0, version);
42
+ }
43
+
44
+ function gitRepositoryIdentity(source: string): GitRepositoryIdentity | undefined {
45
+ let value = source.trim();
46
+ const prefixed = value.startsWith("git:") && !value.startsWith("git://");
47
+ if (prefixed) value = value.slice(4).trim();
48
+ value = value.replace(/^git\+/, "");
49
+
50
+ let host: string;
51
+ let path: string;
52
+ const scp = value.match(/^git@([^:]+):(.+)$/);
53
+ if (scp) {
54
+ if (!prefixed) return undefined;
55
+ host = scp[1]!;
56
+ path = scp[2]!;
57
+ } else if (/^(?:https?|ssh|git):\/\//i.test(value)) {
58
+ try {
59
+ const parsed = new URL(value);
60
+ host = parsed.hostname;
61
+ path = parsed.pathname.replace(/^\/+/, "");
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ } else if (prefixed) {
66
+ const slash = value.indexOf("/");
67
+ if (slash < 0) return undefined;
68
+ host = value.slice(0, slash);
69
+ path = value.slice(slash + 1);
70
+ } else {
71
+ return undefined;
72
+ }
73
+
74
+ path = path.split(/[?#]/, 1)[0]!.split("@", 1)[0]!.replace(/\/+$/, "").replace(/\.git$/i, "");
75
+ if (!host || !path || !path.includes("/")) return undefined;
76
+ return { host: host.toLowerCase(), path };
77
+ }
78
+
79
+ /** 判断 packages 项是否指向本插件(本地路径、npm 名称或 Git 仓库)。 */
80
+ function isSelfEntry(entry: unknown, self: SelfPackageIdentity, baseDir: string): boolean {
81
+ const source = entrySource(entry);
82
+ if (!source) return false;
83
+
84
+ const npmName = npmPackageName(source);
85
+ if (npmName) return npmName === self.name;
86
+
87
+ const repository = gitRepositoryIdentity(source);
88
+ if (repository) {
89
+ return repository.host === self.repository?.host && repository.path === self.repository.path;
90
+ }
91
+ if (/^(?:git:|https?:|ssh:)/i.test(source)) return false;
92
+
93
+ try {
94
+ // isAbsolute 同时认 POSIX 与 Windows 盘符两种形态(win32 join 不会用
95
+ // 绝对路径替换前缀,直接 join 会拼出畸形路径);normalize 统一斜杠方向。
96
+ const resolved = normalize(isAbsolute(source) ? source : join(baseDir, source));
97
+ return resolved === self.dir;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ function resolveSelfPackage(): SelfPackageIdentity | undefined {
104
+ // 从本文件位置逐级向上找声明了 pi.extensions 的 package.json。
105
+ // 源码场景下本文件在包根的 modules/tui/plugin/ 里(四层深),组装后的产物
106
+ // 可能在更浅的位置,因此向上多探几层而不是写死深度。
107
+ let dir = dirname(fileURLToPath(import.meta.url));
108
+ for (let i = 0; i < 8; i += 1) {
109
+ const manifest = join(dir, "package.json");
110
+ if (existsSync(manifest)) {
111
+ try {
112
+ const parsed = JSON.parse(readFileSync(manifest, "utf8")) as {
113
+ name?: unknown;
114
+ pi?: { extensions?: unknown[] };
115
+ repository?: string | { url?: unknown };
116
+ };
117
+ if (parsed.pi?.extensions && typeof parsed.name === "string") {
118
+ const repositoryUrl = typeof parsed.repository === "string"
119
+ ? parsed.repository
120
+ : (typeof parsed.repository?.url === "string" ? parsed.repository.url : undefined);
121
+ return {
122
+ dir: normalize(dir),
123
+ name: parsed.name,
124
+ repository: repositoryUrl ? gitRepositoryIdentity(repositoryUrl) : undefined,
125
+ };
126
+ }
127
+ } catch { /* 继续向上 */ }
128
+ }
129
+ const parent = dirname(dir);
130
+ if (parent === dir) return undefined;
131
+ dir = parent;
132
+ }
133
+ return undefined;
134
+ }
135
+
136
+ export interface PackageOrderResult {
137
+ adjusted: boolean;
138
+ reason?: string;
139
+ }
140
+
141
+ /** 检查并调整本插件在 packages 中的位置到首位;任何失败都不抛出。 */
142
+ export function ensureFirstPackage(agentDir: string, env: Readonly<Record<string, string | undefined>> = process.env): PackageOrderResult {
143
+ if (env.PI_TOOLKIT_KEEP_PACKAGE_ORDER === "1") return { adjusted: false, reason: "disabled-env" };
144
+ const settingsPath = join(agentDir, "settings.json");
145
+ try {
146
+ const raw = readFileSync(settingsPath, "utf8");
147
+ const settings = JSON.parse(raw) as {
148
+ packages?: unknown[];
149
+ piToolkitKeepPackageOrder?: boolean;
150
+ };
151
+ if (settings.piToolkitKeepPackageOrder === true) return { adjusted: false, reason: "disabled-settings" };
152
+ const packages = settings.packages;
153
+ if (!Array.isArray(packages) || packages.length < 2) return { adjusted: false };
154
+ const self = resolveSelfPackage();
155
+ if (!self) return { adjusted: false, reason: "self-not-found" };
156
+ const index = packages.findIndex((entry) => isSelfEntry(entry, self, agentDir));
157
+ if (index <= 0) return { adjusted: false };
158
+ const next = [...packages];
159
+ const [selfEntry] = next.splice(index, 1);
160
+ next.unshift(selfEntry);
161
+ settings.packages = next;
162
+ const tmpPath = `${settingsPath}.pi-toolkit.tmp`;
163
+ writeFileSync(tmpPath, JSON.stringify(settings, null, "\t") + "\n");
164
+ renameSync(tmpPath, settingsPath);
165
+ return { adjusted: true };
166
+ } catch {
167
+ return { adjusted: false, reason: "error" };
168
+ }
169
+ }
@@ -0,0 +1,203 @@
1
+ import type {
2
+ TUI,
3
+ TuiMainScreenRenderState,
4
+ } from "@earendil-works/pi-tui";
5
+
6
+ /** 终端输出抽象:测试可注入伪 TTY。 */
7
+ export type VisibleScreenOutput = {
8
+ isTTY?: boolean;
9
+ };
10
+
11
+ export const CLEAR_VISIBLE_SCREEN = "\x1b[2J\x1b[H";
12
+ export const DIRTY_RENDER_SUFFIX = "\x00";
13
+ /** 过渡揭示的稳定等待:超过该时长没有新 Editor 帧即视为结构稳定。 */
14
+ export const TRANSITION_SETTLE_MS = 32;
15
+
16
+ export function isInteractiveLaunch(): boolean {
17
+ if (!process.stdout.isTTY) return false;
18
+ const nonInteractiveFlags = new Set([
19
+ "-p",
20
+ "--print",
21
+ "--help",
22
+ "-h",
23
+ "--version",
24
+ "-v",
25
+ "--list-models",
26
+ "--export",
27
+ ]);
28
+ return process.argv.slice(2).every((arg) => !nonInteractiveFlags.has(arg) && !arg.startsWith("--mode"));
29
+ }
30
+
31
+ type MainScreenTui = TUI & {
32
+ captureRenderState(): TuiMainScreenRenderState;
33
+ restoreRenderState(state: TuiMainScreenRenderState): void;
34
+ };
35
+
36
+ export function isMainScreenTui(tui: TUI): tui is MainScreenTui {
37
+ return (
38
+ tui.mode === "regular" &&
39
+ typeof (tui as Partial<MainScreenTui>).captureRenderState === "function" &&
40
+ typeof (tui as Partial<MainScreenTui>).restoreRenderState === "function"
41
+ );
42
+ }
43
+
44
+ function hasImageLine(lines: readonly string[]): boolean {
45
+ return lines.some((line) => line.includes("\x1b_G") || line.includes("\x1b]1337;File="));
46
+ }
47
+
48
+ export function isSafeRenderState(tui: TUI, state: TuiMainScreenRenderState): boolean {
49
+ const width = tui.terminal.columns;
50
+ const height = tui.terminal.rows;
51
+ if (!Number.isSafeInteger(width) || width <= 0 || !Number.isSafeInteger(height) || height <= 0) {
52
+ return false;
53
+ }
54
+ if (state.previousWidth !== width || state.previousHeight !== height) return false;
55
+ if (!Array.isArray(state.previousLines) || state.previousLines.length === 0) return false;
56
+ if (hasImageLine(state.previousLines)) return false;
57
+ if (!Number.isSafeInteger(state.previousViewportTop) || state.previousViewportTop < 0) return false;
58
+ return state.previousViewportTop === Math.max(0, state.previousLines.length - height);
59
+ }
60
+
61
+ function isPristineRenderState(state: TuiMainScreenRenderState): boolean {
62
+ return (
63
+ Array.isArray(state.previousLines) &&
64
+ state.previousLines.length === 0 &&
65
+ state.previousWidth === 0 &&
66
+ state.previousHeight === 0 &&
67
+ state.previousViewportTop === 0 &&
68
+ state.cursorRow === 0 &&
69
+ state.hardwareCursorRow === 0 &&
70
+ state.maxLinesRendered === 0
71
+ );
72
+ }
73
+
74
+ function hasStableRenderState(
75
+ left: TuiMainScreenRenderState,
76
+ right: TuiMainScreenRenderState,
77
+ ): boolean {
78
+ if (
79
+ left.previousWidth !== right.previousWidth ||
80
+ left.previousHeight !== right.previousHeight ||
81
+ left.previousViewportTop !== right.previousViewportTop ||
82
+ left.previousLines.length !== right.previousLines.length
83
+ ) {
84
+ return false;
85
+ }
86
+ return left.previousLines.every((line, index) => line === right.previousLines[index]);
87
+ }
88
+
89
+ export function flashVisibleScreen(
90
+ tui: TUI,
91
+ output: VisibleScreenOutput = process.stdout,
92
+ ): TuiMainScreenRenderState | undefined {
93
+ if (!output.isTTY || !isMainScreenTui(tui) || tui.hasOverlay()) return undefined;
94
+ let state: TuiMainScreenRenderState;
95
+ try {
96
+ state = tui.captureRenderState();
97
+ } catch {
98
+ return undefined;
99
+ }
100
+ if (!isPristineRenderState(state) && !isSafeRenderState(tui, state)) return undefined;
101
+
102
+ try {
103
+ tui.terminal.write(CLEAR_VISIBLE_SCREEN);
104
+ return state;
105
+ } catch {
106
+ forceRestoreClearedScreen(tui);
107
+ return undefined;
108
+ }
109
+ }
110
+
111
+ function forceRestoreClearedScreen(tui: TUI): void {
112
+ try {
113
+ tui.renderNow(true);
114
+ } catch {
115
+ // 强制重画已经是闪屏后的最后恢复路径。
116
+ }
117
+ }
118
+
119
+ function captureStableMainScreenState(tui: TUI): TuiMainScreenRenderState | undefined {
120
+ try {
121
+ tui.renderNow(false);
122
+ if (!isMainScreenTui(tui) || tui.hasOverlay()) return undefined;
123
+ const firstState = tui.captureRenderState();
124
+ if (!isSafeRenderState(tui, firstState)) return undefined;
125
+
126
+ // 连续两次同步渲染必须完全稳定,否则不能保证恢复范围。
127
+ tui.renderNow(false);
128
+ const state = tui.captureRenderState();
129
+ if (!isSafeRenderState(tui, state) || !hasStableRenderState(firstState, state)) {
130
+ return undefined;
131
+ }
132
+ return state;
133
+ } catch {
134
+ return undefined;
135
+ }
136
+ }
137
+
138
+ function revealPreparedMainScreen(
139
+ tui: TUI,
140
+ deferred: boolean,
141
+ screenAlreadyCleared: boolean,
142
+ ): boolean {
143
+ try {
144
+ if (deferred) tui.requestRender(false);
145
+ else tui.renderNow(false);
146
+ return true;
147
+ } catch {
148
+ if (screenAlreadyCleared) forceRestoreClearedScreen(tui);
149
+ return false;
150
+ }
151
+ }
152
+
153
+ export function restoreVisibleMainScreen(
154
+ tui: TUI,
155
+ preClearState?: TuiMainScreenRenderState,
156
+ deferred = false,
157
+ ): boolean {
158
+ const screenAlreadyCleared = preClearState !== undefined;
159
+ const state = preClearState ?? captureStableMainScreenState(tui);
160
+ if (!state) {
161
+ if (screenAlreadyCleared) forceRestoreClearedScreen(tui);
162
+ return false;
163
+ }
164
+ if (!isMainScreenTui(tui)) {
165
+ if (screenAlreadyCleared) forceRestoreClearedScreen(tui);
166
+ return false;
167
+ }
168
+ if (screenAlreadyCleared && isPristineRenderState(state)) {
169
+ return revealPreparedMainScreen(tui, deferred, true);
170
+ }
171
+
172
+ const top = state.previousViewportTop;
173
+ const bottom = Math.min(state.previousLines.length, top + state.previousHeight);
174
+ if (bottom <= top) {
175
+ if (screenAlreadyCleared) forceRestoreClearedScreen(tui);
176
+ return false;
177
+ }
178
+ const previousLines = state.previousLines.map((line, index) =>
179
+ index >= top && index < bottom ? `${line}${DIRTY_RENDER_SUFFIX}` : line,
180
+ );
181
+
182
+ try {
183
+ tui.restoreRenderState({
184
+ ...state,
185
+ previousLines,
186
+ cursorRow: top,
187
+ hardwareCursorRow: top,
188
+ maxLinesRendered: 0,
189
+ });
190
+ } catch {
191
+ try {
192
+ tui.restoreRenderState(state);
193
+ } catch {
194
+ // renderer 状态未改变时无需继续恢复。
195
+ }
196
+ if (screenAlreadyCleared) forceRestoreClearedScreen(tui);
197
+ else tui.renderNow(false);
198
+ return false;
199
+ }
200
+
201
+ if (!screenAlreadyCleared) tui.terminal.write(CLEAR_VISIBLE_SCREEN);
202
+ return revealPreparedMainScreen(tui, deferred, screenAlreadyCleared);
203
+ }
@@ -0,0 +1,297 @@
1
+ // pi-tui 迁入 pi-toolkit 后的配置层(只解析,不写盘)。
2
+ //
3
+ // 两块来源:
4
+ // 1. 外观 / 状态 / 数据 / 高级四个子节来自 pi-toolkit.json 的 `modules.tui` 节。
5
+ // 写盘统一走 modules/tui/config.ts(骨架的深合并 + 剥敏感键 + 0600 原子写),
6
+ // 本文件只负责把这些子节规范化成 lifecycle 认识的结构。
7
+ // 2. 供应商查询凭据(queries[].apiKey、credentials 里的 AK/SK、githubDomain)
8
+ // 按用户裁决留在独立文件 <agentDir>/pi-tui.json:本模块只读,toolkit 永不写,
9
+ // 因此凭据永远不会进入 pi-toolkit.json。外观等其余键即使写在 pi-tui.json 里
10
+ // 也不再被读取(合并决策 8:不做旧配置迁移)。
11
+
12
+ import { readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import type { PiProviderAccessConfig } from "../adapter/provider-usage.ts";
15
+ import {
16
+ parseProviderCredentials,
17
+ parseProviderQueries,
18
+ ProviderConfigValidationError,
19
+ } from "../kernel/usage-core.ts";
20
+ import {
21
+ STATUS_PRESET_NAMES,
22
+ STATUS_SEGMENT_IDS,
23
+ type StatusPresetName,
24
+ type StatusSegmentId,
25
+ } from "../status/status-config.ts";
26
+
27
+ /** 供应商凭据独立文件名(相对 pi 的 agentDir) */
28
+ export const PI_TUI_CONFIG_FILENAME = "pi-tui.json";
29
+ export const PROVIDER_REFRESH_INTERVALS = [30_000, 60_000, 120_000, 300_000] as const;
30
+
31
+ export type ProviderRefreshMs = (typeof PROVIDER_REFRESH_INTERVALS)[number];
32
+ export type SpinnerMode = "default" | "static" | "hidden";
33
+
34
+ export interface PiTuiConfig {
35
+ appearance: {
36
+ editor: boolean;
37
+ header: boolean;
38
+ /** 底部状态栏总开关(原版无条件安装,迁入后补上) */
39
+ footer: boolean;
40
+ };
41
+ status: {
42
+ preset: StatusPresetName;
43
+ segments: StatusSegmentId[] | null;
44
+ };
45
+ data: {
46
+ providerRefreshMs: ProviderRefreshMs;
47
+ telemetry: boolean;
48
+ /** 只从独立文件 pi-tui.json 读取,不从 pi-toolkit.json 读取 */
49
+ providerAccess?: PiProviderAccessConfig;
50
+ };
51
+ advanced: {
52
+ spinner: SpinnerMode;
53
+ };
54
+ }
55
+
56
+ export interface LoadedPiTuiConfig {
57
+ config: PiTuiConfig;
58
+ warnings: string[];
59
+ }
60
+
61
+ export const DEFAULT_PI_TUI_CONFIG: Readonly<PiTuiConfig> = Object.freeze({
62
+ appearance: Object.freeze({ editor: true, header: true, footer: true }),
63
+ status: Object.freeze({ preset: "default", segments: null }),
64
+ data: Object.freeze({ providerRefreshMs: 60_000, telemetry: true }),
65
+ advanced: Object.freeze({ spinner: "default" }),
66
+ });
67
+
68
+ const PRESETS = new Set<StatusPresetName>(STATUS_PRESET_NAMES);
69
+ const SEGMENTS = new Set<StatusSegmentId>(STATUS_SEGMENT_IDS);
70
+ const REFRESH_INTERVALS = new Set<number>(PROVIDER_REFRESH_INTERVALS);
71
+ const SPINNER_MODES = new Set<SpinnerMode>(["default", "static", "hidden"]);
72
+
73
+ function isRecord(value: unknown): value is Record<string, unknown> {
74
+ return value !== null && typeof value === "object" && !Array.isArray(value);
75
+ }
76
+
77
+ function optionalRecord(value: unknown, field: string, warnings: string[]): Record<string, unknown> {
78
+ if (value === undefined) return {};
79
+ if (!isRecord(value)) {
80
+ warnings.push(`${field} 必须是对象,已忽略`);
81
+ return {};
82
+ }
83
+ return value;
84
+ }
85
+
86
+ function readBoolean(value: unknown, fallback: boolean, field: string, warnings: string[]): boolean {
87
+ if (value === undefined) return fallback;
88
+ if (typeof value !== "boolean") {
89
+ warnings.push(`${field} 必须是布尔值,已使用默认值`);
90
+ return fallback;
91
+ }
92
+ return value;
93
+ }
94
+
95
+ function readChoice<T extends string>(
96
+ value: unknown,
97
+ choices: ReadonlySet<T>,
98
+ fallback: T,
99
+ field: string,
100
+ warnings: string[],
101
+ ): T {
102
+ if (value === undefined) return fallback;
103
+ if (typeof value !== "string" || !choices.has(value as T)) {
104
+ warnings.push(`${field} 的值无效,已使用默认值`);
105
+ return fallback;
106
+ }
107
+ return value as T;
108
+ }
109
+
110
+ function readSegments(value: unknown, warnings: string[]): StatusSegmentId[] | null {
111
+ if (value === undefined || value === null) return null;
112
+ if (!Array.isArray(value)) {
113
+ warnings.push("status.segments 必须是数组或 null,已使用默认值");
114
+ return null;
115
+ }
116
+ const seen = new Set<StatusSegmentId>();
117
+ for (const segment of value) {
118
+ if (typeof segment !== "string" || !SEGMENTS.has(segment as StatusSegmentId)) {
119
+ warnings.push(`status.segments 包含无效值: ${String(segment)},已忽略`);
120
+ continue;
121
+ }
122
+ seen.add(segment as StatusSegmentId);
123
+ }
124
+ return [...seen];
125
+ }
126
+
127
+ function readStatusPresetValue(value: unknown, warnings: string[]): StatusPresetName {
128
+ if (value === "ascii") return "default";
129
+ return readChoice(value, PRESETS, "default", "status.preset", warnings);
130
+ }
131
+
132
+ function readRefreshMs(value: unknown, warnings: string[]): ProviderRefreshMs {
133
+ if (value === undefined) return DEFAULT_PI_TUI_CONFIG.data.providerRefreshMs;
134
+ if (typeof value !== "number" || !REFRESH_INTERVALS.has(value)) {
135
+ warnings.push("data.providerRefreshMs 的值无效,已使用默认值");
136
+ return DEFAULT_PI_TUI_CONFIG.data.providerRefreshMs;
137
+ }
138
+ return value as ProviderRefreshMs;
139
+ }
140
+
141
+ /**
142
+ * 把 pi-toolkit.json 的 `modules.tui` 节规范化成 PiTuiConfig。
143
+ * 逐字段校验:非法项记 warning 并回退默认值,绝不因单点配置错误拖垮整个模块。
144
+ */
145
+ export function parseTuiSection(section: Record<string, unknown> | undefined): LoadedPiTuiConfig {
146
+ const warnings: string[] = [];
147
+ const source = isRecord(section) ? section : {};
148
+ const appearance = optionalRecord(source.appearance, "appearance", warnings);
149
+ const status = optionalRecord(source.status, "status", warnings);
150
+ const data = optionalRecord(source.data, "data", warnings);
151
+ const advanced = optionalRecord(source.advanced, "advanced", warnings);
152
+ const defaults = DEFAULT_PI_TUI_CONFIG;
153
+
154
+ return {
155
+ config: {
156
+ appearance: {
157
+ editor: readBoolean(appearance.editor, defaults.appearance.editor, "appearance.editor", warnings),
158
+ header: readBoolean(appearance.header, defaults.appearance.header, "appearance.header", warnings),
159
+ footer: readBoolean(appearance.footer, defaults.appearance.footer, "appearance.footer", warnings),
160
+ },
161
+ status: {
162
+ preset: readStatusPresetValue(status.preset, warnings),
163
+ segments: readSegments(status.segments, warnings),
164
+ },
165
+ data: {
166
+ providerRefreshMs: readRefreshMs(data.providerRefreshMs, warnings),
167
+ telemetry: readBoolean(data.telemetry, defaults.data.telemetry, "data.telemetry", warnings),
168
+ },
169
+ advanced: {
170
+ spinner: readChoice(advanced.spinner, SPINNER_MODES, defaults.advanced.spinner, "advanced.spinner", warnings),
171
+ },
172
+ },
173
+ warnings,
174
+ };
175
+ }
176
+
177
+ // ─────────────────────────────────────────────────────────────
178
+ // 独立凭据文件 <agentDir>/pi-tui.json(只读)
179
+ // ─────────────────────────────────────────────────────────────
180
+
181
+ export function piTuiConfigPath(agentDir: string): string {
182
+ return join(agentDir, PI_TUI_CONFIG_FILENAME);
183
+ }
184
+
185
+ export interface LoadedProviderAccess {
186
+ /** 独立文件是否存在;菜单的"已配置/未配置"状态直接看它 */
187
+ readonly exists: boolean;
188
+ /** 解析出的供应商查询配置;文件缺失或无效时为 undefined */
189
+ readonly access?: PiProviderAccessConfig;
190
+ readonly warnings: readonly string[];
191
+ }
192
+
193
+ function readGithubDomain(value: unknown, warnings: string[]): string | undefined {
194
+ if (value === undefined) return undefined;
195
+ if (typeof value !== "string" || !value.trim()) {
196
+ warnings.push("data.providerAccess.githubDomain 必须是非空字符串,已忽略");
197
+ return undefined;
198
+ }
199
+ const domain = value.trim().toLowerCase();
200
+ try {
201
+ const url = new URL(`https://${domain}`);
202
+ if (url.hostname !== domain || url.port || url.pathname !== "/" || url.username || url.password) {
203
+ throw new Error();
204
+ }
205
+ return domain;
206
+ } catch {
207
+ warnings.push("data.providerAccess.githubDomain 必须是主机名,已忽略");
208
+ return undefined;
209
+ }
210
+ }
211
+
212
+ const VALIDATION_RULES = {
213
+ object: "必须是对象",
214
+ array: "必须是数组",
215
+ nonEmptyArray: "必须是非空数组",
216
+ nonEmptyString: "必须是非空字符串",
217
+ choice: "的值无效",
218
+ } as const;
219
+
220
+ function readProviderAccess(value: unknown, warnings: string[]): PiProviderAccessConfig | undefined {
221
+ if (!isRecord(value)) {
222
+ warnings.push("data.providerAccess 必须是对象,已忽略");
223
+ return undefined;
224
+ }
225
+ try {
226
+ const queries = parseProviderQueries(value.queries, "data.providerAccess.queries");
227
+ const githubDomain = readGithubDomain(value.githubDomain, warnings);
228
+ const credentials = parseProviderCredentials(value.credentials, "data.providerAccess.credentials");
229
+ return {
230
+ ...(queries ? { queries } : {}),
231
+ ...(Object.keys(credentials).length > 0 ? { credentials } : {}),
232
+ ...(githubDomain ? { githubDomain } : {}),
233
+ };
234
+ } catch (error) {
235
+ if (!(error instanceof ProviderConfigValidationError)) throw error;
236
+ warnings.push(`${error.field} ${VALIDATION_RULES[error.rule]},已忽略`);
237
+ return undefined;
238
+ }
239
+ }
240
+
241
+ /**
242
+ * 读独立凭据文件的数据源部分。除 `data.providerAccess` 外的键一律忽略。
243
+ * 只读:本文件永不写盘(凭据由用户自行维护)。
244
+ */
245
+ export function loadProviderAccessFile(agentDir: string): LoadedProviderAccess {
246
+ const path = piTuiConfigPath(agentDir);
247
+ let text: string;
248
+ try {
249
+ text = readFileSync(path, "utf8");
250
+ } catch (error) {
251
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
252
+ return { exists: false, warnings: [] };
253
+ }
254
+ return { exists: false, warnings: [`${path}: 读取失败(${(error as Error).message}),供应商查询按未配置处理`] };
255
+ }
256
+
257
+ const warnings: string[] = [];
258
+ let root: unknown;
259
+ try {
260
+ root = JSON.parse(text);
261
+ } catch (error) {
262
+ warnings.push(`${path}: 解析失败(${(error as Error).message}),供应商查询按未配置处理`);
263
+ return { exists: true, warnings };
264
+ }
265
+ if (!isRecord(root)) {
266
+ warnings.push(`${path}: 顶层不是对象,供应商查询按未配置处理`);
267
+ return { exists: true, warnings };
268
+ }
269
+ const data = root.data;
270
+ if (!isRecord(data) || data.providerAccess === undefined) {
271
+ return { exists: true, warnings };
272
+ }
273
+ return { exists: true, access: readProviderAccess(data.providerAccess, warnings), warnings };
274
+ }
275
+
276
+ /** 菜单里的只读状态行:检测到独立文件即视为已配置 */
277
+ export function providerAccessConfigured(agentDir: string): boolean {
278
+ return loadProviderAccessFile(agentDir).exists;
279
+ }
280
+
281
+ /** 没有模块节可用时的兜底:默认外观 + 独立文件里的供应商凭据 */
282
+ export function loadStandaloneTuiConfig(agentDir: string): LoadedPiTuiConfig {
283
+ const provider = loadProviderAccessFile(agentDir);
284
+ const defaults = DEFAULT_PI_TUI_CONFIG;
285
+ return {
286
+ config: {
287
+ appearance: { ...defaults.appearance },
288
+ status: { preset: defaults.status.preset, segments: null },
289
+ data: {
290
+ ...defaults.data,
291
+ ...(provider.access ? { providerAccess: provider.access } : {}),
292
+ },
293
+ advanced: { ...defaults.advanced },
294
+ },
295
+ warnings: [...provider.warnings],
296
+ };
297
+ }