@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.
Files changed (64) hide show
  1. package/README.md +14 -11
  2. package/README.zh-CN.md +12 -12
  3. package/extensions/bytefly/README.md +163 -61
  4. package/extensions/bytefly/doctor/config.ts +4 -1
  5. package/extensions/bytefly/doctor/runtime.ts +2 -0
  6. package/extensions/bytefly/footer/budget.ts +64 -0
  7. package/extensions/bytefly/footer/collect/snapshot.ts +22 -4
  8. package/extensions/bytefly/footer/price/config.ts +18 -1
  9. package/extensions/bytefly/footer/price/types.ts +5 -0
  10. package/extensions/bytefly/footer/register.ts +34 -3
  11. package/extensions/bytefly/header/component.ts +30 -8
  12. package/extensions/bytefly/header/config.ts +22 -0
  13. package/extensions/bytefly/header/register.ts +2 -1
  14. package/extensions/bytefly/index.ts +7 -5
  15. package/extensions/bytefly/notify/config.ts +1 -1
  16. package/extensions/bytefly/notify/register.ts +15 -1
  17. package/extensions/bytefly/panel/commands.ts +1 -1
  18. package/extensions/bytefly/panel/format.ts +12 -19
  19. package/extensions/bytefly/panel/settings-runtime.ts +62 -18
  20. package/extensions/bytefly/panel/settings.ts +269 -42
  21. package/extensions/bytefly/panel/tui/settings-view.ts +49 -3
  22. package/extensions/bytefly/panel/tui.ts +87 -19
  23. package/extensions/bytefly/separator/component.ts +7 -6
  24. package/extensions/bytefly/separator/config.ts +15 -0
  25. package/extensions/bytefly/separator/format.ts +60 -6
  26. package/extensions/bytefly/separator/register.ts +68 -9
  27. package/extensions/bytefly/separator/types.ts +8 -0
  28. package/extensions/bytefly/working/config.ts +4 -5
  29. package/extensions/bytefly/working/format.ts +24 -7
  30. package/extensions/bytefly/working/register.ts +34 -3
  31. package/extensions/job/format.ts +4 -7
  32. package/extensions/memory/README.md +2 -0
  33. package/extensions/plan/README.md +12 -0
  34. package/extensions/subagent/README.md +60 -5
  35. package/extensions/subagent/config.ts +58 -0
  36. package/extensions/subagent/index.ts +27 -1
  37. package/extensions/subagent/orchestrate.ts +16 -3
  38. package/extensions/subagent/render.ts +12 -0
  39. package/extensions/subagent/run.ts +100 -37
  40. package/extensions/subagent/transcripts.ts +202 -0
  41. package/extensions/subagent/types.ts +12 -0
  42. package/extensions/todo/nudge.ts +5 -1
  43. package/extensions/watch/README.md +13 -5
  44. package/extensions/watch/commands.ts +4 -3
  45. package/extensions/watch/config.ts +1 -1
  46. package/extensions/watch/format.ts +4 -7
  47. package/extensions/watch/runtime.ts +83 -12
  48. package/extensions/wechat/README.md +2 -0
  49. package/extensions/worktree/README.md +7 -1
  50. package/extensions/worktree/enter.ts +10 -3
  51. package/extensions/worktree/exit.ts +25 -5
  52. package/extensions/worktree/git.ts +28 -0
  53. package/extensions/worktree/index.ts +2 -0
  54. package/extensions/worktree/root-tools.ts +1 -1
  55. package/extensions/worktree/runtime.ts +5 -0
  56. package/lib/README.md +5 -3
  57. package/lib/config.ts +33 -9
  58. package/lib/format.ts +6 -0
  59. package/lib/requirements.ts +80 -1
  60. package/lib/tui/columns.ts +17 -0
  61. package/lib/tui/tool.ts +6 -2
  62. package/package.json +2 -1
  63. package/themes/bytefly-dark.json +2 -3
  64. 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 two write paths.
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 writer, so an edit cannot disturb a
7
- * sibling key or another extension's section. A failed write leaves the
8
- * snapshot untouched and reports through `onError`; the screen never shows a
9
- * value the file did not accept.
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 { readJson, SHARED_CONFIG_FILE, writeConfigValueAt } from "../../../lib/config.ts";
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
- countSettings,
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(join(deps.agentDir, SHARED_CONFIG_FILE)) ?? {},
66
- project: deps.trusted ? (readJson(join(deps.cwd, CONFIG_DIR_NAME, SHARED_CONFIG_FILE)) ?? {}) : {},
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 || row.kind !== "text") {
163
- deps.onError?.(`${row?.label ?? id} cannot be edited as text.`);
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 covers every scalar preference: booleans, enums, short strings,
10
- * and — with `doctor.timeoutMs` — a number. Each row states its own rule, so a
11
- * value the screen accepts is a value the loader keeps. Arrays (`doctor.skip`),
12
- * the `rates` table, and `price.peak` stay JSON edits: a list, a table, and a
13
- * nested object are not one row.
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 { DEFAULT_CONFIG as DEFAULT_DOCTOR_CONFIG, normalizeConfig as normalizeDoctorConfig } from "../doctor/config.ts";
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
- export type SettingSource = "app" | "bytefly";
43
+ type SettingSource = "app" | "bytefly";
32
44
 
33
45
  /** The row control a preference uses. */
34
- export type SettingKind = "boolean" | "enum" | "text";
46
+ type SettingKind = "boolean" | "enum" | "text";
35
47
 
36
48
  /** Where the effective value came from. */
37
- export type SettingOrigin = "default" | "global" | "project" | "inherited";
49
+ type SettingOrigin = "default" | "global" | "project" | "inherited";
38
50
 
39
51
  /** One curated value a row can take. */
40
- export interface SettingOption {
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
- export interface TextRule {
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
- export interface SettingGroup {
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 = "notify" | "chrome" | "doctor" | "price";
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
- export interface SettingConfig {
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
- export interface SettingLeaf {
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
- export interface GroupedSettings {
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-4, the range the `price` section clamps `decimals` to. */
151
- const DECIMALS_OPTIONS: readonly SettingOption[] = [0, 1, 2, 3, 4].map((value) => ({
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: 40,
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: "How long one doctor probe may take, in milliseconds; 100 to 30000.",
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(100, 30_000),
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: GLYPHS.divider,
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 mirror the loaders (`notify.title` is sliced to 40, the currency
400
- * symbol to 4) so the editor cannot save a value the next read shortens.
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" : "false", origin: quiet === undefined ? "default" : "global" };
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" : "1",
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" : "false",
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
- const SECTION_READERS: Record<ConfigSectionName, SectionReader> = {
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
- /** Read a `bytefly.json` preference, normalized over the global section. */
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 leaf = SECTION_READERS[config.section](globalRaw, projectRaw, config.path);
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
- export function originLine(view: SettingView, paths: { scope: WriteScope; cwd: string; agentDir: string }): string {
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 ["Esc close", "←/→", "Space change", "Tab scope", "↑/↓ select"];
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. */