@gavin-hu/my-pi-agent 0.13.1 → 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 (42) hide show
  1. package/README.md +14 -11
  2. package/README.zh-CN.md +12 -12
  3. package/extensions/bytefly/README.md +140 -58
  4. package/extensions/bytefly/doctor/config.ts +4 -1
  5. package/extensions/bytefly/footer/price/config.ts +3 -2
  6. package/extensions/bytefly/header/component.ts +30 -8
  7. package/extensions/bytefly/header/config.ts +22 -0
  8. package/extensions/bytefly/header/register.ts +2 -1
  9. package/extensions/bytefly/index.ts +3 -3
  10. package/extensions/bytefly/notify/config.ts +1 -1
  11. package/extensions/bytefly/panel/commands.ts +1 -1
  12. package/extensions/bytefly/panel/format.ts +12 -19
  13. package/extensions/bytefly/panel/settings-runtime.ts +62 -18
  14. package/extensions/bytefly/panel/settings.ts +239 -43
  15. package/extensions/bytefly/panel/tui/settings-view.ts +49 -3
  16. package/extensions/bytefly/panel/tui.ts +87 -19
  17. package/extensions/bytefly/separator/component.ts +7 -6
  18. package/extensions/bytefly/separator/config.ts +15 -0
  19. package/extensions/bytefly/separator/format.ts +60 -6
  20. package/extensions/bytefly/separator/register.ts +68 -9
  21. package/extensions/bytefly/separator/types.ts +8 -0
  22. package/extensions/bytefly/working/config.ts +4 -5
  23. package/extensions/bytefly/working/format.ts +24 -7
  24. package/extensions/bytefly/working/register.ts +34 -3
  25. package/extensions/memory/README.md +2 -0
  26. package/extensions/plan/README.md +12 -0
  27. package/extensions/subagent/README.md +60 -5
  28. package/extensions/subagent/config.ts +58 -0
  29. package/extensions/subagent/index.ts +27 -1
  30. package/extensions/subagent/orchestrate.ts +16 -3
  31. package/extensions/subagent/render.ts +12 -0
  32. package/extensions/subagent/run.ts +100 -37
  33. package/extensions/subagent/transcripts.ts +202 -0
  34. package/extensions/subagent/types.ts +12 -0
  35. package/extensions/watch/README.md +2 -0
  36. package/extensions/wechat/README.md +2 -0
  37. package/lib/README.md +1 -1
  38. package/lib/config.ts +33 -9
  39. package/lib/format.ts +6 -0
  40. package/package.json +2 -1
  41. package/themes/bytefly-dark.json +2 -3
  42. package/themes/bytefly-light.json +2 -3
@@ -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
  }));
@@ -292,7 +318,7 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
292
318
  fallback: "Pi",
293
319
  options: [],
294
320
  text: {
295
- maxLength: 40,
321
+ maxLength: MAX_TITLE_LENGTH,
296
322
  validate: (raw) => (raw.trim() === "" ? "A title is required." : undefined),
297
323
  },
298
324
  config: { section: "notify", path: ["title"] },
@@ -313,13 +339,13 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
313
339
  id: "doctor.timeoutMs",
314
340
  group: "diagnostics",
315
341
  label: "Probe timeout",
316
- 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}.`,
317
343
  source: "bytefly",
318
344
  kind: "text",
319
345
  fallback: String(DEFAULT_DOCTOR_CONFIG.timeoutMs),
320
346
  json: "number",
321
347
  options: [],
322
- text: integerRule(100, 30_000),
348
+ text: integerRule(TIMEOUT_MS_RANGE.min, TIMEOUT_MS_RANGE.max),
323
349
  config: { section: "doctor", path: ["timeoutMs"] },
324
350
  },
325
351
  {
@@ -367,7 +393,7 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
367
393
  fallback: "0",
368
394
  json: "number",
369
395
  options: [],
370
- text: amountRule(0, 10_000_000),
396
+ text: amountRule(0, MAX_BUDGET),
371
397
  config: { section: "price", path: ["budget"] },
372
398
  },
373
399
  {
@@ -377,11 +403,25 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
377
403
  description: "The single-column glyph that leads a tool result or notice body, e.g. ╎ │ ┃ ▏.",
378
404
  source: "bytefly",
379
405
  kind: "text",
380
- fallback: GLYPHS.divider,
406
+ fallback: DEFAULT_CHROME_CONFIG.rail,
381
407
  options: [],
382
408
  text: RAIL_RULE,
383
409
  config: { section: "chrome", path: ["rail"] },
384
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
+ },
385
425
  {
386
426
  id: "outputPad",
387
427
  group: "transcript",
@@ -407,6 +447,147 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
407
447
  json: "boolean",
408
448
  options: ON_OFF,
409
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
+ },
410
591
  ];
411
592
 
412
593
  /** The rows a scope can write: app rows are personal-file only. */
@@ -414,21 +595,13 @@ export function rowsForScope(scope: WriteScope): readonly SettingDescriptor[] {
414
595
  return scope === "global" ? SETTING_ROWS : SETTING_ROWS.filter((row) => row.source === "bytefly");
415
596
  }
416
597
 
417
- /** The number of rows the section shows for `scope`. */
418
- export function countSettings(scope: WriteScope): number {
419
- return rowsForScope(scope).length;
420
- }
421
-
422
- /** Whether `raw` is one of a row's curated options. */
423
- export function validValue(row: SettingDescriptor, raw: string): boolean {
424
- return row.options.some((option) => option.value === raw);
425
- }
426
-
427
598
  /**
428
599
  * Why a text row cannot store `raw`, or `undefined` when it can.
429
600
  *
430
- * The limits mirror the loaders (`notify.title` is sliced to 40, the currency
431
- * 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.
432
605
  */
433
606
  export function textError(row: SettingDescriptor, raw: string): string | undefined {
434
607
  const rule = row.text;
@@ -449,16 +622,16 @@ function appValue(snapshot: SettingsSnapshot, row: SettingDescriptor): { value:
449
622
  switch (row.id) {
450
623
  case "byteflyUi": {
451
624
  const quiet = snapshot.app.quietStartup;
452
- return { value: quiet === true ? "true" : "false", origin: quiet === undefined ? "default" : "global" };
625
+ return { value: quiet === true ? "true" : row.fallback, origin: quiet === undefined ? "default" : "global" };
453
626
  }
454
627
  case "outputPad":
455
628
  return {
456
- value: snapshot.app.outputPad === 0 ? "0" : "1",
629
+ value: snapshot.app.outputPad === 0 ? "0" : row.fallback,
457
630
  origin: snapshot.app.outputPad === undefined ? "default" : "global",
458
631
  };
459
632
  case "showHardwareCursor":
460
633
  return {
461
- value: snapshot.app.showHardwareCursor === true ? "true" : "false",
634
+ value: snapshot.app.showHardwareCursor === true ? "true" : row.fallback,
462
635
  origin: snapshot.app.showHardwareCursor === undefined ? "default" : "global",
463
636
  };
464
637
  default:
@@ -484,7 +657,11 @@ type SectionReader = (
484
657
  path: readonly string[],
485
658
  ) => unknown;
486
659
 
487
- 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>> = {
488
665
  notify: (globalRaw, projectRaw, path) =>
489
666
  configValueAt(normalizeNotifyConfig(projectRaw, normalizeNotifyConfig(globalRaw, DEFAULT_NOTIFY_CONFIG)), path),
490
667
  chrome: (globalRaw, projectRaw, path) =>
@@ -495,7 +672,22 @@ const SECTION_READERS: Record<ConfigSectionName, SectionReader> = {
495
672
  configValueAt(normalizeByteflyConfig(projectRaw, normalizeByteflyConfig(globalRaw, DEFAULT_BYTEFLY_CONFIG)), path),
496
673
  };
497
674
 
498
- /** 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. */
499
691
  function byteflyValue(
500
692
  snapshot: SettingsSnapshot,
501
693
  row: SettingDescriptor,
@@ -505,8 +697,12 @@ function byteflyValue(
505
697
  if (!config) throw new Error(`Setting ${row.id} has no config section`);
506
698
  const globalRaw = configSection(snapshot.global, config.section);
507
699
  const projectRaw = scope === "project" ? configSection(snapshot.project, config.section) : undefined;
508
- 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);
509
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.
510
706
  value: leaf === undefined ? row.fallback : String(leaf),
511
707
  origin: originFor(config, snapshot, scope),
512
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. */
@@ -43,6 +43,7 @@ import {
43
43
  import {
44
44
  editHints,
45
45
  projectNote,
46
+ sectionJsonPane,
46
47
  settingDetail,
47
48
  settingRow,
48
49
  settingsHints,
@@ -103,6 +104,8 @@ export class PanelComponent implements Component {
103
104
  private notice: string | undefined;
104
105
  /** The text row being edited: its id, the buffer, and the caret offset. */
105
106
  private editing: { id: string; buffer: string; caret: number } | undefined;
107
+ /** True while the Settings detail pane shows the focused row's section as JSON. */
108
+ private json = false;
106
109
  /** The visible section. */
107
110
  private section: PanelSection;
108
111
 
@@ -182,6 +185,8 @@ export class PanelComponent implements Component {
182
185
  );
183
186
  return;
184
187
  }
188
+ // The JSON pane names a file, so a scope switch has to close it.
189
+ this.json = false;
185
190
  this.options.runtime.setScope(next);
186
191
  this.options.requestRender();
187
192
  }
@@ -234,6 +239,29 @@ export class PanelComponent implements Component {
234
239
  .then(() => this.options.requestRender());
235
240
  }
236
241
 
242
+ /** Delete the focused setting's leaf, so its default applies again. */
243
+ private resetSetting(): void {
244
+ const entry = this.activeEntries()[this.cursor.selected];
245
+ if (entry?.kind !== "setting") return;
246
+ const scope = this.options.runtime.scope();
247
+ this.chain = this.chain
248
+ .then(() => this.options.settings.reset(scope, entry.setting.id))
249
+ .catch((error: unknown) => this.notify(error instanceof Error ? error.message : String(error)))
250
+ .then(() => this.options.requestRender());
251
+ }
252
+
253
+ /** Toggle the detail pane between the row and its whole section as stored. */
254
+ private toggleJson(): void {
255
+ const entry = this.activeEntries()[this.cursor.selected];
256
+ if (entry?.kind !== "setting") return;
257
+ if (!entry.setting.config) {
258
+ this.notify(`${entry.setting.label} is a Pi setting; bytefly.json has no section for it.`);
259
+ return;
260
+ }
261
+ this.json = !this.json;
262
+ this.options.requestRender();
263
+ }
264
+
237
265
  /** Store the editor's buffer; a refusal keeps it open with the reason shown. */
238
266
  private commitEdit(): void {
239
267
  const editing = this.editing;
@@ -348,6 +376,7 @@ export class PanelComponent implements Component {
348
376
  private switchSection(next: PanelSection): void {
349
377
  if (next === this.section) return;
350
378
  this.section = next;
379
+ this.json = false;
351
380
  this.prime();
352
381
  this.options.requestRender();
353
382
  }
@@ -362,6 +391,14 @@ export class PanelComponent implements Component {
362
391
  this.select(lastRow(this.activeEntries()));
363
392
  return;
364
393
  }
394
+ if (data === "[") {
395
+ this.jumpGroup(-1);
396
+ return;
397
+ }
398
+ if (data === "]") {
399
+ this.jumpGroup(1);
400
+ return;
401
+ }
365
402
  switch (navIntent(data)) {
366
403
  case "up":
367
404
  this.move(-1);
@@ -384,6 +421,26 @@ export class PanelComponent implements Component {
384
421
  }
385
422
  }
386
423
 
424
+ /** Move to the first row of the next (or previous) group. */
425
+ private jumpGroup(direction: -1 | 1): void {
426
+ const entries = this.activeEntries();
427
+ const headers = entries.flatMap((entry, index) => (entry.kind === "group" ? [index] : []));
428
+ const selected = this.cursor.selected;
429
+ // Step past the focused row's own header: landing on it would not move.
430
+ const own = headers.filter((index) => index < selected).at(-1);
431
+ const target =
432
+ direction === 1
433
+ ? headers.find((index) => index > selected)
434
+ : headers.filter((index) => index < (own ?? 0)).at(-1);
435
+ if (target === undefined) return;
436
+ for (let index = target + 1; index < entries.length; index += 1) {
437
+ if (isSelectable(entries[index])) {
438
+ this.select(index);
439
+ return;
440
+ }
441
+ }
442
+ }
443
+
387
444
  /** Settings keys: change, scope, navigation. `←`/`→` stay on sections. */
388
445
  private handleSettingsInput(data: string): void {
389
446
  this.clampSelection(this.activeEntries());
@@ -395,6 +452,14 @@ export class PanelComponent implements Component {
395
452
  this.switchScope();
396
453
  return;
397
454
  }
455
+ if (data === "u") {
456
+ this.resetSetting();
457
+ return;
458
+ }
459
+ if (data === "J") {
460
+ this.toggleJson();
461
+ return;
462
+ }
398
463
  this.navigate(data);
399
464
  }
400
465
 
@@ -483,7 +548,7 @@ export class PanelComponent implements Component {
483
548
  if (this.editing) return hintLine(this.theme, width, editHints());
484
549
  const hints =
485
550
  this.section === "settings"
486
- ? settingsHints()
551
+ ? settingsHints(this.json)
487
552
  : extensionHints({
488
553
  expanded,
489
554
  refreshing: this.refreshing,
@@ -527,33 +592,36 @@ export class PanelComponent implements Component {
527
592
  this.theme,
528
593
  width,
529
594
  this.section,
530
- settingsSummary(this.theme, width, scope, this.options.settings.count(scope)),
595
+ settingsSummary(this.theme, width, scope, views.length),
531
596
  );
532
597
  const entries = settingEntries(views);
533
598
  const note = scope === "project" ? projectNote(this.theme, width) : undefined;
534
599
 
535
- if (entries.length === 0) {
536
- return dockListFrame({
537
- header,
538
- summary,
539
- body: emptyState(this.theme, width, { label: "No settings found." }),
540
- hint: this.hintRow(width),
541
- });
542
- }
543
-
544
600
  this.retainFocus(entries);
545
601
  this.clampSelection(entries);
546
602
 
547
603
  const focusedEntry = entries[this.cursor.selected];
548
604
  const focused = focusedEntry?.kind === "setting" ? focusedEntry.setting : undefined;
549
- const detail = settingDetail({
550
- theme: this.theme,
551
- width,
552
- view: focused,
553
- budget: this.detailRows(false),
554
- dirty: this.dirty,
555
- paths: { scope, cwd: this.options.cwd, agentDir: this.options.agentDir },
556
- });
605
+ const jsonConfig = this.json ? focused?.config : undefined;
606
+ const detail =
607
+ jsonConfig && focused
608
+ ? sectionJsonPane({
609
+ theme: this.theme,
610
+ width,
611
+ section: jsonConfig.section,
612
+ view: focused,
613
+ document: this.options.settings.sectionDocument(scope, jsonConfig.section),
614
+ budget: this.detailRows(true),
615
+ paths: { scope, cwd: this.options.cwd, agentDir: this.options.agentDir },
616
+ })
617
+ : settingDetail({
618
+ theme: this.theme,
619
+ width,
620
+ view: focused,
621
+ budget: this.detailRows(false),
622
+ dirty: this.dirty,
623
+ paths: { scope, cwd: this.options.cwd, agentDir: this.options.agentDir },
624
+ });
557
625
 
558
626
  this.cursor.visible = fitRows(
559
627
  this.options.viewportRows,