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 +2 -1
- package/package.json +7 -8
- package/src/core/config.ts +9 -6
- package/src/core/ops.ts +30 -4
- package/src/core/sync.ts +43 -12
- package/src/index.ts +28 -16
- package/src/ui/settings.ts +30 -10
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
|
|
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.
|
|
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.
|
|
58
|
-
"@earendil-works/pi-tui": "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": "
|
|
66
|
-
"@earendil-works/pi-tui": "
|
|
63
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
64
|
+
"@earendil-works/pi-tui": "*",
|
|
65
|
+
"typebox": "*"
|
|
67
66
|
},
|
|
68
67
|
"scripts": {
|
|
69
68
|
"typecheck": "tsc --noEmit",
|
package/src/core/config.ts
CHANGED
|
@@ -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
|
|
3
|
-
//
|
|
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,
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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 {
|
|
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 ??
|
|
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 (
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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(`
|
|
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(`
|
|
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("
|
|
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(`
|
|
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("
|
|
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(`
|
|
276
|
+
if (ctx.hasUI && message !== lastSyncError) ctx.ui.notify(`Sync paused: ${message}`, "warning");
|
|
265
277
|
lastSyncError = message;
|
|
266
278
|
}
|
|
267
279
|
});
|
package/src/ui/settings.ts
CHANGED
|
@@ -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
|
|
6
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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
|
-
?
|
|
180
|
-
:
|
|
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
|
-
?
|
|
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,
|
|
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) => {
|