@yagni-app/code-staging 0.2.1-staging.1030.1 → 0.2.1-staging.1032.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/dist/extension/footer.d.ts +40 -7
- package/dist/extension/footer.js +176 -56
- package/dist/extension/index.js +4 -3
- package/package.json +2 -2
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Custom footer for the YAGNI CLI — replaces pi's built-in footer
|
|
3
|
-
* model name from the status bar. The model is locked to `advanced` (see
|
|
4
|
-
* index.ts's catalog filter) and is not a user-facing concern.
|
|
2
|
+
* Custom footer for the YAGNI CLI — replaces pi's built-in footer.
|
|
5
3
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Renders three lines:
|
|
5
|
+
* 1. folder · [worktree] · branch (git context; ~-path fallback off-repo)
|
|
6
|
+
* 2. model · ↑in ↓out $cost · ctx% (session stats; context % is an integer)
|
|
7
|
+
* 3. extension statuses (branding, todo counter, mode) joined by " · "
|
|
9
8
|
*
|
|
10
9
|
* --- How to customize the status bar (for future tickets) ---
|
|
11
10
|
*
|
|
@@ -40,6 +39,7 @@
|
|
|
40
39
|
* — useful reference for what data to replicate and how to format it.
|
|
41
40
|
*/
|
|
42
41
|
import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
|
|
42
|
+
export declare const BRANCH_MAX_WIDTH = 60;
|
|
43
43
|
/** Format token counts for compact footer display (mirrors pi's formatTokens). */
|
|
44
44
|
export declare function formatTokens(count: number): string;
|
|
45
45
|
/** Shorten cwd relative to home, like pi's built-in footer. */
|
|
@@ -53,6 +53,39 @@ interface UsageTotals {
|
|
|
53
53
|
}
|
|
54
54
|
/** Accumulate usage from all session entries (mirrors pi's built-in footer). */
|
|
55
55
|
export declare function collectUsage(sessionManager: ExtensionContext["sessionManager"]): UsageTotals;
|
|
56
|
+
export interface GitInfo {
|
|
57
|
+
/** Repo-root folder basename (the MAIN repo, stable across worktrees), or ~-path off-repo. */
|
|
58
|
+
folder: string;
|
|
59
|
+
inRepo: boolean;
|
|
60
|
+
/** Current branch, null off-repo. "detached" on detached HEAD. */
|
|
61
|
+
branch: string | null;
|
|
62
|
+
/** Linked-worktree label for [brackets], or null to hide. */
|
|
63
|
+
worktree: string | null;
|
|
64
|
+
}
|
|
65
|
+
/** Git probes `resolveWorktreeLabel` needs, injectable so the rules can be unit-tested. */
|
|
66
|
+
export interface WorktreeProbes {
|
|
67
|
+
isLinkedWorktree(repoRoot: string): boolean;
|
|
68
|
+
worktrees(repoRoot: string): string[];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Show [worktree] only on a LINKED worktree (never the main checkout), when the repo has
|
|
72
|
+
* >1 worktree, AND the dir differs from the branch slug. The main checkout is the default
|
|
73
|
+
* context and earns no label; a [bracket] only disambiguates a secondary working tree.
|
|
74
|
+
*/
|
|
75
|
+
export declare function resolveWorktreeLabel(repoRoot: string, branch: string | null, probes?: WorktreeProbes): string | null;
|
|
76
|
+
/**
|
|
77
|
+
* Detect git info for a cwd, gracefully. Never throws: non-git folders, missing git
|
|
78
|
+
* binary, and corrupt repos all degrade to a safe partial/empty result.
|
|
79
|
+
*/
|
|
80
|
+
export declare function detectGitInfo(cwd: string, home: string | undefined): GitInfo;
|
|
81
|
+
/** Pure line-builder, exported for tests. All data injected; colors via theme. */
|
|
82
|
+
export declare function renderFooterLines(input: {
|
|
83
|
+
git: GitInfo;
|
|
84
|
+
model: string;
|
|
85
|
+
usage: UsageTotals;
|
|
86
|
+
contextPercent: number | null;
|
|
87
|
+
statuses: string[];
|
|
88
|
+
}, theme: Pick<Theme, "fg">, width: number): string[];
|
|
56
89
|
/**
|
|
57
90
|
* Create a footer factory that captures the session `ctx` (for session data)
|
|
58
91
|
* and returns the component `setFooter` expects. Called from the
|
|
@@ -61,7 +94,7 @@ export declare function collectUsage(sessionManager: ExtensionContext["sessionMa
|
|
|
61
94
|
export declare function createYagniFooterFactory(ctx: ExtensionContext): (_tui: unknown, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
|
|
62
95
|
render(width: number): string[];
|
|
63
96
|
invalidate(): void;
|
|
64
|
-
dispose
|
|
97
|
+
dispose(): void;
|
|
65
98
|
};
|
|
66
99
|
export {};
|
|
67
100
|
//# sourceMappingURL=footer.d.ts.map
|
package/dist/extension/footer.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Custom footer for the YAGNI CLI — replaces pi's built-in footer
|
|
3
|
-
* model name from the status bar. The model is locked to `advanced` (see
|
|
4
|
-
* index.ts's catalog filter) and is not a user-facing concern.
|
|
2
|
+
* Custom footer for the YAGNI CLI — replaces pi's built-in footer.
|
|
5
3
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Renders three lines:
|
|
5
|
+
* 1. folder · [worktree] · branch (git context; ~-path fallback off-repo)
|
|
6
|
+
* 2. model · ↑in ↓out $cost · ctx% (session stats; context % is an integer)
|
|
7
|
+
* 3. extension statuses (branding, todo counter, mode) joined by " · "
|
|
9
8
|
*
|
|
10
9
|
* --- How to customize the status bar (for future tickets) ---
|
|
11
10
|
*
|
|
@@ -39,7 +38,14 @@
|
|
|
39
38
|
* `node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js`
|
|
40
39
|
* — useful reference for what data to replicate and how to format it.
|
|
41
40
|
*/
|
|
42
|
-
import {
|
|
41
|
+
import { spawnSync } from "node:child_process";
|
|
42
|
+
import { statSync } from "node:fs";
|
|
43
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
44
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
45
|
+
export const BRANCH_MAX_WIDTH = 60;
|
|
46
|
+
const WORKTREE_MAX_WIDTH = 30;
|
|
47
|
+
/** Section separator: single space + middle dot + single space. */
|
|
48
|
+
const SEP = " · ";
|
|
43
49
|
/** Format token counts for compact footer display (mirrors pi's formatTokens). */
|
|
44
50
|
export function formatTokens(count) {
|
|
45
51
|
if (count < 1000)
|
|
@@ -87,6 +93,139 @@ export function collectUsage(sessionManager) {
|
|
|
87
93
|
}
|
|
88
94
|
return totals;
|
|
89
95
|
}
|
|
96
|
+
/** End-cut ellipsis truncation (ANSI-aware) so the branch prefix stays readable. */
|
|
97
|
+
function truncateEnd(text, maxWidth) {
|
|
98
|
+
if (visibleWidth(text) <= maxWidth)
|
|
99
|
+
return text;
|
|
100
|
+
return truncateToWidth(text, maxWidth, "…");
|
|
101
|
+
}
|
|
102
|
+
function runGit(args, cwd) {
|
|
103
|
+
try {
|
|
104
|
+
const r = spawnSync("git", ["--no-optional-locks", ...args], {
|
|
105
|
+
cwd,
|
|
106
|
+
encoding: "utf8",
|
|
107
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
108
|
+
});
|
|
109
|
+
if (r.error || r.status !== 0)
|
|
110
|
+
return null;
|
|
111
|
+
return r.stdout.trim();
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function gitRepoRoot(cwd) {
|
|
118
|
+
return runGit(["rev-parse", "--show-toplevel"], cwd) || null;
|
|
119
|
+
}
|
|
120
|
+
function gitBranch(repoRoot) {
|
|
121
|
+
const out = runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], repoRoot);
|
|
122
|
+
if (out)
|
|
123
|
+
return out;
|
|
124
|
+
// Detached HEAD, or repo with no commits yet.
|
|
125
|
+
return runGit(["rev-parse", "--verify", "HEAD"], repoRoot) ? "detached" : null;
|
|
126
|
+
}
|
|
127
|
+
function gitWorktrees(repoRoot) {
|
|
128
|
+
const out = runGit(["worktree", "list", "--porcelain"], repoRoot);
|
|
129
|
+
if (!out)
|
|
130
|
+
return [];
|
|
131
|
+
return out
|
|
132
|
+
.split("\n")
|
|
133
|
+
.filter((l) => l.startsWith("worktree "))
|
|
134
|
+
.map((l) => l.slice("worktree ".length).trim())
|
|
135
|
+
.filter(Boolean);
|
|
136
|
+
}
|
|
137
|
+
/** The MAIN repository root (stable across worktrees) — used for the folder name. */
|
|
138
|
+
function gitMainRepoRoot(repoRoot) {
|
|
139
|
+
const common = runGit(["rev-parse", "--git-common-dir"], repoRoot);
|
|
140
|
+
if (!common)
|
|
141
|
+
return repoRoot;
|
|
142
|
+
const abs = isAbsolute(common) ? common : join(repoRoot, common);
|
|
143
|
+
return basename(abs) === ".git" ? dirname(abs) : repoRoot;
|
|
144
|
+
}
|
|
145
|
+
/** Main checkout: .git is a directory. Linked worktree: .git is a "gitdir:" file. */
|
|
146
|
+
function isLinkedWorktree(repoRoot) {
|
|
147
|
+
try {
|
|
148
|
+
return statSync(join(repoRoot, ".git")).isFile();
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const liveWorktreeProbes = { isLinkedWorktree, worktrees: gitWorktrees };
|
|
155
|
+
/**
|
|
156
|
+
* Show [worktree] only on a LINKED worktree (never the main checkout), when the repo has
|
|
157
|
+
* >1 worktree, AND the dir differs from the branch slug. The main checkout is the default
|
|
158
|
+
* context and earns no label; a [bracket] only disambiguates a secondary working tree.
|
|
159
|
+
*/
|
|
160
|
+
export function resolveWorktreeLabel(repoRoot, branch, probes = liveWorktreeProbes) {
|
|
161
|
+
if (!probes.isLinkedWorktree(repoRoot))
|
|
162
|
+
return null;
|
|
163
|
+
if (probes.worktrees(repoRoot).length < 2)
|
|
164
|
+
return null;
|
|
165
|
+
const currentDir = basename(repoRoot);
|
|
166
|
+
if (branch && currentDir === branch)
|
|
167
|
+
return null;
|
|
168
|
+
return truncateEnd(currentDir, WORKTREE_MAX_WIDTH);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Detect git info for a cwd, gracefully. Never throws: non-git folders, missing git
|
|
172
|
+
* binary, and corrupt repos all degrade to a safe partial/empty result.
|
|
173
|
+
*/
|
|
174
|
+
export function detectGitInfo(cwd, home) {
|
|
175
|
+
const root = gitRepoRoot(cwd);
|
|
176
|
+
if (!root) {
|
|
177
|
+
return { folder: formatCwd(cwd, home), inRepo: false, branch: null, worktree: null };
|
|
178
|
+
}
|
|
179
|
+
const branch = gitBranch(root);
|
|
180
|
+
const worktree = resolveWorktreeLabel(root, branch);
|
|
181
|
+
return { folder: basename(gitMainRepoRoot(root)), inRepo: true, branch, worktree };
|
|
182
|
+
}
|
|
183
|
+
/** Context color: dim below 70, warning 70-90, error above 90. */
|
|
184
|
+
function contextColor(percent) {
|
|
185
|
+
if (percent === null)
|
|
186
|
+
return "dim";
|
|
187
|
+
if (percent > 90)
|
|
188
|
+
return "error";
|
|
189
|
+
if (percent > 70)
|
|
190
|
+
return "warning";
|
|
191
|
+
return "dim";
|
|
192
|
+
}
|
|
193
|
+
/** Pure line-builder, exported for tests. All data injected; colors via theme. */
|
|
194
|
+
export function renderFooterLines(input, theme, width) {
|
|
195
|
+
const dim = (s) => theme.fg("dim", s);
|
|
196
|
+
const sep = dim(SEP);
|
|
197
|
+
// Line 1: folder · [worktree] · branch
|
|
198
|
+
const line1Parts = [theme.fg("accent", input.git.folder)];
|
|
199
|
+
if (input.git.inRepo) {
|
|
200
|
+
if (input.git.worktree)
|
|
201
|
+
line1Parts.push(theme.fg("warning", `[${input.git.worktree}]`));
|
|
202
|
+
if (input.git.branch)
|
|
203
|
+
line1Parts.push(theme.fg("border", truncateEnd(input.git.branch, BRANCH_MAX_WIDTH)));
|
|
204
|
+
}
|
|
205
|
+
const line1 = truncateToWidth(line1Parts.join(sep), width, dim("…"));
|
|
206
|
+
// Line 2: model · ↑in ↓out $cost · ctx%
|
|
207
|
+
const statParts = [];
|
|
208
|
+
if (input.usage.input)
|
|
209
|
+
statParts.push(`↑${formatTokens(input.usage.input)}`);
|
|
210
|
+
if (input.usage.output)
|
|
211
|
+
statParts.push(`↓${formatTokens(input.usage.output)}`);
|
|
212
|
+
if (input.usage.cost)
|
|
213
|
+
statParts.push(`$${input.usage.cost.toFixed(3)}`);
|
|
214
|
+
const stats = statParts.join(" ");
|
|
215
|
+
const percentText = input.contextPercent === null ? "?" : `${Math.round(input.contextPercent)}%`;
|
|
216
|
+
const line2Parts = [dim(input.model)];
|
|
217
|
+
if (stats)
|
|
218
|
+
line2Parts.push(dim(stats));
|
|
219
|
+
line2Parts.push(theme.fg(contextColor(input.contextPercent), percentText));
|
|
220
|
+
const line2 = truncateToWidth(line2Parts.join(sep), width, dim("…"));
|
|
221
|
+
// Line 3: extension statuses (branding, todo counter, mode), joined by " · ".
|
|
222
|
+
const statuses = input.statuses.map((s) => s.replace(/[\r\n\t]/g, " ").trim()).filter(Boolean);
|
|
223
|
+
const lines = [line1, line2];
|
|
224
|
+
if (statuses.length > 0) {
|
|
225
|
+
lines.push(truncateToWidth(dim(statuses.join(SEP)), width, dim("…")));
|
|
226
|
+
}
|
|
227
|
+
return lines;
|
|
228
|
+
}
|
|
90
229
|
/**
|
|
91
230
|
* Create a footer factory that captures the session `ctx` (for session data)
|
|
92
231
|
* and returns the component `setFooter` expects. Called from the
|
|
@@ -94,58 +233,39 @@ export function collectUsage(sessionManager) {
|
|
|
94
233
|
*/
|
|
95
234
|
export function createYagniFooterFactory(ctx) {
|
|
96
235
|
return (_tui, theme, footerData) => {
|
|
236
|
+
// Recompute git/worktree info only when the branch actually changes. Optional-
|
|
237
|
+
// chained: onBranchChange is typed on ReadonlyFooterDataProvider, but the runtime
|
|
238
|
+
// provider is whatever pi version is installed — guard so a mismatch can't break
|
|
239
|
+
// footer construction (worst case, git info just doesn't auto-invalidate).
|
|
240
|
+
let gitCache;
|
|
241
|
+
const unsubscribeBranch = footerData.onBranchChange?.(() => {
|
|
242
|
+
gitCache = undefined;
|
|
243
|
+
});
|
|
244
|
+
const gitInfo = () => {
|
|
245
|
+
if (!gitCache) {
|
|
246
|
+
gitCache = detectGitInfo(ctx.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE);
|
|
247
|
+
}
|
|
248
|
+
return gitCache;
|
|
249
|
+
};
|
|
97
250
|
return {
|
|
98
251
|
render(width) {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (usage.cacheRead)
|
|
116
|
-
statsParts.push(`R${formatTokens(usage.cacheRead)}`);
|
|
117
|
-
if (usage.cacheWrite)
|
|
118
|
-
statsParts.push(`W${formatTokens(usage.cacheWrite)}`);
|
|
119
|
-
if (usage.cost)
|
|
120
|
-
statsParts.push(`$${usage.cost.toFixed(3)}`);
|
|
121
|
-
// Context usage (tokens used / window).
|
|
122
|
-
const contextUsage = ctx.getContextUsage();
|
|
123
|
-
if (contextUsage) {
|
|
124
|
-
const pct = contextUsage.percent !== null ? `${contextUsage.percent.toFixed(1)}%` : "?";
|
|
125
|
-
statsParts.push(`${pct}/${formatTokens(contextUsage.contextWindow)} (auto)`);
|
|
126
|
-
}
|
|
127
|
-
const statsLine = statsParts.join(" ");
|
|
128
|
-
// --- Line 3: extension statuses ---
|
|
129
|
-
const statuses = footerData.getExtensionStatuses();
|
|
130
|
-
const statusParts = [];
|
|
131
|
-
const sorted = [...statuses.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
132
|
-
for (const [, text] of sorted) {
|
|
133
|
-
if (text)
|
|
134
|
-
statusParts.push(text.replace(/[\r\n\t]/g, " ").trim());
|
|
135
|
-
}
|
|
136
|
-
// Assemble lines, truncating each to width.
|
|
137
|
-
const lines = [];
|
|
138
|
-
lines.push(theme.fg("dim", pwd.length > width ? pwd.slice(0, width) : pwd));
|
|
139
|
-
if (statsLine) {
|
|
140
|
-
lines.push(theme.fg("dim", statsLine.length > width ? statsLine.slice(0, width) : statsLine));
|
|
141
|
-
}
|
|
142
|
-
if (statusParts.length > 0) {
|
|
143
|
-
const statusLine = statusParts.join(" ");
|
|
144
|
-
lines.push(theme.fg("dim", statusLine.length > width ? statusLine.slice(0, width) : statusLine));
|
|
145
|
-
}
|
|
146
|
-
return lines;
|
|
252
|
+
const statuses = [...footerData.getExtensionStatuses().entries()]
|
|
253
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
254
|
+
.map(([, text]) => text);
|
|
255
|
+
return renderFooterLines({
|
|
256
|
+
git: gitInfo(),
|
|
257
|
+
model: ctx.model?.id ?? "no-model",
|
|
258
|
+
usage: collectUsage(ctx.sessionManager),
|
|
259
|
+
contextPercent: ctx.getContextUsage()?.percent ?? null,
|
|
260
|
+
statuses,
|
|
261
|
+
}, theme, width);
|
|
262
|
+
},
|
|
263
|
+
invalidate() {
|
|
264
|
+
gitCache = undefined;
|
|
265
|
+
},
|
|
266
|
+
dispose() {
|
|
267
|
+
unsubscribeBranch?.();
|
|
147
268
|
},
|
|
148
|
-
invalidate() { },
|
|
149
269
|
};
|
|
150
270
|
};
|
|
151
271
|
}
|
package/dist/extension/index.js
CHANGED
|
@@ -482,9 +482,10 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
482
482
|
// setHeader replaces the built-in header in place (verified against pi
|
|
483
483
|
// 0.80.2 setExtensionHeader); the factory returns a simple Text component.
|
|
484
484
|
ctx.ui?.setHeader?.((_tui, theme) => new Text(buildMastheadString(theme)));
|
|
485
|
-
// Replace the built-in footer with
|
|
486
|
-
//
|
|
487
|
-
//
|
|
485
|
+
// Replace the built-in footer with the YAGNI status bar: folder +
|
|
486
|
+
// [worktree] + branch on line 1, model + token/cost stats + integer
|
|
487
|
+
// context % on line 2, and extension statuses (brand, todos, mode) on
|
|
488
|
+
// line 3. The factory captures ctx so the footer can read session data
|
|
488
489
|
// (token stats, context usage) that isn't on the footerData provider.
|
|
489
490
|
ctx.ui?.setFooter?.((tui, theme, footerData) => createYagniFooterFactory(ctx)(tui, theme, footerData));
|
|
490
491
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "0.2.1-staging.
|
|
3
|
+
"version": "0.2.1-staging.1032.1",
|
|
4
4
|
"description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
|
|
@@ -38,5 +38,5 @@
|
|
|
38
38
|
"@earendil-works/pi-tui": "0.83.0",
|
|
39
39
|
"typebox": "^1.1.38"
|
|
40
40
|
},
|
|
41
|
-
"yagniSourceSha": "
|
|
41
|
+
"yagniSourceSha": "7629e1f12b3d8e06e68101daadd2d8258dd271e9"
|
|
42
42
|
}
|