@gavin-hu/my-pi-agent 0.13.0 → 0.14.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 +14 -11
- package/README.zh-CN.md +12 -12
- package/extensions/bytefly/README.md +163 -61
- package/extensions/bytefly/doctor/config.ts +4 -1
- package/extensions/bytefly/doctor/runtime.ts +2 -0
- package/extensions/bytefly/footer/budget.ts +64 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +22 -4
- package/extensions/bytefly/footer/price/config.ts +18 -1
- package/extensions/bytefly/footer/price/types.ts +5 -0
- package/extensions/bytefly/footer/register.ts +34 -3
- package/extensions/bytefly/header/component.ts +30 -8
- package/extensions/bytefly/header/config.ts +22 -0
- package/extensions/bytefly/header/register.ts +2 -1
- package/extensions/bytefly/index.ts +7 -5
- package/extensions/bytefly/notify/config.ts +1 -1
- package/extensions/bytefly/notify/register.ts +15 -1
- package/extensions/bytefly/panel/commands.ts +1 -1
- package/extensions/bytefly/panel/format.ts +12 -19
- package/extensions/bytefly/panel/settings-runtime.ts +62 -18
- package/extensions/bytefly/panel/settings.ts +269 -42
- package/extensions/bytefly/panel/tui/settings-view.ts +49 -3
- package/extensions/bytefly/panel/tui.ts +87 -19
- package/extensions/bytefly/separator/component.ts +7 -6
- package/extensions/bytefly/separator/config.ts +15 -0
- package/extensions/bytefly/separator/format.ts +60 -6
- package/extensions/bytefly/separator/register.ts +68 -9
- package/extensions/bytefly/separator/types.ts +8 -0
- package/extensions/bytefly/working/config.ts +4 -5
- package/extensions/bytefly/working/format.ts +24 -7
- package/extensions/bytefly/working/register.ts +34 -3
- package/extensions/job/format.ts +4 -7
- package/extensions/memory/README.md +2 -0
- package/extensions/plan/README.md +12 -0
- package/extensions/subagent/README.md +60 -5
- package/extensions/subagent/config.ts +58 -0
- package/extensions/subagent/index.ts +27 -1
- package/extensions/subagent/orchestrate.ts +16 -3
- package/extensions/subagent/render.ts +12 -0
- package/extensions/subagent/run.ts +100 -37
- package/extensions/subagent/transcripts.ts +202 -0
- package/extensions/subagent/types.ts +12 -0
- package/extensions/todo/nudge.ts +5 -1
- package/extensions/watch/README.md +13 -5
- package/extensions/watch/commands.ts +4 -3
- package/extensions/watch/config.ts +1 -1
- package/extensions/watch/format.ts +4 -7
- package/extensions/watch/runtime.ts +83 -12
- package/extensions/wechat/README.md +2 -0
- package/extensions/worktree/README.md +7 -1
- package/extensions/worktree/enter.ts +10 -3
- package/extensions/worktree/exit.ts +25 -5
- package/extensions/worktree/git.ts +28 -0
- package/extensions/worktree/index.ts +2 -0
- package/extensions/worktree/root-tools.ts +1 -1
- package/extensions/worktree/runtime.ts +5 -0
- package/lib/README.md +5 -3
- package/lib/config.ts +33 -9
- package/lib/format.ts +6 -0
- package/lib/requirements.ts +80 -1
- package/lib/tui/columns.ts +17 -0
- package/lib/tui/tool.ts +6 -2
- package/package.json +2 -1
- package/themes/bytefly-dark.json +2 -3
- package/themes/bytefly-light.json +2 -3
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The stateful Settings section: one snapshot of the two config documents and
|
|
3
|
-
* the personal settings, the dirty flag, and the
|
|
3
|
+
* the personal settings, the dirty flag, and the writes it offers.
|
|
4
4
|
*
|
|
5
5
|
* App rows are written through Pi's `SettingsStore` (personal file only), and
|
|
6
|
-
* `bytefly.json` rows through the per-key
|
|
7
|
-
* sibling key or another extension's section. A
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* `bytefly.json` rows through the per-key writers, so an edit cannot disturb a
|
|
7
|
+
* sibling key or another extension's section. A reset deletes one leaf through the
|
|
8
|
+
* same path, and the JSON pane reads the section the row belongs to. A failed
|
|
9
|
+
* write leaves the snapshot untouched and reports through `onError`; the screen
|
|
10
|
+
* never shows a value the file did not accept.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import { join } from "node:path";
|
|
13
14
|
import { CONFIG_DIR_NAME, type QuietStartup } from "@earendil-works/pi-coding-agent";
|
|
14
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
configSection,
|
|
17
|
+
configValueAt,
|
|
18
|
+
readJson,
|
|
19
|
+
SHARED_CONFIG_FILE,
|
|
20
|
+
unsetConfigValueAt,
|
|
21
|
+
writeConfigValueAt,
|
|
22
|
+
} from "../../../lib/config.ts";
|
|
15
23
|
import type { SettingsStore, WriteScope } from "../picker/types.ts";
|
|
16
24
|
import {
|
|
17
|
-
|
|
25
|
+
type ConfigSectionName,
|
|
18
26
|
nextValue,
|
|
19
27
|
type SettingDescriptor,
|
|
20
28
|
type SettingsSnapshot,
|
|
@@ -23,7 +31,6 @@ import {
|
|
|
23
31
|
settingViews,
|
|
24
32
|
storedValue,
|
|
25
33
|
textError,
|
|
26
|
-
validValue,
|
|
27
34
|
} from "./settings.ts";
|
|
28
35
|
|
|
29
36
|
/** Host seams for the Settings runtime. */
|
|
@@ -40,11 +47,14 @@ export interface SettingsRuntimeDeps {
|
|
|
40
47
|
/** What the panel needs from the runtime. */
|
|
41
48
|
export interface SettingsSurface {
|
|
42
49
|
views(scope: WriteScope): SettingView[];
|
|
43
|
-
count(scope: WriteScope): number;
|
|
44
50
|
/** Cycle an enum or toggle a boolean. */
|
|
45
51
|
change(scope: WriteScope, id: string): Promise<void>;
|
|
46
52
|
/** Store a value the user typed; `false` when the row refused it. */
|
|
47
53
|
setValue(scope: WriteScope, id: string, raw: string): Promise<boolean>;
|
|
54
|
+
/** Delete the focused row's leaf so its default applies; `false` when nothing changed. */
|
|
55
|
+
reset(scope: WriteScope, id: string): Promise<boolean>;
|
|
56
|
+
/** The raw `bytefly.json` section for `scope`, for the JSON pane. */
|
|
57
|
+
sectionDocument(scope: WriteScope, section: ConfigSectionName): unknown;
|
|
48
58
|
isDirty(): boolean;
|
|
49
59
|
}
|
|
50
60
|
|
|
@@ -62,8 +72,8 @@ export function configPath(scope: WriteScope, cwd: string, agentDir: string): st
|
|
|
62
72
|
function readSnapshot(deps: SettingsRuntimeDeps): SettingsSnapshot {
|
|
63
73
|
return {
|
|
64
74
|
app: deps.stores.global.globalSettings(),
|
|
65
|
-
global: readJson(
|
|
66
|
-
project: deps.trusted ? (readJson(
|
|
75
|
+
global: readJson(configPath("global", deps.cwd, deps.agentDir)) ?? {},
|
|
76
|
+
project: deps.trusted ? (readJson(configPath("project", deps.cwd, deps.agentDir)) ?? {}) : {},
|
|
67
77
|
};
|
|
68
78
|
}
|
|
69
79
|
|
|
@@ -133,10 +143,15 @@ export function createSettingsRuntime(deps: SettingsRuntimeDeps): SettingsRuntim
|
|
|
133
143
|
|
|
134
144
|
return {
|
|
135
145
|
views: (scope) => settingViews(snapshot, scope),
|
|
136
|
-
count: (scope) => countSettings(scope),
|
|
137
146
|
change: async (scope, id) => {
|
|
138
147
|
const row = SETTING_ROWS.find((entry) => entry.id === id);
|
|
139
148
|
if (!row) return;
|
|
149
|
+
// Only a boolean or an enum cycles; a text row is stored from its editor,
|
|
150
|
+
// so refusing here keeps `nextValue`'s cycle contract honest.
|
|
151
|
+
if (row.kind === "text") {
|
|
152
|
+
deps.onError?.(`${row.label} is edited as text, not cycled.`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
140
155
|
const refused = refusal(row, scope, deps.trusted);
|
|
141
156
|
if (refused !== undefined) {
|
|
142
157
|
deps.onError?.(refused);
|
|
@@ -145,10 +160,6 @@ export function createSettingsRuntime(deps: SettingsRuntimeDeps): SettingsRuntim
|
|
|
145
160
|
const current = settingViews(snapshot, scope).find((entry) => entry.id === id);
|
|
146
161
|
if (!current) return;
|
|
147
162
|
const next = nextValue(row, current.value);
|
|
148
|
-
if (!validValue(row, next.value)) {
|
|
149
|
-
deps.onError?.(`Cannot save ${row.label}: unknown value.`);
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
163
|
try {
|
|
153
164
|
await write(scope, row, next.value);
|
|
154
165
|
} catch (error) {
|
|
@@ -159,8 +170,8 @@ export function createSettingsRuntime(deps: SettingsRuntimeDeps): SettingsRuntim
|
|
|
159
170
|
},
|
|
160
171
|
async setValue(scope, id, raw) {
|
|
161
172
|
const row = SETTING_ROWS.find((entry) => entry.id === id);
|
|
162
|
-
if (!row
|
|
163
|
-
deps.onError?.(`${
|
|
173
|
+
if (!row) {
|
|
174
|
+
deps.onError?.(`${id} is not a setting.`);
|
|
164
175
|
return false;
|
|
165
176
|
}
|
|
166
177
|
const refused = refusal(row, scope, deps.trusted);
|
|
@@ -182,6 +193,39 @@ export function createSettingsRuntime(deps: SettingsRuntimeDeps): SettingsRuntim
|
|
|
182
193
|
committed();
|
|
183
194
|
return true;
|
|
184
195
|
},
|
|
196
|
+
async reset(scope, id) {
|
|
197
|
+
const row = SETTING_ROWS.find((entry) => entry.id === id);
|
|
198
|
+
if (!row) {
|
|
199
|
+
deps.onError?.(`${id} is not a setting.`);
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
if (row.source === "app") {
|
|
203
|
+
deps.onError?.(`${row.label} is a Pi setting; Pi has no unset, so set the value instead.`);
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
const refused = refusal(row, scope, deps.trusted);
|
|
207
|
+
if (refused !== undefined) {
|
|
208
|
+
deps.onError?.(refused);
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
const config = row.config;
|
|
212
|
+
if (!config) return false;
|
|
213
|
+
const document = scope === "global" ? snapshot.global : snapshot.project;
|
|
214
|
+
if (configValueAt(configSection(document, config.section), config.path) === undefined) {
|
|
215
|
+
deps.onError?.(`${row.label} is already at its default.`);
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
unsetConfigValueAt(configPath(scope, deps.cwd, deps.agentDir), config.section, config.path);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
deps.onError?.(`Could not reset ${row.label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
committed();
|
|
225
|
+
return true;
|
|
226
|
+
},
|
|
227
|
+
sectionDocument: (scope, section) =>
|
|
228
|
+
configSection(scope === "global" ? snapshot.global : snapshot.project, section),
|
|
185
229
|
isDirty: () => dirty,
|
|
186
230
|
onChange: (listener) => {
|
|
187
231
|
listeners.add(listener);
|
|
@@ -6,44 +6,56 @@
|
|
|
6
6
|
* views, cycling, validation, and grouping — so the screen and the runtime share
|
|
7
7
|
* one definition and the tests need no host.
|
|
8
8
|
*
|
|
9
|
-
* The catalogue
|
|
10
|
-
*
|
|
11
|
-
* value the screen accepts is a value the loader keeps.
|
|
12
|
-
* the
|
|
13
|
-
*
|
|
9
|
+
* The catalogue is curated rather than exhaustive: it holds the preferences a
|
|
10
|
+
* person changes, across every extension of the package, and each row states its
|
|
11
|
+
* own rule so a value the screen accepts is a value the loader keeps. The rest of
|
|
12
|
+
* the document stays JSON, reachable from the screen's JSON pane: a list
|
|
13
|
+
* (`doctor.skip`), a table (`price.rates`), and a schedule (`price.peak`) are not
|
|
14
|
+
* one row.
|
|
15
|
+
*
|
|
16
|
+
* Four sections have a normalizer here, so the screen shows what the footer, the
|
|
17
|
+
* notifier, and the doctor keep. The six owned by other extensions are read as the
|
|
18
|
+
* file states them, because importing their normalizer would be one extension
|
|
19
|
+
* importing another's module; each such row declares the loader's default, and
|
|
20
|
+
* `test/settings-coverage.test.ts` holds the pair together.
|
|
14
21
|
*/
|
|
15
22
|
|
|
16
23
|
import { configSection, configValueAt } from "../../../lib/config.ts";
|
|
17
|
-
import { GLYPHS } from "../../../lib/tui/glyphs.ts";
|
|
18
24
|
import { DEFAULT_CHROME_CONFIG, isRailGlyph, normalizeChromeConfig } from "../chrome.ts";
|
|
19
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
DEFAULT_CONFIG as DEFAULT_DOCTOR_CONFIG,
|
|
27
|
+
normalizeConfig as normalizeDoctorConfig,
|
|
28
|
+
TIMEOUT_MS_RANGE,
|
|
29
|
+
} from "../doctor/config.ts";
|
|
20
30
|
import {
|
|
21
31
|
CURRENCY_DEFAULTS,
|
|
22
32
|
DEFAULT_BYTEFLY_CONFIG,
|
|
23
33
|
DEFAULT_CURRENCY,
|
|
34
|
+
MAX_BUDGET,
|
|
35
|
+
MAX_DECIMALS,
|
|
24
36
|
normalizeByteflyConfig,
|
|
25
37
|
} from "../footer/price/config.ts";
|
|
26
38
|
import type { CurrencyCode } from "../footer/price/types.ts";
|
|
27
|
-
import { DEFAULT_NOTIFY_CONFIG, normalizeNotifyConfig } from "../notify/config.ts";
|
|
39
|
+
import { DEFAULT_NOTIFY_CONFIG, MAX_TITLE_LENGTH, normalizeNotifyConfig } from "../notify/config.ts";
|
|
28
40
|
import type { Settings, WriteScope } from "../picker/types.ts";
|
|
29
41
|
|
|
30
42
|
/** Where a preference is stored. */
|
|
31
|
-
|
|
43
|
+
type SettingSource = "app" | "bytefly";
|
|
32
44
|
|
|
33
45
|
/** The row control a preference uses. */
|
|
34
|
-
|
|
46
|
+
type SettingKind = "boolean" | "enum" | "text";
|
|
35
47
|
|
|
36
48
|
/** Where the effective value came from. */
|
|
37
|
-
|
|
49
|
+
type SettingOrigin = "default" | "global" | "project" | "inherited";
|
|
38
50
|
|
|
39
51
|
/** One curated value a row can take. */
|
|
40
|
-
|
|
52
|
+
interface SettingOption {
|
|
41
53
|
value: string;
|
|
42
54
|
label: string;
|
|
43
55
|
}
|
|
44
56
|
|
|
45
57
|
/** What a text row accepts, checked before a write so the file cannot hold less. */
|
|
46
|
-
|
|
58
|
+
interface TextRule {
|
|
47
59
|
/** Longest accepted value, in the units the loader slices with. */
|
|
48
60
|
maxLength: number;
|
|
49
61
|
/** Why `raw` cannot be stored, or `undefined` when it can. */
|
|
@@ -51,22 +63,32 @@ export interface TextRule {
|
|
|
51
63
|
}
|
|
52
64
|
|
|
53
65
|
/** One heading in the Settings list. */
|
|
54
|
-
|
|
66
|
+
interface SettingGroup {
|
|
55
67
|
key: string;
|
|
56
68
|
label: string;
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
/** The `bytefly.json` sections the Settings section can read and write. */
|
|
60
|
-
export type ConfigSectionName =
|
|
72
|
+
export type ConfigSectionName =
|
|
73
|
+
| "notify"
|
|
74
|
+
| "chrome"
|
|
75
|
+
| "doctor"
|
|
76
|
+
| "price"
|
|
77
|
+
| "worktree"
|
|
78
|
+
| "rewind"
|
|
79
|
+
| "job"
|
|
80
|
+
| "watch"
|
|
81
|
+
| "memory"
|
|
82
|
+
| "goal";
|
|
61
83
|
|
|
62
84
|
/** Where a `bytefly.json` row lives: a section, and the path to its leaf. */
|
|
63
|
-
|
|
85
|
+
interface SettingConfig {
|
|
64
86
|
section: ConfigSectionName;
|
|
65
87
|
path: readonly string[];
|
|
66
88
|
}
|
|
67
89
|
|
|
68
90
|
/** One `bytefly.json` leaf a row writes alongside its own. */
|
|
69
|
-
|
|
91
|
+
interface SettingLeaf {
|
|
70
92
|
section: ConfigSectionName;
|
|
71
93
|
path: readonly string[];
|
|
72
94
|
value: unknown;
|
|
@@ -116,7 +138,7 @@ export interface SettingsSnapshot {
|
|
|
116
138
|
}
|
|
117
139
|
|
|
118
140
|
/** One rendered group: its heading and the rows under it. */
|
|
119
|
-
|
|
141
|
+
interface GroupedSettings {
|
|
120
142
|
key: string;
|
|
121
143
|
label: string;
|
|
122
144
|
views: SettingView[];
|
|
@@ -130,6 +152,10 @@ export const SETTING_GROUPS: readonly SettingGroup[] = [
|
|
|
130
152
|
{ key: "pricing", label: "Pricing" },
|
|
131
153
|
{ key: "transcript", label: "Transcript" },
|
|
132
154
|
{ key: "terminal", label: "Terminal" },
|
|
155
|
+
{ key: "worktree", label: "Worktree" },
|
|
156
|
+
{ key: "snapshots", label: "Snapshots" },
|
|
157
|
+
{ key: "jobs", label: "Jobs and watchers" },
|
|
158
|
+
{ key: "memory", label: "Memory" },
|
|
133
159
|
];
|
|
134
160
|
|
|
135
161
|
const ON_OFF: readonly SettingOption[] = [
|
|
@@ -147,8 +173,8 @@ const METHOD_OPTIONS: readonly SettingOption[] = [
|
|
|
147
173
|
{ value: "notify-send", label: "Linux" },
|
|
148
174
|
];
|
|
149
175
|
|
|
150
|
-
/** Cost precision 0
|
|
151
|
-
const DECIMALS_OPTIONS: readonly SettingOption[] =
|
|
176
|
+
/** Cost precision 0 through the loader's ceiling, the range `price` clamps `decimals` to. */
|
|
177
|
+
const DECIMALS_OPTIONS: readonly SettingOption[] = Array.from({ length: MAX_DECIMALS + 1 }, (_, value) => ({
|
|
152
178
|
value: String(value),
|
|
153
179
|
label: String(value),
|
|
154
180
|
}));
|
|
@@ -173,6 +199,24 @@ function integerRule(min: number, max: number): TextRule {
|
|
|
173
199
|
};
|
|
174
200
|
}
|
|
175
201
|
|
|
202
|
+
/**
|
|
203
|
+
* A text rule for a money amount inside an inclusive range: a whole number or a
|
|
204
|
+
* decimal with up to four places, matching the loader's clamp for `price.budget`.
|
|
205
|
+
* `0` is a legitimate value (the guardrail is off), so it is not rejected.
|
|
206
|
+
*/
|
|
207
|
+
function amountRule(min: number, max: number): TextRule {
|
|
208
|
+
return {
|
|
209
|
+
maxLength: 16,
|
|
210
|
+
validate(raw) {
|
|
211
|
+
const text = raw.trim();
|
|
212
|
+
if (!/^\d+(?:\.\d{1,4})?$/.test(text)) return `Use an amount like 5 or 2.50, ${min} to ${max}.`;
|
|
213
|
+
const value = Number(text);
|
|
214
|
+
if (!Number.isFinite(value) || value < min || value > max) return `Out of range: ${min} to ${max}.`;
|
|
215
|
+
return undefined;
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
176
220
|
/** The rail's rule: the loader drops anything that is not one column. */
|
|
177
221
|
const RAIL_RULE: TextRule = {
|
|
178
222
|
maxLength: 4,
|
|
@@ -274,7 +318,7 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
|
|
|
274
318
|
fallback: "Pi",
|
|
275
319
|
options: [],
|
|
276
320
|
text: {
|
|
277
|
-
maxLength:
|
|
321
|
+
maxLength: MAX_TITLE_LENGTH,
|
|
278
322
|
validate: (raw) => (raw.trim() === "" ? "A title is required." : undefined),
|
|
279
323
|
},
|
|
280
324
|
config: { section: "notify", path: ["title"] },
|
|
@@ -295,13 +339,13 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
|
|
|
295
339
|
id: "doctor.timeoutMs",
|
|
296
340
|
group: "diagnostics",
|
|
297
341
|
label: "Probe timeout",
|
|
298
|
-
description:
|
|
342
|
+
description: `How long one doctor probe may take, in milliseconds; ${TIMEOUT_MS_RANGE.min} to ${TIMEOUT_MS_RANGE.max}.`,
|
|
299
343
|
source: "bytefly",
|
|
300
344
|
kind: "text",
|
|
301
345
|
fallback: String(DEFAULT_DOCTOR_CONFIG.timeoutMs),
|
|
302
346
|
json: "number",
|
|
303
347
|
options: [],
|
|
304
|
-
text: integerRule(
|
|
348
|
+
text: integerRule(TIMEOUT_MS_RANGE.min, TIMEOUT_MS_RANGE.max),
|
|
305
349
|
config: { section: "doctor", path: ["timeoutMs"] },
|
|
306
350
|
},
|
|
307
351
|
{
|
|
@@ -339,6 +383,19 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
|
|
|
339
383
|
options: DECIMALS_OPTIONS,
|
|
340
384
|
config: { section: "price", path: ["currency", "decimals"] },
|
|
341
385
|
},
|
|
386
|
+
{
|
|
387
|
+
id: "price.budget",
|
|
388
|
+
group: "pricing",
|
|
389
|
+
label: "Session budget",
|
|
390
|
+
description: "Warn once when the session's cost reaches this amount; 0 is off. It never stops a turn.",
|
|
391
|
+
source: "bytefly",
|
|
392
|
+
kind: "text",
|
|
393
|
+
fallback: "0",
|
|
394
|
+
json: "number",
|
|
395
|
+
options: [],
|
|
396
|
+
text: amountRule(0, MAX_BUDGET),
|
|
397
|
+
config: { section: "price", path: ["budget"] },
|
|
398
|
+
},
|
|
342
399
|
{
|
|
343
400
|
id: "chrome.rail",
|
|
344
401
|
group: "transcript",
|
|
@@ -346,11 +403,25 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
|
|
|
346
403
|
description: "The single-column glyph that leads a tool result or notice body, e.g. ╎ │ ┃ ▏.",
|
|
347
404
|
source: "bytefly",
|
|
348
405
|
kind: "text",
|
|
349
|
-
fallback:
|
|
406
|
+
fallback: DEFAULT_CHROME_CONFIG.rail,
|
|
350
407
|
options: [],
|
|
351
408
|
text: RAIL_RULE,
|
|
352
409
|
config: { section: "chrome", path: ["rail"] },
|
|
353
410
|
},
|
|
411
|
+
{
|
|
412
|
+
id: "goal.achieved",
|
|
413
|
+
group: "transcript",
|
|
414
|
+
label: "Achieved goal",
|
|
415
|
+
description: "How a completed goal renders: the dimmed rail line, or nothing.",
|
|
416
|
+
source: "bytefly",
|
|
417
|
+
kind: "enum",
|
|
418
|
+
fallback: "hide",
|
|
419
|
+
options: [
|
|
420
|
+
{ value: "show", label: "Show" },
|
|
421
|
+
{ value: "hide", label: "Hide" },
|
|
422
|
+
],
|
|
423
|
+
config: { section: "goal", path: ["achieved"] },
|
|
424
|
+
},
|
|
354
425
|
{
|
|
355
426
|
id: "outputPad",
|
|
356
427
|
group: "transcript",
|
|
@@ -376,6 +447,147 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
|
|
|
376
447
|
json: "boolean",
|
|
377
448
|
options: ON_OFF,
|
|
378
449
|
},
|
|
450
|
+
{
|
|
451
|
+
id: "worktree.baseRef",
|
|
452
|
+
group: "worktree",
|
|
453
|
+
label: "Base ref",
|
|
454
|
+
description: "Where a new worktree branches from: the remote default branch, or the current local HEAD.",
|
|
455
|
+
source: "bytefly",
|
|
456
|
+
kind: "enum",
|
|
457
|
+
fallback: "fresh",
|
|
458
|
+
options: [
|
|
459
|
+
{ value: "fresh", label: "Fresh" },
|
|
460
|
+
{ value: "head", label: "Head" },
|
|
461
|
+
],
|
|
462
|
+
config: { section: "worktree", path: ["baseRef"] },
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
id: "worktree.branchPrefix",
|
|
466
|
+
group: "worktree",
|
|
467
|
+
label: "Branch prefix",
|
|
468
|
+
description: "Prepended to every branch a managed worktree creates.",
|
|
469
|
+
source: "bytefly",
|
|
470
|
+
kind: "text",
|
|
471
|
+
fallback: "worktree-",
|
|
472
|
+
options: [],
|
|
473
|
+
text: {
|
|
474
|
+
// The loader trims and rejects an empty prefix but sets no ceiling; this
|
|
475
|
+
// only stops a runaway buffer.
|
|
476
|
+
maxLength: 80,
|
|
477
|
+
validate: (raw) => (raw.trim() === "" ? "A prefix is required." : undefined),
|
|
478
|
+
},
|
|
479
|
+
config: { section: "worktree", path: ["branchPrefix"] },
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
id: "worktree.fetchRemote",
|
|
483
|
+
group: "worktree",
|
|
484
|
+
label: "Fetch remote",
|
|
485
|
+
description: "Fetch the remote before branching, so a new worktree starts from its latest commit.",
|
|
486
|
+
source: "bytefly",
|
|
487
|
+
kind: "boolean",
|
|
488
|
+
fallback: "true",
|
|
489
|
+
json: "boolean",
|
|
490
|
+
options: ON_OFF,
|
|
491
|
+
config: { section: "worktree", path: ["fetchRemote"] },
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
id: "worktree.pruneAfterDays",
|
|
495
|
+
group: "worktree",
|
|
496
|
+
label: "Prune after (days)",
|
|
497
|
+
description: "Age at which `/worktree prune` removes a clean worktree nobody is using.",
|
|
498
|
+
source: "bytefly",
|
|
499
|
+
kind: "text",
|
|
500
|
+
fallback: "7",
|
|
501
|
+
json: "number",
|
|
502
|
+
options: [],
|
|
503
|
+
text: integerRule(0, 3650),
|
|
504
|
+
config: { section: "worktree", path: ["pruneAfterDays"] },
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
id: "rewind.autoSnapshots",
|
|
508
|
+
group: "snapshots",
|
|
509
|
+
label: "Automatic snapshots",
|
|
510
|
+
description: "Take one rewind snapshot per prompt, before the first mutating tool call.",
|
|
511
|
+
source: "bytefly",
|
|
512
|
+
kind: "boolean",
|
|
513
|
+
fallback: "true",
|
|
514
|
+
json: "boolean",
|
|
515
|
+
options: ON_OFF,
|
|
516
|
+
config: { section: "rewind", path: ["autoSnapshots"] },
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
id: "rewind.max",
|
|
520
|
+
group: "snapshots",
|
|
521
|
+
label: "Snapshots kept",
|
|
522
|
+
description: "Snapshots kept per repository before the oldest are pruned; 0 keeps none.",
|
|
523
|
+
source: "bytefly",
|
|
524
|
+
kind: "text",
|
|
525
|
+
fallback: "20",
|
|
526
|
+
json: "number",
|
|
527
|
+
options: [],
|
|
528
|
+
text: integerRule(0, 1000),
|
|
529
|
+
config: { section: "rewind", path: ["max"] },
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
id: "rewind.safetySnapshot",
|
|
533
|
+
group: "snapshots",
|
|
534
|
+
label: "Safety snapshot",
|
|
535
|
+
description: "Snapshot the current state before a restore, so a rewind can itself be undone.",
|
|
536
|
+
source: "bytefly",
|
|
537
|
+
kind: "boolean",
|
|
538
|
+
fallback: "true",
|
|
539
|
+
json: "boolean",
|
|
540
|
+
options: ON_OFF,
|
|
541
|
+
config: { section: "rewind", path: ["safetySnapshot"] },
|
|
542
|
+
},
|
|
543
|
+
{
|
|
544
|
+
id: "job.wakeOnFinish",
|
|
545
|
+
group: "jobs",
|
|
546
|
+
label: "Wake when a job finishes",
|
|
547
|
+
description: "Start one agent turn when any background job finishes.",
|
|
548
|
+
source: "bytefly",
|
|
549
|
+
kind: "boolean",
|
|
550
|
+
fallback: "false",
|
|
551
|
+
json: "boolean",
|
|
552
|
+
options: ON_OFF,
|
|
553
|
+
config: { section: "job", path: ["wakeOnFinish"] },
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
id: "job.detachedByDefault",
|
|
557
|
+
group: "jobs",
|
|
558
|
+
label: "Keep jobs after exit",
|
|
559
|
+
description: "Leave jobs running when the session ends, unless the job opts out.",
|
|
560
|
+
source: "bytefly",
|
|
561
|
+
kind: "boolean",
|
|
562
|
+
fallback: "false",
|
|
563
|
+
json: "boolean",
|
|
564
|
+
options: ON_OFF,
|
|
565
|
+
config: { section: "job", path: ["detachedByDefault"] },
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
id: "watch.wakeOnMatch",
|
|
569
|
+
group: "jobs",
|
|
570
|
+
label: "Wake on a match",
|
|
571
|
+
description: "Start one agent turn when a watcher matches while the agent is idle.",
|
|
572
|
+
source: "bytefly",
|
|
573
|
+
kind: "boolean",
|
|
574
|
+
fallback: "false",
|
|
575
|
+
json: "boolean",
|
|
576
|
+
options: ON_OFF,
|
|
577
|
+
config: { section: "watch", path: ["wakeOnMatch"] },
|
|
578
|
+
},
|
|
579
|
+
{
|
|
580
|
+
id: "memory.inject",
|
|
581
|
+
group: "memory",
|
|
582
|
+
label: "Inject notes",
|
|
583
|
+
description: "Put stored memory notes in context before each run.",
|
|
584
|
+
source: "bytefly",
|
|
585
|
+
kind: "boolean",
|
|
586
|
+
fallback: "true",
|
|
587
|
+
json: "boolean",
|
|
588
|
+
options: ON_OFF,
|
|
589
|
+
config: { section: "memory", path: ["inject"] },
|
|
590
|
+
},
|
|
379
591
|
];
|
|
380
592
|
|
|
381
593
|
/** The rows a scope can write: app rows are personal-file only. */
|
|
@@ -383,21 +595,13 @@ export function rowsForScope(scope: WriteScope): readonly SettingDescriptor[] {
|
|
|
383
595
|
return scope === "global" ? SETTING_ROWS : SETTING_ROWS.filter((row) => row.source === "bytefly");
|
|
384
596
|
}
|
|
385
597
|
|
|
386
|
-
/** The number of rows the section shows for `scope`. */
|
|
387
|
-
export function countSettings(scope: WriteScope): number {
|
|
388
|
-
return rowsForScope(scope).length;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
/** Whether `raw` is one of a row's curated options. */
|
|
392
|
-
export function validValue(row: SettingDescriptor, raw: string): boolean {
|
|
393
|
-
return row.options.some((option) => option.value === raw);
|
|
394
|
-
}
|
|
395
|
-
|
|
396
598
|
/**
|
|
397
599
|
* Why a text row cannot store `raw`, or `undefined` when it can.
|
|
398
600
|
*
|
|
399
|
-
* The limits
|
|
400
|
-
*
|
|
601
|
+
* The limits are the loaders' own (`notify.title` is sliced to `MAX_TITLE_LENGTH`,
|
|
602
|
+
* the rail to one column) so the editor cannot save a value the next read shortens.
|
|
603
|
+
* A row that is not text is refused here rather than in the caller, so a programmatic
|
|
604
|
+
* `setValue` gets the same reason the screen would show.
|
|
401
605
|
*/
|
|
402
606
|
export function textError(row: SettingDescriptor, raw: string): string | undefined {
|
|
403
607
|
const rule = row.text;
|
|
@@ -418,16 +622,16 @@ function appValue(snapshot: SettingsSnapshot, row: SettingDescriptor): { value:
|
|
|
418
622
|
switch (row.id) {
|
|
419
623
|
case "byteflyUi": {
|
|
420
624
|
const quiet = snapshot.app.quietStartup;
|
|
421
|
-
return { value: quiet === true ? "true" :
|
|
625
|
+
return { value: quiet === true ? "true" : row.fallback, origin: quiet === undefined ? "default" : "global" };
|
|
422
626
|
}
|
|
423
627
|
case "outputPad":
|
|
424
628
|
return {
|
|
425
|
-
value: snapshot.app.outputPad === 0 ? "0" :
|
|
629
|
+
value: snapshot.app.outputPad === 0 ? "0" : row.fallback,
|
|
426
630
|
origin: snapshot.app.outputPad === undefined ? "default" : "global",
|
|
427
631
|
};
|
|
428
632
|
case "showHardwareCursor":
|
|
429
633
|
return {
|
|
430
|
-
value: snapshot.app.showHardwareCursor === true ? "true" :
|
|
634
|
+
value: snapshot.app.showHardwareCursor === true ? "true" : row.fallback,
|
|
431
635
|
origin: snapshot.app.showHardwareCursor === undefined ? "default" : "global",
|
|
432
636
|
};
|
|
433
637
|
default:
|
|
@@ -453,7 +657,11 @@ type SectionReader = (
|
|
|
453
657
|
path: readonly string[],
|
|
454
658
|
) => unknown;
|
|
455
659
|
|
|
456
|
-
|
|
660
|
+
/**
|
|
661
|
+
* The four sections bytefly owns, each read through its own normalizer so the
|
|
662
|
+
* screen shows the value the loader keeps.
|
|
663
|
+
*/
|
|
664
|
+
const SECTION_READERS: Partial<Record<ConfigSectionName, SectionReader>> = {
|
|
457
665
|
notify: (globalRaw, projectRaw, path) =>
|
|
458
666
|
configValueAt(normalizeNotifyConfig(projectRaw, normalizeNotifyConfig(globalRaw, DEFAULT_NOTIFY_CONFIG)), path),
|
|
459
667
|
chrome: (globalRaw, projectRaw, path) =>
|
|
@@ -464,7 +672,22 @@ const SECTION_READERS: Record<ConfigSectionName, SectionReader> = {
|
|
|
464
672
|
configValueAt(normalizeByteflyConfig(projectRaw, normalizeByteflyConfig(globalRaw, DEFAULT_BYTEFLY_CONFIG)), path),
|
|
465
673
|
};
|
|
466
674
|
|
|
467
|
-
/**
|
|
675
|
+
/**
|
|
676
|
+
* Read a leaf whose owning extension owns the normalizer.
|
|
677
|
+
*
|
|
678
|
+
* A section without a reader here belongs to another extension, and importing its
|
|
679
|
+
* normalizer would be one extension importing another's module. So the row states
|
|
680
|
+
* the loader's default in `fallback`, `test/settings-coverage.test.ts` holds the
|
|
681
|
+
* two together, and the visible cost is that a hand-edited value outside the
|
|
682
|
+
* loader's clamp shows as written until it is reset.
|
|
683
|
+
*/
|
|
684
|
+
function rawLeaf(snapshot: SettingsSnapshot, config: SettingConfig, scope: WriteScope): unknown {
|
|
685
|
+
const projectLeaf =
|
|
686
|
+
scope === "project" ? configValueAt(configSection(snapshot.project, config.section), config.path) : undefined;
|
|
687
|
+
return projectLeaf ?? configValueAt(configSection(snapshot.global, config.section), config.path);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** Read a `bytefly.json` preference: normalized where bytefly owns the section. */
|
|
468
691
|
function byteflyValue(
|
|
469
692
|
snapshot: SettingsSnapshot,
|
|
470
693
|
row: SettingDescriptor,
|
|
@@ -474,8 +697,12 @@ function byteflyValue(
|
|
|
474
697
|
if (!config) throw new Error(`Setting ${row.id} has no config section`);
|
|
475
698
|
const globalRaw = configSection(snapshot.global, config.section);
|
|
476
699
|
const projectRaw = scope === "project" ? configSection(snapshot.project, config.section) : undefined;
|
|
477
|
-
const
|
|
700
|
+
const reader = SECTION_READERS[config.section];
|
|
701
|
+
const leaf = reader ? reader(globalRaw, projectRaw, config.path) : rawLeaf(snapshot, config, scope);
|
|
478
702
|
return {
|
|
703
|
+
// A normalizer is total, so its fallback only covers a future row whose
|
|
704
|
+
// normalizer has no default yet; a raw read needs it when neither document
|
|
705
|
+
// states the key.
|
|
479
706
|
value: leaf === undefined ? row.fallback : String(leaf),
|
|
480
707
|
origin: originFor(config, snapshot, scope),
|
|
481
708
|
};
|
|
@@ -57,7 +57,7 @@ export function projectNote(theme: Theme, width: number): string {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/** Where the focused row's value is written, and which scope supplied it. */
|
|
60
|
-
|
|
60
|
+
function originLine(view: SettingView, paths: { scope: WriteScope; cwd: string; agentDir: string }): string {
|
|
61
61
|
const path =
|
|
62
62
|
view.source === "app" ? join(paths.agentDir, PI_SETTINGS_FILE) : configPath(paths.scope, paths.cwd, paths.agentDir);
|
|
63
63
|
const file = sanitizeLine(path);
|
|
@@ -105,8 +105,54 @@ export function settingDetail(options: {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
/** The section's key hints, in the order they drop as the width runs out. */
|
|
108
|
-
export function settingsHints(): string[] {
|
|
109
|
-
return [
|
|
108
|
+
export function settingsHints(json = false): string[] {
|
|
109
|
+
return [
|
|
110
|
+
"Esc close",
|
|
111
|
+
"←/→",
|
|
112
|
+
"Space change",
|
|
113
|
+
"Tab scope",
|
|
114
|
+
"u reset",
|
|
115
|
+
json ? "J hide" : "J JSON",
|
|
116
|
+
"[ ] group",
|
|
117
|
+
"↑/↓ select",
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The focused row's section as the active scope stores it.
|
|
123
|
+
*
|
|
124
|
+
* This is the only view of the keys the catalogue leaves to JSON, so it names the
|
|
125
|
+
* section and its file rather than pretending to be complete: the pane is clipped
|
|
126
|
+
* to the detail budget and says how many lines it left out. `section` is the row's
|
|
127
|
+
* `bytefly.json` section, which the caller has already established.
|
|
128
|
+
*/
|
|
129
|
+
export function sectionJsonPane(options: {
|
|
130
|
+
theme: Theme;
|
|
131
|
+
width: number;
|
|
132
|
+
section: string;
|
|
133
|
+
view: SettingView;
|
|
134
|
+
document: unknown;
|
|
135
|
+
budget: number;
|
|
136
|
+
paths: { scope: WriteScope; cwd: string; agentDir: string };
|
|
137
|
+
}): string[] {
|
|
138
|
+
const { theme, width, section, view, document, budget, paths } = options;
|
|
139
|
+
const source = paint(theme, "faint", originLine(view, paths));
|
|
140
|
+
const head = `${paint(theme, "meta", sanitizeLine(section))} ${paint(theme, "faint", "—")} ${source}`;
|
|
141
|
+
const lines = [clip(`${INDENT}${head}`, width)];
|
|
142
|
+
const room = Math.max(0, budget - lines.length);
|
|
143
|
+
const body = document === undefined ? [] : JSON.stringify(document, null, 2).split("\n");
|
|
144
|
+
|
|
145
|
+
if (body.length === 0) {
|
|
146
|
+
if (room > 0) lines.push(clip(`${INDENT}${paint(theme, "faint", "Not set in this scope.")}`, width));
|
|
147
|
+
return lines;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const shown = body.slice(0, room);
|
|
151
|
+
if (body.length > room && shown.length > 0) {
|
|
152
|
+
shown[shown.length - 1] = `… ${body.length - room} more lines`;
|
|
153
|
+
}
|
|
154
|
+
for (const line of shown) lines.push(clip(`${INDENT}${paint(theme, "faint", sanitizeLine(line))}`, width));
|
|
155
|
+
return lines;
|
|
110
156
|
}
|
|
111
157
|
|
|
112
158
|
/** The editor's key hints: `Esc` cancels the edit, it does not close the screen. */
|