@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.
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/assembler.ts +127 -0
- package/config.ts +201 -0
- package/i18n.ts +693 -0
- package/index.ts +99 -0
- package/menu/items.ts +264 -0
- package/menu/panels.ts +91 -0
- package/menu/settings-list.ts +84 -0
- package/menu/theme.ts +31 -0
- package/menu/toolkit-menu.ts +139 -0
- package/module.ts +156 -0
- package/modules/eyes/chain.ts +173 -0
- package/modules/eyes/config.ts +319 -0
- package/modules/eyes/index.ts +161 -0
- package/modules/eyes/menu.ts +828 -0
- package/modules/eyes/pi-model-backend.ts +245 -0
- package/modules/eyes/resilience.ts +161 -0
- package/modules/eyes/vision-bridge.ts +232 -0
- package/modules/eyes/vision-cache.ts +86 -0
- package/modules/eyes/vision-json.ts +19 -0
- package/modules/eyes/vision-preprocess.ts +192 -0
- package/modules/eyes/vision-probe.ts +21 -0
- package/modules/eyes/vision-prompt.ts +90 -0
- package/modules/eyes/vision-tool.ts +59 -0
- package/modules/eyes/vision-types.ts +33 -0
- package/modules/index.ts +10 -0
- package/modules/subagents/agents/researcher.md +51 -0
- package/modules/subagents/agents/scout.md +40 -0
- package/modules/subagents/agents/worker.md +79 -0
- package/modules/subagents/config.json.example +6 -0
- package/modules/subagents/config.ts +144 -0
- package/modules/subagents/index.ts +91 -0
- package/modules/subagents/menu.ts +388 -0
- package/modules/subagents/src/activity.ts +511 -0
- package/modules/subagents/src/agents.ts +126 -0
- package/modules/subagents/src/command.ts +37 -0
- package/modules/subagents/src/dependencies.ts +246 -0
- package/modules/subagents/src/diagnostics.ts +13 -0
- package/modules/subagents/src/display.ts +94 -0
- package/modules/subagents/src/headless.ts +342 -0
- package/modules/subagents/src/herdr.ts +203 -0
- package/modules/subagents/src/index.ts +2798 -0
- package/modules/subagents/src/inspect-tool.ts +839 -0
- package/modules/subagents/src/launch-config.ts +196 -0
- package/modules/subagents/src/layout-budget.ts +26 -0
- package/modules/subagents/src/list-tool.ts +292 -0
- package/modules/subagents/src/message-tool.ts +808 -0
- package/modules/subagents/src/names.ts +17 -0
- package/modules/subagents/src/pane-layout.ts +49 -0
- package/modules/subagents/src/params.ts +145 -0
- package/modules/subagents/src/renderers.ts +181 -0
- package/modules/subagents/src/result.ts +43 -0
- package/modules/subagents/src/retention.ts +114 -0
- package/modules/subagents/src/route-error.ts +212 -0
- package/modules/subagents/src/routing.ts +235 -0
- package/modules/subagents/src/runtime-registry.ts +159 -0
- package/modules/subagents/src/session.ts +801 -0
- package/modules/subagents/src/status.ts +513 -0
- package/modules/subagents/src/stop-tool.ts +235 -0
- package/modules/subagents/src/subagent-done.ts +590 -0
- package/modules/subagents/src/subagent-tool.ts +1155 -0
- package/modules/subagents/src/surface.ts +355 -0
- package/modules/subagents/src/team-dispatch-tool.ts +219 -0
- package/modules/subagents/src/team.ts +232 -0
- package/modules/subagents/src/tmux.ts +210 -0
- package/modules/subagents/src/tools/safe-bash.ts +72 -0
- package/modules/subagents/src/types.ts +131 -0
- package/modules/tui/adapter/provider-usage.ts +143 -0
- package/modules/tui/config.ts +50 -0
- package/modules/tui/index.ts +120 -0
- package/modules/tui/kernel/pkg/shared/grok-subscription.ts +169 -0
- package/modules/tui/kernel/pkg/shared/official-subscription.ts +237 -0
- package/modules/tui/kernel/pkg/shared/provider-catalog.ts +367 -0
- package/modules/tui/kernel/pkg/shared/provider-contracts.ts +150 -0
- package/modules/tui/kernel/pkg/shared/provider-display.ts +55 -0
- package/modules/tui/kernel/pkg/shared/provider-parsers.ts +171 -0
- package/modules/tui/kernel/pkg/shared/volcengine.ts +191 -0
- package/modules/tui/kernel/pkg/shared/zhipu.ts +149 -0
- package/modules/tui/kernel/pkg/usage-core/index.ts +335 -0
- package/modules/tui/kernel/pkg/usage-core/provider-routes.ts +187 -0
- package/modules/tui/kernel/pkg/usage-node/index.ts +635 -0
- package/modules/tui/kernel/pkg/usage-node/provider-usage.ts +388 -0
- package/modules/tui/kernel/usage-core.ts +2 -0
- package/modules/tui/kernel/usage-node.ts +2 -0
- package/modules/tui/menu.ts +418 -0
- package/modules/tui/plugin/editor.ts +396 -0
- package/modules/tui/plugin/footer.ts +161 -0
- package/modules/tui/plugin/index.ts +30 -0
- package/modules/tui/plugin/lifecycle.ts +637 -0
- package/modules/tui/plugin/package-order.ts +169 -0
- package/modules/tui/plugin/screen-transition.ts +203 -0
- package/modules/tui/plugin/settings-config.ts +297 -0
- package/modules/tui/plugin/status-sources.ts +49 -0
- package/modules/tui/plugin/transition-gate.ts +261 -0
- package/modules/tui/renderer/custom-header.ts +157 -0
- package/modules/tui/renderer/editor.ts +70 -0
- package/modules/tui/renderer/header.ts +72 -0
- package/modules/tui/renderer/icons.ts +149 -0
- package/modules/tui/renderer/pi-installer-logo.ts +194 -0
- package/modules/tui/status/auto-compaction.ts +67 -0
- package/modules/tui/status/project-status.ts +655 -0
- package/modules/tui/status/provider-status.ts +120 -0
- package/modules/tui/status/runtime-status.ts +307 -0
- package/modules/tui/status/session-status.ts +325 -0
- package/modules/tui/status/status-config.ts +95 -0
- package/modules/tui/status/status-segments.ts +263 -0
- package/modules/tui/status/turn-telemetry.ts +466 -0
- package/modules/tui/themes/LICENSE.pi-themes-bundle +21 -0
- package/modules/tui/themes/UPSTREAM.md +7 -0
- package/modules/tui/themes/catppuccin-latte.json +80 -0
- package/modules/tui/themes/catppuccin-mocha.json +79 -0
- package/modules/tui/themes/crimson-noir.json +85 -0
- package/modules/tui/themes/dracula.json +79 -0
- package/modules/tui/themes/everforest-dark.json +85 -0
- package/modules/tui/themes/gruvbox-dark.json +85 -0
- package/modules/tui/themes/gruvbox-light.json +85 -0
- package/modules/tui/themes/matrix.json +85 -0
- package/modules/tui/themes/nord.json +85 -0
- package/modules/tui/themes/one-dark.json +85 -0
- package/modules/tui/themes/rose-pine-dawn.json +85 -0
- package/modules/tui/themes/rose-pine.json +85 -0
- package/modules/tui/themes/solarized-dark.json +85 -0
- package/modules/tui/themes/solarized-light.json +85 -0
- package/modules/tui/themes/tokyo-night-storm.json +79 -0
- package/modules/tui/themes/tokyo-night.json +79 -0
- package/package.json +28 -0
- package/services.ts +38 -0
- package/toolkit.ts +147 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
import { posix, win32 } from "node:path";
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ReadonlyFooterDataProvider,
|
|
5
|
+
Theme,
|
|
6
|
+
ThemeColor,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import {
|
|
10
|
+
formatLeadingIcon,
|
|
11
|
+
resolveGlyphs,
|
|
12
|
+
type IconGlyphs,
|
|
13
|
+
} from "../renderer/icons.ts";
|
|
14
|
+
import type { RuntimeStatusSnapshot } from "./runtime-status.ts";
|
|
15
|
+
import {
|
|
16
|
+
durationStatusColor,
|
|
17
|
+
formatElapsed,
|
|
18
|
+
type TurnTimerSnapshot,
|
|
19
|
+
} from "./status-segments.ts";
|
|
20
|
+
|
|
21
|
+
const SEPARATOR = " · ";
|
|
22
|
+
const MIN_SEGMENT_WIDTH = 8;
|
|
23
|
+
const DEFAULT_GLYPHS = resolveGlyphs("unicode");
|
|
24
|
+
const DEFAULT_PROJECT_STATUS_SEGMENTS: readonly ProjectStatusSegmentId[] = ["project", "git"];
|
|
25
|
+
const SAFE_SGR_SEQUENCE = /\x1b\[[0-9:;]*m/g;
|
|
26
|
+
const STYLE_MARKER_START = "\ufdd0";
|
|
27
|
+
const STYLE_MARKER_END = "\ufdd1";
|
|
28
|
+
const STYLE_MARKER_SEQUENCE = /\ufdd0(\d+)\ufdd1/g;
|
|
29
|
+
|
|
30
|
+
export type GitRefreshState = "idle" | "loading" | "ready" | "error";
|
|
31
|
+
export type ProjectStatusSegmentId = "project" | "git" | "duration" | "runtime";
|
|
32
|
+
|
|
33
|
+
export interface GitStatusCodeCount {
|
|
34
|
+
/** Git porcelain v2 的原始可见状态码;普通记录为 XY,未跟踪记录为 ?。 */
|
|
35
|
+
code: string;
|
|
36
|
+
count: number;
|
|
37
|
+
unmerged: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface GitStatusDetails {
|
|
41
|
+
branch: string | null;
|
|
42
|
+
detached: boolean;
|
|
43
|
+
unborn: boolean;
|
|
44
|
+
oid?: string;
|
|
45
|
+
exactTag?: string;
|
|
46
|
+
upstream?: string;
|
|
47
|
+
ahead: number;
|
|
48
|
+
behind: number;
|
|
49
|
+
stashed: number;
|
|
50
|
+
statusCodes: GitStatusCodeCount[];
|
|
51
|
+
dirty: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ProjectStatusSnapshot extends Partial<GitStatusDetails> {
|
|
55
|
+
cwd: string;
|
|
56
|
+
branch: string | null;
|
|
57
|
+
refreshState?: GitRefreshState;
|
|
58
|
+
runtime?: RuntimeStatusSnapshot | null;
|
|
59
|
+
duration?: TurnTimerSnapshot;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type GitStatusQuery = (
|
|
63
|
+
cwd: string,
|
|
64
|
+
signal: AbortSignal,
|
|
65
|
+
) => Promise<GitStatusDetails | undefined>;
|
|
66
|
+
|
|
67
|
+
/** 命令失败不向界面抛错,返回 undefined 让控制器保留最后成功快照。 */
|
|
68
|
+
export function createGitStatusQuery(exec: ExtensionAPI["exec"]): GitStatusQuery {
|
|
69
|
+
return async (cwd, signal) => {
|
|
70
|
+
try {
|
|
71
|
+
const result = await exec(
|
|
72
|
+
"git",
|
|
73
|
+
[
|
|
74
|
+
"--no-optional-locks",
|
|
75
|
+
"status",
|
|
76
|
+
"--porcelain=v2",
|
|
77
|
+
"--branch",
|
|
78
|
+
"--show-stash",
|
|
79
|
+
"--untracked-files=normal",
|
|
80
|
+
"--ignore-submodules=dirty",
|
|
81
|
+
],
|
|
82
|
+
{ cwd, signal, timeout: 2000 },
|
|
83
|
+
);
|
|
84
|
+
if (result.killed || result.code !== 0) return undefined;
|
|
85
|
+
const status = parseGitStatusV2(result.stdout);
|
|
86
|
+
if (!status.detached || !status.oid) return status;
|
|
87
|
+
|
|
88
|
+
// 绑定本次 status 的 oid,避免两次命令之间 HEAD 移动导致 tag 串配。
|
|
89
|
+
const tags = await exec(
|
|
90
|
+
"git",
|
|
91
|
+
["--no-optional-locks", "tag", "--points-at", status.oid, "--sort=refname"],
|
|
92
|
+
{ cwd, signal, timeout: 2000 },
|
|
93
|
+
);
|
|
94
|
+
if (tags.killed || tags.code !== 0) return status;
|
|
95
|
+
const exactTag = tags.stdout
|
|
96
|
+
.split("\n")
|
|
97
|
+
.map((tag) => tag.trim())
|
|
98
|
+
.filter(Boolean)[0];
|
|
99
|
+
return exactTag ? { ...status, exactTag } : status;
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type ProjectStatusRole =
|
|
107
|
+
| "path"
|
|
108
|
+
| "separator"
|
|
109
|
+
| "branch"
|
|
110
|
+
| "branch-pending"
|
|
111
|
+
| "changed"
|
|
112
|
+
| "untracked"
|
|
113
|
+
| "ahead"
|
|
114
|
+
| "behind"
|
|
115
|
+
| "duration"
|
|
116
|
+
| "runtime";
|
|
117
|
+
|
|
118
|
+
export interface ProjectStatusPart {
|
|
119
|
+
text: string;
|
|
120
|
+
role: ProjectStatusRole;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const ROLE_COLORS: Readonly<Record<ProjectStatusRole, ThemeColor>> = {
|
|
124
|
+
path: "text",
|
|
125
|
+
separator: "text",
|
|
126
|
+
branch: "accent",
|
|
127
|
+
"branch-pending": "dim",
|
|
128
|
+
changed: "success",
|
|
129
|
+
untracked: "error",
|
|
130
|
+
ahead: "warning",
|
|
131
|
+
behind: "warning",
|
|
132
|
+
duration: "dim",
|
|
133
|
+
runtime: "success",
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export function sanitizeSingleLine(text: string): string {
|
|
137
|
+
return stripTerminalSequences(text)
|
|
138
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
139
|
+
.replace(/ +/g, " ")
|
|
140
|
+
.trim();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 扩展状态允许保留自身的 SGR 颜色,但不能把光标、清屏、标题等终端控制序列
|
|
145
|
+
* 带进主界面。先暂存白名单内的 SGR,再使用 Pi TUI 的完整终端序列清理器。
|
|
146
|
+
*/
|
|
147
|
+
export function sanitizeStyledSingleLine(text: string): string {
|
|
148
|
+
const styles: string[] = [];
|
|
149
|
+
const input = text.replaceAll(STYLE_MARKER_START, "").replaceAll(STYLE_MARKER_END, "");
|
|
150
|
+
const masked = input.replace(SAFE_SGR_SEQUENCE, (sequence) => {
|
|
151
|
+
const index = styles.push(sequence) - 1;
|
|
152
|
+
return `${STYLE_MARKER_START}${index}${STYLE_MARKER_END}`;
|
|
153
|
+
});
|
|
154
|
+
const restored = sanitizeSingleLine(masked).replace(
|
|
155
|
+
STYLE_MARKER_SEQUENCE,
|
|
156
|
+
(_match, index: string) => styles[Number(index)] ?? "",
|
|
157
|
+
);
|
|
158
|
+
if (!stripTerminalSequences(restored).trim()) return "";
|
|
159
|
+
return styles.length > 0 ? `${restored}\x1b[0m` : restored;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function formatProjectPath(cwd: string, home?: string): string {
|
|
163
|
+
const safeCwd = sanitizeSingleLine(cwd).replaceAll("\\", "/") || ".";
|
|
164
|
+
if (!home) return safeCwd;
|
|
165
|
+
|
|
166
|
+
const pathApi = win32.isAbsolute(cwd) || win32.isAbsolute(home) ? win32 : posix;
|
|
167
|
+
const resolvedCwd = pathApi.resolve(cwd);
|
|
168
|
+
const resolvedHome = pathApi.resolve(home);
|
|
169
|
+
const relativeToHome = pathApi.relative(resolvedHome, resolvedCwd);
|
|
170
|
+
const isInsideHome =
|
|
171
|
+
relativeToHome === "" ||
|
|
172
|
+
(relativeToHome !== ".." &&
|
|
173
|
+
!relativeToHome.startsWith(`..${pathApi.sep}`) &&
|
|
174
|
+
!pathApi.isAbsolute(relativeToHome));
|
|
175
|
+
if (!isInsideHome) return safeCwd;
|
|
176
|
+
const displayRelative = sanitizeSingleLine(relativeToHome).replaceAll("\\", "/");
|
|
177
|
+
return relativeToHome === "" ? "~" : `~/${displayRelative}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function truncateFromStart(text: string, width: number): string {
|
|
181
|
+
if (width <= 0) return "";
|
|
182
|
+
if (visibleWidth(text) <= width) return text;
|
|
183
|
+
if (width === 1) return "…";
|
|
184
|
+
|
|
185
|
+
let suffix = "";
|
|
186
|
+
for (const character of Array.from(text).reverse()) {
|
|
187
|
+
if (visibleWidth(`…${character}${suffix}`) > width) break;
|
|
188
|
+
suffix = `${character}${suffix}`;
|
|
189
|
+
}
|
|
190
|
+
return `…${suffix}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function truncateFromEnd(text: string, width: number): string {
|
|
194
|
+
if (width <= 0) return "";
|
|
195
|
+
if (visibleWidth(text) <= width) return text;
|
|
196
|
+
if (width === 1) return "…";
|
|
197
|
+
|
|
198
|
+
let prefix = "";
|
|
199
|
+
for (const character of Array.from(text)) {
|
|
200
|
+
if (visibleWidth(`${prefix}${character}…`) > width) break;
|
|
201
|
+
prefix += character;
|
|
202
|
+
}
|
|
203
|
+
return `${prefix}…`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function fitProjectPath(path: string, width: number, glyphs: IconGlyphs): string {
|
|
207
|
+
const prefix = formatLeadingIcon(glyphs.project);
|
|
208
|
+
const prefixWidth = visibleWidth(prefix);
|
|
209
|
+
if (width <= prefixWidth) return truncateFromEnd(glyphs.project, width);
|
|
210
|
+
return `${prefix}${truncateFromStart(path, width - prefixWidth)}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function partsWidth(parts: readonly ProjectStatusPart[]): number {
|
|
214
|
+
return parts.reduce((width, part) => width + visibleWidth(part.text), 0);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function branchName(snapshot: ProjectStatusSnapshot): string | null {
|
|
218
|
+
if (snapshot.detached || snapshot.branch === "detached" || snapshot.branch === "(detached)") {
|
|
219
|
+
return "(detached)";
|
|
220
|
+
}
|
|
221
|
+
const branch = sanitizeSingleLine(snapshot.branch ?? "");
|
|
222
|
+
return branch || null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function projectGitParts(
|
|
226
|
+
snapshot: ProjectStatusSnapshot,
|
|
227
|
+
glyphs: IconGlyphs,
|
|
228
|
+
): ProjectStatusPart[] {
|
|
229
|
+
const branch = branchName(snapshot);
|
|
230
|
+
// Git 未就绪时保留弱化占位:Footer 结构首帧定型,查询完成后原地替换,
|
|
231
|
+
// 避免揭示帧与补帧之间的结构跳变(会话 Token 零值占位采用同一策略)。
|
|
232
|
+
// 仅在控制器处于查询流程(loading,含首轮)时占位;未启用 Git 段不渲染。
|
|
233
|
+
if (!branch) {
|
|
234
|
+
if (snapshot.refreshState === "loading" || snapshot.refreshState === "idle") {
|
|
235
|
+
return [{ text: `${glyphs.gitBranch} …`, role: "branch-pending" }];
|
|
236
|
+
}
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let changed = 0;
|
|
241
|
+
let untracked = 0;
|
|
242
|
+
for (const entry of snapshot.statusCodes ?? []) {
|
|
243
|
+
if (entry.code === "?") untracked += entry.count;
|
|
244
|
+
else changed += entry.count;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const parts: ProjectStatusPart[] = [
|
|
248
|
+
{ text: `${glyphs.gitBranch} ${branch}`, role: "branch" },
|
|
249
|
+
];
|
|
250
|
+
if (changed > 0) parts.push({ text: ` ${glyphs.changed}${changed}`, role: "changed" });
|
|
251
|
+
if (untracked > 0) parts.push({ text: ` ${glyphs.untracked}${untracked}`, role: "untracked" });
|
|
252
|
+
if ((snapshot.ahead ?? 0) > 0) {
|
|
253
|
+
parts.push({ text: ` ${glyphs.ahead}${snapshot.ahead}`, role: "ahead" });
|
|
254
|
+
}
|
|
255
|
+
if ((snapshot.behind ?? 0) > 0) {
|
|
256
|
+
parts.push({ text: ` ${glyphs.behind}${snapshot.behind}`, role: "behind" });
|
|
257
|
+
}
|
|
258
|
+
return parts;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function projectRuntimeParts(
|
|
262
|
+
snapshot: ProjectStatusSnapshot,
|
|
263
|
+
glyphs: IconGlyphs,
|
|
264
|
+
): ProjectStatusPart[] {
|
|
265
|
+
if (!snapshot.runtime) return [];
|
|
266
|
+
const name = sanitizeSingleLine(snapshot.runtime.name);
|
|
267
|
+
if (!name) return [];
|
|
268
|
+
const version = sanitizeSingleLine(snapshot.runtime.version ?? "");
|
|
269
|
+
return [{
|
|
270
|
+
text: `${glyphs.runtime} ${name}${version ? ` ${version}` : ""}`,
|
|
271
|
+
role: "runtime",
|
|
272
|
+
}];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function projectDurationParts(
|
|
276
|
+
snapshot: ProjectStatusSnapshot,
|
|
277
|
+
glyphs: IconGlyphs,
|
|
278
|
+
): ProjectStatusPart[] {
|
|
279
|
+
if (!snapshot.duration) return [];
|
|
280
|
+
return [{
|
|
281
|
+
text: `${glyphs.duration} ${formatElapsed(snapshot.duration.elapsedMs)}`,
|
|
282
|
+
role: "duration",
|
|
283
|
+
}];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function fitGitParts(
|
|
287
|
+
snapshot: ProjectStatusSnapshot,
|
|
288
|
+
width: number,
|
|
289
|
+
glyphs: IconGlyphs,
|
|
290
|
+
): ProjectStatusPart[] {
|
|
291
|
+
if (width <= 0) return [];
|
|
292
|
+
const full = projectGitParts(snapshot, glyphs);
|
|
293
|
+
if (partsWidth(full) <= width) return full;
|
|
294
|
+
|
|
295
|
+
const branch = full[0];
|
|
296
|
+
if (!branch) return [];
|
|
297
|
+
const suffix = full.slice(1);
|
|
298
|
+
while (
|
|
299
|
+
suffix.length > 0 &&
|
|
300
|
+
visibleWidth(`${glyphs.gitBranch} …`) + partsWidth(suffix) > width
|
|
301
|
+
) {
|
|
302
|
+
suffix.pop();
|
|
303
|
+
}
|
|
304
|
+
const branchWidth = Math.max(1, width - partsWidth(suffix));
|
|
305
|
+
return [{ ...branch, text: truncateFromEnd(branch.text, branchWidth) }, ...suffix];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function joinProjectStatusGroups(
|
|
309
|
+
path: ProjectStatusPart[],
|
|
310
|
+
git: ProjectStatusPart[],
|
|
311
|
+
duration: ProjectStatusPart[],
|
|
312
|
+
runtime: ProjectStatusPart[],
|
|
313
|
+
order: readonly ProjectStatusSegmentId[],
|
|
314
|
+
): ProjectStatusPart[] {
|
|
315
|
+
const groups = order
|
|
316
|
+
.map((segment) => {
|
|
317
|
+
if (segment === "project") return path;
|
|
318
|
+
if (segment === "git") return git;
|
|
319
|
+
if (segment === "duration") return duration;
|
|
320
|
+
return runtime;
|
|
321
|
+
})
|
|
322
|
+
.filter((group) => group.length > 0);
|
|
323
|
+
if (groups.length === 0) return [];
|
|
324
|
+
return groups.flatMap((group, index) => index === 0
|
|
325
|
+
? group
|
|
326
|
+
: [{ text: SEPARATOR, role: "separator" } as ProjectStatusPart, ...group]);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function layoutProjectStatusLine(
|
|
330
|
+
snapshot: ProjectStatusSnapshot,
|
|
331
|
+
width: number,
|
|
332
|
+
home = process.env.HOME ?? process.env.USERPROFILE,
|
|
333
|
+
glyphs: IconGlyphs = DEFAULT_GLYPHS,
|
|
334
|
+
segments: readonly ProjectStatusSegmentId[] = DEFAULT_PROJECT_STATUS_SEGMENTS,
|
|
335
|
+
): ProjectStatusPart[] {
|
|
336
|
+
if (width <= 0) return [];
|
|
337
|
+
const order = [...new Set(segments)].filter(
|
|
338
|
+
(segment): segment is ProjectStatusSegmentId =>
|
|
339
|
+
segment === "project" || segment === "git" || segment === "duration" || segment === "runtime",
|
|
340
|
+
);
|
|
341
|
+
const showPath = order.includes("project");
|
|
342
|
+
const showGit = order.includes("git");
|
|
343
|
+
const showDuration = order.includes("duration");
|
|
344
|
+
const showRuntime = order.includes("runtime");
|
|
345
|
+
if (!showPath && !showGit && !showDuration && !showRuntime) return [];
|
|
346
|
+
const path = formatProjectPath(snapshot.cwd, home);
|
|
347
|
+
const projectPath = `${formatLeadingIcon(glyphs.project)}${path}`;
|
|
348
|
+
const fullPath: ProjectStatusPart[] = showPath ? [{ text: projectPath, role: "path" }] : [];
|
|
349
|
+
const fullGit = showGit ? projectGitParts(snapshot, glyphs) : [];
|
|
350
|
+
const fullDuration = showDuration ? projectDurationParts(snapshot, glyphs) : [];
|
|
351
|
+
const fullRuntime = showRuntime ? projectRuntimeParts(snapshot, glyphs) : [];
|
|
352
|
+
if (!showPath && fullGit.length === 0 && fullDuration.length === 0) {
|
|
353
|
+
return fullRuntime.length > 0
|
|
354
|
+
? [{ ...fullRuntime[0]!, text: truncateFromEnd(fullRuntime[0]!.text, width) }]
|
|
355
|
+
: [];
|
|
356
|
+
}
|
|
357
|
+
const allGroups = joinProjectStatusGroups(fullPath, fullGit, fullDuration, fullRuntime, order);
|
|
358
|
+
if (partsWidth(allGroups) <= width) return allGroups;
|
|
359
|
+
|
|
360
|
+
if (fullDuration.length > 0) {
|
|
361
|
+
const durationWidth = partsWidth(fullDuration);
|
|
362
|
+
const baseWidth = width - durationWidth - visibleWidth(SEPARATOR);
|
|
363
|
+
if (baseWidth >= MIN_SEGMENT_WIDTH) {
|
|
364
|
+
const baseOrder = order.filter(
|
|
365
|
+
(segment): segment is ProjectStatusSegmentId => segment === "project" || segment === "git",
|
|
366
|
+
);
|
|
367
|
+
const base = layoutProjectStatusLine(
|
|
368
|
+
{ ...snapshot, duration: undefined, runtime: undefined },
|
|
369
|
+
baseWidth,
|
|
370
|
+
home,
|
|
371
|
+
glyphs,
|
|
372
|
+
baseOrder,
|
|
373
|
+
);
|
|
374
|
+
if (base.length > 0) {
|
|
375
|
+
return [...base, { text: SEPARATOR, role: "separator" }, ...fullDuration];
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const reducedOrder = order.filter((segment) => segment !== "duration");
|
|
381
|
+
if (fullGit.length === 0 && showPath) {
|
|
382
|
+
return [{ text: fitProjectPath(path, width, glyphs), role: "path" }];
|
|
383
|
+
}
|
|
384
|
+
if (!showPath) return fitGitParts(snapshot, width, glyphs);
|
|
385
|
+
|
|
386
|
+
const fullWidth = visibleWidth(projectPath) + visibleWidth(SEPARATOR) + partsWidth(fullGit);
|
|
387
|
+
if (fullWidth <= width) {
|
|
388
|
+
return joinProjectStatusGroups(fullPath, fullGit, [], [], reducedOrder);
|
|
389
|
+
}
|
|
390
|
+
const minProjectWidth = MIN_SEGMENT_WIDTH + visibleWidth(formatLeadingIcon(glyphs.project));
|
|
391
|
+
if (width < visibleWidth(SEPARATOR) + MIN_SEGMENT_WIDTH * 2) {
|
|
392
|
+
return [{ text: fitProjectPath(path, width, glyphs), role: "path" }];
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const contentWidth = width - visibleWidth(SEPARATOR);
|
|
396
|
+
const gitBudget = contentWidth - minProjectWidth;
|
|
397
|
+
const fittedGit = fitGitParts(snapshot, gitBudget, glyphs);
|
|
398
|
+
const pathWidth = contentWidth - partsWidth(fittedGit);
|
|
399
|
+
return joinProjectStatusGroups(
|
|
400
|
+
[{ text: fitProjectPath(path, pathWidth, glyphs), role: "path" }],
|
|
401
|
+
fittedGit,
|
|
402
|
+
[],
|
|
403
|
+
[],
|
|
404
|
+
reducedOrder,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export function formatProjectStatusLine(
|
|
409
|
+
snapshot: ProjectStatusSnapshot,
|
|
410
|
+
width: number,
|
|
411
|
+
home = process.env.HOME ?? process.env.USERPROFILE,
|
|
412
|
+
glyphs: IconGlyphs = DEFAULT_GLYPHS,
|
|
413
|
+
segments: readonly ProjectStatusSegmentId[] = DEFAULT_PROJECT_STATUS_SEGMENTS,
|
|
414
|
+
): string {
|
|
415
|
+
return layoutProjectStatusLine(snapshot, width, home, glyphs, segments)
|
|
416
|
+
.map((part) => part.text)
|
|
417
|
+
.join("");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function renderProjectStatusLine(
|
|
421
|
+
snapshot: ProjectStatusSnapshot,
|
|
422
|
+
width: number,
|
|
423
|
+
theme: Theme,
|
|
424
|
+
home = process.env.HOME ?? process.env.USERPROFILE,
|
|
425
|
+
glyphs: IconGlyphs = DEFAULT_GLYPHS,
|
|
426
|
+
segments: readonly ProjectStatusSegmentId[] = DEFAULT_PROJECT_STATUS_SEGMENTS,
|
|
427
|
+
): string {
|
|
428
|
+
return layoutProjectStatusLine(snapshot, width, home, glyphs, segments)
|
|
429
|
+
.map((part) => {
|
|
430
|
+
if (part.role === "path" && part.text.startsWith(glyphs.project)) {
|
|
431
|
+
return `${theme.fg("accent", glyphs.project)}${theme.fg(
|
|
432
|
+
ROLE_COLORS.path,
|
|
433
|
+
part.text.slice(glyphs.project.length),
|
|
434
|
+
)}`;
|
|
435
|
+
}
|
|
436
|
+
return theme.fg(
|
|
437
|
+
part.role === "duration"
|
|
438
|
+
? durationStatusColor(snapshot.duration?.state ?? "idle")
|
|
439
|
+
: ROLE_COLORS[part.role],
|
|
440
|
+
part.text,
|
|
441
|
+
);
|
|
442
|
+
})
|
|
443
|
+
.join("");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function parseGitStatusV2(stdout: string, exactTag?: string): GitStatusDetails {
|
|
447
|
+
const status: GitStatusDetails = {
|
|
448
|
+
branch: null,
|
|
449
|
+
detached: false,
|
|
450
|
+
unborn: false,
|
|
451
|
+
ahead: 0,
|
|
452
|
+
behind: 0,
|
|
453
|
+
stashed: 0,
|
|
454
|
+
statusCodes: [],
|
|
455
|
+
dirty: false,
|
|
456
|
+
};
|
|
457
|
+
const statusCodes = new Map<string, GitStatusCodeCount>();
|
|
458
|
+
const addStatusCode = (code: string, unmerged: boolean): void => {
|
|
459
|
+
const previous = statusCodes.get(code);
|
|
460
|
+
if (previous) {
|
|
461
|
+
previous.count += 1;
|
|
462
|
+
previous.unmerged ||= unmerged;
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
statusCodes.set(code, { code, count: 1, unmerged });
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
for (const line of stdout.split("\n")) {
|
|
469
|
+
if (line.startsWith("# branch.oid ")) {
|
|
470
|
+
const value = line.slice("# branch.oid ".length).trim();
|
|
471
|
+
if (value === "(initial)") status.unborn = true;
|
|
472
|
+
else if (value) status.oid = value;
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (line.startsWith("# branch.head ")) {
|
|
476
|
+
const value = sanitizeSingleLine(line.slice("# branch.head ".length));
|
|
477
|
+
status.detached = value === "(detached)";
|
|
478
|
+
status.branch = status.detached ? null : value || null;
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
if (line.startsWith("# branch.upstream ")) {
|
|
482
|
+
status.upstream = sanitizeSingleLine(line.slice("# branch.upstream ".length)) || undefined;
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
if (line.startsWith("# branch.ab ")) {
|
|
486
|
+
const match = line.match(/^# branch\.ab \+(\d+) -(\d+)$/);
|
|
487
|
+
if (match) {
|
|
488
|
+
status.ahead = Number.parseInt(match[1] ?? "0", 10);
|
|
489
|
+
status.behind = Number.parseInt(match[2] ?? "0", 10);
|
|
490
|
+
}
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
if (line.startsWith("# stash ")) {
|
|
494
|
+
const count = Number.parseInt(line.slice("# stash ".length).trim(), 10);
|
|
495
|
+
if (Number.isFinite(count)) status.stashed = count;
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
const unmerged = line.match(/^u ([.MTADRCU]{2}) /);
|
|
499
|
+
if (unmerged) {
|
|
500
|
+
addStatusCode(unmerged[1] ?? "UU", true);
|
|
501
|
+
status.dirty = true;
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
if (line.startsWith("? ")) {
|
|
505
|
+
addStatusCode("?", false);
|
|
506
|
+
status.dirty = true;
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const tracked = line.match(/^[12] ([.MTADRCU]{2}) /);
|
|
511
|
+
if (!tracked) continue;
|
|
512
|
+
addStatusCode(tracked[1] ?? "..", false);
|
|
513
|
+
status.dirty = true;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
status.statusCodes = [...statusCodes.values()];
|
|
517
|
+
if (status.detached && exactTag) status.exactTag = sanitizeSingleLine(exactTag) || undefined;
|
|
518
|
+
return status;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export class ProjectStatusController {
|
|
522
|
+
private readonly cwd: string;
|
|
523
|
+
private readonly queryGitStatus: GitStatusQuery;
|
|
524
|
+
private readonly debounceMs: number;
|
|
525
|
+
private readonly pollIntervalMs: number;
|
|
526
|
+
private provisionalBranch: string | null = null;
|
|
527
|
+
private details: GitStatusDetails | undefined;
|
|
528
|
+
private refreshState: GitRefreshState = "idle";
|
|
529
|
+
private requestRender: (() => void) | undefined;
|
|
530
|
+
private unsubscribeBranch: (() => void) | undefined;
|
|
531
|
+
private refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
|
532
|
+
private pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
533
|
+
private refreshInFlight = false;
|
|
534
|
+
private refreshPending = false;
|
|
535
|
+
private abortController: AbortController | undefined;
|
|
536
|
+
private connected = false;
|
|
537
|
+
private disposed = false;
|
|
538
|
+
|
|
539
|
+
constructor(cwd: string, queryGitStatus: GitStatusQuery, debounceMs = 120, pollIntervalMs = 1000) {
|
|
540
|
+
this.cwd = cwd;
|
|
541
|
+
this.queryGitStatus = queryGitStatus;
|
|
542
|
+
this.debounceMs = debounceMs;
|
|
543
|
+
this.pollIntervalMs = pollIntervalMs;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
connect(footerData: ReadonlyFooterDataProvider, requestRender: () => void): void {
|
|
547
|
+
this.disconnect();
|
|
548
|
+
if (this.disposed) return;
|
|
549
|
+
this.connected = true;
|
|
550
|
+
this.requestRender = requestRender;
|
|
551
|
+
this.provisionalBranch = footerData.getGitBranch();
|
|
552
|
+
this.unsubscribeBranch = footerData.onBranchChange(() => {
|
|
553
|
+
const nextBranch = footerData.getGitBranch();
|
|
554
|
+
if (nextBranch !== this.provisionalBranch) {
|
|
555
|
+
this.provisionalBranch = nextBranch;
|
|
556
|
+
this.details = undefined;
|
|
557
|
+
this.refreshState = "loading";
|
|
558
|
+
this.requestRender?.();
|
|
559
|
+
}
|
|
560
|
+
this.requestRefresh();
|
|
561
|
+
});
|
|
562
|
+
this.requestRefresh(0);
|
|
563
|
+
if (this.pollIntervalMs > 0) {
|
|
564
|
+
this.pollTimer = setInterval(() => this.requestPollRefresh(), this.pollIntervalMs);
|
|
565
|
+
this.pollTimer.unref();
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
disconnect(): void {
|
|
570
|
+
this.connected = false;
|
|
571
|
+
this.unsubscribeBranch?.();
|
|
572
|
+
this.unsubscribeBranch = undefined;
|
|
573
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
574
|
+
this.pollTimer = undefined;
|
|
575
|
+
this.requestRender = undefined;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
getSnapshot(): ProjectStatusSnapshot {
|
|
579
|
+
return {
|
|
580
|
+
cwd: this.cwd,
|
|
581
|
+
branch: this.details?.branch ?? this.provisionalBranch,
|
|
582
|
+
...this.details,
|
|
583
|
+
refreshState: this.refreshState,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
requestRefresh(delay = this.debounceMs): void {
|
|
588
|
+
if (this.disposed || !this.connected || this.refreshTimer) return;
|
|
589
|
+
if (this.refreshInFlight) {
|
|
590
|
+
this.refreshPending = true;
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
this.scheduleRefresh(delay);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private requestPollRefresh(): void {
|
|
597
|
+
if (this.disposed || !this.connected || this.refreshTimer || this.refreshInFlight) return;
|
|
598
|
+
this.scheduleRefresh(0);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private scheduleRefresh(delay: number): void {
|
|
602
|
+
this.refreshTimer = setTimeout(() => {
|
|
603
|
+
this.refreshTimer = undefined;
|
|
604
|
+
void this.refresh();
|
|
605
|
+
}, delay);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async refresh(): Promise<void> {
|
|
609
|
+
if (this.disposed || !this.connected) return;
|
|
610
|
+
if (this.refreshInFlight) {
|
|
611
|
+
this.refreshPending = true;
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
this.refreshInFlight = true;
|
|
616
|
+
const wasError = this.refreshState === "error";
|
|
617
|
+
if (this.refreshState === "idle") this.refreshState = "loading";
|
|
618
|
+
const branchAtStart = this.provisionalBranch;
|
|
619
|
+
const abortController = new AbortController();
|
|
620
|
+
this.abortController = abortController;
|
|
621
|
+
try {
|
|
622
|
+
const result = await this.queryGitStatus(this.cwd, abortController.signal);
|
|
623
|
+
if (this.disposed || branchAtStart !== this.provisionalBranch) {
|
|
624
|
+
this.refreshPending = !this.disposed;
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (!result) {
|
|
628
|
+
this.refreshState = "error";
|
|
629
|
+
if (!wasError) this.requestRender?.();
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const changed = JSON.stringify(result) !== JSON.stringify(this.details);
|
|
633
|
+
this.details = result;
|
|
634
|
+
this.provisionalBranch = result.branch;
|
|
635
|
+
this.refreshState = "ready";
|
|
636
|
+
if (changed || wasError) this.requestRender?.();
|
|
637
|
+
} finally {
|
|
638
|
+
if (this.abortController === abortController) this.abortController = undefined;
|
|
639
|
+
this.refreshInFlight = false;
|
|
640
|
+
if (this.refreshPending && !this.disposed) {
|
|
641
|
+
this.refreshPending = false;
|
|
642
|
+
this.requestRefresh(0);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
dispose(): void {
|
|
648
|
+
this.disposed = true;
|
|
649
|
+
if (this.refreshTimer) clearTimeout(this.refreshTimer);
|
|
650
|
+
this.refreshTimer = undefined;
|
|
651
|
+
this.abortController?.abort();
|
|
652
|
+
this.abortController = undefined;
|
|
653
|
+
this.disconnect();
|
|
654
|
+
}
|
|
655
|
+
}
|