pi-tui-footer 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/extensions/config.ts +196 -0
- package/extensions/editor.ts +295 -0
- package/extensions/footer.ts +344 -0
- package/extensions/fullscreen-scroll.ts +34 -0
- package/extensions/git.ts +151 -0
- package/extensions/header.ts +244 -0
- package/extensions/icons.ts +190 -0
- package/extensions/index.ts +457 -0
- package/extensions/peek.ts +168 -0
- package/extensions/rounded-tools.ts +269 -0
- package/extensions/runtime.ts +174 -0
- package/extensions/session-lifecycle.ts +43 -0
- package/extensions/settings-command.ts +511 -0
- package/extensions/state.ts +104 -0
- package/extensions/telemetry.ts +303 -0
- package/extensions/utils.ts +381 -0
- package/package.json +41 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import type { ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { hostname as osHostname } from "node:os";
|
|
3
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
4
|
+
import type { OpenTuiConfig } from "./config.ts";
|
|
5
|
+
import type { IconGlyphs } from "./icons.ts";
|
|
6
|
+
import { resolveGlyphs, resolveIconMode, runtimeSymbol } from "./icons.ts";
|
|
7
|
+
import type { GitStatus } from "./git.ts";
|
|
8
|
+
import type { RuntimeInfo } from "./runtime.ts";
|
|
9
|
+
import {
|
|
10
|
+
alignRight,
|
|
11
|
+
basenamePath,
|
|
12
|
+
cacheHitColor,
|
|
13
|
+
effortColor,
|
|
14
|
+
fitSegmentsByPriority,
|
|
15
|
+
fmtTokens,
|
|
16
|
+
formatCwd,
|
|
17
|
+
formatDuration,
|
|
18
|
+
formatInputBreakdown,
|
|
19
|
+
providerColor,
|
|
20
|
+
sanitizeStatus,
|
|
21
|
+
stressColor,
|
|
22
|
+
truncateBranch,
|
|
23
|
+
truncatePath,
|
|
24
|
+
type PrioritizedSegment,
|
|
25
|
+
} from "./utils.ts";
|
|
26
|
+
import type { FooterState, ModelMeta, UsageTotals } from "./state.ts";
|
|
27
|
+
import { getUsageTotals } from "./state.ts";
|
|
28
|
+
|
|
29
|
+
export function shortHostname(hostname: string): string {
|
|
30
|
+
return hostname.split(".")[0] ?? "";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function renderBar(theme: Theme, pct: number, barWidth: number, ascii: boolean): string {
|
|
34
|
+
const filled = Math.max(0, Math.min(barWidth, Math.round((pct / 100) * barWidth)));
|
|
35
|
+
const empty = barWidth - filled;
|
|
36
|
+
const color = stressColor(pct);
|
|
37
|
+
// Use half-height parallelogram blocks (▰/▱) instead of full-height
|
|
38
|
+
// █/░ so the bar's visual height matches the surrounding [ ] brackets.
|
|
39
|
+
const filledCell = ascii ? "#" : "▰";
|
|
40
|
+
const emptyCell = ascii ? "-" : "▱";
|
|
41
|
+
return (
|
|
42
|
+
theme.fg("dim", "[") +
|
|
43
|
+
theme.fg(color, filledCell.repeat(filled)) +
|
|
44
|
+
theme.fg("dim", emptyCell.repeat(empty)) +
|
|
45
|
+
theme.fg("dim", "]")
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Compact context form: icon + percentage, no bar or token counts. */
|
|
50
|
+
function renderContextCompact(theme: Theme, ctx: ExtensionContext, glyphs: IconGlyphs): string {
|
|
51
|
+
const contextUsage = ctx.getContextUsage();
|
|
52
|
+
const contextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
|
|
53
|
+
if (contextWindow <= 0) return "";
|
|
54
|
+
const contextPct = contextUsage?.percent ?? 0;
|
|
55
|
+
return `${theme.fg(stressColor(contextPct), glyphs.context)} ${theme.fg(stressColor(contextPct), `${contextPct.toFixed(1)}%`)}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function renderGitSegment(
|
|
59
|
+
theme: Theme,
|
|
60
|
+
git: GitStatus,
|
|
61
|
+
glyphs: IconGlyphs,
|
|
62
|
+
segments: OpenTuiConfig["footerSegments"],
|
|
63
|
+
maxBranchLen = 20,
|
|
64
|
+
): string {
|
|
65
|
+
const parts: string[] = [];
|
|
66
|
+
if (segments.gitBranch) {
|
|
67
|
+
if (git.branch) {
|
|
68
|
+
parts.push(theme.fg("mdLink", glyphs.git));
|
|
69
|
+
parts.push(theme.fg("mdLink", truncateBranch(git.branch, maxBranchLen)));
|
|
70
|
+
} else if (git.commit?.detached) {
|
|
71
|
+
parts.push(theme.fg("warning", glyphs.git));
|
|
72
|
+
parts.push(theme.fg("warning", "HEAD"));
|
|
73
|
+
if (git.commit.oid) {
|
|
74
|
+
const shortHash = git.commit.oid.slice(0, 7);
|
|
75
|
+
const tag = git.commit.tag ? ` ${git.commit.tag}` : "";
|
|
76
|
+
parts.push(theme.fg("dim", `${shortHash}${tag}`));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (segments.gitStatus) {
|
|
82
|
+
const statusIcons: string[] = [];
|
|
83
|
+
// ponytail: always show count — `!1` not `!`, so 1 vs 100 is distinguishable.
|
|
84
|
+
const addStatus = (count: number, glyph: string, color: ThemeColor) => {
|
|
85
|
+
if (count > 0) statusIcons.push(theme.fg(color, `${glyph}${count}`));
|
|
86
|
+
};
|
|
87
|
+
addStatus(git.conflicted, glyphs.conflicted, "error");
|
|
88
|
+
addStatus(git.deleted, glyphs.deleted, "error");
|
|
89
|
+
addStatus(git.modified, glyphs.modified, "warning");
|
|
90
|
+
addStatus(git.renamed, glyphs.renamed, "warning");
|
|
91
|
+
addStatus(git.staged, glyphs.staged, "success");
|
|
92
|
+
addStatus(git.untracked, glyphs.untracked, "muted");
|
|
93
|
+
addStatus(git.stashed, glyphs.stashed, "muted");
|
|
94
|
+
|
|
95
|
+
if (git.ahead > 0 && git.behind > 0) {
|
|
96
|
+
statusIcons.push(theme.fg("warning", `${glyphs.diverged}${git.ahead}/${git.behind}`));
|
|
97
|
+
} else if (git.ahead > 0) {
|
|
98
|
+
statusIcons.push(theme.fg("success", `${glyphs.ahead}${git.ahead}`));
|
|
99
|
+
} else if (git.behind > 0) {
|
|
100
|
+
statusIcons.push(theme.fg("warning", `${glyphs.behind}${git.behind}`));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const statusBlock = statusIcons.join(" ");
|
|
104
|
+
if (statusBlock) {
|
|
105
|
+
parts.push(`${theme.fg("dim", "[")}${statusBlock}${theme.fg("dim", "]")}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return parts.join(" ");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function renderRuntimeSegment(
|
|
113
|
+
theme: Theme,
|
|
114
|
+
runtime: RuntimeInfo | null,
|
|
115
|
+
iconMode: OpenTuiConfig["icons"]["mode"],
|
|
116
|
+
): string {
|
|
117
|
+
if (!runtime) return "";
|
|
118
|
+
const symbol = theme.fg("success", runtimeSymbol(runtime.name, iconMode));
|
|
119
|
+
const version = runtime.version ? theme.fg("muted", runtime.version) : "";
|
|
120
|
+
const label = [symbol, version].filter(Boolean).join(" ");
|
|
121
|
+
return label;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function renderTimerSegment(theme: Theme, state: FooterState, glyphs: IconGlyphs): string {
|
|
125
|
+
if (state.workingSince !== undefined) {
|
|
126
|
+
return `${theme.fg("accent", glyphs.working)} ${theme.fg("dim", "working")} ${theme.fg("accent", formatDuration(Date.now() - state.workingSince))}`;
|
|
127
|
+
}
|
|
128
|
+
if (state.lastDoneIn !== undefined) {
|
|
129
|
+
return `${theme.fg("success", glyphs.done)} ${theme.fg("success", "done")} ${theme.fg("text", formatDuration(state.lastDoneIn))}`;
|
|
130
|
+
}
|
|
131
|
+
return "";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function renderContextBar(
|
|
135
|
+
theme: Theme,
|
|
136
|
+
ctx: ExtensionContext,
|
|
137
|
+
width: number,
|
|
138
|
+
glyphs: IconGlyphs,
|
|
139
|
+
iconMode: OpenTuiConfig["icons"]["mode"],
|
|
140
|
+
): string {
|
|
141
|
+
const contextUsage = ctx.getContextUsage();
|
|
142
|
+
const contextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
|
|
143
|
+
const contextTokens = contextUsage?.tokens ?? 0;
|
|
144
|
+
const contextPct = contextUsage?.percent ?? 0;
|
|
145
|
+
|
|
146
|
+
// ponytail: render 0% bar once we know the window — keeps the right side
|
|
147
|
+
// populated instead of collapsing everything left in an empty session.
|
|
148
|
+
if (contextWindow <= 0) return "";
|
|
149
|
+
|
|
150
|
+
const pctText = theme.fg(stressColor(contextPct), `${contextPct.toFixed(1)}%`);
|
|
151
|
+
const ctxText = `${theme.fg("text", fmtTokens(contextTokens))}${theme.fg("dim", "/")}${theme.fg("text", fmtTokens(contextWindow))}`;
|
|
152
|
+
const contextIcon = theme.fg(stressColor(contextPct), glyphs.context);
|
|
153
|
+
// Cap the bar at `width - reserved` (with a floor of 4) so the full form
|
|
154
|
+
// never forces the left segments out before compact/drop logic kicks in.
|
|
155
|
+
const reserved = visibleWidth(contextIcon) + visibleWidth(pctText) + visibleWidth(ctxText) + 5 + 2;
|
|
156
|
+
const barWidth = Math.max(4, Math.min(12, width - reserved));
|
|
157
|
+
return `${contextIcon} ${renderBar(theme, contextPct, barWidth, resolveIconMode(iconMode) === "ascii")} ${pctText} ${theme.fg("dim", "·")} ${ctxText}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function renderStatsBlock(
|
|
161
|
+
theme: Theme,
|
|
162
|
+
totals: UsageTotals,
|
|
163
|
+
glyphs: IconGlyphs,
|
|
164
|
+
segments: OpenTuiConfig["footerSegments"],
|
|
165
|
+
): string {
|
|
166
|
+
const stats: string[] = [];
|
|
167
|
+
if (segments.tokens) {
|
|
168
|
+
stats.push(theme.fg("accent", `${glyphs.input} ${formatInputBreakdown(totals.input, totals.cacheRead)}`));
|
|
169
|
+
stats.push(theme.fg("success", `${glyphs.output} ${fmtTokens(totals.output)}`));
|
|
170
|
+
// ponytail: hide cache-hit rate when the provider never reported cache
|
|
171
|
+
// tokens — avoids a misleading "0%" on providers without prompt caching.
|
|
172
|
+
const hasCacheTokens = totals.cacheRead > 0 || totals.cacheWrite > 0;
|
|
173
|
+
if (hasCacheTokens && totals.latestCacheHitRate !== undefined) {
|
|
174
|
+
stats.push(theme.fg(cacheHitColor(totals.latestCacheHitRate), `${glyphs.cacheHit} ${totals.latestCacheHitRate.toFixed(1)}%`));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (segments.cost) {
|
|
178
|
+
stats.push(theme.fg("warning", `${glyphs.cost} $${totals.cost.toFixed(3)}`));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return stats.join(` ${theme.fg("dim", "|")} `);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function renderExtensionStatusLines(
|
|
185
|
+
theme: Theme,
|
|
186
|
+
extensionStatuses: ReadonlyMap<string, string>,
|
|
187
|
+
glyphs: IconGlyphs,
|
|
188
|
+
width: number,
|
|
189
|
+
): string[] {
|
|
190
|
+
const statuses = Array.from(extensionStatuses.entries())
|
|
191
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
192
|
+
.map(([, text]) => sanitizeStatus(text))
|
|
193
|
+
.filter((text) => text.length > 0);
|
|
194
|
+
if (statuses.length === 0) return [];
|
|
195
|
+
|
|
196
|
+
const separator = ` ${theme.fg("dim", "|")} `;
|
|
197
|
+
const statusText = statuses.map((status) => theme.fg("muted", status)).join(separator);
|
|
198
|
+
const line = `${theme.fg("mdLink", glyphs.extensions)} ${statusText}`;
|
|
199
|
+
return wrapTextWithAnsi(line, width);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface FooterHooks {
|
|
203
|
+
setRequestRender: (fn: (() => void) | undefined) => void;
|
|
204
|
+
scheduleGitRefresh: () => void;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function installFooter(
|
|
208
|
+
ctx: ExtensionContext,
|
|
209
|
+
getState: () => FooterState,
|
|
210
|
+
getConfig: () => OpenTuiConfig,
|
|
211
|
+
getModelMeta: () => ModelMeta,
|
|
212
|
+
hooks: FooterHooks,
|
|
213
|
+
): () => void {
|
|
214
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
215
|
+
hooks.setRequestRender(() => tui.requestRender());
|
|
216
|
+
const unsubBranch = footerData.onBranchChange(() => {
|
|
217
|
+
hooks.scheduleGitRefresh();
|
|
218
|
+
tui.requestRender();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
dispose() {
|
|
223
|
+
unsubBranch();
|
|
224
|
+
hooks.setRequestRender(undefined);
|
|
225
|
+
},
|
|
226
|
+
invalidate() {},
|
|
227
|
+
render(width: number): string[] {
|
|
228
|
+
if (width <= 0) return [""];
|
|
229
|
+
const state = getState();
|
|
230
|
+
const config = getConfig();
|
|
231
|
+
const glyphs = resolveGlyphs(config.icons.mode);
|
|
232
|
+
const segments = config.footerSegments;
|
|
233
|
+
const meta = getModelMeta();
|
|
234
|
+
|
|
235
|
+
const totals = getUsageTotals(ctx);
|
|
236
|
+
|
|
237
|
+
const leftParts: PrioritizedSegment[] = [];
|
|
238
|
+
if (segments.cwd) {
|
|
239
|
+
const maxCwd = Math.min(30, Math.max(10, Math.floor(width * 0.4)));
|
|
240
|
+
const cwd = formatCwd(ctx.sessionManager.getCwd());
|
|
241
|
+
const cwdPrefix = `${theme.fg("mdLink", glyphs.cwd)} `;
|
|
242
|
+
const accent = (text: string) => theme.fg("accent", text);
|
|
243
|
+
leftParts.push({
|
|
244
|
+
text: `${cwdPrefix}${accent(truncatePath(cwd, maxCwd))}`,
|
|
245
|
+
compactText: `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), maxCwd))}`,
|
|
246
|
+
priority: 0,
|
|
247
|
+
truncate: (_text, maxWidth, ellipsis) => {
|
|
248
|
+
const pathWidth = maxWidth - visibleWidth(cwdPrefix);
|
|
249
|
+
if (pathWidth <= visibleWidth(ellipsis)) {
|
|
250
|
+
return truncateToWidth(`${cwdPrefix}${accent(basenamePath(cwd))}`, maxWidth, ellipsis);
|
|
251
|
+
}
|
|
252
|
+
return `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), pathWidth))}`;
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
if (segments.hostname) {
|
|
257
|
+
const shortHost = shortHostname(osHostname());
|
|
258
|
+
if (shortHost) {
|
|
259
|
+
leftParts.push({
|
|
260
|
+
text: `${theme.fg("dim", glyphs.host)} ${theme.fg("accent", shortHost)}`,
|
|
261
|
+
priority: 1,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (segments.sessionName) {
|
|
266
|
+
const sessionName = ctx.sessionManager.getSessionName();
|
|
267
|
+
if (sessionName) {
|
|
268
|
+
leftParts.push({
|
|
269
|
+
text: `${theme.fg("dim", glyphs.session)} ${theme.fg("text", truncateToWidth(sessionName, 24, theme.fg("dim", "...")))}`,
|
|
270
|
+
priority: 2,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const gitSeg = renderGitSegment(theme, state.git, glyphs, segments);
|
|
275
|
+
if (gitSeg) leftParts.push({ text: gitSeg, priority: 3 });
|
|
276
|
+
if (segments.runtime) {
|
|
277
|
+
const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode);
|
|
278
|
+
if (runtimeSeg) leftParts.push({ text: runtimeSeg, priority: 4 });
|
|
279
|
+
}
|
|
280
|
+
const timerSeg = renderTimerSegment(theme, state, glyphs);
|
|
281
|
+
if (timerSeg) leftParts.push({ text: timerSeg, priority: 1 });
|
|
282
|
+
|
|
283
|
+
// The context bar competes with the left segments for the same row:
|
|
284
|
+
// full bar first, then the compact icon+pct form, then dropped.
|
|
285
|
+
let contextText = "";
|
|
286
|
+
let contextCompact: string | undefined;
|
|
287
|
+
if (segments.context) {
|
|
288
|
+
contextText = renderContextBar(theme, ctx, width, glyphs, config.icons.mode);
|
|
289
|
+
const compact = renderContextCompact(theme, ctx, glyphs);
|
|
290
|
+
if (compact && visibleWidth(compact) < visibleWidth(contextText)) {
|
|
291
|
+
contextCompact = compact;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const allParts: PrioritizedSegment[] = [...leftParts];
|
|
295
|
+
if (contextText) {
|
|
296
|
+
// ponytail: priority 4 = sheds with runtime, before git/timer/cwd.
|
|
297
|
+
allParts.push({ text: contextText, compactText: contextCompact, priority: 4 });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const fitted = fitSegmentsByPriority(allParts, width, theme.fg("dim", "..."));
|
|
301
|
+
const fittedContext = contextText ? fitted.pop() ?? "" : "";
|
|
302
|
+
const line1 = alignRight(fitted.join(" "), fittedContext, width, theme);
|
|
303
|
+
|
|
304
|
+
const modelParts: string[] = [];
|
|
305
|
+
modelParts.push(theme.fg("mdLink", glyphs.model));
|
|
306
|
+
if (meta.provider && meta.provider !== "Unknown") {
|
|
307
|
+
modelParts.push(theme.fg(providerColor(ctx.model?.provider ?? "none"), meta.provider));
|
|
308
|
+
}
|
|
309
|
+
modelParts.push(theme.fg("text", meta.model));
|
|
310
|
+
if (meta.effort && meta.effort !== "off") {
|
|
311
|
+
modelParts.push(theme.fg(effortColor(meta.effort), `${glyphs.thinking} ${meta.effort}`));
|
|
312
|
+
}
|
|
313
|
+
const modelBlock = modelParts.join(theme.fg("dim", " · "));
|
|
314
|
+
|
|
315
|
+
const statsBlock = renderStatsBlock(
|
|
316
|
+
theme,
|
|
317
|
+
totals,
|
|
318
|
+
glyphs,
|
|
319
|
+
segments,
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
const line2 = alignRight(modelBlock, statsBlock, width, theme);
|
|
323
|
+
|
|
324
|
+
const mainLines = [line1, line2]
|
|
325
|
+
.map((line) => truncateToWidth(line, width, theme.fg("dim", "...")));
|
|
326
|
+
return segments.extensionStatuses
|
|
327
|
+
? [
|
|
328
|
+
...mainLines,
|
|
329
|
+
...renderExtensionStatusLines(
|
|
330
|
+
theme,
|
|
331
|
+
footerData.getExtensionStatuses(),
|
|
332
|
+
glyphs,
|
|
333
|
+
width,
|
|
334
|
+
),
|
|
335
|
+
]
|
|
336
|
+
: mainLines;
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
return () => {
|
|
342
|
+
ctx.ui.setFooter(undefined);
|
|
343
|
+
};
|
|
344
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
export const MIN_FULLSCREEN_WHEEL_SCROLL_LINES = 1;
|
|
4
|
+
export const MAX_FULLSCREEN_WHEEL_SCROLL_LINES = 10;
|
|
5
|
+
export const DEFAULT_FULLSCREEN_WHEEL_SCROLL_LINES = 4;
|
|
6
|
+
|
|
7
|
+
export function normalizeFullscreenWheelScrollLines(
|
|
8
|
+
value: unknown,
|
|
9
|
+
fallback = DEFAULT_FULLSCREEN_WHEEL_SCROLL_LINES,
|
|
10
|
+
): number {
|
|
11
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
12
|
+
return Math.min(
|
|
13
|
+
MAX_FULLSCREEN_WHEEL_SCROLL_LINES,
|
|
14
|
+
Math.max(MIN_FULLSCREEN_WHEEL_SCROLL_LINES, Math.floor(value)),
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type FullscreenWheelTui = TUI & { wheelScrollLines?: unknown };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Pi 0.84.2 keeps this constructor option in a private runtime field. Remove
|
|
22
|
+
* this shim once Pi exposes a public setter; incompatible versions are no-ops.
|
|
23
|
+
*/
|
|
24
|
+
export function applyFullscreenWheelScrollLines(tui: TUI, value: number): boolean {
|
|
25
|
+
try {
|
|
26
|
+
const fullscreenTui = tui as FullscreenWheelTui;
|
|
27
|
+
if (fullscreenTui.mode !== "fullscreen" || typeof fullscreenTui.wheelScrollLines !== "number") {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return Reflect.set(fullscreenTui, "wheelScrollLines", normalizeFullscreenWheelScrollLines(value));
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
const GIT_TIMEOUT_MS = 2000;
|
|
6
|
+
|
|
7
|
+
export interface GitCommitInfo {
|
|
8
|
+
oid: string | null;
|
|
9
|
+
detached: boolean;
|
|
10
|
+
tag: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface GitStatus {
|
|
14
|
+
branch: string | undefined;
|
|
15
|
+
ahead: number;
|
|
16
|
+
behind: number;
|
|
17
|
+
modified: number;
|
|
18
|
+
untracked: number;
|
|
19
|
+
staged: number;
|
|
20
|
+
stashed: number;
|
|
21
|
+
conflicted: number;
|
|
22
|
+
renamed: number;
|
|
23
|
+
deleted: number;
|
|
24
|
+
commit: GitCommitInfo | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function emptyGitStatus(): GitStatus {
|
|
28
|
+
return {
|
|
29
|
+
branch: undefined,
|
|
30
|
+
ahead: 0,
|
|
31
|
+
behind: 0,
|
|
32
|
+
modified: 0,
|
|
33
|
+
untracked: 0,
|
|
34
|
+
staged: 0,
|
|
35
|
+
stashed: 0,
|
|
36
|
+
conflicted: 0,
|
|
37
|
+
renamed: 0,
|
|
38
|
+
deleted: 0,
|
|
39
|
+
commit: null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function gitExec(args: string[], cwd: string): Promise<string | null> {
|
|
44
|
+
try {
|
|
45
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
46
|
+
cwd,
|
|
47
|
+
timeout: GIT_TIMEOUT_MS,
|
|
48
|
+
maxBuffer: 1024 * 1024,
|
|
49
|
+
});
|
|
50
|
+
return stdout;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function readGitStatus(
|
|
57
|
+
cwd: string,
|
|
58
|
+
options: { readCommit?: boolean; readTag?: boolean; readCounts?: boolean } = {},
|
|
59
|
+
): Promise<GitStatus> {
|
|
60
|
+
const stdout = await gitExec(
|
|
61
|
+
["status", "--porcelain=v1", "--branch", "--show-stash"],
|
|
62
|
+
cwd,
|
|
63
|
+
);
|
|
64
|
+
if (stdout === null) {
|
|
65
|
+
return emptyGitStatus();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const status = emptyGitStatus();
|
|
69
|
+
const lines = stdout.split("\n");
|
|
70
|
+
let stashSupported = true;
|
|
71
|
+
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
if (line.startsWith("## ")) {
|
|
74
|
+
const branchPart = line.slice(3);
|
|
75
|
+
const detached = branchPart.startsWith("HEAD (no branch)");
|
|
76
|
+
if (detached) {
|
|
77
|
+
status.branch = undefined;
|
|
78
|
+
status.commit = { oid: null, detached: true, tag: null };
|
|
79
|
+
} else {
|
|
80
|
+
const branchMatch = branchPart.match(/^(\S+?)(?:\.\.\.(\S+))?(?:\s+\[(ahead|behind) (\d+)\])?$/);
|
|
81
|
+
if (branchMatch) {
|
|
82
|
+
status.branch = branchMatch[1];
|
|
83
|
+
if (branchMatch[3] === "ahead") status.ahead = parseInt(branchMatch[4]!, 10);
|
|
84
|
+
if (branchMatch[3] === "behind") status.behind = parseInt(branchMatch[4]!, 10);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (line.startsWith("# stash ")) {
|
|
91
|
+
const stashCount = parseInt(line.slice(8).trim(), 10);
|
|
92
|
+
if (!Number.isNaN(stashCount)) {
|
|
93
|
+
status.stashed = stashCount;
|
|
94
|
+
stashSupported = true;
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ponytail: skip file-entry counting when only branch/commit display is on;
|
|
100
|
+
// the first `git status` call itself can't be split, but the stash fallback below can.
|
|
101
|
+
if (options.readCounts === false) continue;
|
|
102
|
+
if (line.length < 3) continue;
|
|
103
|
+
const x = line[0]!;
|
|
104
|
+
const y = line[1]!;
|
|
105
|
+
|
|
106
|
+
if (x === "U" || y === "U" || (x === "C" && y === "C")) status.conflicted++;
|
|
107
|
+
else if (x === "?" && y === "?") status.untracked++;
|
|
108
|
+
else if (x === "R") status.renamed++;
|
|
109
|
+
else if (x === "D" || y === "D") status.deleted++;
|
|
110
|
+
else {
|
|
111
|
+
if (x !== " " && x !== "?") status.staged++;
|
|
112
|
+
if (y === "M" || y === "D") status.modified++;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (options.readCounts !== false && stashSupported && status.stashed === 0 && !stdout.includes("# stash")) {
|
|
117
|
+
const stashOut = await gitExec(["stash", "list", "--count"], cwd);
|
|
118
|
+
if (stashOut !== null) {
|
|
119
|
+
const count = parseInt(stashOut.trim(), 10);
|
|
120
|
+
if (!Number.isNaN(count)) status.stashed = count;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (options.readCommit && status.commit?.detached) {
|
|
125
|
+
const oid = await gitExec(["rev-parse", "HEAD"], cwd);
|
|
126
|
+
if (oid) {
|
|
127
|
+
status.commit.oid = oid.trim();
|
|
128
|
+
}
|
|
129
|
+
if (options.readTag) {
|
|
130
|
+
const tag = await gitExec(["describe", "--tags", "--exact-match", "HEAD"], cwd);
|
|
131
|
+
if (tag) {
|
|
132
|
+
status.commit.tag = tag.trim();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return status;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function hasGitChanges(s: GitStatus): boolean {
|
|
141
|
+
return (
|
|
142
|
+
s.modified > 0 ||
|
|
143
|
+
s.untracked > 0 ||
|
|
144
|
+
s.staged > 0 ||
|
|
145
|
+
s.conflicted > 0 ||
|
|
146
|
+
s.renamed > 0 ||
|
|
147
|
+
s.deleted > 0 ||
|
|
148
|
+
s.ahead > 0 ||
|
|
149
|
+
s.behind > 0
|
|
150
|
+
);
|
|
151
|
+
}
|