@gavin-hu/my-pi-agent 0.14.0 → 0.15.1

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 (229) hide show
  1. package/README.md +54 -34
  2. package/README.zh-CN.md +44 -28
  3. package/extensions/bytefly/README.md +193 -140
  4. package/extensions/bytefly/block/component.ts +7 -15
  5. package/extensions/bytefly/block/register.ts +6 -1
  6. package/extensions/bytefly/doctor/register.ts +24 -5
  7. package/extensions/bytefly/doctor/tools.ts +4 -0
  8. package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
  9. package/extensions/bytefly/footer/config.ts +10 -6
  10. package/extensions/bytefly/footer/price/price.ts +5 -3
  11. package/extensions/bytefly/footer/price/rates.ts +63 -26
  12. package/extensions/bytefly/footer/render/status.ts +3 -3
  13. package/extensions/bytefly/header/component.ts +3 -2
  14. package/extensions/bytefly/index.ts +3 -3
  15. package/extensions/bytefly/notice/component.ts +8 -3
  16. package/extensions/bytefly/notify/config.ts +6 -11
  17. package/extensions/bytefly/notify/dispatch.ts +13 -5
  18. package/extensions/bytefly/panel/commands.ts +21 -1
  19. package/extensions/bytefly/panel/format.ts +5 -2
  20. package/extensions/bytefly/panel/settings-runtime.ts +26 -6
  21. package/extensions/bytefly/panel/settings.ts +41 -4
  22. package/extensions/bytefly/panel/themes.ts +32 -0
  23. package/extensions/bytefly/panel/tui/editing.ts +77 -0
  24. package/extensions/bytefly/panel/tui/entries.ts +24 -0
  25. package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
  26. package/extensions/bytefly/panel/tui/frame.ts +121 -4
  27. package/extensions/bytefly/panel/tui/settings-view.ts +54 -15
  28. package/extensions/bytefly/panel/tui.ts +94 -152
  29. package/extensions/bytefly/picker/discovery.ts +1 -0
  30. package/extensions/bytefly/picker/types.ts +2 -0
  31. package/extensions/bytefly/separator/component.ts +14 -4
  32. package/extensions/bytefly/update/cache.ts +58 -19
  33. package/extensions/bytefly/update/check.ts +35 -0
  34. package/extensions/bytefly/update/format.ts +76 -35
  35. package/extensions/bytefly/update/register.ts +144 -55
  36. package/extensions/bytefly/update/state.ts +4 -2
  37. package/extensions/bytefly/update/suppress.ts +66 -0
  38. package/extensions/bytefly/update/types.ts +15 -0
  39. package/extensions/check/README.md +253 -0
  40. package/extensions/check/commands.ts +128 -0
  41. package/extensions/check/config.ts +39 -0
  42. package/extensions/check/detect.ts +269 -0
  43. package/extensions/check/failures.ts +230 -0
  44. package/extensions/check/format.ts +135 -0
  45. package/extensions/check/index.ts +26 -0
  46. package/extensions/check/render.ts +185 -0
  47. package/extensions/check/runner.ts +176 -0
  48. package/extensions/check/runtime.ts +244 -0
  49. package/extensions/check/source.ts +80 -0
  50. package/extensions/check/tools.ts +173 -0
  51. package/extensions/check/tui.ts +195 -0
  52. package/extensions/check/types.ts +99 -0
  53. package/extensions/document/README.md +1 -1
  54. package/extensions/document/extract/pdf.ts +8 -16
  55. package/extensions/document/formats.ts +2 -2
  56. package/extensions/file-browse/README.md +126 -37
  57. package/extensions/file-browse/client.ts +98 -33
  58. package/extensions/file-browse/config.ts +24 -9
  59. package/extensions/file-browse/files.ts +183 -48
  60. package/extensions/file-browse/fuzzy.ts +65 -11
  61. package/extensions/file-browse/git.ts +37 -47
  62. package/extensions/file-browse/html.ts +97 -171
  63. package/extensions/file-browse/live-client.ts +158 -0
  64. package/extensions/file-browse/live.ts +222 -0
  65. package/extensions/file-browse/markdown.ts +110 -92
  66. package/extensions/file-browse/raw-html.ts +478 -0
  67. package/extensions/file-browse/router.ts +65 -78
  68. package/extensions/file-browse/server.ts +75 -59
  69. package/extensions/file-browse/styles.ts +124 -0
  70. package/extensions/file-browse/urls.ts +80 -0
  71. package/extensions/goal/README.md +103 -47
  72. package/extensions/goal/clarify-parse.ts +4 -9
  73. package/extensions/goal/clarify.ts +27 -4
  74. package/extensions/goal/commands.ts +24 -11
  75. package/extensions/goal/format.ts +41 -10
  76. package/extensions/goal/index.ts +39 -16
  77. package/extensions/goal/plan-route.ts +51 -0
  78. package/extensions/goal/schema.ts +6 -23
  79. package/extensions/goal/state.ts +39 -57
  80. package/extensions/goal/tools.ts +26 -20
  81. package/extensions/goal/types.ts +11 -1
  82. package/extensions/job/README.md +20 -6
  83. package/extensions/job/format.ts +18 -68
  84. package/extensions/job/index.ts +26 -41
  85. package/extensions/job/log-writer.ts +208 -0
  86. package/extensions/job/logs.ts +3 -2
  87. package/extensions/job/process.ts +6 -99
  88. package/extensions/job/runtime.ts +42 -181
  89. package/extensions/job/schema.ts +1 -4
  90. package/extensions/job/tools.ts +1 -15
  91. package/extensions/job/tui.ts +117 -307
  92. package/extensions/job/ui.ts +7 -33
  93. package/extensions/memory/README.md +1 -1
  94. package/extensions/memory/errors.ts +5 -6
  95. package/extensions/memory/tui.ts +14 -24
  96. package/extensions/plan/README.md +94 -18
  97. package/extensions/plan/commands.ts +9 -5
  98. package/extensions/plan/format.ts +87 -0
  99. package/extensions/plan/index.ts +31 -15
  100. package/extensions/plan/plans.ts +2 -3
  101. package/extensions/plan/policy.ts +0 -6
  102. package/extensions/plan/runtime.ts +4 -2
  103. package/extensions/plan/steps.ts +37 -8
  104. package/extensions/plan/todos.ts +44 -3
  105. package/extensions/plan/tools/enter.ts +94 -0
  106. package/extensions/plan/tools/exit.ts +296 -0
  107. package/extensions/plan/tools/nested.ts +21 -0
  108. package/extensions/plan/tools/steps.ts +136 -0
  109. package/extensions/plan/tools/todo-seed.ts +76 -0
  110. package/extensions/plan/tools/write.ts +127 -0
  111. package/extensions/plan/tools.ts +18 -703
  112. package/extensions/plan/web-client.ts +6 -22
  113. package/extensions/plan/web-page.ts +19 -30
  114. package/extensions/plan/web-router.ts +32 -79
  115. package/extensions/plan/web.ts +21 -42
  116. package/extensions/question/answers.ts +3 -8
  117. package/extensions/question/tui.ts +6 -6
  118. package/extensions/rewind/README.md +95 -22
  119. package/extensions/rewind/checklist.ts +192 -0
  120. package/extensions/rewind/commands.ts +8 -0
  121. package/extensions/rewind/config.ts +3 -11
  122. package/extensions/rewind/format.ts +220 -2
  123. package/extensions/rewind/git.ts +101 -21
  124. package/extensions/rewind/hunk-picker.ts +114 -0
  125. package/extensions/rewind/patch.ts +88 -0
  126. package/extensions/rewind/path-picker.ts +67 -100
  127. package/extensions/rewind/paths.ts +93 -19
  128. package/extensions/rewind/restore.ts +53 -10
  129. package/extensions/rewind/review-tui.ts +320 -0
  130. package/extensions/rewind/review.ts +397 -0
  131. package/extensions/rewind/rewind-tui.ts +4 -3
  132. package/extensions/rewind/runtime.ts +94 -17
  133. package/extensions/rewind/snapshot.ts +13 -4
  134. package/extensions/subagent/README.md +61 -6
  135. package/extensions/subagent/background-tui.ts +208 -0
  136. package/extensions/subagent/background.ts +470 -0
  137. package/extensions/subagent/commands.ts +80 -0
  138. package/extensions/subagent/config.ts +4 -0
  139. package/extensions/subagent/format.ts +24 -1
  140. package/extensions/subagent/index.ts +91 -2
  141. package/extensions/subagent/render.ts +13 -0
  142. package/extensions/subagent/run.ts +17 -16
  143. package/extensions/subagent/schema.ts +12 -0
  144. package/extensions/subagent/types.ts +11 -0
  145. package/extensions/subagent/ui.ts +50 -0
  146. package/extensions/todo/README.md +18 -13
  147. package/extensions/todo/format.ts +3 -2
  148. package/extensions/todo/runtime.ts +3 -2
  149. package/extensions/todo/schema.ts +8 -84
  150. package/extensions/todo/state.ts +5 -46
  151. package/extensions/todo/tools.ts +2 -1
  152. package/extensions/todo/types.ts +6 -15
  153. package/extensions/watch/README.md +14 -6
  154. package/extensions/watch/child.ts +150 -0
  155. package/extensions/watch/format.ts +8 -48
  156. package/extensions/watch/index.ts +42 -58
  157. package/extensions/watch/runtime.ts +49 -128
  158. package/extensions/watch/schema.ts +1 -4
  159. package/extensions/watch/tools.ts +1 -11
  160. package/extensions/watch/tui.ts +103 -273
  161. package/extensions/watch/ui.ts +5 -32
  162. package/extensions/web-access/README.md +1 -1
  163. package/extensions/web-access/fetch/page.ts +1 -1
  164. package/extensions/web-access/fetch/tool.ts +6 -2
  165. package/extensions/web-access/search/search.ts +2 -5
  166. package/extensions/wechat/README.md +1 -1
  167. package/extensions/wechat/client.ts +23 -0
  168. package/extensions/wechat/commands.ts +57 -42
  169. package/extensions/wechat/deliver.ts +5 -9
  170. package/extensions/wechat/errors.ts +3 -6
  171. package/extensions/wechat/inbound.ts +2 -5
  172. package/extensions/wechat/index.ts +2 -8
  173. package/extensions/wechat/outbound.ts +2 -4
  174. package/extensions/wechat/peer-worker.ts +3 -8
  175. package/extensions/wechat/poll.ts +10 -9
  176. package/extensions/wechat/presence.ts +35 -62
  177. package/extensions/wechat/reply-pipeline.ts +3 -1
  178. package/extensions/wechat/status-screen.ts +1 -1
  179. package/extensions/wechat/timer.ts +57 -0
  180. package/extensions/worktree/config.ts +8 -20
  181. package/extensions/worktree/enter.ts +7 -12
  182. package/extensions/worktree/git.ts +38 -69
  183. package/extensions/worktree/index.ts +5 -4
  184. package/extensions/worktree/registry.ts +7 -7
  185. package/extensions/worktree/root-tools.ts +2 -2
  186. package/extensions/worktree/status.ts +15 -9
  187. package/lib/README.md +23 -8
  188. package/lib/config.ts +50 -3
  189. package/lib/errors.ts +12 -0
  190. package/lib/format.ts +50 -0
  191. package/lib/git/read.ts +28 -0
  192. package/lib/goal-state.ts +23 -8
  193. package/lib/http/headers.ts +74 -0
  194. package/lib/http/index.ts +13 -0
  195. package/lib/http/response.ts +46 -0
  196. package/lib/http/router.ts +52 -0
  197. package/lib/http/server.ts +109 -0
  198. package/lib/lines.ts +77 -0
  199. package/lib/pdf.ts +82 -0
  200. package/lib/plan-entry.ts +71 -0
  201. package/lib/plan-state.ts +34 -0
  202. package/lib/pool.ts +62 -0
  203. package/lib/report.ts +99 -0
  204. package/lib/requirements.ts +25 -19
  205. package/lib/spawn.ts +170 -0
  206. package/lib/todo-state.ts +110 -23
  207. package/lib/tool-names.ts +7 -0
  208. package/lib/tool-result.ts +33 -0
  209. package/lib/tui/README.md +68 -3
  210. package/lib/tui/chrome.ts +28 -4
  211. package/lib/tui/columns.ts +6 -1
  212. package/lib/tui/confirm.ts +65 -0
  213. package/lib/tui/dock-screen.ts +418 -0
  214. package/lib/tui/glyphs.ts +2 -1
  215. package/lib/tui/indent.ts +1 -1
  216. package/lib/tui/index.ts +2 -0
  217. package/lib/tui/rows.ts +21 -0
  218. package/lib/tui/status-controller.ts +35 -1
  219. package/lib/tui/tool.ts +59 -1
  220. package/lib/web/components.ts +20 -1
  221. package/lib/web/markup.ts +32 -0
  222. package/lib/web/runtime.ts +50 -0
  223. package/package.json +22 -5
  224. package/skills/init/SKILL.md +165 -0
  225. package/skills/init/references/agents-md-quality.md +103 -0
  226. package/skills/init/references/interview-questions.md +81 -0
  227. package/skills/refactor/SKILL.md +141 -0
  228. package/extensions/watch/process.ts +0 -63
  229. package/extensions/web-access/fetch/pdf.ts +0 -45
@@ -18,7 +18,7 @@ import {
18
18
  readJson,
19
19
  SHARED_CONFIG_FILE,
20
20
  unsetConfigValueAt,
21
- writeConfigValueAt,
21
+ writeConfigValuesAt,
22
22
  } from "../../../lib/config.ts";
23
23
  import type { SettingsStore, WriteScope } from "../picker/types.ts";
24
24
  import {
@@ -31,6 +31,7 @@ import {
31
31
  settingViews,
32
32
  storedValue,
33
33
  textError,
34
+ UNTRUSTED_SETTINGS_NOTE,
34
35
  } from "./settings.ts";
35
36
 
36
37
  /** Host seams for the Settings runtime. */
@@ -42,6 +43,8 @@ export interface SettingsRuntimeDeps {
42
43
  trusted: boolean;
43
44
  /** Report a refused or failed write to the screen. */
44
45
  onError?: (message: string) => void;
46
+ /** Theme names for the Theme row, already narrowed to this package's palettes; empty outside the TUI. */
47
+ themes?: readonly string[];
45
48
  }
46
49
 
47
50
  /** What the panel needs from the runtime. */
@@ -74,6 +77,7 @@ function readSnapshot(deps: SettingsRuntimeDeps): SettingsSnapshot {
74
77
  app: deps.stores.global.globalSettings(),
75
78
  global: readJson(configPath("global", deps.cwd, deps.agentDir)) ?? {},
76
79
  project: deps.trusted ? (readJson(configPath("project", deps.cwd, deps.agentDir)) ?? {}) : {},
80
+ themes: deps.themes ?? [],
77
81
  };
78
82
  }
79
83
 
@@ -90,6 +94,9 @@ function applyAppSetting(store: SettingsStore, row: SettingDescriptor, raw: stri
90
94
  case "showHardwareCursor":
91
95
  store.setShowHardwareCursor(value as boolean);
92
96
  return;
97
+ case "theme":
98
+ store.setTheme(value as string);
99
+ return;
93
100
  default:
94
101
  throw new Error(`Unknown app setting ${row.id}`);
95
102
  }
@@ -98,7 +105,7 @@ function applyAppSetting(store: SettingsStore, row: SettingDescriptor, raw: stri
98
105
  /** Why `row` cannot be written in `scope`, or `undefined` when it can. */
99
106
  function refusal(row: SettingDescriptor, scope: WriteScope, trusted: boolean): string | undefined {
100
107
  if (scope === "project" && !trusted) {
101
- return "Project is not trusted; settings can only be changed in Global scope.";
108
+ return UNTRUSTED_SETTINGS_NOTE;
102
109
  }
103
110
  if (row.source === "app" && scope !== "global") return `${row.label} is a Global setting.`;
104
111
  return undefined;
@@ -130,8 +137,12 @@ export function createSettingsRuntime(deps: SettingsRuntimeDeps): SettingsRuntim
130
137
  const config = row.config;
131
138
  if (!config) throw new Error(`Setting ${row.id} has no config section`);
132
139
  const path = configPath(scope, deps.cwd, deps.agentDir);
133
- writeConfigValueAt(path, config.section, config.path, storedValue(row, raw));
134
- for (const leaf of row.also?.(raw) ?? []) writeConfigValueAt(path, leaf.section, leaf.path, leaf.value);
140
+ // One document pass for the row and every companion leaf it declares, so a
141
+ // failure cannot leave the row written and its companion not.
142
+ writeConfigValuesAt(path, [
143
+ { section: config.section, path: config.path, value: storedValue(row, raw) },
144
+ ...(row.also?.(raw) ?? []),
145
+ ]);
135
146
  };
136
147
 
137
148
  /** Take a completed write: re-read the snapshot, mark it dirty, repaint. */
@@ -159,7 +170,9 @@ export function createSettingsRuntime(deps: SettingsRuntimeDeps): SettingsRuntim
159
170
  }
160
171
  const current = settingViews(snapshot, scope).find((entry) => entry.id === id);
161
172
  if (!current) return;
162
- const next = nextValue(row, current.value);
173
+ // Use the view's options: the Theme row's list comes from the host, not
174
+ // from the static descriptor.
175
+ const next = nextValue(current, current.value);
163
176
  try {
164
177
  await write(scope, row, next.value);
165
178
  } catch (error) {
@@ -212,7 +225,14 @@ export function createSettingsRuntime(deps: SettingsRuntimeDeps): SettingsRuntim
212
225
  if (!config) return false;
213
226
  const document = scope === "global" ? snapshot.global : snapshot.project;
214
227
  if (configValueAt(configSection(document, config.section), config.path) === undefined) {
215
- deps.onError?.(`${row.label} is already at its default.`);
228
+ const inherited =
229
+ scope === "project" &&
230
+ configValueAt(configSection(snapshot.global, config.section), config.path) !== undefined;
231
+ deps.onError?.(
232
+ inherited
233
+ ? `${row.label} is inherited from Global; there is nothing to reset in Project.`
234
+ : `${row.label} is already at its default.`,
235
+ );
216
236
  return false;
217
237
  }
218
238
  try {
@@ -135,6 +135,8 @@ export interface SettingsSnapshot {
135
135
  app: Settings;
136
136
  global: Record<string, unknown>;
137
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[];
138
140
  }
139
141
 
140
142
  /** One rendered group: its heading and the rows under it. */
@@ -436,6 +438,18 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
436
438
  { value: "1", label: "1" },
437
439
  ],
438
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
+ },
439
453
  {
440
454
  id: "showHardwareCursor",
441
455
  group: "terminal",
@@ -519,7 +533,7 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
519
533
  id: "rewind.max",
520
534
  group: "snapshots",
521
535
  label: "Snapshots kept",
522
- description: "Snapshots kept per repository before the oldest are pruned; 0 keeps none.",
536
+ description: "Snapshot refs kept per repository, including pre-restore safety snapshots; 0 keeps none.",
523
537
  source: "bytefly",
524
538
  kind: "text",
525
539
  fallback: "20",
@@ -590,6 +604,9 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
590
604
  },
591
605
  ];
592
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
+
593
610
  /** The rows a scope can write: app rows are personal-file only. */
594
611
  export function rowsForScope(scope: WriteScope): readonly SettingDescriptor[] {
595
612
  return scope === "global" ? SETTING_ROWS : SETTING_ROWS.filter((row) => row.source === "bytefly");
@@ -612,9 +629,18 @@ export function textError(row: SettingDescriptor, raw: string): string | undefin
612
629
  return rule.validate?.(raw);
613
630
  }
614
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
+
615
638
  /** The display label for `raw`, or the raw value when it is a valid custom one. */
616
639
  function labelFor(row: SettingDescriptor, raw: string): string {
617
- 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;
618
644
  }
619
645
 
620
646
  /** Read an app preference and where it came from. */
@@ -634,6 +660,11 @@ function appValue(snapshot: SettingsSnapshot, row: SettingDescriptor): { value:
634
660
  value: snapshot.app.showHardwareCursor === true ? "true" : row.fallback,
635
661
  origin: snapshot.app.showHardwareCursor === undefined ? "default" : "global",
636
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
+ }
637
668
  default:
638
669
  throw new Error(`Unknown app setting ${row.id}`);
639
670
  }
@@ -646,7 +677,11 @@ function statesConfig(config: SettingConfig, document: Record<string, unknown>):
646
677
 
647
678
  /** Where the effective value came from: the project file, the personal one, or nothing. */
648
679
  function originFor(config: SettingConfig, snapshot: SettingsSnapshot, scope: WriteScope): SettingOrigin {
649
- 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
+ }
650
685
  return statesConfig(config, snapshot.global) ? "global" : "default";
651
686
  }
652
687
 
@@ -711,8 +746,10 @@ function byteflyValue(
711
746
  /** Build the rows for `scope` from a snapshot. */
712
747
  export function settingViews(snapshot: SettingsSnapshot, scope: WriteScope): SettingView[] {
713
748
  return rowsForScope(scope).map((row) => {
749
+ const options = row.id === "theme" ? themeOptions(snapshot.themes) : row.options;
714
750
  const { value, origin } = row.source === "app" ? appValue(snapshot, row) : byteflyValue(snapshot, row, scope);
715
- return { ...row, value, valueLabel: labelFor(row, value), origin };
751
+ const view = { ...row, options, value };
752
+ return { ...view, valueLabel: labelFor(view, value), origin };
716
753
  });
717
754
  }
718
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) {
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { Theme } from "@earendil-works/pi-coding-agent";
10
+ import type { ScreenHint } from "../../../../lib/tui/chrome.ts";
10
11
  import { sanitizeLine } from "../../../../lib/format.ts";
11
12
  import { clip } from "../../../../lib/tui/columns.ts";
12
13
  import { checkbox, listRow } from "../../../../lib/tui/rows.ts";
@@ -46,7 +47,7 @@ export function extensionRow(theme: Theme, width: number, row: PanelRow, selecte
46
47
  });
47
48
  }
48
49
 
49
- /** The focused extension's label, findings, and the pending-changes note. */
50
+ /** The focused extension's findings and the pending-changes note. */
50
51
  export function extensionDetail(
51
52
  theme: Theme,
52
53
  width: number,
@@ -57,10 +58,10 @@ export function extensionDetail(
57
58
  ): string[] {
58
59
  const lines: string[] = [];
59
60
  const badge = row ? rowBadge(row) : undefined;
60
- if (row && badge?.text !== undefined) {
61
- const head = `${paint(theme, "meta", sanitizeLine(row.label))} ${paint(theme, "faint", "—")} ${paint(theme, badge.role, badge.text)}`;
62
- lines.push(clip(` ${head}`, width));
63
- } else if (row?.item) {
61
+ // The focused row directly above already carries the entry's name and verdict,
62
+ // so the pane starts with what the row cannot say: the findings, or the
63
+ // resource path when nothing was reported.
64
+ if (badge?.text === undefined && row?.item) {
64
65
  lines.push(clip(` ${paint(theme, "meta", sanitizeLine(row.item.path))}`, width));
65
66
  }
66
67
  if (row?.report && budget > 0) {
@@ -71,12 +72,16 @@ export function extensionDetail(
71
72
  }
72
73
 
73
74
  /** The section's key hints, in the order they drop as the width runs out. */
74
- export function extensionHints(options: { expanded: boolean; refreshing: boolean; projectScope: boolean }): string[] {
75
+ export function extensionHints(options: {
76
+ expanded: boolean;
77
+ refreshing: boolean;
78
+ projectScope: boolean;
79
+ }): ScreenHint[] {
75
80
  return [
76
81
  "Esc close",
77
82
  "←/→",
78
- "Space toggle",
79
- options.expanded ? "Enter collapse" : "Enter detail",
83
+ { text: "Space toggle", color: "accent" },
84
+ { text: options.expanded ? "Enter collapse" : "Enter detail", color: "accent" },
80
85
  "Tab scope",
81
86
  // Only Project scope can hold an override, so the key is not offered elsewhere.
82
87
  ...(options.projectScope ? ["x inherit"] : []),
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * The chrome around the panel's body: the layout constants, the section bar with
3
- * its counts, a group heading, and the hint row that a transient notice replaces.
3
+ * its counts, a group heading, the hint row that a transient notice replaces, and
4
+ * the frame the two sections share.
4
5
  *
5
6
  * Every section draws its body through these, so the two views cannot drift on
6
7
  * spacing, roles, or the width arithmetic.
@@ -8,12 +9,20 @@
8
9
 
9
10
  import type { Theme } from "@earendil-works/pi-coding-agent";
10
11
  import { sanitizeLine } from "../../../../lib/format.ts";
11
- import { screenHint } from "../../../../lib/tui/chrome.ts";
12
+ import {
13
+ detailBlockRows,
14
+ dockListFrame,
15
+ screenHint,
16
+ type ScreenHint,
17
+ type ViewportRowsSource,
18
+ } from "../../../../lib/tui/chrome.ts";
12
19
  import { clip, fitCells, splitRow } from "../../../../lib/tui/columns.ts";
20
+ import { fitRows, formatRange, type ListCursor } from "../../../../lib/tui/cursor.ts";
13
21
  import { BODY_INDENT } from "../../../../lib/tui/indent.ts";
14
22
  import { paint } from "../../../../lib/tui/tokens.ts";
15
23
  import { sectionSegments, type SummarySegment } from "../format.ts";
16
24
  import type { PanelSection } from "../types.ts";
25
+ import { selectableRange, type PanelEntry } from "./entries.ts";
17
26
 
18
27
  /** Body rows when the terminal height is unknown. */
19
28
  export const SCREEN_DEFAULT_ROWS = 16;
@@ -54,8 +63,8 @@ export function groupLine(theme: Theme, width: number, entry: { label: string; c
54
63
  return clip(` ${label} ${count}`, width);
55
64
  }
56
65
 
57
- /** The key-hint row. */
58
- export function hintLine(theme: Theme, width: number, hints: string[]): string {
66
+ /** The key-hint row, or a section's notice that replaces it. */
67
+ export function hintLine(theme: Theme, width: number, hints: ScreenHint[]): string {
59
68
  return screenHint(theme, width, hints);
60
69
  }
61
70
 
@@ -69,3 +78,111 @@ export function savedLine(theme: Theme, width: number, dirty: boolean): string[]
69
78
  if (!dirty) return [];
70
79
  return [clip(`${INDENT}${paint(theme, "warn", "Saved · reload to apply")}`, width)];
71
80
  }
81
+
82
+ /** Terminal rows, or `undefined` when the height is unknown. */
83
+ export function totalRows(source: ViewportRowsSource | undefined): number | undefined {
84
+ const rows = typeof source === "function" ? source() : source;
85
+ return rows !== undefined && Number.isFinite(rows) && rows > 0 ? rows : undefined;
86
+ }
87
+
88
+ /**
89
+ * Detail rows the focused row gets: the fixed pane, or the free height when
90
+ * expanded. Zero when there is no free row for a pane at all, so a short
91
+ * terminal keeps its body; the caller then drops optional rows if needed.
92
+ */
93
+ export function detailRows(expanded: boolean, viewportRows: ViewportRowsSource | undefined): number {
94
+ const total = totalRows(viewportRows);
95
+ if (total === undefined) return expanded ? EXPANDED_DETAIL_FALLBACK : DETAIL_ROWS;
96
+ // One row pays for the blank line the frame puts above the pane.
97
+ const available = total - FRAME_ROWS - MIN_BODY_ROWS - 1;
98
+ if (available < 1) return 0;
99
+ return expanded ? available : Math.min(DETAIL_ROWS, available);
100
+ }
101
+
102
+ /**
103
+ * Drop optional rows until the frame fits the viewport: the range row first,
104
+ * then the project note, then the blank above the detail pane, then the pane.
105
+ * The header, summary, one body row, and the hint always survive; a terminal
106
+ * shorter than those is left to the host.
107
+ */
108
+ export function fitOptionalLines(options: {
109
+ viewportRows: ViewportRowsSource | undefined;
110
+ body: string[];
111
+ note: string | undefined;
112
+ range: string | undefined;
113
+ detail: string[];
114
+ }): { body: string[]; range: string | undefined; detail: string[]; noteShown: boolean; detailBlank: boolean } {
115
+ const total = totalRows(options.viewportRows);
116
+ let { body, range, detail } = options;
117
+ let noteShown = options.note !== undefined && body[0] === options.note;
118
+ if (total === undefined) return { body, range, detail, noteShown, detailBlank: true };
119
+ const fits = (blank: boolean): boolean =>
120
+ FRAME_ROWS + body.length + (range === undefined ? 0 : 1) + detailBlockRows(detail, blank) <= total;
121
+ if (!fits(true)) range = undefined;
122
+ if (!fits(true) && noteShown) {
123
+ body = body.slice(1);
124
+ noteShown = false;
125
+ }
126
+ // The separator goes before the pane does: the focused row's findings are
127
+ // worth more than the blank above them.
128
+ const detailBlank = fits(true) || !fits(false);
129
+ if (!fits(false)) detail = [];
130
+ return { body, range, detail, noteShown, detailBlank };
131
+ }
132
+
133
+ /** What {@link panelFrame} draws: the window, the body, and the chrome around it. */
134
+ export interface PanelFrameInput {
135
+ theme: Theme;
136
+ viewportRows: ViewportRowsSource | undefined;
137
+ cursor: ListCursor;
138
+ width: number;
139
+ header: string;
140
+ summary: string;
141
+ entries: readonly PanelEntry[];
142
+ note?: string;
143
+ detail: string[];
144
+ hint: string;
145
+ draw: (entry: PanelEntry, index: number) => string | undefined;
146
+ }
147
+
148
+ /**
149
+ * The frame both sections share: the cursor window, the visible body rows, the
150
+ * range row, the optional rows that give way on a short terminal, and the
151
+ * chrome around them. Each section supplies its summary, its detail pane, its
152
+ * hint row, and how one entry is drawn.
153
+ */
154
+ export function panelFrame(input: PanelFrameInput): { lines: string[]; noteShown: boolean } {
155
+ const { theme, viewportRows, cursor, width, entries, note, detail, hint, draw } = input;
156
+ cursor.visible = fitRows(
157
+ viewportRows,
158
+ entries.length,
159
+ FRAME_ROWS + detailBlockRows(detail) + (note ? 1 : 0),
160
+ SCREEN_DEFAULT_ROWS,
161
+ );
162
+ cursor.sync(entries.length);
163
+ const { start, end } = cursor.window(entries.length);
164
+
165
+ const body: string[] = [];
166
+ if (note) body.push(note);
167
+ for (let index = start; index < end; index += 1) {
168
+ const line = draw(entries[index], index);
169
+ if (line !== undefined) body.push(line);
170
+ }
171
+
172
+ const range = cursor.clipped(entries.length)
173
+ ? formatRange(theme, width, selectableRange(entries, { start, end }))
174
+ : undefined;
175
+ const fitted = fitOptionalLines({ viewportRows, body, note, range, detail });
176
+ return {
177
+ lines: dockListFrame({
178
+ header: input.header,
179
+ summary: input.summary,
180
+ body: fitted.body,
181
+ range: fitted.range,
182
+ detail: fitted.detail.length > 0 ? fitted.detail : undefined,
183
+ detailBlank: fitted.detailBlank,
184
+ hint,
185
+ }),
186
+ noteShown: fitted.noteShown,
187
+ };
188
+ }