@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.
@@ -1,11 +1,10 @@
1
1
  /**
2
- * Custom footer for the YAGNI CLI — replaces pi's built-in footer to hide the
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
- * Replicates the useful parts of pi's built-in footer (cwd + git branch,
7
- * token usage stats, context usage, extension statuses) without the model
8
- * name on the right side.
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?(): void;
97
+ dispose(): void;
65
98
  };
66
99
  export {};
67
100
  //# sourceMappingURL=footer.d.ts.map
@@ -1,11 +1,10 @@
1
1
  /**
2
- * Custom footer for the YAGNI CLI — replaces pi's built-in footer to hide the
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
- * Replicates the useful parts of pi's built-in footer (cwd + git branch,
7
- * token usage stats, context usage, extension statuses) without the model
8
- * name on the right side.
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 { isAbsolute, relative, resolve, sep } from "node:path";
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 sessionManager = ctx.sessionManager;
100
- // --- Line 1: cwd + git branch + session name ---
101
- let pwd = formatCwd(sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE);
102
- const branch = footerData.getGitBranch();
103
- if (branch)
104
- pwd += ` (${branch})`;
105
- const sessionName = sessionManager.getSessionName();
106
- if (sessionName)
107
- pwd += ` • ${sessionName}`;
108
- // --- Line 2: token stats + context usage (no model name) ---
109
- const usage = collectUsage(sessionManager);
110
- const statsParts = [];
111
- if (usage.input)
112
- statsParts.push(`↑${formatTokens(usage.input)}`);
113
- if (usage.output)
114
- statsParts.push(`↓${formatTokens(usage.output)}`);
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
  }
@@ -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 a custom one that hides the model
486
- // name. The model is locked to `advanced` and is not user-facing.
487
- // The factory captures ctx so the footer can read session data
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.1030.1",
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": "fa0e9589a46769d609647b69946b9366ceaf88ca"
41
+ "yagniSourceSha": "7629e1f12b3d8e06e68101daadd2d8258dd271e9"
42
42
  }