pi-fovea 0.6.0 → 0.7.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/README.md CHANGED
@@ -129,7 +129,7 @@ Files over the size cap and extraction failures remain visible in `/fovea status
129
129
 
130
130
  Continuous sync is on by default. Before an agent starts, Fovea establishes its baseline or injects any out-of-band drift before the first model call. After every assistant turn it compares extracted symbols, calls, imports, literals, and anchors again. Content hashes keep the unchanged fast path cheap, while comment- and formatting-only edits do not wake the model.
131
131
 
132
- A meaningful change found before agent start is injected directly into that run. A post-turn route or dependency change is sent with `deliverAs: "steer"`; if the agent would otherwise settle, `triggerTurn` starts the continuation automatically. The compact update names directly changed files, route deltas, newly relevant files, causal channels such as calls, imports, shared literals, tests, or co-change history, and a suggested next focus probe so the update continues graph navigation. Clean turns remain silent unless `sync.ackClean` is enabled.
132
+ A meaningful change found before agent start is injected directly into that run. A post-turn route or dependency change is sent with `deliverAs: "steer"`; if the agent would otherwise settle, `triggerTurn` starts the continuation automatically. The compact update names directly changed files, route deltas, newly relevant files, causal channels such as calls, imports, shared literals, tests, or co-change history, and—by default—the top drift target's refreshed focus context embedded inline (push). With `sync.pushFocus` off, the update ends in a suggested next focus probe instead (pull). Clean turns remain silent unless `sync.ackClean` is enabled.
133
133
 
134
134
  Runtime controls:
135
135
 
@@ -154,6 +154,7 @@ Global settings live in `~/.pi/agent/fovea.json`. A trusted repo-level override
154
154
  | `sync.budget` | `1024` | token cap for proactive steering context |
155
155
  | `sync.ackClean` | `false` | toast after clean structural turns |
156
156
  | `sync.warmFileThreshold` | `2` | newly relevant files that justify proactive model steering |
157
+ | `sync.pushFocus` | `true` | embed a budgeted focus preview of the top drift target in red syncs |
157
158
  | `tools.defaultBudget` | `2000` | fallback maxTokens for the fovea_* tools |
158
159
  | `tools.replaceGrep` | `true` | install hybrid native-text / bare-query graph grep |
159
160
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fovea",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Token-budgeted repo mapping for agent sessions: foveated heat diffusion over a cross-language code graph, with progressive disclosure.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -50,20 +50,19 @@
50
50
  "bin": {
51
51
  "fovea": "cli.ts"
52
52
  },
53
- "dependencies": {
54
- "typebox": "^1.0.76"
55
- },
56
53
  "devDependencies": {
57
- "@earendil-works/pi-coding-agent": "0.83.0",
58
- "@earendil-works/pi-tui": "0.83.0",
54
+ "@earendil-works/pi-coding-agent": "0.84.0",
55
+ "@earendil-works/pi-tui": "0.84.0",
59
56
  "@types/node": "^22.0.0",
60
57
  "tsx": "^4.19.0",
58
+ "typebox": "^1.3.10",
61
59
  "typescript": "^5.6.0",
62
60
  "vitest": "^3.0.0"
63
61
  },
64
62
  "peerDependencies": {
65
- "@earendil-works/pi-coding-agent": ">=0.80.6",
66
- "@earendil-works/pi-tui": ">=0.80.6"
63
+ "@earendil-works/pi-coding-agent": "*",
64
+ "@earendil-works/pi-tui": "*",
65
+ "typebox": "*"
67
66
  },
68
67
  "scripts": {
69
68
  "typecheck": "tsc --noEmit",
@@ -1,11 +1,11 @@
1
1
  // pi-fovea configuration. Mirrors pi-fabric's two-scope model: a global file
2
- // under the pi agent dir (~/.pi/agent/fovea.json) and a project override at
3
- // <repo>/.pi/fovea.json when the project is trusted. Settings merge over
2
+ // under the pi agent dir and a trusted project override beneath pi's
3
+ // configurable project resource directory. Settings merge over
4
4
  // defaults; the FOVEA_TURN_SYNC=off environment variable always wins, the
5
5
  // same way PI_FABRIC_* overrides win over stored fabric config.
6
6
 
7
+ import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
7
8
  import fs from "node:fs";
8
- import os from "node:os";
9
9
  import path from "node:path";
10
10
 
11
11
  interface FoveaSyncConfig {
@@ -17,6 +17,9 @@ interface FoveaSyncConfig {
17
17
  ackClean: boolean;
18
18
  /** Number of newly relevant files that justifies proactive steering on its own. */
19
19
  warmFileThreshold: number;
20
+ /** Push (default): red syncs embed a budgeted focus preview of the top drift
21
+ * target. Pull: the update ends in a Next: advisory the model may follow. */
22
+ pushFocus: boolean;
20
23
  }
21
24
 
22
25
  interface FoveaToolsConfig {
@@ -37,6 +40,7 @@ export const DEFAULT_FOVEA_CONFIG: FoveaConfig = {
37
40
  budget: 1024,
38
41
  ackClean: false,
39
42
  warmFileThreshold: 2,
43
+ pushFocus: true,
40
44
  },
41
45
  tools: {
42
46
  defaultBudget: 2000,
@@ -59,7 +63,7 @@ export interface FoveaConfigScopes {
59
63
  }
60
64
 
61
65
  export const globalFoveaConfigPath = (agentDir: string): string => path.join(agentDir, "fovea.json");
62
- export const projectFoveaConfigPath = (cwd: string): string => path.join(cwd, ".pi", "fovea.json");
66
+ export const projectFoveaConfigPath = (cwd: string): string => path.join(cwd, CONFIG_DIR_NAME, "fovea.json");
63
67
 
64
68
  const BOUNDS: Record<string, [number, number]> = {
65
69
  "sync.budget": [128, 8192],
@@ -89,6 +93,7 @@ const applyPartial = (base: FoveaConfig, partial: unknown): FoveaConfig => {
89
93
  budget: intValue("sync.budget", sync.budget, base.sync.budget),
90
94
  ackClean: boolValue(sync.ackClean, base.sync.ackClean),
91
95
  warmFileThreshold: intValue("sync.warmFileThreshold", sync.warmFileThreshold, base.sync.warmFileThreshold),
96
+ pushFocus: boolValue(sync.pushFocus, base.sync.pushFocus),
92
97
  },
93
98
  tools: {
94
99
  defaultBudget: intValue("tools.defaultBudget", tools.defaultBudget, base.tools.defaultBudget),
@@ -151,8 +156,6 @@ export const saveFoveaConfig = (
151
156
  return { scope, path: targetPath };
152
157
  };
153
158
 
154
- export const defaultAgentDir = (): string => path.join(os.homedir(), ".pi", "agent");
155
-
156
159
  /** Dotted-id helper used by the settings UI -> saveFoveaConfig partials. */
157
160
  export const buildPartialFromId = (id: string, value: unknown): Record<string, unknown> => {
158
161
  const segments = id.split(".");
package/src/core/ops.ts CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { createHash } from "node:crypto";
7
7
  import { stat } from "node:fs/promises";
8
- import { posix } from "node:path";
8
+ import { join, posix } from "node:path";
9
9
  import { hasAstGrepAsync } from "./astgrep.js";
10
10
  import {
11
11
  assembleGraphWithIndex,
@@ -55,6 +55,11 @@ export interface RepoState {
55
55
  probedAt: number;
56
56
  walkedAt: number;
57
57
  sweptAt: number;
58
+ /** Porcelain-dirty paths at last probe. Porcelain diffs the worktree against
59
+ * HEAD, but facts track the last seen worktree: a file reverting to
60
+ * porcelain-clean with unmoved HEAD would otherwise keep serving its dirty
61
+ * facts until the next edit. */
62
+ dirty: Set<string>;
58
63
  }
59
64
 
60
65
  // State lifecycle: background builds, probe-gated refreshes, LRU eviction.
@@ -126,6 +131,7 @@ const assembleState = async (
126
131
  extraction: ExtractionReport,
127
132
  gitKind: "git" | "plain",
128
133
  head: string | undefined,
134
+ dirty: Set<string>,
129
135
  ): Promise<RepoState> => {
130
136
  // Snapshot the generation: refresh replaces fact records wholesale, so the
131
137
  // Record view stays a stable witness for baselines (sync).
@@ -148,7 +154,7 @@ const assembleState = async (
148
154
  list.sort((a, b) => Number(a.kind === "contains") - Number(b.kind === "contains") || b.w - a.w || a.to - b.to);
149
155
  }
150
156
  const stamp = Date.now();
151
- return { root, version, graph, csr, joinIndex, facts, extraction, adjacency, store, files, gitKind, head, probedAt: stamp, walkedAt: stamp, sweptAt: stamp };
157
+ return { root, version, graph, csr, joinIndex, facts, extraction, adjacency, store, files, gitKind, head, dirty, probedAt: stamp, walkedAt: stamp, sweptAt: stamp };
152
158
  };
153
159
 
154
160
  const buildState = async (root: string): Promise<RepoState> => {
@@ -163,7 +169,8 @@ const buildState = async (root: string): Promise<RepoState> => {
163
169
  const gitKind: RepoState["gitKind"] = probe ? "git" : "plain";
164
170
  const files = await listFiles(root, routeRes);
165
171
  const { store, report } = await factPass(() => loadFacts(root, files));
166
- return assembleState(root, files, store, report, gitKind, probe?.head);
172
+ return assembleState(root, files, store, report, gitKind, probe?.head,
173
+ new Set(probe ? probe.changes.map((c) => c.path).filter((p) => p && !p.endsWith("/")) : []));
167
174
  };
168
175
 
169
176
  const refreshState = async (state: RepoState, hints: string[] = [], force = false): Promise<RepoState> => {
@@ -205,6 +212,25 @@ const refreshState = async (state: RepoState, hints: string[] = [], force = fals
205
212
  }
206
213
  }
207
214
  }
215
+ const nowDirty = new Set(
216
+ probe.changes.map((c) => c.path).filter((p) => p && !p.endsWith("/")),
217
+ );
218
+ if (!headMoved && !needsList) {
219
+ // Porcelain-clean with unmoved HEAD hides reverts: a previously dirty
220
+ // file vanishes from the probe while its captured facts stay dirty.
221
+ // Resurrect it once so the snapshot follows the worktree (covers
222
+ // checkout/restore and untracked files that disappear).
223
+ for (const p of state.dirty) {
224
+ if (nowDirty.has(p)) continue;
225
+ // stat is the arbiter: a restored file whose facts were dropped
226
+ // with the deletion must come back through changed, not sit
227
+ // deleted until its next porcelain-visible edit.
228
+ const onDisk = await stat(join(state.root, p)).then((s) => s.isFile(), () => false);
229
+ if (onDisk) changed.push(p);
230
+ else if (store.facts.has(p) || store.failedSha.has(p)) deleted.push(p);
231
+ }
232
+ }
233
+ state.dirty = nowDirty;
208
234
  } else {
209
235
  // .git vanished (moved/renamed out from under us): degrade to plain.
210
236
  state.gitKind = "plain";
@@ -237,7 +263,7 @@ const refreshState = async (state: RepoState, hints: string[] = [], force = fals
237
263
  state.files = files;
238
264
  return state;
239
265
  }
240
- const fresh = await assembleState(state.root, files, store, report, state.gitKind, state.head);
266
+ const fresh = await assembleState(state.root, files, store, report, state.gitKind, state.head, state.dirty);
241
267
  states.set(state.root, fresh);
242
268
  return fresh;
243
269
  };
package/src/core/sync.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  // and /fovea reset alongside focus sessions.
13
13
 
14
14
  import { ROOT_CACHE_LIMIT, forEachChunked } from "./asyncutil.js";
15
- import { ensureState, ensureStateBackground, getInflight, getState, impact, isTestScope } from "./ops.js";
15
+ import { ensureState, ensureStateBackground, focus, getInflight, getState, impact, isTestScope } from "./ops.js";
16
16
  import type { RepoState } from "./ops.js";
17
17
  import { getSession } from "./session.js";
18
18
 
@@ -25,6 +25,8 @@ interface SyncBaseline {
25
25
  semantics: Map<string, string>;
26
26
  /** Previously reported undisclosed warmth, for delta delivery. */
27
27
  warmed?: Set<string>;
28
+ /** Drift targets already push-embedded in this baseline chain (embed-once). */
29
+ pushed?: Set<string>;
28
30
  }
29
31
 
30
32
  const baselines = new Map<string, SyncBaseline>();
@@ -50,6 +52,8 @@ export interface SyncParams {
50
52
  files?: string[];
51
53
  budget: number;
52
54
  warmFileThreshold: number;
55
+ /** Push vs pull (default push): embed the top file target's focus context. */
56
+ pushFocus?: boolean;
53
57
  }
54
58
 
55
59
  export interface SyncOutcome {
@@ -168,7 +172,8 @@ export const sync = async (
168
172
  : [...warmNow].filter((file) => !prev.warmed!.has(file));
169
173
  }
170
174
 
171
- setBaseline(root, { ...(await snapshot(state)), warmed: warmNow });
175
+ const pushed = new Set(prev.pushed ?? []);
176
+ setBaseline(root, { ...(await snapshot(state)), warmed: warmNow, pushed });
172
177
 
173
178
  // Extraction failures leave fact gaps that look like anchor *removals* —
174
179
  // never escalate red on removals while degraded; the degraded note is
@@ -196,34 +201,59 @@ export const sync = async (
196
201
  const changedSummary = files.length
197
202
  ? `${files.slice(0, 4).join(", ")}${files.length > 4 ? ` (+${files.length - 4} more)` : ""}`
198
203
  : deleted.length
199
- ? `${deleted.length} deleted file${deleted.length === 1 ? "" : "s"}`
204
+ ? `deleted ${deleted.slice(0, 4).join(", ")}${deleted.length > 4 ? ` (+${deleted.length - 4} more)` : ""}`
200
205
  : "route structure";
201
206
  const orderedWarm = [...warmNew].sort((a, b) =>
202
207
  Number(isTestScope(a)) - Number(isTestScope(b)) || a.localeCompare(b));
203
208
  const lines: string[] = [
204
- "Fovea continuous update — repository structure changed.",
209
+ "Repository structure changed.",
205
210
  `Changed: ${changedSummary}`,
206
211
  ];
207
212
  for (const id of added.filter((anchor) => !newlyImplicit.includes(anchor)).slice(0, 6)) {
208
213
  lines.push(`Route added: ${id}`);
209
214
  }
210
- for (const id of removed.slice(0, 6)) lines.push(`Route removed: ${id}`);
215
+ // Degraded removals are suspect (extraction gaps look like removals); the
216
+ // escalation gate already distrusts them, so the message does too.
217
+ if (!degraded) for (const id of removed.slice(0, 6)) lines.push(`Route removed: ${id}`);
211
218
  if (orderedWarm.length) {
212
219
  lines.push("Newly relevant files:");
213
220
  for (const file of orderedWarm.slice(0, 8)) {
214
221
  lines.push(` ${file} — ${(warmReasons[file] ?? ["graph path"]).join(", ")}`);
215
222
  }
216
223
  }
217
- // Hand the model a next probe, not just a verdict: point focus at the
218
- // most consequential consequence of the drift (new route > changed file >
219
- // newly warm file), so the update continues graph navigation.
224
+ // Push vs pull on the consequence probe. A file target gets its refreshed
225
+ // focus context embedded inline (the embed discloses it, so any follow-up
226
+ // probe is a session delta and nearly free); each target embeds at most
227
+ // once per baseline chain. Route targets keep the advisory pending route
228
+ // fuzzy-match quality, and the no-target case stays verdict-only. Pull
229
+ // mode renders the advisory unconditionally.
220
230
  const focusTarget = added.find((id) => !newlyImplicit.includes(id))?.replace(/^\w+\s+(?=\/)/, "")
221
231
  ?? files[0]
222
232
  ?? orderedWarm[0];
223
- lines.push(focusTarget
224
- ? `Next: fovea_focus ${JSON.stringify(focusTarget)} to see what it now connects to.`
225
- : "Next: fovea_sketch for the updated silhouette.");
226
- lines.push("Steer: account for this update before continuing; inspect only the files relevant to the current task.");
233
+ const pushFocus = params.pushFocus !== false;
234
+ const steerLine = "Steer: account for this update before continuing.";
235
+ let embedded = false;
236
+ if (pushFocus && focusTarget && focusTarget in state.facts && !pushed.has(focusTarget)) {
237
+ const detailBudget = params.budget - Math.ceil([...lines, steerLine].join("\n").length / 4);
238
+ if (detailBudget >= 128) {
239
+ try {
240
+ const detail = await focus(root, focusTarget, detailBudget);
241
+ if (detail.text.trim()) {
242
+ lines.push(...detail.text.split("\n"));
243
+ pushed.add(focusTarget);
244
+ embedded = true;
245
+ }
246
+ } catch {
247
+ // Focus is best-effort context; fall through to the advisory.
248
+ }
249
+ }
250
+ }
251
+ if (!embedded && (!pushFocus || focusTarget)) {
252
+ lines.push(focusTarget
253
+ ? `Next: fovea_focus ${JSON.stringify(focusTarget)} to see what it now connects to.`
254
+ : "Next: fovea_sketch for the updated silhouette.");
255
+ }
256
+ lines.push(steerLine);
227
257
  while (lines.length > 3 && Math.ceil(lines.join("\n").length / 4) > params.budget) {
228
258
  lines.splice(lines.length - 2, 1);
229
259
  }
@@ -239,6 +269,7 @@ export const sync = async (
239
269
  warmNew: orderedWarm,
240
270
  warmReasons,
241
271
  deletedFiles: deleted,
272
+ ...(embedded ? { pushedFocus: focusTarget } : {}),
242
273
  ...(degraded ? { extractionDegraded: true } : {}),
243
274
  },
244
275
  };
package/src/index.ts CHANGED
@@ -4,9 +4,9 @@
4
4
  // incremental across sessions.
5
5
 
6
6
  import { readFileSync } from "node:fs";
7
- import { createGrepTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
+ import { createGrepTool, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
8
  import { Type } from "typebox";
9
- import { defaultAgentDir, loadFoveaConfig, type FoveaConfig } from "./core/config.js";
9
+ import { loadFoveaConfig, type FoveaConfig } from "./core/config.js";
10
10
  import { hasAstGrep } from "./core/astgrep.js";
11
11
  import { ROOT_CACHE_LIMIT } from "./core/asyncutil.js";
12
12
  import { dwell, ensureStateBackground, focus, impact, sketch } from "./core/ops.js";
@@ -78,7 +78,7 @@ export default function fovea(pi: ExtensionAPI) {
78
78
  configs.set(root, hit);
79
79
  return hit;
80
80
  }
81
- const cfg = loadFoveaConfig({ cwd: root, agentDir: agentDir ?? defaultAgentDir(), projectTrusted: trusted });
81
+ const cfg = loadFoveaConfig({ cwd: root, agentDir: agentDir ?? getAgentDir(), projectTrusted: trusted });
82
82
  configs.set(root, cfg);
83
83
  while (configs.size > ROOT_CACHE_LIMIT) configs.delete(configs.keys().next().value!);
84
84
  return cfg;
@@ -104,6 +104,7 @@ export default function fovea(pi: ExtensionAPI) {
104
104
  throw error instanceof Error ? error : new Error(String(error));
105
105
  };
106
106
 
107
+ let lifecycleEpoch = 0;
107
108
  let grepOverrideRegistered = false;
108
109
  const registerGrepOverride = (): void => {
109
110
  if (grepOverrideRegistered) return;
@@ -152,11 +153,13 @@ export default function fovea(pi: ExtensionAPI) {
152
153
  });
153
154
  };
154
155
 
155
- pi.on("session_start", async (event, ctx) => {
156
- if (event.reason === "new" || event.reason === "fork") {
157
- resetSessions();
158
- resetSyncBaselines();
159
- }
156
+ pi.on("session_start", async (_event, ctx) => {
157
+ const epoch = ++lifecycleEpoch;
158
+ // pi 0.84 replaces extension runtimes on resume/fork/new/reload. Core
159
+ // module caches may outlive one factory instance, but disclosure and sync
160
+ // baselines are session-local and must never cross that boundary.
161
+ resetSessions();
162
+ resetSyncBaselines();
160
163
  if (configFor(ctx.cwd, ctx.isProjectTrusted()).tools.replaceGrep) registerGrepOverride();
161
164
  // Kick indexing in the background — the very first prompt must never
162
165
  // wait on hashing/ast-grep. Slow cold builds surface a ready notice so
@@ -168,14 +171,16 @@ export default function fovea(pi: ExtensionAPI) {
168
171
  // unhandled rejection when ast-grep is unavailable.
169
172
  void kick.promise.then(
170
173
  (st) => {
174
+ if (epoch !== lifecycleEpoch) return;
171
175
  const ms = Date.now() - t0;
172
176
  if (kick.started && ctx.hasUI && ms > 4000) {
173
- ctx.ui.notify(`Fovea index ready — ${st.graph.files.length} files (${(ms / 1000).toFixed(1)}s)`, "info");
177
+ ctx.ui.notify(`Index ready — ${st.graph.files.length} files (${(ms / 1000).toFixed(1)}s)`, "info");
174
178
  }
175
179
  },
176
180
  (error) => {
181
+ if (epoch !== lifecycleEpoch) return;
177
182
  if (kick.started && ctx.hasUI) {
178
- ctx.ui.notify(`Fovea index failed: ${error instanceof Error ? error.message : error}`, "warning");
183
+ ctx.ui.notify(`Index failed: ${error instanceof Error ? error.message : error}`, "warning");
179
184
  }
180
185
  },
181
186
  );
@@ -190,6 +195,13 @@ export default function fovea(pi: ExtensionAPI) {
190
195
  // conversation turns exit at zero cost through the version fast path.
191
196
  let turnFiles: string[] = [];
192
197
  let lastSyncError: string | undefined;
198
+ pi.on("session_shutdown", () => {
199
+ lifecycleEpoch++;
200
+ turnFiles = [];
201
+ lastSyncError = undefined;
202
+ resetSessions();
203
+ resetSyncBaselines();
204
+ });
193
205
  pi.on("turn_start", () => {
194
206
  turnFiles = [];
195
207
  });
@@ -204,7 +216,7 @@ export default function fovea(pi: ExtensionAPI) {
204
216
  if (!cfg.sync.enabled) return;
205
217
  const outcome = await sync(
206
218
  ctx.cwd,
207
- { files: [], budget: cfg.sync.budget, warmFileThreshold: cfg.sync.warmFileThreshold },
219
+ { files: [], budget: cfg.sync.budget, warmFileThreshold: cfg.sync.warmFileThreshold, pushFocus: cfg.sync.pushFocus },
208
220
  undefined,
209
221
  { probe: "cheap" },
210
222
  );
@@ -220,11 +232,11 @@ export default function fovea(pi: ExtensionAPI) {
220
232
  };
221
233
  }
222
234
  if (outcome.structural && !outcome.details.baseline && cfg.sync.ackClean && ctx.hasUI) {
223
- ctx.ui.notify("Fovea checked repository changes; no new action is needed.", "info");
235
+ ctx.ui.notify("Checked repository changes; no new action is needed.", "info");
224
236
  }
225
237
  } catch (error) {
226
238
  const message = error instanceof Error ? error.message : String(error);
227
- if (ctx.hasUI && message !== lastSyncError) ctx.ui.notify(`Fovea sync paused: ${message}`, "warning");
239
+ if (ctx.hasUI && message !== lastSyncError) ctx.ui.notify(`Sync paused: ${message}`, "warning");
228
240
  lastSyncError = message;
229
241
  }
230
242
  });
@@ -239,7 +251,7 @@ export default function fovea(pi: ExtensionAPI) {
239
251
  if (!cfg.sync.enabled) return;
240
252
  const outcome = await sync(
241
253
  ctx.cwd,
242
- { files: rels, budget: cfg.sync.budget, warmFileThreshold: cfg.sync.warmFileThreshold },
254
+ { files: rels, budget: cfg.sync.budget, warmFileThreshold: cfg.sync.warmFileThreshold, pushFocus: cfg.sync.pushFocus },
243
255
  undefined,
244
256
  { probe: "cheap" },
245
257
  );
@@ -255,13 +267,13 @@ export default function fovea(pi: ExtensionAPI) {
255
267
  details: outcome.details,
256
268
  }, { deliverAs: "steer", triggerTurn: true });
257
269
  } else if (!outcome.details.baseline && cfg.sync.ackClean && ctx.hasUI) {
258
- ctx.ui.notify("Fovea checked repository changes; no new action is needed.", "info");
270
+ ctx.ui.notify("Checked repository changes; no new action is needed.", "info");
259
271
  }
260
272
  } catch (error) {
261
273
  // Turn-sync stays nonfatal, but a persistent index failure must not look
262
274
  // like a clean repository. Notify once until a successful sync clears it.
263
275
  const message = error instanceof Error ? error.message : String(error);
264
- if (ctx.hasUI && message !== lastSyncError) ctx.ui.notify(`Fovea sync paused: ${message}`, "warning");
276
+ if (ctx.hasUI && message !== lastSyncError) ctx.ui.notify(`Sync paused: ${message}`, "warning");
265
277
  lastSyncError = message;
266
278
  }
267
279
  });
@@ -2,10 +2,11 @@
2
2
  // settings component structure (SettingsList in a bordered Container,
3
3
  // SelectList submenus, dotted-id coercion) so both extensions feel like one
4
4
  // settings surface. Persistence: fovea.json under the pi agent dir, with a
5
- // project override at <repo>/.pi/fovea.json for trusted projects; Ctrl+G in
6
- // the overlay toggles which scope the next change saves to.
5
+ // project override beneath pi's configured project resource directory; the
6
+ // external-editor keybinding toggles which scope the next change saves to.
7
7
 
8
8
  import {
9
+ CONFIG_DIR_NAME,
9
10
  DynamicBorder,
10
11
  getAgentDir,
11
12
  type ExtensionContext,
@@ -14,8 +15,7 @@ import {
14
15
  import {
15
16
  Container,
16
17
  type Component,
17
- Key,
18
- matchesKey,
18
+ type KeybindingsManager,
19
19
  SelectList,
20
20
  type SelectItem,
21
21
  type SelectListLayoutOptions,
@@ -43,7 +43,6 @@ const SELECT_LAYOUT: SelectListLayoutOptions = {
43
43
  maxPrimaryColumnWidth: 32,
44
44
  };
45
45
 
46
- const SAVE_SCOPE_SHORTCUT = Key.ctrl("g");
47
46
 
48
47
  type SettingsSubmenu = (currentValue: string, done: (selectedValue?: string) => void) => Component;
49
48
 
@@ -123,7 +122,13 @@ const numericSubmenu = (
123
122
  );
124
123
  };
125
124
 
125
+ const formatKey = (key: string): string => key
126
+ .split("+")
127
+ .map((part) => part === "ctrl" ? "Ctrl" : part === "alt" ? "Alt" : part === "shift" ? "Shift" : part.toUpperCase())
128
+ .join("+");
129
+
126
130
  export interface FoveaSettingsComponentOptions {
131
+ keybindings?: Pick<KeybindingsManager, "matches" | "getKeys">;
127
132
  initialSaveScope?: FoveaConfigScope;
128
133
  projectScopeAvailable?: boolean;
129
134
  onSaveScopeChange?: (scope: FoveaConfigScope) => void;
@@ -132,6 +137,7 @@ export interface FoveaSettingsComponentOptions {
132
137
  export class FoveaSettingsComponent extends Container {
133
138
  readonly settingsList: SettingsList;
134
139
  private readonly theme: Theme;
140
+ private readonly keybindings: Pick<KeybindingsManager, "matches" | "getKeys"> | undefined;
135
141
  private readonly saveScopeText: Text;
136
142
  private readonly projectScopeAvailable: boolean;
137
143
  private readonly onSaveScopeChange: (scope: FoveaConfigScope) => void;
@@ -146,6 +152,7 @@ export class FoveaSettingsComponent extends Container {
146
152
  ) {
147
153
  super();
148
154
  this.theme = theme;
155
+ this.keybindings = options.keybindings;
149
156
  this.projectScopeAvailable = options.projectScopeAvailable ?? true;
150
157
  this.saveScope = options.initialSaveScope === "global" || !this.projectScopeAvailable
151
158
  ? "global"
@@ -164,7 +171,7 @@ export class FoveaSettingsComponent extends Container {
164
171
  }
165
172
 
166
173
  handleInput(data: string): void {
167
- if (matchesKey(data, SAVE_SCOPE_SHORTCUT)) {
174
+ if (this.keybindings?.matches(data, "app.editor.external")) {
168
175
  if (!this.projectScopeAvailable) return;
169
176
  this.saveScope = this.saveScope === "project" ? "global" : "project";
170
177
  this.updateSaveScopeText();
@@ -174,12 +181,19 @@ export class FoveaSettingsComponent extends Container {
174
181
  this.settingsList.handleInput(data);
175
182
  }
176
183
 
184
+ override invalidate(): void {
185
+ super.invalidate();
186
+ this.updateSaveScopeText();
187
+ }
188
+
177
189
  private updateSaveScopeText(): void {
178
190
  const destination = this.saveScope === "project"
179
- ? "Project (.pi/fovea.json)"
180
- : "Global (~/.pi/agent/fovea.json)";
191
+ ? `Project (${CONFIG_DIR_NAME}/fovea.json)`
192
+ : `Global (~/${CONFIG_DIR_NAME}/agent/fovea.json)`;
193
+ const keys = this.keybindings?.getKeys("app.editor.external") ?? [];
194
+ const shortcut = keys.map(formatKey).join("/");
181
195
  const hint = this.projectScopeAvailable
182
- ? " · Ctrl+G toggles save scope"
196
+ ? shortcut ? ` · ${shortcut} toggles save scope` : " · scope toggle key unavailable"
183
197
  : " · project scope unavailable for untrusted projects";
184
198
  this.saveScopeText.setText(
185
199
  this.theme.fg("muted", "Save scope: ") +
@@ -240,6 +254,11 @@ const buildItems = (
240
254
  "How many newly relevant files justify steering the model. Higher means fewer continuations; route changes always steer.",
241
255
  submenu: numericSubmenu(theme, THRESHOLDS, "Relevance threshold", "Newly relevant file count that steers the model."),
242
256
  }),
257
+ setting("sync.pushFocus", "Push focus detail", config.sync.pushFocus ? "true" : "false", {
258
+ description:
259
+ "Embed a budgeted focus preview of the top drift target in red sync messages so no extra probe turn is needed. Off keeps the Next: advisory. Default on.",
260
+ values: BOOLEANS,
261
+ }),
243
262
  setting("tools.replaceGrep", "Hybrid grep", config.tools.replaceGrep ? "true" : "false", {
244
263
  description:
245
264
  "Preserve native grep for scoped/regex/text searches and use Fovea only for bare symbol queries. Default on; changing it reloads extensions.",
@@ -294,7 +313,7 @@ export const openFoveaSettings = async (
294
313
  deps.onConfigApplied?.();
295
314
  };
296
315
 
297
- await context.ui.custom<void>((tui, theme, _keybindings, done) => {
316
+ await context.ui.custom<void>((tui, theme, keybindings, done) => {
298
317
  const items = buildItems(theme, config);
299
318
  return new FoveaSettingsComponent(
300
319
  theme,
@@ -302,6 +321,7 @@ export const openFoveaSettings = async (
302
321
  (id, newValue) => apply(id, coerceValue(id, newValue, config)),
303
322
  () => done(),
304
323
  {
324
+ keybindings,
305
325
  initialSaveScope: saveScope,
306
326
  projectScopeAvailable: scopes.projectTrusted,
307
327
  onSaveScopeChange: (scope) => {