@gavin-hu/my-pi-agent 0.13.1 → 0.15.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 (237) hide show
  1. package/README.md +66 -41
  2. package/README.zh-CN.md +53 -36
  3. package/extensions/bytefly/README.md +321 -186
  4. package/extensions/bytefly/block/component.ts +7 -15
  5. package/extensions/bytefly/block/register.ts +6 -1
  6. package/extensions/bytefly/doctor/config.ts +4 -1
  7. package/extensions/bytefly/doctor/register.ts +24 -5
  8. package/extensions/bytefly/doctor/tools.ts +4 -0
  9. package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
  10. package/extensions/bytefly/footer/config.ts +10 -6
  11. package/extensions/bytefly/footer/price/config.ts +3 -2
  12. package/extensions/bytefly/footer/price/price.ts +5 -3
  13. package/extensions/bytefly/footer/price/rates.ts +63 -26
  14. package/extensions/bytefly/footer/render/status.ts +3 -3
  15. package/extensions/bytefly/header/component.ts +33 -10
  16. package/extensions/bytefly/header/config.ts +22 -0
  17. package/extensions/bytefly/header/register.ts +2 -1
  18. package/extensions/bytefly/index.ts +6 -6
  19. package/extensions/bytefly/notice/component.ts +8 -3
  20. package/extensions/bytefly/notify/config.ts +7 -12
  21. package/extensions/bytefly/notify/dispatch.ts +13 -5
  22. package/extensions/bytefly/panel/commands.ts +22 -2
  23. package/extensions/bytefly/panel/format.ts +17 -21
  24. package/extensions/bytefly/panel/settings-runtime.ts +86 -22
  25. package/extensions/bytefly/panel/settings.ts +279 -46
  26. package/extensions/bytefly/panel/themes.ts +32 -0
  27. package/extensions/bytefly/panel/tui/editing.ts +77 -0
  28. package/extensions/bytefly/panel/tui/entries.ts +24 -0
  29. package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
  30. package/extensions/bytefly/panel/tui/frame.ts +121 -4
  31. package/extensions/bytefly/panel/tui/settings-view.ts +98 -13
  32. package/extensions/bytefly/panel/tui.ts +176 -166
  33. package/extensions/bytefly/picker/discovery.ts +1 -0
  34. package/extensions/bytefly/picker/types.ts +2 -0
  35. package/extensions/bytefly/separator/component.ts +17 -6
  36. package/extensions/bytefly/separator/config.ts +15 -0
  37. package/extensions/bytefly/separator/format.ts +60 -6
  38. package/extensions/bytefly/separator/register.ts +68 -9
  39. package/extensions/bytefly/separator/types.ts +8 -0
  40. package/extensions/bytefly/update/cache.ts +58 -19
  41. package/extensions/bytefly/update/check.ts +35 -0
  42. package/extensions/bytefly/update/format.ts +76 -35
  43. package/extensions/bytefly/update/register.ts +144 -55
  44. package/extensions/bytefly/update/state.ts +4 -2
  45. package/extensions/bytefly/update/suppress.ts +66 -0
  46. package/extensions/bytefly/update/types.ts +15 -0
  47. package/extensions/bytefly/working/config.ts +4 -5
  48. package/extensions/bytefly/working/format.ts +24 -7
  49. package/extensions/bytefly/working/register.ts +34 -3
  50. package/extensions/check/README.md +253 -0
  51. package/extensions/check/commands.ts +128 -0
  52. package/extensions/check/config.ts +39 -0
  53. package/extensions/check/detect.ts +269 -0
  54. package/extensions/check/failures.ts +230 -0
  55. package/extensions/check/format.ts +135 -0
  56. package/extensions/check/index.ts +26 -0
  57. package/extensions/check/render.ts +185 -0
  58. package/extensions/check/runner.ts +176 -0
  59. package/extensions/check/runtime.ts +244 -0
  60. package/extensions/check/source.ts +80 -0
  61. package/extensions/check/tools.ts +173 -0
  62. package/extensions/check/tui.ts +195 -0
  63. package/extensions/check/types.ts +99 -0
  64. package/extensions/document/README.md +1 -1
  65. package/extensions/document/extract/pdf.ts +8 -16
  66. package/extensions/document/formats.ts +2 -2
  67. package/extensions/file-browse/README.md +66 -32
  68. package/extensions/file-browse/client.ts +95 -32
  69. package/extensions/file-browse/config.ts +9 -9
  70. package/extensions/file-browse/git.ts +37 -47
  71. package/extensions/file-browse/html.ts +39 -58
  72. package/extensions/file-browse/markdown.ts +110 -92
  73. package/extensions/file-browse/raw-html.ts +478 -0
  74. package/extensions/file-browse/router.ts +32 -70
  75. package/extensions/file-browse/server.ts +18 -56
  76. package/extensions/file-browse/urls.ts +80 -0
  77. package/extensions/goal/README.md +103 -47
  78. package/extensions/goal/clarify-parse.ts +4 -9
  79. package/extensions/goal/clarify.ts +27 -4
  80. package/extensions/goal/commands.ts +24 -11
  81. package/extensions/goal/format.ts +41 -10
  82. package/extensions/goal/index.ts +39 -16
  83. package/extensions/goal/plan-route.ts +51 -0
  84. package/extensions/goal/schema.ts +6 -23
  85. package/extensions/goal/state.ts +39 -57
  86. package/extensions/goal/tools.ts +26 -20
  87. package/extensions/goal/types.ts +11 -1
  88. package/extensions/job/README.md +20 -6
  89. package/extensions/job/format.ts +18 -68
  90. package/extensions/job/index.ts +26 -41
  91. package/extensions/job/log-writer.ts +208 -0
  92. package/extensions/job/logs.ts +3 -2
  93. package/extensions/job/process.ts +6 -99
  94. package/extensions/job/runtime.ts +42 -181
  95. package/extensions/job/schema.ts +1 -4
  96. package/extensions/job/tools.ts +1 -15
  97. package/extensions/job/tui.ts +117 -307
  98. package/extensions/job/ui.ts +7 -33
  99. package/extensions/memory/README.md +3 -1
  100. package/extensions/memory/errors.ts +5 -6
  101. package/extensions/memory/tui.ts +14 -24
  102. package/extensions/plan/README.md +106 -18
  103. package/extensions/plan/commands.ts +9 -5
  104. package/extensions/plan/format.ts +87 -0
  105. package/extensions/plan/index.ts +31 -15
  106. package/extensions/plan/plans.ts +2 -3
  107. package/extensions/plan/policy.ts +0 -6
  108. package/extensions/plan/runtime.ts +4 -2
  109. package/extensions/plan/steps.ts +37 -8
  110. package/extensions/plan/todos.ts +44 -3
  111. package/extensions/plan/tools/enter.ts +94 -0
  112. package/extensions/plan/tools/exit.ts +296 -0
  113. package/extensions/plan/tools/nested.ts +21 -0
  114. package/extensions/plan/tools/steps.ts +136 -0
  115. package/extensions/plan/tools/todo-seed.ts +76 -0
  116. package/extensions/plan/tools/write.ts +127 -0
  117. package/extensions/plan/tools.ts +18 -703
  118. package/extensions/plan/web-client.ts +6 -22
  119. package/extensions/plan/web-page.ts +19 -30
  120. package/extensions/plan/web-router.ts +34 -71
  121. package/extensions/plan/web.ts +13 -40
  122. package/extensions/question/answers.ts +3 -8
  123. package/extensions/question/tui.ts +6 -6
  124. package/extensions/rewind/README.md +95 -22
  125. package/extensions/rewind/checklist.ts +185 -0
  126. package/extensions/rewind/commands.ts +8 -0
  127. package/extensions/rewind/config.ts +3 -11
  128. package/extensions/rewind/format.ts +220 -2
  129. package/extensions/rewind/git.ts +74 -7
  130. package/extensions/rewind/hunk-picker.ts +114 -0
  131. package/extensions/rewind/patch.ts +88 -0
  132. package/extensions/rewind/path-picker.ts +67 -100
  133. package/extensions/rewind/paths.ts +93 -19
  134. package/extensions/rewind/restore.ts +53 -10
  135. package/extensions/rewind/review-tui.ts +320 -0
  136. package/extensions/rewind/review.ts +397 -0
  137. package/extensions/rewind/rewind-tui.ts +4 -3
  138. package/extensions/rewind/runtime.ts +94 -17
  139. package/extensions/subagent/README.md +118 -8
  140. package/extensions/subagent/background-tui.ts +208 -0
  141. package/extensions/subagent/background.ts +470 -0
  142. package/extensions/subagent/commands.ts +80 -0
  143. package/extensions/subagent/config.ts +62 -0
  144. package/extensions/subagent/format.ts +24 -1
  145. package/extensions/subagent/index.ts +116 -1
  146. package/extensions/subagent/orchestrate.ts +16 -3
  147. package/extensions/subagent/render.ts +25 -0
  148. package/extensions/subagent/run.ts +113 -49
  149. package/extensions/subagent/schema.ts +12 -0
  150. package/extensions/subagent/transcripts.ts +202 -0
  151. package/extensions/subagent/types.ts +23 -0
  152. package/extensions/subagent/ui.ts +50 -0
  153. package/extensions/todo/README.md +18 -13
  154. package/extensions/todo/format.ts +3 -2
  155. package/extensions/todo/runtime.ts +3 -2
  156. package/extensions/todo/schema.ts +8 -84
  157. package/extensions/todo/state.ts +5 -46
  158. package/extensions/todo/tools.ts +2 -1
  159. package/extensions/todo/types.ts +6 -15
  160. package/extensions/watch/README.md +16 -6
  161. package/extensions/watch/child.ts +150 -0
  162. package/extensions/watch/format.ts +8 -48
  163. package/extensions/watch/index.ts +42 -58
  164. package/extensions/watch/runtime.ts +49 -128
  165. package/extensions/watch/schema.ts +1 -4
  166. package/extensions/watch/tools.ts +1 -11
  167. package/extensions/watch/tui.ts +103 -273
  168. package/extensions/watch/ui.ts +5 -32
  169. package/extensions/web-access/README.md +1 -1
  170. package/extensions/web-access/fetch/page.ts +1 -1
  171. package/extensions/web-access/fetch/tool.ts +6 -2
  172. package/extensions/web-access/search/search.ts +2 -5
  173. package/extensions/wechat/README.md +3 -1
  174. package/extensions/wechat/client.ts +23 -0
  175. package/extensions/wechat/commands.ts +57 -42
  176. package/extensions/wechat/deliver.ts +5 -9
  177. package/extensions/wechat/errors.ts +3 -6
  178. package/extensions/wechat/inbound.ts +2 -5
  179. package/extensions/wechat/index.ts +2 -8
  180. package/extensions/wechat/outbound.ts +2 -4
  181. package/extensions/wechat/peer-worker.ts +3 -8
  182. package/extensions/wechat/poll.ts +10 -9
  183. package/extensions/wechat/presence.ts +35 -62
  184. package/extensions/wechat/reply-pipeline.ts +3 -1
  185. package/extensions/wechat/status-screen.ts +1 -1
  186. package/extensions/wechat/timer.ts +57 -0
  187. package/extensions/worktree/config.ts +8 -20
  188. package/extensions/worktree/enter.ts +7 -12
  189. package/extensions/worktree/git.ts +38 -69
  190. package/extensions/worktree/index.ts +5 -4
  191. package/extensions/worktree/registry.ts +7 -7
  192. package/extensions/worktree/root-tools.ts +2 -2
  193. package/extensions/worktree/status.ts +3 -2
  194. package/lib/README.md +22 -8
  195. package/lib/config.ts +83 -12
  196. package/lib/errors.ts +12 -0
  197. package/lib/format.ts +56 -0
  198. package/lib/git/read.ts +8 -0
  199. package/lib/goal-state.ts +23 -8
  200. package/lib/http/headers.ts +54 -0
  201. package/lib/http/index.ts +13 -0
  202. package/lib/http/response.ts +46 -0
  203. package/lib/http/router.ts +52 -0
  204. package/lib/http/server.ts +91 -0
  205. package/lib/lines.ts +77 -0
  206. package/lib/pdf.ts +82 -0
  207. package/lib/plan-entry.ts +71 -0
  208. package/lib/plan-state.ts +34 -0
  209. package/lib/report.ts +99 -0
  210. package/lib/requirements.ts +25 -19
  211. package/lib/spawn.ts +170 -0
  212. package/lib/todo-state.ts +110 -23
  213. package/lib/tool-names.ts +7 -0
  214. package/lib/tool-result.ts +33 -0
  215. package/lib/tui/README.md +68 -3
  216. package/lib/tui/chrome.ts +28 -4
  217. package/lib/tui/columns.ts +6 -1
  218. package/lib/tui/confirm.ts +65 -0
  219. package/lib/tui/dock-screen.ts +410 -0
  220. package/lib/tui/glyphs.ts +2 -1
  221. package/lib/tui/indent.ts +1 -1
  222. package/lib/tui/index.ts +2 -0
  223. package/lib/tui/rows.ts +21 -0
  224. package/lib/tui/status-controller.ts +35 -1
  225. package/lib/tui/tool.ts +59 -1
  226. package/lib/web/components.ts +20 -1
  227. package/lib/web/markup.ts +32 -0
  228. package/lib/web/runtime.ts +50 -0
  229. package/package.json +23 -5
  230. package/skills/init/SKILL.md +165 -0
  231. package/skills/init/references/agents-md-quality.md +103 -0
  232. package/skills/init/references/interview-questions.md +81 -0
  233. package/skills/refactor/SKILL.md +141 -0
  234. package/themes/bytefly-dark.json +2 -3
  235. package/themes/bytefly-light.json +2 -3
  236. package/extensions/watch/process.ts +0 -63
  237. package/extensions/web-access/fetch/pdf.ts +0 -45
@@ -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;
@@ -113,10 +135,12 @@ export interface SettingsSnapshot {
113
135
  app: Settings;
114
136
  global: Record<string, unknown>;
115
137
  project: Record<string, unknown>;
138
+ /** Theme names the Theme row offers, narrowed to this package's palettes by the caller. */
139
+ themes: readonly string[];
116
140
  }
117
141
 
118
142
  /** One rendered group: its heading and the rows under it. */
119
- export interface GroupedSettings {
143
+ interface GroupedSettings {
120
144
  key: string;
121
145
  label: string;
122
146
  views: SettingView[];
@@ -130,6 +154,10 @@ export const SETTING_GROUPS: readonly SettingGroup[] = [
130
154
  { key: "pricing", label: "Pricing" },
131
155
  { key: "transcript", label: "Transcript" },
132
156
  { key: "terminal", label: "Terminal" },
157
+ { key: "worktree", label: "Worktree" },
158
+ { key: "snapshots", label: "Snapshots" },
159
+ { key: "jobs", label: "Jobs and watchers" },
160
+ { key: "memory", label: "Memory" },
133
161
  ];
134
162
 
135
163
  const ON_OFF: readonly SettingOption[] = [
@@ -147,8 +175,8 @@ const METHOD_OPTIONS: readonly SettingOption[] = [
147
175
  { value: "notify-send", label: "Linux" },
148
176
  ];
149
177
 
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) => ({
178
+ /** Cost precision 0 through the loader's ceiling, the range `price` clamps `decimals` to. */
179
+ const DECIMALS_OPTIONS: readonly SettingOption[] = Array.from({ length: MAX_DECIMALS + 1 }, (_, value) => ({
152
180
  value: String(value),
153
181
  label: String(value),
154
182
  }));
@@ -292,7 +320,7 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
292
320
  fallback: "Pi",
293
321
  options: [],
294
322
  text: {
295
- maxLength: 40,
323
+ maxLength: MAX_TITLE_LENGTH,
296
324
  validate: (raw) => (raw.trim() === "" ? "A title is required." : undefined),
297
325
  },
298
326
  config: { section: "notify", path: ["title"] },
@@ -313,13 +341,13 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
313
341
  id: "doctor.timeoutMs",
314
342
  group: "diagnostics",
315
343
  label: "Probe timeout",
316
- description: "How long one doctor probe may take, in milliseconds; 100 to 30000.",
344
+ description: `How long one doctor probe may take, in milliseconds; ${TIMEOUT_MS_RANGE.min} to ${TIMEOUT_MS_RANGE.max}.`,
317
345
  source: "bytefly",
318
346
  kind: "text",
319
347
  fallback: String(DEFAULT_DOCTOR_CONFIG.timeoutMs),
320
348
  json: "number",
321
349
  options: [],
322
- text: integerRule(100, 30_000),
350
+ text: integerRule(TIMEOUT_MS_RANGE.min, TIMEOUT_MS_RANGE.max),
323
351
  config: { section: "doctor", path: ["timeoutMs"] },
324
352
  },
325
353
  {
@@ -367,7 +395,7 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
367
395
  fallback: "0",
368
396
  json: "number",
369
397
  options: [],
370
- text: amountRule(0, 10_000_000),
398
+ text: amountRule(0, MAX_BUDGET),
371
399
  config: { section: "price", path: ["budget"] },
372
400
  },
373
401
  {
@@ -377,11 +405,25 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
377
405
  description: "The single-column glyph that leads a tool result or notice body, e.g. ╎ │ ┃ ▏.",
378
406
  source: "bytefly",
379
407
  kind: "text",
380
- fallback: GLYPHS.divider,
408
+ fallback: DEFAULT_CHROME_CONFIG.rail,
381
409
  options: [],
382
410
  text: RAIL_RULE,
383
411
  config: { section: "chrome", path: ["rail"] },
384
412
  },
413
+ {
414
+ id: "goal.achieved",
415
+ group: "transcript",
416
+ label: "Achieved goal",
417
+ description: "How a completed goal renders: the dimmed rail line, or nothing.",
418
+ source: "bytefly",
419
+ kind: "enum",
420
+ fallback: "hide",
421
+ options: [
422
+ { value: "show", label: "Show" },
423
+ { value: "hide", label: "Hide" },
424
+ ],
425
+ config: { section: "goal", path: ["achieved"] },
426
+ },
385
427
  {
386
428
  id: "outputPad",
387
429
  group: "transcript",
@@ -396,6 +438,18 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
396
438
  { value: "1", label: "1" },
397
439
  ],
398
440
  },
441
+ {
442
+ id: "theme",
443
+ group: "terminal",
444
+ label: "Theme",
445
+ description:
446
+ "Pi's color theme, chosen from the palettes this package ships; `system` follows the terminal's colors. Takes effect after reload.",
447
+ source: "app",
448
+ kind: "enum",
449
+ fallback: "system",
450
+ // The host supplies this package's theme names in the snapshot; the static list stays empty.
451
+ options: [],
452
+ },
399
453
  {
400
454
  id: "showHardwareCursor",
401
455
  group: "terminal",
@@ -407,28 +461,164 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
407
461
  json: "boolean",
408
462
  options: ON_OFF,
409
463
  },
464
+ {
465
+ id: "worktree.baseRef",
466
+ group: "worktree",
467
+ label: "Base ref",
468
+ description: "Where a new worktree branches from: the remote default branch, or the current local HEAD.",
469
+ source: "bytefly",
470
+ kind: "enum",
471
+ fallback: "fresh",
472
+ options: [
473
+ { value: "fresh", label: "Fresh" },
474
+ { value: "head", label: "Head" },
475
+ ],
476
+ config: { section: "worktree", path: ["baseRef"] },
477
+ },
478
+ {
479
+ id: "worktree.branchPrefix",
480
+ group: "worktree",
481
+ label: "Branch prefix",
482
+ description: "Prepended to every branch a managed worktree creates.",
483
+ source: "bytefly",
484
+ kind: "text",
485
+ fallback: "worktree-",
486
+ options: [],
487
+ text: {
488
+ // The loader trims and rejects an empty prefix but sets no ceiling; this
489
+ // only stops a runaway buffer.
490
+ maxLength: 80,
491
+ validate: (raw) => (raw.trim() === "" ? "A prefix is required." : undefined),
492
+ },
493
+ config: { section: "worktree", path: ["branchPrefix"] },
494
+ },
495
+ {
496
+ id: "worktree.fetchRemote",
497
+ group: "worktree",
498
+ label: "Fetch remote",
499
+ description: "Fetch the remote before branching, so a new worktree starts from its latest commit.",
500
+ source: "bytefly",
501
+ kind: "boolean",
502
+ fallback: "true",
503
+ json: "boolean",
504
+ options: ON_OFF,
505
+ config: { section: "worktree", path: ["fetchRemote"] },
506
+ },
507
+ {
508
+ id: "worktree.pruneAfterDays",
509
+ group: "worktree",
510
+ label: "Prune after (days)",
511
+ description: "Age at which `/worktree prune` removes a clean worktree nobody is using.",
512
+ source: "bytefly",
513
+ kind: "text",
514
+ fallback: "7",
515
+ json: "number",
516
+ options: [],
517
+ text: integerRule(0, 3650),
518
+ config: { section: "worktree", path: ["pruneAfterDays"] },
519
+ },
520
+ {
521
+ id: "rewind.autoSnapshots",
522
+ group: "snapshots",
523
+ label: "Automatic snapshots",
524
+ description: "Take one rewind snapshot per prompt, before the first mutating tool call.",
525
+ source: "bytefly",
526
+ kind: "boolean",
527
+ fallback: "true",
528
+ json: "boolean",
529
+ options: ON_OFF,
530
+ config: { section: "rewind", path: ["autoSnapshots"] },
531
+ },
532
+ {
533
+ id: "rewind.max",
534
+ group: "snapshots",
535
+ label: "Snapshots kept",
536
+ description: "Snapshot refs kept per repository, including pre-restore safety snapshots; 0 keeps none.",
537
+ source: "bytefly",
538
+ kind: "text",
539
+ fallback: "20",
540
+ json: "number",
541
+ options: [],
542
+ text: integerRule(0, 1000),
543
+ config: { section: "rewind", path: ["max"] },
544
+ },
545
+ {
546
+ id: "rewind.safetySnapshot",
547
+ group: "snapshots",
548
+ label: "Safety snapshot",
549
+ description: "Snapshot the current state before a restore, so a rewind can itself be undone.",
550
+ source: "bytefly",
551
+ kind: "boolean",
552
+ fallback: "true",
553
+ json: "boolean",
554
+ options: ON_OFF,
555
+ config: { section: "rewind", path: ["safetySnapshot"] },
556
+ },
557
+ {
558
+ id: "job.wakeOnFinish",
559
+ group: "jobs",
560
+ label: "Wake when a job finishes",
561
+ description: "Start one agent turn when any background job finishes.",
562
+ source: "bytefly",
563
+ kind: "boolean",
564
+ fallback: "false",
565
+ json: "boolean",
566
+ options: ON_OFF,
567
+ config: { section: "job", path: ["wakeOnFinish"] },
568
+ },
569
+ {
570
+ id: "job.detachedByDefault",
571
+ group: "jobs",
572
+ label: "Keep jobs after exit",
573
+ description: "Leave jobs running when the session ends, unless the job opts out.",
574
+ source: "bytefly",
575
+ kind: "boolean",
576
+ fallback: "false",
577
+ json: "boolean",
578
+ options: ON_OFF,
579
+ config: { section: "job", path: ["detachedByDefault"] },
580
+ },
581
+ {
582
+ id: "watch.wakeOnMatch",
583
+ group: "jobs",
584
+ label: "Wake on a match",
585
+ description: "Start one agent turn when a watcher matches while the agent is idle.",
586
+ source: "bytefly",
587
+ kind: "boolean",
588
+ fallback: "false",
589
+ json: "boolean",
590
+ options: ON_OFF,
591
+ config: { section: "watch", path: ["wakeOnMatch"] },
592
+ },
593
+ {
594
+ id: "memory.inject",
595
+ group: "memory",
596
+ label: "Inject notes",
597
+ description: "Put stored memory notes in context before each run.",
598
+ source: "bytefly",
599
+ kind: "boolean",
600
+ fallback: "true",
601
+ json: "boolean",
602
+ options: ON_OFF,
603
+ config: { section: "memory", path: ["inject"] },
604
+ },
410
605
  ];
411
606
 
607
+ /** The one refusal for a Project write while the project is untrusted. */
608
+ export const UNTRUSTED_SETTINGS_NOTE = "Project is not trusted; settings can only be changed in Global scope.";
609
+
412
610
  /** The rows a scope can write: app rows are personal-file only. */
413
611
  export function rowsForScope(scope: WriteScope): readonly SettingDescriptor[] {
414
612
  return scope === "global" ? SETTING_ROWS : SETTING_ROWS.filter((row) => row.source === "bytefly");
415
613
  }
416
614
 
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
615
  /**
428
616
  * Why a text row cannot store `raw`, or `undefined` when it can.
429
617
  *
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.
618
+ * The limits are the loaders' own (`notify.title` is sliced to `MAX_TITLE_LENGTH`,
619
+ * the rail to one column) so the editor cannot save a value the next read shortens.
620
+ * A row that is not text is refused here rather than in the caller, so a programmatic
621
+ * `setValue` gets the same reason the screen would show.
432
622
  */
433
623
  export function textError(row: SettingDescriptor, raw: string): string | undefined {
434
624
  const rule = row.text;
@@ -439,9 +629,18 @@ export function textError(row: SettingDescriptor, raw: string): string | undefin
439
629
  return rule.validate?.(raw);
440
630
  }
441
631
 
632
+ /** The Theme row's values: `system` first, then the names the caller offers. */
633
+ function themeOptions(themes: readonly string[]): readonly SettingOption[] {
634
+ const names = ["system", ...new Set(themes.filter((name) => name !== "" && name !== "system"))];
635
+ return names.map((name) => ({ value: name, label: name }));
636
+ }
637
+
442
638
  /** The display label for `raw`, or the raw value when it is a valid custom one. */
443
639
  function labelFor(row: SettingDescriptor, raw: string): string {
444
- return row.options.find((option) => option.value === raw)?.label ?? raw;
640
+ const option = row.options.find((entry) => entry.value === raw);
641
+ if (option) return option.label;
642
+ // A stored light/dark pair is Pi's automatic mode, not a missing theme.
643
+ return row.id === "theme" && raw.includes("/") ? `${raw} (automatic)` : raw;
445
644
  }
446
645
 
447
646
  /** Read an app preference and where it came from. */
@@ -449,18 +648,23 @@ function appValue(snapshot: SettingsSnapshot, row: SettingDescriptor): { value:
449
648
  switch (row.id) {
450
649
  case "byteflyUi": {
451
650
  const quiet = snapshot.app.quietStartup;
452
- return { value: quiet === true ? "true" : "false", origin: quiet === undefined ? "default" : "global" };
651
+ return { value: quiet === true ? "true" : row.fallback, origin: quiet === undefined ? "default" : "global" };
453
652
  }
454
653
  case "outputPad":
455
654
  return {
456
- value: snapshot.app.outputPad === 0 ? "0" : "1",
655
+ value: snapshot.app.outputPad === 0 ? "0" : row.fallback,
457
656
  origin: snapshot.app.outputPad === undefined ? "default" : "global",
458
657
  };
459
658
  case "showHardwareCursor":
460
659
  return {
461
- value: snapshot.app.showHardwareCursor === true ? "true" : "false",
660
+ value: snapshot.app.showHardwareCursor === true ? "true" : row.fallback,
462
661
  origin: snapshot.app.showHardwareCursor === undefined ? "default" : "global",
463
662
  };
663
+ case "theme": {
664
+ const theme = snapshot.app.theme;
665
+ const set = typeof theme === "string" && theme !== "";
666
+ return { value: set ? theme : row.fallback, origin: set ? "global" : "default" };
667
+ }
464
668
  default:
465
669
  throw new Error(`Unknown app setting ${row.id}`);
466
670
  }
@@ -473,7 +677,11 @@ function statesConfig(config: SettingConfig, document: Record<string, unknown>):
473
677
 
474
678
  /** Where the effective value came from: the project file, the personal one, or nothing. */
475
679
  function originFor(config: SettingConfig, snapshot: SettingsSnapshot, scope: WriteScope): SettingOrigin {
476
- if (scope === "project") return statesConfig(config, snapshot.project) ? "project" : "inherited";
680
+ if (scope === "project") {
681
+ if (statesConfig(config, snapshot.project)) return "project";
682
+ if (statesConfig(config, snapshot.global)) return "inherited";
683
+ return "default";
684
+ }
477
685
  return statesConfig(config, snapshot.global) ? "global" : "default";
478
686
  }
479
687
 
@@ -484,7 +692,11 @@ type SectionReader = (
484
692
  path: readonly string[],
485
693
  ) => unknown;
486
694
 
487
- const SECTION_READERS: Record<ConfigSectionName, SectionReader> = {
695
+ /**
696
+ * The four sections bytefly owns, each read through its own normalizer so the
697
+ * screen shows the value the loader keeps.
698
+ */
699
+ const SECTION_READERS: Partial<Record<ConfigSectionName, SectionReader>> = {
488
700
  notify: (globalRaw, projectRaw, path) =>
489
701
  configValueAt(normalizeNotifyConfig(projectRaw, normalizeNotifyConfig(globalRaw, DEFAULT_NOTIFY_CONFIG)), path),
490
702
  chrome: (globalRaw, projectRaw, path) =>
@@ -495,7 +707,22 @@ const SECTION_READERS: Record<ConfigSectionName, SectionReader> = {
495
707
  configValueAt(normalizeByteflyConfig(projectRaw, normalizeByteflyConfig(globalRaw, DEFAULT_BYTEFLY_CONFIG)), path),
496
708
  };
497
709
 
498
- /** Read a `bytefly.json` preference, normalized over the global section. */
710
+ /**
711
+ * Read a leaf whose owning extension owns the normalizer.
712
+ *
713
+ * A section without a reader here belongs to another extension, and importing its
714
+ * normalizer would be one extension importing another's module. So the row states
715
+ * the loader's default in `fallback`, `test/settings-coverage.test.ts` holds the
716
+ * two together, and the visible cost is that a hand-edited value outside the
717
+ * loader's clamp shows as written until it is reset.
718
+ */
719
+ function rawLeaf(snapshot: SettingsSnapshot, config: SettingConfig, scope: WriteScope): unknown {
720
+ const projectLeaf =
721
+ scope === "project" ? configValueAt(configSection(snapshot.project, config.section), config.path) : undefined;
722
+ return projectLeaf ?? configValueAt(configSection(snapshot.global, config.section), config.path);
723
+ }
724
+
725
+ /** Read a `bytefly.json` preference: normalized where bytefly owns the section. */
499
726
  function byteflyValue(
500
727
  snapshot: SettingsSnapshot,
501
728
  row: SettingDescriptor,
@@ -505,8 +732,12 @@ function byteflyValue(
505
732
  if (!config) throw new Error(`Setting ${row.id} has no config section`);
506
733
  const globalRaw = configSection(snapshot.global, config.section);
507
734
  const projectRaw = scope === "project" ? configSection(snapshot.project, config.section) : undefined;
508
- const leaf = SECTION_READERS[config.section](globalRaw, projectRaw, config.path);
735
+ const reader = SECTION_READERS[config.section];
736
+ const leaf = reader ? reader(globalRaw, projectRaw, config.path) : rawLeaf(snapshot, config, scope);
509
737
  return {
738
+ // A normalizer is total, so its fallback only covers a future row whose
739
+ // normalizer has no default yet; a raw read needs it when neither document
740
+ // states the key.
510
741
  value: leaf === undefined ? row.fallback : String(leaf),
511
742
  origin: originFor(config, snapshot, scope),
512
743
  };
@@ -515,8 +746,10 @@ function byteflyValue(
515
746
  /** Build the rows for `scope` from a snapshot. */
516
747
  export function settingViews(snapshot: SettingsSnapshot, scope: WriteScope): SettingView[] {
517
748
  return rowsForScope(scope).map((row) => {
749
+ const options = row.id === "theme" ? themeOptions(snapshot.themes) : row.options;
518
750
  const { value, origin } = row.source === "app" ? appValue(snapshot, row) : byteflyValue(snapshot, row, scope);
519
- return { ...row, value, valueLabel: labelFor(row, value), origin };
751
+ const view = { ...row, options, value };
752
+ return { ...view, valueLabel: labelFor(view, value), origin };
520
753
  });
521
754
  }
522
755
 
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Which themes the Settings **Theme** row offers: this package's own two, not
3
+ * every theme in play.
4
+ *
5
+ * The host resolves Pi's built-ins, this package's palettes, and any user or
6
+ * project theme into one list, but the row writes Pi's single `theme`
7
+ * preference from a bytefly screen, so it offers only the palettes this package
8
+ * ships and can vouch for. The file's directory identifies a shipped theme, not
9
+ * its name: `themes/` is the manifest's own resource glob, so a renamed palette
10
+ * or a look-alike name cannot drift the list away from what the package loads.
11
+ */
12
+
13
+ import { dirname, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ /** This package's `themes/` directory. */
17
+ export const SHIPPED_THEMES_DIR = resolve(fileURLToPath(new URL("../../../themes", import.meta.url)));
18
+
19
+ /** One theme as the host reports it. */
20
+ export interface HostTheme {
21
+ name: string;
22
+ path?: string;
23
+ }
24
+
25
+ /** The shipped theme names in the host's order; a pathless or foreign theme is dropped. */
26
+ export function shippedThemeNames(themes: readonly HostTheme[]): string[] {
27
+ return themes.filter(isShipped).map((theme) => theme.name);
28
+ }
29
+
30
+ function isShipped(theme: HostTheme): boolean {
31
+ return theme.path !== undefined && dirname(resolve(theme.path)) === SHIPPED_THEMES_DIR;
32
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The panel's text-editing state machine.
3
+ *
4
+ * One key in, the next editing state out; the component owns the settings write,
5
+ * the write chain, and the repaint. Esc cancels the edit instead of closing the
6
+ * screen, Enter asks the caller to commit, and everything else the screen binds
7
+ * is inert until the editor closes.
8
+ */
9
+
10
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
11
+ import { isCloseKey } from "../../../../lib/tui/cursor.ts";
12
+
13
+ /** Bracketed-paste markers a terminal wraps around pasted text. */
14
+ const PASTE_START = "\x1b[200~";
15
+ const PASTE_END = "\x1b[201~";
16
+
17
+ /** The text row being edited: its id, the buffer, and the caret offset. */
18
+ export interface EditingState {
19
+ id: string;
20
+ buffer: string;
21
+ caret: number;
22
+ }
23
+
24
+ /** What one editing key asked for. */
25
+ export type EditAction =
26
+ | { kind: "cancel" }
27
+ | { kind: "commit" }
28
+ | { kind: "update"; state: EditingState; changed: boolean };
29
+
30
+ /**
31
+ * Apply one key to the editing state.
32
+ *
33
+ * `maxLength` comes from the setting's text rule; an undefined limit means the
34
+ * row has no text rule, so typed characters are refused rather than stored
35
+ * unvalidated. A key that changes nothing (Backspace at the start, a refused
36
+ * character) reports `changed: false`, so the caller does not ask for a frame.
37
+ */
38
+ export function applyEditKey(data: string, state: EditingState, maxLength: number | undefined): EditAction {
39
+ if (isCloseKey(data)) return { kind: "cancel" };
40
+ if (matchesKey(data, Key.enter)) return { kind: "commit" };
41
+
42
+ const { buffer, caret } = state;
43
+ let next = state;
44
+ if (matchesKey(data, Key.backspace)) {
45
+ if (caret > 0) next = { ...state, buffer: buffer.slice(0, caret - 1) + buffer.slice(caret), caret: caret - 1 };
46
+ } else if (matchesKey(data, Key.delete)) {
47
+ if (caret < buffer.length) next = { ...state, buffer: buffer.slice(0, caret) + buffer.slice(caret + 1) };
48
+ } else if (matchesKey(data, Key.left)) {
49
+ next = { ...state, caret: Math.max(0, caret - 1) };
50
+ } else if (matchesKey(data, Key.right)) {
51
+ next = { ...state, caret: Math.min(buffer.length, caret + 1) };
52
+ // `Ctrl+A`/`Ctrl+E` are the emacs spellings of Home/End, which pi-tui's
53
+ // `Key.home`/`Key.end` do not match; both are offered because a terminal
54
+ // may send either.
55
+ } else if (matchesKey(data, Key.home) || matchesKey(data, "ctrl+a")) {
56
+ next = { ...state, caret: 0 };
57
+ } else if (matchesKey(data, Key.end) || matchesKey(data, "ctrl+e")) {
58
+ next = { ...state, caret: buffer.length };
59
+ } else if (matchesKey(data, "ctrl+u")) {
60
+ next = { ...state, buffer: "", caret: 0 };
61
+ } else {
62
+ // A pasted chunk arrives wrapped in bracketed-paste markers. Its ESC is
63
+ // dropped by the filter below, which would leave `[200~` in the value, so
64
+ // strip the markers first, the way Pi's own Input and Editor do.
65
+ const text = data.replaceAll(PASTE_START, "").replaceAll(PASTE_END, "");
66
+ // Anything else must be printable; control sequences are not text.
67
+ const typed = [...text].filter((char) => char >= " " && char !== "\u007f").join("");
68
+ if (typed && maxLength !== undefined && buffer.length + typed.length <= maxLength) {
69
+ next = {
70
+ ...state,
71
+ buffer: buffer.slice(0, caret) + typed + buffer.slice(caret),
72
+ caret: caret + typed.length,
73
+ };
74
+ }
75
+ }
76
+ return { kind: "update", state: next, changed: next.buffer !== buffer || next.caret !== caret };
77
+ }
@@ -27,6 +27,30 @@ export function isSelectable(entry: PanelEntry | undefined): boolean {
27
27
  return entryKey(entry) !== undefined;
28
28
  }
29
29
 
30
+ /**
31
+ * One display window as ordinals over the selectable rows it contains.
32
+ *
33
+ * The range row counts rows, not entries: a list of twenty-nine settings in ten
34
+ * groups spans thirty-nine entries, but `showing 3–5 of 29` is what the user can
35
+ * focus. `formatRange` prints `start + 1` through `end`, so the ordinals here are
36
+ * zero-based and `end` is exclusive.
37
+ */
38
+ export function selectableRange(
39
+ entries: readonly PanelEntry[],
40
+ window: { start: number; end: number },
41
+ ): { start: number; end: number; total: number } {
42
+ let before = 0;
43
+ let shown = 0;
44
+ let total = 0;
45
+ for (let index = 0; index < entries.length; index += 1) {
46
+ if (!isSelectable(entries[index])) continue;
47
+ total += 1;
48
+ if (index < window.start) before += 1;
49
+ else if (index < window.end) shown += 1;
50
+ }
51
+ return { start: before, end: before + shown, total };
52
+ }
53
+
30
54
  /** The next selectable entry from `from` in `direction`, or `undefined`. */
31
55
  export function nextRow(entries: PanelEntry[], from: number, direction: -1 | 1): number | undefined {
32
56
  for (let index = from + direction; index >= 0 && index < entries.length; index += direction) {