pi-editor-footer 0.1.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/AGENTS.md +19 -0
- package/CHANGELOG.md +26 -0
- package/CONTEXT.md +29 -0
- package/README.md +84 -0
- package/docs/adr/0001-tracking-editor-for-skill-descriptions.md +18 -0
- package/docs/adr/0002-own-editor-slot-port-model-info-glow.md +18 -0
- package/docs/agents/domain.md +51 -0
- package/docs/agents/issue-tracker.md +45 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/reference/pi-tui-internals.md +144 -0
- package/docs/specs/01-config.md +57 -0
- package/docs/specs/02-identity.md +20 -0
- package/docs/specs/03-border-telemetry.md +48 -0
- package/docs/specs/04-header.md +30 -0
- package/docs/specs/05-footer.md +30 -0
- package/docs/specs/06-git.md +36 -0
- package/docs/specs/07-runtime.md +28 -0
- package/docs/specs/theme-overview.md +116 -0
- package/package.json +16 -0
- package/src/config.ts +184 -0
- package/src/detail-render.ts +119 -0
- package/src/footer.ts +479 -0
- package/src/git.ts +170 -0
- package/src/header.ts +185 -0
- package/src/icons.ts +197 -0
- package/src/index.ts +607 -0
- package/src/model-info.ts +341 -0
- package/src/runtime.ts +318 -0
- package/src/state.ts +144 -0
- package/src/telemetry.ts +437 -0
- package/src/theme-settings.ts +461 -0
- package/src/tracking-editor.ts +352 -0
- package/src/utils-workspace.ts +48 -0
- package/src/utils.ts +388 -0
- package/src/window-presentation.ts +56 -0
- package/test/config.test.ts +146 -0
- package/test/detail-render.test.ts +202 -0
- package/test/footer.test.ts +86 -0
- package/test/git.test.ts +45 -0
- package/test/header.test.ts +169 -0
- package/test/icons.test.ts +24 -0
- package/test/runtime.test.ts +71 -0
- package/test/telemetry.test.ts +199 -0
- package/test/utils.test.ts +71 -0
- package/test/window-presentation.test.ts +73 -0
- package/tsconfig.json +13 -0
package/src/footer.ts
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import {
|
|
2
|
+
truncateToWidth,
|
|
3
|
+
visibleWidth,
|
|
4
|
+
wrapTextWithAnsi,
|
|
5
|
+
} from "@earendil-works/pi-tui";
|
|
6
|
+
import type { ThemeConfig } from "./config.js";
|
|
7
|
+
import type { GitStatus } from "./git.js";
|
|
8
|
+
import type { RuntimeInfo } from "./runtime.js";
|
|
9
|
+
import type { FooterState, ModelMeta, UsageTotals } from "./state.js";
|
|
10
|
+
import { getUsageTotals } from "./state.js";
|
|
11
|
+
import type { IconGlyphs } from "./icons.js";
|
|
12
|
+
import { resolveGlyphs, resolveIconMode, runtimeSymbol } from "./icons.js";
|
|
13
|
+
import {
|
|
14
|
+
alignRight,
|
|
15
|
+
basenamePath,
|
|
16
|
+
cacheHitColor,
|
|
17
|
+
effortColor,
|
|
18
|
+
fitSegmentsByPriority,
|
|
19
|
+
fmtTokens,
|
|
20
|
+
formatCwd,
|
|
21
|
+
formatDuration,
|
|
22
|
+
formatProviderLabel,
|
|
23
|
+
providerColor,
|
|
24
|
+
sanitizeStatus,
|
|
25
|
+
stressColor,
|
|
26
|
+
truncateBranch,
|
|
27
|
+
truncatePath,
|
|
28
|
+
type PrioritizedSegment,
|
|
29
|
+
type Theme,
|
|
30
|
+
} from "./utils.js";
|
|
31
|
+
|
|
32
|
+
function renderBar(
|
|
33
|
+
theme: Theme,
|
|
34
|
+
pct: number,
|
|
35
|
+
barWidth: number,
|
|
36
|
+
ascii: boolean,
|
|
37
|
+
): string {
|
|
38
|
+
const filled = Math.max(
|
|
39
|
+
0,
|
|
40
|
+
Math.min(barWidth, Math.round((pct / 100) * barWidth)),
|
|
41
|
+
);
|
|
42
|
+
const empty = barWidth - filled;
|
|
43
|
+
const color = stressColor(pct);
|
|
44
|
+
const filledCell = ascii ? "#" : "█";
|
|
45
|
+
const emptyCell = ascii ? "-" : "░";
|
|
46
|
+
return (
|
|
47
|
+
theme.fg("dim", "[") +
|
|
48
|
+
theme.fg(color, filledCell.repeat(filled)) +
|
|
49
|
+
theme.fg("dim", emptyCell.repeat(empty)) +
|
|
50
|
+
theme.fg("dim", "]")
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renderGitSegment(
|
|
55
|
+
theme: Theme,
|
|
56
|
+
git: GitStatus,
|
|
57
|
+
glyphs: IconGlyphs,
|
|
58
|
+
segments: ThemeConfig["footerSegments"],
|
|
59
|
+
maxBranchLen = 20,
|
|
60
|
+
): string {
|
|
61
|
+
const parts: string[] = [];
|
|
62
|
+
if (segments.gitBranch) {
|
|
63
|
+
if (git.branch) {
|
|
64
|
+
parts.push(theme.fg("mdLink", truncateBranch(git.branch, maxBranchLen)));
|
|
65
|
+
} else if (git.commit?.detached) {
|
|
66
|
+
parts.push(theme.fg("warning", "HEAD"));
|
|
67
|
+
if (git.commit.oid) {
|
|
68
|
+
const shortHash = git.commit.oid.slice(0, 7);
|
|
69
|
+
const tag = git.commit.tag ? ` ${git.commit.tag}` : "";
|
|
70
|
+
parts.push(theme.fg("dim", `${shortHash}${tag}`));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (segments.gitStatus) {
|
|
76
|
+
const statusIcons: string[] = [];
|
|
77
|
+
const addStatus = (count: number, glyph: string, color: string) => {
|
|
78
|
+
if (count > 0) statusIcons.push(theme.fg(color, `${glyph}${count}`));
|
|
79
|
+
};
|
|
80
|
+
addStatus(git.conflicted, glyphs.conflicted, "error");
|
|
81
|
+
addStatus(git.deleted, glyphs.deleted, "error");
|
|
82
|
+
addStatus(git.modified, glyphs.modified, "warning");
|
|
83
|
+
addStatus(git.renamed, glyphs.renamed, "warning");
|
|
84
|
+
addStatus(git.staged, glyphs.staged, "success");
|
|
85
|
+
addStatus(git.untracked, glyphs.untracked, "muted");
|
|
86
|
+
addStatus(git.stashed, glyphs.stashed, "muted");
|
|
87
|
+
|
|
88
|
+
if (git.ahead > 0 && git.behind > 0) {
|
|
89
|
+
statusIcons.push(
|
|
90
|
+
theme.fg("warning", `${glyphs.diverged}${git.ahead}/${git.behind}`),
|
|
91
|
+
);
|
|
92
|
+
} else if (git.ahead > 0) {
|
|
93
|
+
statusIcons.push(theme.fg("success", `${glyphs.ahead}${git.ahead}`));
|
|
94
|
+
} else if (git.behind > 0) {
|
|
95
|
+
statusIcons.push(theme.fg("warning", `${glyphs.behind}${git.behind}`));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const statusBlock = statusIcons.join(" ");
|
|
99
|
+
if (statusBlock) {
|
|
100
|
+
parts.push(
|
|
101
|
+
`${theme.fg("dim", "[")}${statusBlock}${theme.fg("dim", "]")}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return parts.join(" ");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function renderRuntimeSegment(
|
|
110
|
+
theme: Theme,
|
|
111
|
+
runtime: RuntimeInfo | null,
|
|
112
|
+
iconMode: ThemeConfig["icons"]["mode"],
|
|
113
|
+
): string {
|
|
114
|
+
if (!runtime) return "";
|
|
115
|
+
const symbol = theme.fg("success", runtimeSymbol(runtime.name, iconMode));
|
|
116
|
+
const version = runtime.version ? theme.fg("muted", runtime.version) : "";
|
|
117
|
+
const label = [symbol, version].filter(Boolean).join(" ");
|
|
118
|
+
return label;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function renderTimerSegment(
|
|
122
|
+
theme: Theme,
|
|
123
|
+
state: FooterState,
|
|
124
|
+
glyphs: IconGlyphs,
|
|
125
|
+
): string {
|
|
126
|
+
if (state.workingSince !== undefined) {
|
|
127
|
+
return `${theme.fg("accent", glyphs.working)} ${theme.fg("dim", "working")} ${theme.fg("accent", formatDuration(Date.now() - state.workingSince))}`;
|
|
128
|
+
}
|
|
129
|
+
if (state.lastDoneIn !== undefined) {
|
|
130
|
+
return `${theme.fg("success", glyphs.done)} ${theme.fg("success", "done")} ${theme.fg("text", formatDuration(state.lastDoneIn))}`;
|
|
131
|
+
}
|
|
132
|
+
return "";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function renderFooter(
|
|
136
|
+
width: number,
|
|
137
|
+
state: FooterState,
|
|
138
|
+
config: ThemeConfig,
|
|
139
|
+
theme: Theme,
|
|
140
|
+
ctx: {
|
|
141
|
+
cwd: string;
|
|
142
|
+
sessionName?: string;
|
|
143
|
+
contextUsage?: {
|
|
144
|
+
percent?: number;
|
|
145
|
+
tokens?: number;
|
|
146
|
+
contextWindow?: number;
|
|
147
|
+
};
|
|
148
|
+
model?: { provider?: string; id?: string; name?: string };
|
|
149
|
+
totals?: UsageTotals;
|
|
150
|
+
extensionStatuses?: ReadonlyMap<string, string>;
|
|
151
|
+
getModelMeta?: () => ModelMeta;
|
|
152
|
+
},
|
|
153
|
+
): string[] {
|
|
154
|
+
if (width <= 0) return [""];
|
|
155
|
+
const glyphs = resolveGlyphs(config.icons.mode);
|
|
156
|
+
const segments = config.footerSegments;
|
|
157
|
+
const totals = ctx.totals ?? {
|
|
158
|
+
input: 0,
|
|
159
|
+
output: 0,
|
|
160
|
+
cacheRead: 0,
|
|
161
|
+
cacheWrite: 0,
|
|
162
|
+
cost: 0,
|
|
163
|
+
latestCacheHitRate: undefined,
|
|
164
|
+
};
|
|
165
|
+
const meta = ctx.getModelMeta
|
|
166
|
+
? ctx.getModelMeta()
|
|
167
|
+
: {
|
|
168
|
+
provider: formatProviderLabel(ctx.model?.provider),
|
|
169
|
+
model: ctx.model?.name ?? ctx.model?.id ?? "no-model",
|
|
170
|
+
effort: undefined as string | undefined,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const leftParts: PrioritizedSegment[] = [];
|
|
174
|
+
if (segments.cwd) {
|
|
175
|
+
const maxCwd = Math.min(30, Math.max(10, Math.floor(width * 0.4)));
|
|
176
|
+
const rawCwd = formatCwd(ctx.cwd);
|
|
177
|
+
const displayCwd = config.workspaceDisplay === "name" ? basenamePath(rawCwd) : rawCwd;
|
|
178
|
+
const cwdPrefix = `${theme.fg("mdLink", glyphs.cwd)} `;
|
|
179
|
+
const accent = (text: string) => theme.fg("accent", text);
|
|
180
|
+
leftParts.push({
|
|
181
|
+
text: `${cwdPrefix}${accent(truncatePath(displayCwd, maxCwd))}`,
|
|
182
|
+
compactText: `${cwdPrefix}${accent(truncatePath(basenamePath(displayCwd), maxCwd))}`,
|
|
183
|
+
priority: 5,
|
|
184
|
+
truncate: (_text, maxWidth, ellipsis) => {
|
|
185
|
+
const pathWidth = maxWidth - visibleWidth(cwdPrefix);
|
|
186
|
+
if (pathWidth <= visibleWidth(ellipsis)) {
|
|
187
|
+
return truncateToWidth(
|
|
188
|
+
`${cwdPrefix}${accent(basenamePath(displayCwd))}`,
|
|
189
|
+
maxWidth,
|
|
190
|
+
ellipsis,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return `${cwdPrefix}${accent(truncatePath(basenamePath(displayCwd), pathWidth))}`;
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
if (segments.sessionName) {
|
|
198
|
+
const sessionName = ctx.sessionName;
|
|
199
|
+
if (sessionName) {
|
|
200
|
+
const sep = leftParts.length > 0 ? `${theme.fg("dim", " • ")}` : "";
|
|
201
|
+
leftParts.push({
|
|
202
|
+
text: `${sep}${theme.fg("dim", glyphs.session)} ${theme.fg("text", truncateToWidth(sessionName, 24, theme.fg("dim", "...")))}`,
|
|
203
|
+
priority: 2,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const gitSeg = renderGitSegment(theme, state.git, glyphs, segments);
|
|
208
|
+
if (gitSeg) {
|
|
209
|
+
const sep = leftParts.length > 0 ? `${theme.fg("dim", " · ")}` : "";
|
|
210
|
+
leftParts.push({ text: `${sep}${gitSeg}`, priority: 4 });
|
|
211
|
+
}
|
|
212
|
+
if (segments.runtime) {
|
|
213
|
+
const runtimeSeg = renderRuntimeSegment(
|
|
214
|
+
theme,
|
|
215
|
+
state.runtime,
|
|
216
|
+
config.icons.mode,
|
|
217
|
+
);
|
|
218
|
+
if (runtimeSeg) {
|
|
219
|
+
const sep = leftParts.length > 0 ? `${theme.fg("dim", " • ")}` : "";
|
|
220
|
+
leftParts.push({ text: `${sep}${runtimeSeg}`, priority: 4 });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const timerSeg = renderTimerSegment(theme, state, glyphs);
|
|
224
|
+
if (timerSeg) {
|
|
225
|
+
const sep = leftParts.length > 0 ? `${theme.fg("dim", " • ")}` : "";
|
|
226
|
+
leftParts.push({ text: `${sep}${timerSeg}`, priority: 1 });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const stats: string[] = [];
|
|
230
|
+
if (segments.tokens) {
|
|
231
|
+
stats.push(
|
|
232
|
+
theme.fg("accent", `${glyphs.input} ${fmtTokens(totals.input)}`),
|
|
233
|
+
);
|
|
234
|
+
stats.push(
|
|
235
|
+
theme.fg("success", `${glyphs.output} ${fmtTokens(totals.output)}`),
|
|
236
|
+
);
|
|
237
|
+
const hasCacheTokens = totals.cacheRead > 0 || totals.cacheWrite > 0;
|
|
238
|
+
if (hasCacheTokens && totals.latestCacheHitRate !== undefined) {
|
|
239
|
+
stats.push(
|
|
240
|
+
theme.fg(
|
|
241
|
+
cacheHitColor(totals.latestCacheHitRate),
|
|
242
|
+
`${glyphs.cacheHit} ${totals.latestCacheHitRate.toFixed(1)}%`,
|
|
243
|
+
),
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (segments.cost) {
|
|
248
|
+
stats.push(
|
|
249
|
+
theme.fg("warning", `${glyphs.cost} $${totals.cost.toFixed(3)}`),
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
const statsBlock = stats.join(` ${theme.fg("dim", "|")} `);
|
|
253
|
+
|
|
254
|
+
// Context
|
|
255
|
+
let contextText = "";
|
|
256
|
+
let contextCompact: string | undefined;
|
|
257
|
+
if (segments.context) {
|
|
258
|
+
const contextUsage = ctx.contextUsage;
|
|
259
|
+
const contextWindow = contextUsage?.contextWindow ?? 0;
|
|
260
|
+
if (contextWindow > 0) {
|
|
261
|
+
const contextPct = contextUsage?.percent ?? 0;
|
|
262
|
+
const pctText = theme.fg(
|
|
263
|
+
stressColor(contextPct),
|
|
264
|
+
`${contextPct.toFixed(1)}%`,
|
|
265
|
+
);
|
|
266
|
+
const contextTokens = contextUsage?.tokens ?? 0;
|
|
267
|
+
const ctxText = `${theme.fg("text", fmtTokens(contextTokens))}${theme.fg("dim", "/")}${theme.fg("text", fmtTokens(contextWindow))}`;
|
|
268
|
+
const contextIcon = theme.fg(stressColor(contextPct), glyphs.context);
|
|
269
|
+
const reserved =
|
|
270
|
+
visibleWidth(contextIcon) +
|
|
271
|
+
visibleWidth(pctText) +
|
|
272
|
+
visibleWidth(ctxText) +
|
|
273
|
+
7;
|
|
274
|
+
const barWidth = Math.max(4, Math.min(12, width - reserved));
|
|
275
|
+
contextText = `${contextIcon} ${renderBar(theme, contextPct, barWidth, resolveIconMode(config.icons.mode) === "ascii")} ${pctText} ${theme.fg("dim", "·")} ${ctxText}`;
|
|
276
|
+
const compact = `${theme.fg(stressColor(contextPct), glyphs.context)} ${theme.fg(stressColor(contextPct), `${contextPct.toFixed(1)}%`)}`;
|
|
277
|
+
if (visibleWidth(compact) < visibleWidth(contextText))
|
|
278
|
+
contextCompact = compact;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Tokens right next to context bar (user request): combine them as single right block
|
|
282
|
+
const rightBlock = [statsBlock, contextText].filter(Boolean).join(" ");
|
|
283
|
+
const rightCompact = statsBlock && contextCompact ? `${statsBlock} ${contextCompact}` : statsBlock || contextCompact;
|
|
284
|
+
const allParts: PrioritizedSegment[] = [...leftParts];
|
|
285
|
+
if (rightBlock) {
|
|
286
|
+
allParts.push({
|
|
287
|
+
text: rightBlock,
|
|
288
|
+
compactText: rightCompact,
|
|
289
|
+
priority: 4,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const fitted = fitSegmentsByPriority(allParts, width, theme.fg("dim", "..."));
|
|
294
|
+
const fittedContext = rightBlock ? (fitted.pop() ?? "") : "";
|
|
295
|
+
const line1 = alignRight(fitted.join(" "), fittedContext, width, theme);
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
const mainLines = [line1].map((line) =>
|
|
299
|
+
truncateToWidth(line, width, theme.fg("dim", "...")),
|
|
300
|
+
);
|
|
301
|
+
if (segments.extensionStatuses && ctx.extensionStatuses) {
|
|
302
|
+
const statuses = Array.from(ctx.extensionStatuses.entries())
|
|
303
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
304
|
+
.map(([, text]) => sanitizeStatus(text))
|
|
305
|
+
.filter((text) => text.length > 0);
|
|
306
|
+
if (statuses.length > 0) {
|
|
307
|
+
const separator = ` ${theme.fg("dim", "|")} `;
|
|
308
|
+
const statusText = statuses
|
|
309
|
+
.map((status) => theme.fg("muted", status))
|
|
310
|
+
.join(separator);
|
|
311
|
+
const line = `${theme.fg("mdLink", glyphs.extensions)} ${statusText}`;
|
|
312
|
+
return [...mainLines, ...wrapTextWithAnsi(line, width)];
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return mainLines;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export interface FooterHooks {
|
|
319
|
+
setRequestRender: (fn: (() => void) | undefined) => void;
|
|
320
|
+
scheduleGitRefresh: () => void;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Simplified installFooter for typecheck — real pi integration will wire via ExtensionContext
|
|
324
|
+
export function installFooter(
|
|
325
|
+
ctx: {
|
|
326
|
+
ui: {
|
|
327
|
+
setWidget?: (key: string, content: unknown, opts?: unknown) => void;
|
|
328
|
+
setFooter?: (fn: unknown) => void;
|
|
329
|
+
};
|
|
330
|
+
sessionManager?: { getCwd(): string; getSessionName?: () => string };
|
|
331
|
+
getContextUsage?: () => {
|
|
332
|
+
percent?: number;
|
|
333
|
+
tokens?: number;
|
|
334
|
+
contextWindow?: number;
|
|
335
|
+
};
|
|
336
|
+
model?: {
|
|
337
|
+
provider?: string;
|
|
338
|
+
id?: string;
|
|
339
|
+
name?: string;
|
|
340
|
+
reasoning?: boolean;
|
|
341
|
+
};
|
|
342
|
+
},
|
|
343
|
+
getState: () => FooterState,
|
|
344
|
+
getConfig: () => ThemeConfig,
|
|
345
|
+
getModelMeta: () => ModelMeta,
|
|
346
|
+
hooks: FooterHooks,
|
|
347
|
+
): () => void {
|
|
348
|
+
// Try setFooter if available (pi-coding-agent), else fallback to setWidget belowEditor
|
|
349
|
+
const themeStub: Theme = { fg: (_s: string, t: string) => t };
|
|
350
|
+
const render = (width: number): string[] => {
|
|
351
|
+
const state = getState();
|
|
352
|
+
const config = getConfig();
|
|
353
|
+
const cwd = ctx.sessionManager?.getCwd() ?? process.cwd();
|
|
354
|
+
const totals = getUsageTotals(
|
|
355
|
+
ctx as unknown as {
|
|
356
|
+
sessionManager?: {
|
|
357
|
+
getEntries(): {
|
|
358
|
+
type: string;
|
|
359
|
+
message?: {
|
|
360
|
+
role: string;
|
|
361
|
+
usage?: {
|
|
362
|
+
input?: number;
|
|
363
|
+
output?: number;
|
|
364
|
+
cacheRead?: number;
|
|
365
|
+
cacheWrite?: number;
|
|
366
|
+
cost?: { total?: number };
|
|
367
|
+
};
|
|
368
|
+
};
|
|
369
|
+
}[];
|
|
370
|
+
};
|
|
371
|
+
},
|
|
372
|
+
);
|
|
373
|
+
return renderFooter(width, state, config, themeStub, {
|
|
374
|
+
cwd,
|
|
375
|
+
sessionName: ctx.sessionManager?.getSessionName?.(),
|
|
376
|
+
contextUsage: ctx.getContextUsage?.(),
|
|
377
|
+
model: ctx.model,
|
|
378
|
+
totals,
|
|
379
|
+
getModelMeta,
|
|
380
|
+
});
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// Prefer native footer if available
|
|
384
|
+
if (
|
|
385
|
+
typeof (ctx.ui as unknown as { setFooter?: unknown }).setFooter ===
|
|
386
|
+
"function"
|
|
387
|
+
) {
|
|
388
|
+
const ui = ctx.ui as unknown as {
|
|
389
|
+
setFooter: (
|
|
390
|
+
fn: (
|
|
391
|
+
tui: { requestRender(): void },
|
|
392
|
+
theme: Theme,
|
|
393
|
+
data: {
|
|
394
|
+
onBranchChange(cb: () => void): () => void;
|
|
395
|
+
getExtensionStatuses(): ReadonlyMap<string, string>;
|
|
396
|
+
},
|
|
397
|
+
) => {
|
|
398
|
+
render(width: number): string[];
|
|
399
|
+
dispose?(): void;
|
|
400
|
+
invalidate?(): void;
|
|
401
|
+
},
|
|
402
|
+
) => void;
|
|
403
|
+
};
|
|
404
|
+
ui.setFooter((tui, _theme, footerData) => {
|
|
405
|
+
hooks.setRequestRender(() => tui.requestRender());
|
|
406
|
+
const unsub = footerData.onBranchChange(() => {
|
|
407
|
+
hooks.scheduleGitRefresh();
|
|
408
|
+
tui.requestRender();
|
|
409
|
+
});
|
|
410
|
+
return {
|
|
411
|
+
dispose() {
|
|
412
|
+
unsub();
|
|
413
|
+
hooks.setRequestRender(undefined);
|
|
414
|
+
},
|
|
415
|
+
invalidate() {},
|
|
416
|
+
render(width: number) {
|
|
417
|
+
// Use real theme when rendering
|
|
418
|
+
const theme = _theme as unknown as Theme;
|
|
419
|
+
const state = getState();
|
|
420
|
+
const config = getConfig();
|
|
421
|
+
const cwd = ctx.sessionManager?.getCwd() ?? process.cwd();
|
|
422
|
+
const totals = getUsageTotals(
|
|
423
|
+
ctx as unknown as {
|
|
424
|
+
sessionManager?: {
|
|
425
|
+
getEntries(): {
|
|
426
|
+
type: string;
|
|
427
|
+
message?: {
|
|
428
|
+
role: string;
|
|
429
|
+
usage?: {
|
|
430
|
+
input?: number;
|
|
431
|
+
output?: number;
|
|
432
|
+
cacheRead?: number;
|
|
433
|
+
cacheWrite?: number;
|
|
434
|
+
cost?: { total?: number };
|
|
435
|
+
};
|
|
436
|
+
};
|
|
437
|
+
}[];
|
|
438
|
+
};
|
|
439
|
+
},
|
|
440
|
+
);
|
|
441
|
+
return renderFooter(width, state, config, theme, {
|
|
442
|
+
cwd,
|
|
443
|
+
sessionName: ctx.sessionManager?.getSessionName?.(),
|
|
444
|
+
contextUsage: ctx.getContextUsage?.(),
|
|
445
|
+
model: ctx.model,
|
|
446
|
+
totals,
|
|
447
|
+
extensionStatuses: footerData.getExtensionStatuses(),
|
|
448
|
+
getModelMeta,
|
|
449
|
+
});
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
});
|
|
453
|
+
return () => {
|
|
454
|
+
(ctx.ui as unknown as { setFooter: (v: undefined) => void }).setFooter(
|
|
455
|
+
undefined,
|
|
456
|
+
);
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Fallback: widget belowEditor
|
|
461
|
+
if (ctx.ui.setWidget) {
|
|
462
|
+
ctx.ui.setWidget(
|
|
463
|
+
"theme-footer",
|
|
464
|
+
() => ({
|
|
465
|
+
invalidate() {},
|
|
466
|
+
render,
|
|
467
|
+
}),
|
|
468
|
+
{ placement: "belowEditor" },
|
|
469
|
+
);
|
|
470
|
+
hooks.setRequestRender(() => {});
|
|
471
|
+
return () => {
|
|
472
|
+
ctx.ui.setWidget?.("theme-footer", undefined);
|
|
473
|
+
hooks.setRequestRender(undefined);
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
hooks.setRequestRender(undefined);
|
|
478
|
+
return () => {};
|
|
479
|
+
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const GIT_TIMEOUT_MS = 2000;
|
|
8
|
+
|
|
9
|
+
export interface GitCommitInfo {
|
|
10
|
+
oid: string | null;
|
|
11
|
+
detached: boolean;
|
|
12
|
+
tag: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface GitStatus {
|
|
16
|
+
branch: string | undefined;
|
|
17
|
+
ahead: number;
|
|
18
|
+
behind: number;
|
|
19
|
+
modified: number;
|
|
20
|
+
untracked: number;
|
|
21
|
+
staged: number;
|
|
22
|
+
stashed: number;
|
|
23
|
+
conflicted: number;
|
|
24
|
+
renamed: number;
|
|
25
|
+
deleted: number;
|
|
26
|
+
commit: GitCommitInfo | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function emptyGitStatus(): GitStatus {
|
|
30
|
+
return {
|
|
31
|
+
branch: undefined,
|
|
32
|
+
ahead: 0,
|
|
33
|
+
behind: 0,
|
|
34
|
+
modified: 0,
|
|
35
|
+
untracked: 0,
|
|
36
|
+
staged: 0,
|
|
37
|
+
stashed: 0,
|
|
38
|
+
conflicted: 0,
|
|
39
|
+
renamed: 0,
|
|
40
|
+
deleted: 0,
|
|
41
|
+
commit: null,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function gitExec(args: string[], cwd: string): Promise<string | null> {
|
|
46
|
+
try {
|
|
47
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
48
|
+
cwd,
|
|
49
|
+
timeout: GIT_TIMEOUT_MS,
|
|
50
|
+
maxBuffer: 1024 * 1024,
|
|
51
|
+
});
|
|
52
|
+
return stdout;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function readGitStatus(
|
|
59
|
+
cwd: string,
|
|
60
|
+
options: {
|
|
61
|
+
readCommit?: boolean;
|
|
62
|
+
readTag?: boolean;
|
|
63
|
+
readCounts?: boolean;
|
|
64
|
+
} = {},
|
|
65
|
+
): Promise<GitStatus> {
|
|
66
|
+
if (!existsSync(join(cwd, ".git"))) {
|
|
67
|
+
return emptyGitStatus();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const stdout = await gitExec(
|
|
71
|
+
["status", "--porcelain=v1", "--branch", "--show-stash"],
|
|
72
|
+
cwd,
|
|
73
|
+
);
|
|
74
|
+
if (stdout === null) {
|
|
75
|
+
return emptyGitStatus();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const status = emptyGitStatus();
|
|
79
|
+
const lines = stdout.split("\n");
|
|
80
|
+
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
if (line.startsWith("## ")) {
|
|
83
|
+
const branchPart = line.slice(3);
|
|
84
|
+
const detached = branchPart.startsWith("HEAD (no branch)");
|
|
85
|
+
if (detached) {
|
|
86
|
+
status.branch = undefined;
|
|
87
|
+
status.commit = { oid: null, detached: true, tag: null };
|
|
88
|
+
} else {
|
|
89
|
+
const branchMatch = branchPart.match(
|
|
90
|
+
/^(\S+?)(?:\.\.\.(\S+))?(?:\s+\[(ahead|behind) (\d+)\])?$/,
|
|
91
|
+
);
|
|
92
|
+
if (branchMatch) {
|
|
93
|
+
status.branch = branchMatch[1];
|
|
94
|
+
if (branchMatch[3] === "ahead")
|
|
95
|
+
status.ahead = parseInt(branchMatch[4]!, 10);
|
|
96
|
+
if (branchMatch[3] === "behind")
|
|
97
|
+
status.behind = parseInt(branchMatch[4]!, 10);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (line.startsWith("# stash ")) {
|
|
104
|
+
const stashCount = parseInt(line.slice(8).trim(), 10);
|
|
105
|
+
if (!Number.isNaN(stashCount)) {
|
|
106
|
+
status.stashed = stashCount;
|
|
107
|
+
}
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (options.readCounts === false) continue;
|
|
112
|
+
if (line.length < 3) continue;
|
|
113
|
+
const x = line[0]!;
|
|
114
|
+
const y = line[1]!;
|
|
115
|
+
|
|
116
|
+
if (x === "U" || y === "U" || (x === "C" && y === "C")) status.conflicted++;
|
|
117
|
+
else if (x === "?" && y === "?") status.untracked++;
|
|
118
|
+
else if (x === "R") status.renamed++;
|
|
119
|
+
else if (x === "D" || y === "D") status.deleted++;
|
|
120
|
+
else {
|
|
121
|
+
if (x !== " " && x !== "?") status.staged++;
|
|
122
|
+
if (y === "M" || y === "D") status.modified++;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (
|
|
127
|
+
options.readCounts !== false &&
|
|
128
|
+
status.stashed === 0 &&
|
|
129
|
+
!stdout.includes("# stash")
|
|
130
|
+
) {
|
|
131
|
+
const stashOut = await gitExec(["stash", "list"], cwd);
|
|
132
|
+
if (stashOut !== null) {
|
|
133
|
+
const count = stashOut
|
|
134
|
+
.split("\n")
|
|
135
|
+
.filter((l) => l.trim().length > 0).length;
|
|
136
|
+
if (!Number.isNaN(count)) status.stashed = count;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (options.readCommit && status.commit?.detached) {
|
|
141
|
+
const oid = await gitExec(["rev-parse", "HEAD"], cwd);
|
|
142
|
+
if (oid) {
|
|
143
|
+
status.commit.oid = oid.trim();
|
|
144
|
+
}
|
|
145
|
+
if (options.readTag) {
|
|
146
|
+
const tag = await gitExec(
|
|
147
|
+
["describe", "--tags", "--exact-match", "HEAD"],
|
|
148
|
+
cwd,
|
|
149
|
+
);
|
|
150
|
+
if (tag) {
|
|
151
|
+
status.commit.tag = tag.trim();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return status;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function hasGitChanges(s: GitStatus): boolean {
|
|
160
|
+
return (
|
|
161
|
+
s.modified > 0 ||
|
|
162
|
+
s.untracked > 0 ||
|
|
163
|
+
s.staged > 0 ||
|
|
164
|
+
s.conflicted > 0 ||
|
|
165
|
+
s.renamed > 0 ||
|
|
166
|
+
s.deleted > 0 ||
|
|
167
|
+
s.ahead > 0 ||
|
|
168
|
+
s.behind > 0
|
|
169
|
+
);
|
|
170
|
+
}
|