@yagni-app/code-staging 0.3.0-staging.1098.1 → 0.3.0-staging.1099.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.
@@ -41,6 +41,9 @@
41
41
  import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
42
42
  import type { ModeHolder, PermissionMode } from "./permission.js";
43
43
  export declare const BRANCH_MAX_WIDTH = 60;
44
+ export declare function cyclePermissionMode(current: PermissionMode): PermissionMode;
45
+ export declare function isShiftTab(data: string): boolean;
46
+ export declare const GIT_MUTATING_PATTERN: RegExp;
44
47
  /**
45
48
  * Resolve the status bar's left pad from the launcher's `YAGNI_PAD_X` env so it
46
49
  * aligns with the editor input and the chat/output area on one shared column.
@@ -102,7 +105,11 @@ export declare function renderFooterLines(input: {
102
105
  * and returns the component `setFooter` expects. Called from the
103
106
  * `session_start` handler in index.ts.
104
107
  */
105
- export declare function createYagniFooterFactory(ctx: ExtensionContext, modeHolder?: ModeHolder): (_tui: unknown, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
108
+ export interface FooterInvalidateHandle {
109
+ invalidateGit(): void;
110
+ requestRender(): void;
111
+ }
112
+ export declare function createYagniFooterFactory(ctx: ExtensionContext, modeHolder?: ModeHolder, invalidateHandle?: FooterInvalidateHandle): (_tui: unknown, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
106
113
  render(width: number): string[];
107
114
  invalidate(): void;
108
115
  dispose(): void;
@@ -41,11 +41,20 @@
41
41
  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
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
44
+ import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
45
45
  export const BRANCH_MAX_WIDTH = 60;
46
46
  const WORKTREE_MAX_WIDTH = 30;
47
47
  /** Section separator: single space + middle dot + single space. */
48
48
  const SEP = " · ";
49
+ const MODE_CYCLE = ["auto", "review", "plan"];
50
+ export function cyclePermissionMode(current) {
51
+ const idx = MODE_CYCLE.indexOf(current);
52
+ return MODE_CYCLE[(idx + 1) % MODE_CYCLE.length];
53
+ }
54
+ export function isShiftTab(data) {
55
+ return matchesKey(data, "shift+tab");
56
+ }
57
+ export const GIT_MUTATING_PATTERN = /\bgit\s+(?:checkout|switch|branch|worktree|reset|restore|rebase|merge|cherry-pick|bisect)\b/;
49
58
  /** Default horizontal pad when the launcher didn't forward one (matches outputPad=1). */
50
59
  const DEFAULT_PAD_X = 1;
51
60
  /**
@@ -107,10 +116,10 @@ export function collectUsage(sessionManager) {
107
116
  return totals;
108
117
  }
109
118
  /** End-cut ellipsis truncation (ANSI-aware) so the branch prefix stays readable. */
110
- function truncateEnd(text, maxWidth) {
119
+ function truncateEnd(text, maxWidth, ellipsis = "…") {
111
120
  if (visibleWidth(text) <= maxWidth)
112
121
  return text;
113
- return truncateToWidth(text, maxWidth, "…");
122
+ return truncateToWidth(text, maxWidth, ellipsis);
114
123
  }
115
124
  function runGit(args, cwd) {
116
125
  try {
@@ -193,15 +202,23 @@ export function detectGitInfo(cwd, home) {
193
202
  const worktree = resolveWorktreeLabel(root, branch);
194
203
  return { folder: basename(gitMainRepoRoot(root)), inRepo: true, branch, worktree };
195
204
  }
196
- /** Context color: dim below 70, warning 70-90, error above 90. */
205
+ const MODE_DISPLAY = {
206
+ auto: { text: "⏵⏵ auto mode", color: "accent" },
207
+ review: { text: "✓ review mode", color: "warning" },
208
+ plan: { text: "⏸ plan mode", color: "success" },
209
+ };
210
+ function modeDisplay(mode) {
211
+ return MODE_DISPLAY[mode];
212
+ }
213
+ /** Context color: success below 70, warning 70-90, error above 90. */
197
214
  function contextColor(percent) {
198
215
  if (percent === null)
199
- return "dim";
216
+ return "success";
200
217
  if (percent > 90)
201
218
  return "error";
202
219
  if (percent > 70)
203
220
  return "warning";
204
- return "dim";
221
+ return "success";
205
222
  }
206
223
  /** Pure line-builder, exported for tests. All data injected; colors via theme. */
207
224
  export function renderFooterLines(input, theme, width, padX = 0) {
@@ -218,7 +235,7 @@ export function renderFooterLines(input, theme, width, padX = 0) {
218
235
  if (input.git.worktree)
219
236
  line1Parts.push(theme.fg("warning", `[${input.git.worktree}]`));
220
237
  if (input.git.branch)
221
- line1Parts.push(theme.fg("border", truncateEnd(input.git.branch, BRANCH_MAX_WIDTH)));
238
+ line1Parts.push(theme.fg("border", truncateEnd(input.git.branch, BRANCH_MAX_WIDTH, dim("…"))));
222
239
  }
223
240
  const line1 = pad + truncateToWidth(line1Parts.join(sep), contentWidth, dim("…"));
224
241
  // Line 2: [mode ·] model · ↑in ↓out $cost · ctx%
@@ -232,8 +249,10 @@ export function renderFooterLines(input, theme, width, padX = 0) {
232
249
  const stats = statParts.join(" ");
233
250
  const percentText = input.contextPercent === null ? "?" : `${Math.round(input.contextPercent)}%`;
234
251
  const line2Parts = [];
235
- if (input.mode)
236
- line2Parts.push(dim(`${input.mode} mode`));
252
+ if (input.mode) {
253
+ const modeLabel = modeDisplay(input.mode);
254
+ line2Parts.push(theme.fg(modeLabel.color, modeLabel.text) + dim(" (shift+tab to change)"));
255
+ }
237
256
  line2Parts.push(dim(input.model));
238
257
  if (stats)
239
258
  line2Parts.push(dim(stats));
@@ -247,17 +266,8 @@ export function renderFooterLines(input, theme, width, padX = 0) {
247
266
  }
248
267
  return lines;
249
268
  }
250
- /**
251
- * Create a footer factory that captures the session `ctx` (for session data)
252
- * and returns the component `setFooter` expects. Called from the
253
- * `session_start` handler in index.ts.
254
- */
255
- export function createYagniFooterFactory(ctx, modeHolder) {
269
+ export function createYagniFooterFactory(ctx, modeHolder, invalidateHandle) {
256
270
  return (_tui, theme, footerData) => {
257
- // Recompute git/worktree info only when the branch actually changes. Optional-
258
- // chained: onBranchChange is typed on ReadonlyFooterDataProvider, but the runtime
259
- // provider is whatever pi version is installed — guard so a mismatch can't break
260
- // footer construction (worst case, git info just doesn't auto-invalidate).
261
271
  let gitCache;
262
272
  const unsubscribeBranch = footerData.onBranchChange?.(() => {
263
273
  gitCache = undefined;
@@ -268,6 +278,10 @@ export function createYagniFooterFactory(ctx, modeHolder) {
268
278
  }
269
279
  return gitCache;
270
280
  };
281
+ if (invalidateHandle) {
282
+ invalidateHandle.invalidateGit = () => { gitCache = undefined; };
283
+ invalidateHandle.requestRender = () => { _tui?.requestRender?.(); };
284
+ }
271
285
  return {
272
286
  render(width) {
273
287
  const statuses = [...footerData.getExtensionStatuses().entries()]
@@ -20,7 +20,7 @@ import { registerCostCommand } from "./costHud.js";
20
20
  import { isDebug } from "./diagnostics.js";
21
21
  import { droppedSessionRuns, sessionRunIds } from "./sessionRuns.js";
22
22
  import { codeStateHome } from "./stateHome.js";
23
- import { createYagniFooterFactory, formatCwd } from "./footer.js";
23
+ import { createYagniFooterFactory, cyclePermissionMode, formatCwd, GIT_MUTATING_PATTERN, isShiftTab } from "./footer.js";
24
24
  import { RerouteNotifier } from "./rerouteNotice.js";
25
25
  import { isFreshWorkspace, registerTeamSetupCommand, runInitPass as defaultRunInitPass } from "./initPass.js";
26
26
  import { isInitDone as defaultIsInitDone, markInitDone as defaultMarkInitDone } from "./initDone.js";
@@ -250,6 +250,7 @@ export async function registerYagni(pi, deps = {}) {
250
250
  // Mutating MCP tools join write/edit/bash in the gate policy: plan mode
251
251
  // holds them, review mode confirms them.
252
252
  const modeHolder = createModeHolder();
253
+ const footerInvalidateHandle = { invalidateGit: () => { }, requestRender: () => { } };
253
254
  const guardianState = makeGuardianState();
254
255
  // Disabled by the local env override OR the workspace kill switch
255
256
  // (yagni_code.guardian, read from the catalog response at launch). The env
@@ -717,7 +718,15 @@ export async function registerYagni(pi, deps = {}) {
717
718
  // context % on line 2, and extension statuses (brand, todos, mode) on
718
719
  // line 3. The factory captures ctx so the footer can read session data
719
720
  // (token stats, context usage) that isn't on the footerData provider.
720
- ctx.ui?.setFooter?.((tui, theme, footerData) => createYagniFooterFactory(ctx, modeHolder)(tui, theme, footerData));
721
+ ctx.ui?.setFooter?.((tui, theme, footerData) => createYagniFooterFactory(ctx, modeHolder, footerInvalidateHandle)(tui, theme, footerData));
722
+ ctx.ui?.onTerminalInput?.((data) => {
723
+ if (isShiftTab(data)) {
724
+ modeHolder.set(cyclePermissionMode(modeHolder.get()));
725
+ footerInvalidateHandle.requestRender();
726
+ return { consume: true };
727
+ }
728
+ return undefined;
729
+ });
721
730
  // Label the collapsed chain-of-thought line so users know it is reasoning
722
731
  // and how to reveal the full trace. Harmless when reasoning is expanded
723
732
  // (the label only shows on hidden thinking blocks). Fails closed to pi's
@@ -814,6 +823,16 @@ export async function registerYagni(pi, deps = {}) {
814
823
  }
815
824
  }
816
825
  });
826
+ pi.on("tool_result", (event) => {
827
+ if (event.toolName !== "bash")
828
+ return;
829
+ const command = typeof event.input?.command === "string"
830
+ ? event.input.command
831
+ : "";
832
+ if (GIT_MUTATING_PATTERN.test(command)) {
833
+ footerInvalidateHandle.invalidateGit();
834
+ }
835
+ });
817
836
  }
818
837
  export default async function (pi) {
819
838
  await registerYagni(pi);
@@ -41,6 +41,7 @@ export type PermissionMode = "auto" | "plan" | "review";
41
41
  export interface ModeHolder {
42
42
  get(): PermissionMode;
43
43
  set(m: PermissionMode): void;
44
+ onSet(fn: (m: PermissionMode) => void): void;
44
45
  }
45
46
  export declare function createModeHolder(initial?: PermissionMode): ModeHolder;
46
47
  /** Which tools each tier acts on, plus the optional grounding-bless predicate. */
@@ -33,9 +33,15 @@ import { isDebug } from "./diagnostics.js";
33
33
  import { buildDiagnosticEvent, checkCircuitBreaker, DEFAULT_GUARDIAN_LIMITS, } from "./guardian.js";
34
34
  export function createModeHolder(initial = "auto") {
35
35
  let current = initial;
36
+ const listeners = new Set();
36
37
  return {
37
38
  get: () => current,
38
- set: (m) => { current = m; },
39
+ set: (m) => {
40
+ current = m;
41
+ for (const fn of listeners)
42
+ fn(m);
43
+ },
44
+ onSet: (fn) => { listeners.add(fn); },
39
45
  };
40
46
  }
41
47
  export const DEFAULT_PERMISSION_POLICY = {
@@ -199,11 +205,6 @@ export function filterStaleModeContext(messages, currentMode) {
199
205
  }
200
206
  /** Legacy alias — the original plan-mode filter name. */
201
207
  export const filterStalePlanContext = filterStaleModeContext;
202
- const MODE_STATUS = {
203
- auto: undefined,
204
- plan: "⏸ plan",
205
- review: "✓ review",
206
- };
207
208
  const MODE_COPY = {
208
209
  auto: "auto: coding changes apply directly; external tracker changes ask first (default).",
209
210
  plan: "plan: write, edit, and bash are held so the agent can explore and propose only.",
@@ -257,6 +258,11 @@ export function registerPermissionGate(pi, deps = {}) {
257
258
  const basePolicy = deps.policy ?? DEFAULT_PERMISSION_POLICY;
258
259
  let mode = deps.mode ?? "auto";
259
260
  const makeStore = deps.makeBlessStore ?? defaultMakeBlessStore;
261
+ deps.modeHolder?.onSet((m) => {
262
+ if (m !== mode)
263
+ approvedCommands.clear();
264
+ mode = m;
265
+ });
260
266
  // The session bless store is created lazily on the first tool_call (it needs
261
267
  // the cwd). Its isBlessed backs the review-mode auto-approve, UNLESS the caller
262
268
  // injected its own isBlessed (e.g. a test policy) — that always wins.
@@ -453,7 +459,7 @@ export function registerPermissionGate(pi, deps = {}) {
453
459
  }
454
460
  // Show the reviewing chip.
455
461
  if (ctx?.hasUI)
456
- ctx.ui.setStatus?.("yagni-guardian", "🛡 reviewing");
462
+ ctx.ui.setStatus?.("yagni-guardian", "Guardian Reviewing");
457
463
  const startMs = Date.now();
458
464
  let reviewResult;
459
465
  try {
@@ -746,13 +752,6 @@ export function registerPermissionGate(pi, deps = {}) {
746
752
  });
747
753
  const paintMode = (ctx) => {
748
754
  deps.modeHolder?.set(mode);
749
- try {
750
- if (ctx.hasUI)
751
- ctx.ui.setStatus?.("yagni-mode", MODE_STATUS[mode]);
752
- }
753
- catch {
754
- // The chip is chrome; never let it break /mode.
755
- }
756
755
  };
757
756
  pi.registerCommand("mode", {
758
757
  description: "Set the permission tier: /mode auto | plan | review. No argument shows the current mode.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "0.3.0-staging.1098.1",
3
+ "version": "0.3.0-staging.1099.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)",
@@ -39,5 +39,5 @@
39
39
  "smol-toml": "^1.8.0",
40
40
  "typebox": "^1.3.11"
41
41
  },
42
- "yagniSourceSha": "41fc21aacfab499d38635d4dad5bace90249b438"
42
+ "yagniSourceSha": "c94b255f76f60efa95f4c2c016b1c533bbeae49f"
43
43
  }