@yagni-app/code-staging 1.0.0-staging.1183.1 → 1.0.0-staging.1184.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.
@@ -109,7 +109,28 @@ export declare class ChipEditor extends CustomEditor {
109
109
  /** Drop all stashed images. Called after a successful submit so a sent image
110
110
  * is not re-attached to the next message. */
111
111
  clearStash(): void;
112
- /** Re-style the chip tokens so they read as chips; layout is untouched. */
112
+ /**
113
+ * Re-style chip tokens AND lay down the Kimi-style prompt box: a rounded
114
+ * border on all four sides, with a bold `›` caret on the first content
115
+ * line and continuation lines indented so wrapped text aligns under it.
116
+ *
117
+ * The base editor renders full-width lines carrying its own 1-column left
118
+ * padding, so we render it narrower, strip that padding, and re-wrap each
119
+ * line in the frame. Exact column layout (0-indexed):
120
+ *
121
+ * 0 ╭ │ ╰ border
122
+ * 1 space
123
+ * 2 › (first line) / space (continuation)
124
+ * 3 space
125
+ * 4… text — same column on every line
126
+ * width-2 space
127
+ * width-1 │ border
128
+ *
129
+ * Box-drawing glyphs are drawn centered in their cell while text glyphs
130
+ * start at the cell's left bearing, so no whole-column position lands the
131
+ * border ink exactly on the footer's text column: column 0 reads a hair
132
+ * outside it, column 1 a hair inside. Column 0 is the accepted tradeoff.
133
+ */
113
134
  render(width: number): string[];
114
135
  }
115
136
  /**
@@ -22,7 +22,7 @@
22
22
  * the extension's pi imports to the same module instance pi uses.
23
23
  */
24
24
  import { CustomEditor } from "@earendil-works/pi-coding-agent";
25
- import { matchesKey } from "@earendil-works/pi-tui";
25
+ import { matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
26
26
  import { spawnSync } from "node:child_process";
27
27
  import { readFileSync, unlinkSync, existsSync } from "node:fs";
28
28
  import { tmpdir } from "node:os";
@@ -381,11 +381,64 @@ export class ChipEditor extends CustomEditor {
381
381
  clearStash() {
382
382
  this.stashed = [];
383
383
  }
384
- /** Re-style the chip tokens so they read as chips; layout is untouched. */
384
+ /**
385
+ * Re-style chip tokens AND lay down the Kimi-style prompt box: a rounded
386
+ * border on all four sides, with a bold `›` caret on the first content
387
+ * line and continuation lines indented so wrapped text aligns under it.
388
+ *
389
+ * The base editor renders full-width lines carrying its own 1-column left
390
+ * padding, so we render it narrower, strip that padding, and re-wrap each
391
+ * line in the frame. Exact column layout (0-indexed):
392
+ *
393
+ * 0 ╭ │ ╰ border
394
+ * 1 space
395
+ * 2 › (first line) / space (continuation)
396
+ * 3 space
397
+ * 4… text — same column on every line
398
+ * width-2 space
399
+ * width-1 │ border
400
+ *
401
+ * Box-drawing glyphs are drawn centered in their cell while text glyphs
402
+ * start at the cell's left bearing, so no whole-column position lands the
403
+ * border ink exactly on the footer's text column: column 0 reads a hair
404
+ * outside it, column 1 a hair inside. Column 0 is the accepted tradeoff.
405
+ */
385
406
  render(width) {
386
- return super
387
- .render(width)
388
- .map((line) => line.replace(CHIP_RE, (m) => `${CHIP_ON}${m}${CHIP_OFF}`));
407
+ const styled = super.render(Math.max(1, width - 4)).map((line) => line.replace(CHIP_RE, (m) => `${CHIP_ON}${m}${CHIP_OFF}`));
408
+ const border = (s) => this.borderColor(s);
409
+ const CARET = "\u001b[1m›\u001b[22m";
410
+ const FIRST_PREFIX = `${border("│")} ${CARET} `;
411
+ const NEXT_PREFIX = `${border("│")} `;
412
+ const SUFFIX = ` ${border("│")}`;
413
+ const contentWidth = Math.max(1, width - 6);
414
+ const out = [];
415
+ let borderCount = 0;
416
+ let firstContentLine = true;
417
+ for (const line of styled) {
418
+ const stripped = stripTerminalSequences(line);
419
+ if (/^─+$/.test(stripped) || /^─*\s*[↑↓]/.test(stripped)) {
420
+ borderCount += 1;
421
+ const [left, right] = borderCount === 1 ? ["╭", "╮"] : ["╰", "╯"];
422
+ // One corner + one dash on each side lands the row at `width`.
423
+ out.push(`${border(`${left}─`)}${line}${border(`─${right}`)}`);
424
+ continue;
425
+ }
426
+ // Strip the base editor's own left padding (the frame replaces it),
427
+ // then fit the body to the content width exactly so no line can
428
+ // exceed the terminal width.
429
+ let body = line.startsWith(" ") ? line.slice(1) : line;
430
+ if (visibleWidth(body) > contentWidth)
431
+ body = truncateToWidth(body, contentWidth, "");
432
+ const pad = " ".repeat(Math.max(0, contentWidth - visibleWidth(body)));
433
+ if (borderCount !== 1) {
434
+ // Autocomplete rows (after the bottom border): align under the text.
435
+ out.push(`${" ".repeat(4)}${body}${pad}${" ".repeat(2)}`);
436
+ continue;
437
+ }
438
+ out.push(`${firstContentLine ? FIRST_PREFIX : NEXT_PREFIX}${body}${pad}${SUFFIX}`);
439
+ firstContentLine = false;
440
+ }
441
+ return out;
389
442
  }
390
443
  }
391
444
  /**
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Committed branch diff stat for the footer's git badge.
3
+ *
4
+ * Counting follows Codex (`tui/src/branch_summary.rs`), not Kimi: it sums the
5
+ * line delta between `HEAD` and the merge base with the repository's *default*
6
+ * branch, so the badge answers "what does this branch contain" — the same
7
+ * number a GitHub PR UI would show. Uncommitted working-tree edits are
8
+ * intentionally ignored (Codex: "the status-line item summarizes the checked-
9
+ * out branch, not the current dirty worktree").
10
+ *
11
+ * Display follows Kimi: `[+N -M]` appended to the branch, dim grey.
12
+ *
13
+ * Refresh reuses Kimi's synchronous TTL model: re-read at most every 15s,
14
+ * demanded on each footer render. The only divergence from Codex is that their
15
+ * probe is `async`; ours is `spawnSync` because the footer render path is
16
+ * synchronous.
17
+ */
18
+ /** Committed branch delta relative to the default branch. */
19
+ export interface BranchDiffStat {
20
+ readonly added: number;
21
+ readonly deleted: number;
22
+ }
23
+ /** Diff-stat source with a workDir, callable on demand. */
24
+ export interface DiffStatCache {
25
+ /** Current stat, or `null` when workDir isn't a git repo. */
26
+ get(): BranchDiffStat | null;
27
+ }
28
+ /**
29
+ * Injectable git runner seam (tests stub it; production shells out to `git`).
30
+ * Returns stdout, or `null` on any failure.
31
+ */
32
+ export type GitRunner = (args: string[], cwd: string) => string | null;
33
+ /** True when `cwd` is inside a git work tree. Never throws. */
34
+ export declare function detectGitRepo(run: GitRunner, cwd: string): boolean;
35
+ /** Codex's `parseDiffNumstatCount`-equivalent: `-` (binary) → 0; non-numeric → 0. */
36
+ export declare function parseNumstatCount(value: string | undefined): number;
37
+ /**
38
+ * Resolve the default branch name, preferring remote-tracking refs over local
39
+ * branches so a stale local `main` doesn't inflate the diff (Codex's ordering).
40
+ * Falls back to common local names when no remote advertises a default.
41
+ * Returns null when nothing resolves.
42
+ */
43
+ export declare function resolveDefaultBranch(run: GitRunner, cwd: string): string | null;
44
+ /**
45
+ * Sum the committed line delta between `HEAD` and the merge base with the
46
+ * default branch: `git diff --numstat <merge-base>..HEAD`. Returns null when
47
+ * the default branch or merge base can't be resolved (hide the badge).
48
+ */
49
+ export declare function readDiffStat(run: GitRunner, cwd: string): BranchDiffStat | null;
50
+ /**
51
+ * Kimi-style display: `+N -M`, or null when the branch is even with its base
52
+ * (a fresh/unchanged branch shows no badge). Both-zero still renders nothing,
53
+ * matching "our style" (Kimi hides a clean tree; Codex would say "No changes").
54
+ */
55
+ export declare function formatDiffStat(diff: BranchDiffStat | null): string | null;
56
+ /**
57
+ * Time-bucketed cache: `get()` re-reads git only when the last read was more
58
+ * than 15s ago, null off-repo. All git failures → null (badge hidden), never
59
+ * throws.
60
+ */
61
+ export declare function createDiffStatCache(workDir: string, run?: GitRunner): DiffStatCache;
62
+ //# sourceMappingURL=diffStat.d.ts.map
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Committed branch diff stat for the footer's git badge.
3
+ *
4
+ * Counting follows Codex (`tui/src/branch_summary.rs`), not Kimi: it sums the
5
+ * line delta between `HEAD` and the merge base with the repository's *default*
6
+ * branch, so the badge answers "what does this branch contain" — the same
7
+ * number a GitHub PR UI would show. Uncommitted working-tree edits are
8
+ * intentionally ignored (Codex: "the status-line item summarizes the checked-
9
+ * out branch, not the current dirty worktree").
10
+ *
11
+ * Display follows Kimi: `[+N -M]` appended to the branch, dim grey.
12
+ *
13
+ * Refresh reuses Kimi's synchronous TTL model: re-read at most every 15s,
14
+ * demanded on each footer render. The only divergence from Codex is that their
15
+ * probe is `async`; ours is `spawnSync` because the footer render path is
16
+ * synchronous.
17
+ */
18
+ import { spawnSync } from "node:child_process";
19
+ /** Status refresh interval — same value Kimi uses (STATUS_TTL_MS). */
20
+ const STATUS_TTL_MS = 15_000;
21
+ /** git spawn timeout — same value Kimi uses (SPAWN_TIMEOUT_MS). */
22
+ const SPAWN_TIMEOUT_MS = 500;
23
+ const SPAWN_MAX_BUFFER = 4 * 1024 * 1024;
24
+ /** Production runner: `spawnSync` with a short timeout, all errors → null. */
25
+ function runGit(args, cwd) {
26
+ try {
27
+ const r = spawnSync("git", ["--no-optional-locks", ...args], {
28
+ cwd,
29
+ encoding: "utf8",
30
+ timeout: SPAWN_TIMEOUT_MS,
31
+ maxBuffer: SPAWN_MAX_BUFFER,
32
+ stdio: ["ignore", "pipe", "ignore"],
33
+ });
34
+ if (r.error || r.status !== 0)
35
+ return null;
36
+ return r.stdout;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ /** True when `cwd` is inside a git work tree. Never throws. */
43
+ export function detectGitRepo(run, cwd) {
44
+ const out = run(["rev-parse", "--is-inside-work-tree"], cwd);
45
+ return out !== null && out.trim() === "true";
46
+ }
47
+ /** Codex's `parseDiffNumstatCount`-equivalent: `-` (binary) → 0; non-numeric → 0. */
48
+ export function parseNumstatCount(value) {
49
+ if (value === undefined || value === "-")
50
+ return 0;
51
+ const n = Number.parseInt(value, 10);
52
+ return Number.isFinite(n) && n > 0 ? n : 0;
53
+ }
54
+ /** List remotes, `origin` first (mirrors Codex's `get_git_remotes`). */
55
+ function gitRemotes(run, cwd) {
56
+ const out = run(["remote"], cwd);
57
+ if (out === null)
58
+ return [];
59
+ const remotes = out.split("\n").map((l) => l.trim()).filter(Boolean);
60
+ const originIdx = remotes.indexOf("origin");
61
+ if (originIdx > 0) {
62
+ remotes.splice(originIdx, 1);
63
+ remotes.unshift("origin");
64
+ }
65
+ return remotes;
66
+ }
67
+ /**
68
+ * Resolve the default branch name, preferring remote-tracking refs over local
69
+ * branches so a stale local `main` doesn't inflate the diff (Codex's ordering).
70
+ * Falls back to common local names when no remote advertises a default.
71
+ * Returns null when nothing resolves.
72
+ */
73
+ export function resolveDefaultBranch(run, cwd) {
74
+ // Tier 1: remote default via `refs/remotes/<remote>/HEAD` symbolic ref.
75
+ for (const remote of gitRemotes(run, cwd)) {
76
+ const out = run(["symbolic-ref", "--quiet", `refs/remotes/${remote}/HEAD`], cwd);
77
+ if (out === null)
78
+ continue;
79
+ const ref = out.trim();
80
+ if (!ref)
81
+ continue;
82
+ // `refs/remotes/origin/main` → `main`.
83
+ const name = ref.replace(/^refs\/remotes\/[^/]+\//, "");
84
+ if (name && name !== "HEAD")
85
+ return name;
86
+ }
87
+ // Tier 2: local main/master/trunk/develop.
88
+ for (const candidate of ["main", "master", "trunk", "develop"]) {
89
+ const out = run(["rev-parse", "--verify", "--quiet", candidate], cwd);
90
+ if (out !== null && out.trim())
91
+ return candidate;
92
+ }
93
+ return null;
94
+ }
95
+ /**
96
+ * Sum the committed line delta between `HEAD` and the merge base with the
97
+ * default branch: `git diff --numstat <merge-base>..HEAD`. Returns null when
98
+ * the default branch or merge base can't be resolved (hide the badge).
99
+ */
100
+ export function readDiffStat(run, cwd) {
101
+ const defaultBranch = resolveDefaultBranch(run, cwd);
102
+ if (defaultBranch === null)
103
+ return null;
104
+ const mergeBase = run(["merge-base", "HEAD", defaultBranch], cwd);
105
+ if (mergeBase === null || !mergeBase.trim())
106
+ return null;
107
+ const out = run(["diff", "--numstat", `${mergeBase.trim()}..HEAD`, "--"], cwd);
108
+ if (out === null)
109
+ return null;
110
+ let added = 0;
111
+ let deleted = 0;
112
+ for (const line of out.split("\n")) {
113
+ if (!line)
114
+ continue;
115
+ const [addedText, deletedText] = line.split("\t");
116
+ added += parseNumstatCount(addedText);
117
+ deleted += parseNumstatCount(deletedText);
118
+ }
119
+ return { added, deleted };
120
+ }
121
+ /**
122
+ * Kimi-style display: `+N -M`, or null when the branch is even with its base
123
+ * (a fresh/unchanged branch shows no badge). Both-zero still renders nothing,
124
+ * matching "our style" (Kimi hides a clean tree; Codex would say "No changes").
125
+ */
126
+ export function formatDiffStat(diff) {
127
+ if (diff === null)
128
+ return null;
129
+ const parts = [];
130
+ if (diff.added > 0)
131
+ parts.push(`+${String(diff.added)}`);
132
+ if (diff.deleted > 0)
133
+ parts.push(`-${String(diff.deleted)}`);
134
+ return parts.length > 0 ? parts.join(" ") : null;
135
+ }
136
+ /**
137
+ * Time-bucketed cache: `get()` re-reads git only when the last read was more
138
+ * than 15s ago, null off-repo. All git failures → null (badge hidden), never
139
+ * throws.
140
+ */
141
+ export function createDiffStatCache(workDir, run = runGit) {
142
+ const isRepo = detectGitRepo(run, workDir);
143
+ let cached = null;
144
+ let fetchedAt = 0;
145
+ return {
146
+ get() {
147
+ if (!isRepo)
148
+ return null;
149
+ const now = Date.now();
150
+ if (cached === null || now - fetchedAt >= STATUS_TTL_MS) {
151
+ cached = readDiffStat(run, workDir);
152
+ fetchedAt = now;
153
+ }
154
+ return cached;
155
+ },
156
+ };
157
+ }
158
+ //# sourceMappingURL=diffStat.js.map
@@ -96,6 +96,8 @@ export declare function renderFooterLines(input: {
96
96
  model: string;
97
97
  /** Current permission mode; shown on line 2 to the left of the model as "<mode> mode". */
98
98
  mode?: PermissionMode | null;
99
+ /** Formatted `+N -M` / `±`, or null when clean — appended to the branch as `[ … ]`. */
100
+ diff?: string | null;
99
101
  usage: UsageTotals;
100
102
  contextPercent: number | null;
101
103
  statuses: string[];
@@ -42,6 +42,7 @@ import { spawnSync } from "node:child_process";
42
42
  import { statSync } from "node:fs";
43
43
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
44
44
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
45
+ import { createDiffStatCache, formatDiffStat } from "./diffStat.js";
45
46
  export const BRANCH_MAX_WIDTH = 60;
46
47
  const WORKTREE_MAX_WIDTH = 30;
47
48
  /** Section separator: single space + middle dot + single space. */
@@ -229,13 +230,18 @@ export function renderFooterLines(input, theme, width, padX = 0) {
229
230
  // terminal edge. Truncation runs against the reduced content width.
230
231
  const pad = " ".repeat(Math.max(0, Math.min(3, Math.floor(padX))));
231
232
  const contentWidth = Math.max(1, width - pad.length);
232
- // Line 1: folder · [worktree] · branch
233
+ // Line 1: folder · [worktree] · branch [+N -M]
233
234
  const line1Parts = [theme.fg("accent", input.git.folder)];
234
235
  if (input.git.inRepo) {
235
236
  if (input.git.worktree)
236
237
  line1Parts.push(theme.fg("warning", `[${input.git.worktree}]`));
237
- if (input.git.branch)
238
- line1Parts.push(theme.fg("border", truncateEnd(input.git.branch, BRANCH_MAX_WIDTH, dim("…"))));
238
+ if (input.git.branch) {
239
+ const branchSpan = theme.fg("border", truncateEnd(input.git.branch, BRANCH_MAX_WIDTH, dim("…")));
240
+ // Diff badge rides the branch segment (Kimi's `branch [ +N -M ]` shape),
241
+ // colored dim — the same grey as the cost stats on line 2.
242
+ const diffSpan = input.diff ? ` ${dim(`[${input.diff}]`)}` : "";
243
+ line1Parts.push(branchSpan + diffSpan);
244
+ }
239
245
  }
240
246
  const line1 = pad + truncateToWidth(line1Parts.join(sep), contentWidth, dim("…"));
241
247
  // Line 2: [mode ·] model · ↑in ↓out $cost · ctx%
@@ -269,17 +275,23 @@ export function renderFooterLines(input, theme, width, padX = 0) {
269
275
  export function createYagniFooterFactory(ctx, modeHolder, invalidateHandle) {
270
276
  return (_tui, theme, footerData) => {
271
277
  let gitCache;
272
- const unsubscribeBranch = footerData.onBranchChange?.(() => {
278
+ let diffStatCache;
279
+ const clearCaches = () => {
273
280
  gitCache = undefined;
274
- });
281
+ diffStatCache = undefined;
282
+ };
283
+ const unsubscribeBranch = footerData.onBranchChange?.(clearCaches);
275
284
  const gitInfo = () => {
276
285
  if (!gitCache) {
277
- gitCache = detectGitInfo(ctx.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE);
286
+ // Read cwd once; both the git info and the diff cache key off it.
287
+ const dir = ctx.sessionManager.getCwd();
288
+ gitCache = detectGitInfo(dir, process.env.HOME || process.env.USERPROFILE);
289
+ diffStatCache = createDiffStatCache(dir);
278
290
  }
279
291
  return gitCache;
280
292
  };
281
293
  if (invalidateHandle) {
282
- invalidateHandle.invalidateGit = () => { gitCache = undefined; };
294
+ invalidateHandle.invalidateGit = clearCaches;
283
295
  invalidateHandle.requestRender = () => { _tui?.requestRender?.(); };
284
296
  }
285
297
  return {
@@ -291,13 +303,14 @@ export function createYagniFooterFactory(ctx, modeHolder, invalidateHandle) {
291
303
  git: gitInfo(),
292
304
  model: ctx.model?.id ?? "no-model",
293
305
  mode: modeHolder?.get() ?? null,
306
+ diff: formatDiffStat(diffStatCache?.get() ?? null),
294
307
  usage: collectUsage(ctx.sessionManager),
295
308
  contextPercent: ctx.getContextUsage()?.percent ?? null,
296
309
  statuses,
297
310
  }, theme, width, resolveFooterPadX());
298
311
  },
299
312
  invalidate() {
300
- gitCache = undefined;
313
+ clearCaches();
301
314
  },
302
315
  dispose() {
303
316
  unsubscribeBranch?.();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.0.0-staging.1183.1",
3
+ "version": "1.0.0-staging.1184.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)",
@@ -41,5 +41,5 @@
41
41
  "turndown": "^7.2.4",
42
42
  "typebox": "^1.3.15"
43
43
  },
44
- "yagniSourceSha": "723499f455c038162f0ddb3bc886e7e3c55b07f5"
44
+ "yagniSourceSha": "4e0c38ced9c2bc27fd28c01af4e22a1539fd1637"
45
45
  }