@gavin-hu/my-pi-agent 0.15.1 → 0.17.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 (189) hide show
  1. package/README.md +22 -13
  2. package/README.zh-CN.md +19 -11
  3. package/extensions/bytefly/README.md +255 -200
  4. package/extensions/bytefly/footer/collect/entries.ts +1 -6
  5. package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
  6. package/extensions/bytefly/footer/price/price.ts +12 -8
  7. package/extensions/bytefly/footer/register.ts +13 -1
  8. package/extensions/bytefly/footer/types.ts +11 -0
  9. package/extensions/bytefly/footer/unpriced.ts +36 -0
  10. package/extensions/bytefly/header/component.ts +15 -29
  11. package/extensions/bytefly/header/identity.ts +71 -0
  12. package/extensions/bytefly/header/register.ts +9 -3
  13. package/extensions/bytefly/index.ts +13 -5
  14. package/extensions/bytefly/message/format.ts +20 -13
  15. package/extensions/bytefly/message/register.ts +8 -7
  16. package/extensions/bytefly/notice/component.ts +6 -4
  17. package/extensions/bytefly/notice/config.ts +16 -2
  18. package/extensions/bytefly/panel/commands.ts +46 -25
  19. package/extensions/bytefly/panel/format.ts +52 -22
  20. package/extensions/bytefly/panel/join.ts +36 -15
  21. package/extensions/bytefly/panel/settings.ts +72 -392
  22. package/extensions/bytefly/panel/skills.ts +51 -0
  23. package/extensions/bytefly/panel/tui/entries.ts +16 -14
  24. package/extensions/bytefly/panel/tui/extensions-view.ts +7 -58
  25. package/extensions/bytefly/panel/tui/frame.ts +11 -3
  26. package/extensions/bytefly/panel/tui/list-view.ts +72 -0
  27. package/extensions/bytefly/panel/tui/nav.ts +71 -3
  28. package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
  29. package/extensions/bytefly/panel/tui/settings-view.ts +8 -12
  30. package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
  31. package/extensions/bytefly/panel/tui.ts +223 -333
  32. package/extensions/bytefly/panel/types.ts +26 -8
  33. package/extensions/bytefly/picker/discovery.ts +32 -27
  34. package/extensions/bytefly/picker/resources.ts +22 -10
  35. package/extensions/bytefly/picker/runtime.ts +62 -34
  36. package/extensions/bytefly/picker/toggle.ts +16 -10
  37. package/extensions/bytefly/picker/types.ts +31 -13
  38. package/extensions/bytefly/update/format.ts +66 -8
  39. package/extensions/bytefly/update/patch.ts +66 -0
  40. package/extensions/bytefly/update/register.ts +4 -11
  41. package/extensions/bytefly/update/release.ts +65 -0
  42. package/extensions/bytefly/update/state.ts +14 -0
  43. package/extensions/bytefly/update/suppress.ts +47 -29
  44. package/extensions/bytefly/update/types.ts +15 -5
  45. package/extensions/bytefly/working/register.ts +2 -8
  46. package/extensions/check/tools.ts +2 -11
  47. package/extensions/check/tui.ts +2 -0
  48. package/extensions/context/README.md +172 -0
  49. package/extensions/context/collect.ts +53 -0
  50. package/extensions/context/commands.ts +100 -0
  51. package/extensions/context/format.ts +84 -0
  52. package/extensions/context/index.ts +16 -0
  53. package/extensions/context/report.ts +332 -0
  54. package/extensions/context/tui.ts +284 -0
  55. package/extensions/context/types.ts +124 -0
  56. package/extensions/file-browse/README.md +67 -19
  57. package/extensions/file-browse/client.ts +16 -463
  58. package/extensions/file-browse/config.ts +8 -0
  59. package/extensions/file-browse/html.ts +9 -7
  60. package/extensions/file-browse/index.ts +17 -2
  61. package/extensions/file-browse/live-client.ts +13 -13
  62. package/extensions/file-browse/live.ts +44 -10
  63. package/extensions/file-browse/markdown.ts +3 -10
  64. package/extensions/file-browse/palette.ts +224 -0
  65. package/extensions/file-browse/raw-html.ts +3 -10
  66. package/extensions/file-browse/router.ts +2 -2
  67. package/extensions/file-browse/server.ts +13 -2
  68. package/extensions/file-browse/styles.ts +8 -3
  69. package/extensions/file-browse/tree.ts +381 -0
  70. package/extensions/file-open/README.md +1 -2
  71. package/extensions/{document → file-read}/README.md +76 -57
  72. package/extensions/{document → file-read}/config.ts +12 -9
  73. package/extensions/{document → file-read}/extract/doc.ts +2 -2
  74. package/extensions/{document → file-read}/extract/docx.ts +3 -3
  75. package/extensions/{document → file-read}/extract/file.ts +9 -9
  76. package/extensions/{document → file-read}/extract/odt.ts +10 -8
  77. package/extensions/{document → file-read}/extract/pdf.ts +3 -3
  78. package/extensions/{document → file-read}/extract/rtf.ts +3 -3
  79. package/extensions/{document → file-read}/extract/xlsx.ts +4 -4
  80. package/extensions/{document → file-read}/formats.ts +11 -11
  81. package/extensions/file-read/index.ts +17 -0
  82. package/extensions/{document → file-read}/paging.ts +15 -13
  83. package/extensions/{document → file-read}/render.ts +17 -13
  84. package/extensions/{document → file-read}/schema.ts +7 -7
  85. package/extensions/{document → file-read}/tool.ts +36 -28
  86. package/extensions/goal/tools.ts +16 -25
  87. package/extensions/job/commands.ts +32 -46
  88. package/extensions/job/index.ts +20 -45
  89. package/extensions/job/runtime.ts +6 -8
  90. package/extensions/job/tools.ts +58 -103
  91. package/extensions/memory/commands.ts +30 -44
  92. package/extensions/memory/tools.ts +10 -20
  93. package/extensions/memory/tui.ts +5 -1
  94. package/extensions/plan/README.md +88 -33
  95. package/extensions/plan/commands.ts +19 -4
  96. package/extensions/plan/config.ts +27 -0
  97. package/extensions/plan/plan-index.ts +3 -1
  98. package/extensions/plan/plans.ts +31 -13
  99. package/extensions/plan/review.ts +34 -14
  100. package/extensions/plan/runtime.ts +3 -1
  101. package/extensions/plan/steps.ts +43 -14
  102. package/extensions/plan/tools/cancel.ts +79 -0
  103. package/extensions/plan/tools/enter.ts +16 -17
  104. package/extensions/plan/tools/exit.ts +60 -50
  105. package/extensions/plan/tools/steps.ts +13 -26
  106. package/extensions/plan/tools/write.ts +30 -38
  107. package/extensions/plan/tools.ts +5 -2
  108. package/extensions/plan/tui.ts +9 -7
  109. package/extensions/plan/types.ts +8 -0
  110. package/extensions/plan/web-chrome.ts +101 -0
  111. package/extensions/plan/web-client.ts +98 -311
  112. package/extensions/plan/web-markdown.ts +222 -0
  113. package/extensions/plan/web-page.ts +28 -2
  114. package/extensions/plan/web-router.ts +2 -2
  115. package/extensions/plan/web.ts +21 -12
  116. package/extensions/question/tools.ts +5 -4
  117. package/extensions/rewind/checklist.ts +4 -0
  118. package/extensions/rewind/review-tui.ts +3 -1
  119. package/extensions/rewind/rewind-tui.ts +4 -0
  120. package/extensions/subagent/README.md +25 -12
  121. package/extensions/subagent/background.ts +1 -7
  122. package/extensions/subagent/format.ts +72 -25
  123. package/extensions/subagent/index.ts +21 -47
  124. package/extensions/subagent/orchestrate.ts +18 -41
  125. package/extensions/subagent/render.ts +29 -42
  126. package/extensions/subagent/stream.ts +24 -18
  127. package/extensions/subagent/types.ts +7 -0
  128. package/extensions/todo/tools.ts +15 -20
  129. package/extensions/todo/tui.ts +5 -1
  130. package/extensions/watch/README.md +5 -5
  131. package/extensions/watch/commands.ts +33 -47
  132. package/extensions/watch/index.ts +10 -35
  133. package/extensions/watch/runtime.ts +5 -7
  134. package/extensions/watch/tools.ts +38 -77
  135. package/extensions/web-access/README.md +3 -3
  136. package/extensions/web-access/search/search.ts +9 -18
  137. package/extensions/wechat/README.md +40 -18
  138. package/extensions/wechat/bridge.ts +38 -40
  139. package/extensions/wechat/client.ts +1 -4
  140. package/extensions/wechat/commands.ts +1 -1
  141. package/extensions/wechat/interaction.ts +25 -4
  142. package/extensions/wechat/login-screen.ts +2 -3
  143. package/extensions/wechat/notices.ts +7 -5
  144. package/extensions/wechat/prompt.ts +19 -4
  145. package/extensions/wechat/qrcode.ts +9 -9
  146. package/extensions/wechat/slash.ts +14 -0
  147. package/extensions/wechat/status-screen.ts +2 -2
  148. package/extensions/wechat/timer.ts +9 -30
  149. package/extensions/wechat/tools.ts +7 -18
  150. package/extensions/worktree/README.md +48 -12
  151. package/extensions/worktree/commands.ts +109 -65
  152. package/extensions/worktree/enter.ts +1 -1
  153. package/extensions/worktree/exit.ts +11 -1
  154. package/extensions/worktree/lifecycle.ts +9 -1
  155. package/extensions/worktree/screen.ts +263 -0
  156. package/extensions/worktree/status.ts +84 -38
  157. package/lib/README.md +15 -11
  158. package/lib/config.ts +12 -0
  159. package/lib/escape.ts +15 -2
  160. package/lib/format.ts +6 -2
  161. package/lib/interaction.ts +18 -1
  162. package/lib/pdf.ts +2 -2
  163. package/lib/report.ts +75 -2
  164. package/lib/requirements.ts +2 -2
  165. package/lib/screen-session.ts +85 -0
  166. package/lib/timer.ts +61 -0
  167. package/lib/tool-result.ts +54 -4
  168. package/lib/tui/README.md +19 -11
  169. package/lib/tui/chrome.ts +27 -6
  170. package/lib/tui/columns.ts +33 -8
  171. package/lib/tui/dock-screen.ts +6 -2
  172. package/lib/tui/gauge.ts +9 -5
  173. package/lib/tui/rows.ts +15 -6
  174. package/lib/tui/rules.ts +4 -8
  175. package/lib/tui/tool.ts +55 -2
  176. package/lib/usage.ts +26 -0
  177. package/lib/web/README.md +131 -0
  178. package/lib/web/base.ts +19 -2
  179. package/lib/web/components.ts +10 -6
  180. package/lib/web/layout.ts +2 -0
  181. package/lib/web/markup.ts +2 -1
  182. package/lib/web/runtime.ts +103 -25
  183. package/lib/web/tokens.ts +23 -7
  184. package/package.json +10 -4
  185. package/skills/changelog/SKILL.md +148 -0
  186. package/skills/next/SKILL.md +184 -0
  187. package/extensions/document/index.ts +0 -17
  188. /package/extensions/{document → file-read}/deadline.ts +0 -0
  189. /package/extensions/{document → file-read}/extract/vendor.d.ts +0 -0
@@ -1,9 +1,10 @@
1
1
  /**
2
- * The `/bytefly` surface: one command, `/bytefly [doctor] [settings] [update] [extension] [refresh]`.
2
+ * The `/bytefly` surface: one command, `/bytefly [doctor] [settings] [skills] [update] [extension] [refresh]`.
3
3
  *
4
- * Without `doctor` it opens the extension screen: the dock in `tui`, the
4
+ * Without a section word it opens the extension screen: the dock in `tui`, the
5
5
  * grouped listing elsewhere. `settings` opens the Settings section, or prints
6
- * its grouped listing outside `tui` without resolving packages. `update`
6
+ * its grouped listing outside `tui` without resolving packages. `skills` opens
7
+ * the Skills section and lists the resolved skills in text modes. `update`
7
8
  * re-runs the published-version check and reports what it found, which is the
8
9
  * one place a request is made on demand. With `doctor` it produces the doctor
9
10
  * report — the same text `run_doctor` returns — for the whole set or one named
@@ -23,12 +24,13 @@ import { formatExtension, formatReport, hasProblems } from "../doctor/format.ts"
23
24
  import type { DoctorRuntime } from "../doctor/runtime.ts";
24
25
  import { updateOutcomeText } from "../update/format.ts";
25
26
  import type { UpdateHandle } from "../update/register.ts";
26
- import { createSettingsStores, discoverExtensions, type DiscoveryDeps } from "../picker/discovery.ts";
27
- import { createExtensionsPickerRuntime, type ExtensionsPickerRuntime } from "../picker/runtime.ts";
27
+ import { createSettingsStores, discoverResources, type DiscoveryDeps } from "../picker/discovery.ts";
28
+ import { createPickerRuntime, type PickerRuntime } from "../picker/runtime.ts";
28
29
  import type { Discovery } from "../picker/types.ts";
29
- import { formatListing, formatSettingsListing } from "./format.ts";
30
- import { buildPanelModel, extensionName } from "./join.ts";
30
+ import { formatListing, formatSettingsListing, SKILL_NOUN } from "./format.ts";
31
+ import { buildPanelModel, buildSkillModel, extensionName } from "./join.ts";
31
32
  import { createSettingsRuntime, type SettingsRuntime, type SettingsRuntimeDeps } from "./settings-runtime.ts";
33
+ import { loadSkillIndex, resolvedSkillPaths, type SkillIndex } from "./skills.ts";
32
34
  import { shippedThemeNames } from "./themes.ts";
33
35
  import { PanelComponent } from "./tui.ts";
34
36
  import type { PanelSection } from "./types.ts";
@@ -43,6 +45,8 @@ export interface PanelCommandDeps {
43
45
  discover?: (deps: DiscoveryDeps) => Promise<Discovery>;
44
46
  /** The Settings runtime seam. */
45
47
  settings?: (deps: SettingsRuntimeDeps) => SettingsRuntime;
48
+ /** The skill-metadata seam; `loadSkillIndex` unless a test supplies one. */
49
+ skills?: (paths: readonly string[], cwd: string, agentDir: string) => SkillIndex;
46
50
  }
47
51
 
48
52
  /**
@@ -52,19 +56,21 @@ export interface PanelCommandDeps {
52
56
  */
53
57
  function selectableNames(discovery: Discovery): Set<string> {
54
58
  const names = new Set<string>(Object.keys(REQUIREMENTS));
55
- for (const item of [...discovery.global, ...discovery.effective]) names.add(extensionName(item.path));
59
+ for (const item of [...discovery.extensions.global, ...discovery.extensions.effective]) {
60
+ names.add(extensionName(item.path));
61
+ }
56
62
  return names;
57
63
  }
58
64
 
59
65
  /** The values a bare `/bytefly <tab>` offers, in screen order. */
60
- const FIRST_TOKENS = ["doctor", "settings", "update", ...Object.keys(REQUIREMENTS), "refresh"] as const;
66
+ const FIRST_TOKENS = ["doctor", "settings", "skills", "update", ...Object.keys(REQUIREMENTS), "refresh"] as const;
61
67
 
62
68
  /**
63
69
  * Argument completions. The host replaces the whole argument text with the
64
70
  * returned `value`, so a second token returns the full `doctor <name>` or
65
- * `<name> refresh` argument rather than just the word. `/bytefly settings` and
66
- * `/bytefly update` take no name, and `refresh` is never followed by another
67
- * token.
71
+ * `<name> refresh` argument rather than just the word. `/bytefly settings`,
72
+ * `/bytefly skills`, and `/bytefly update` take no name, and `refresh` is never
73
+ * followed by another token.
68
74
  */
69
75
  function completions(prefix: string): Array<{ value: string; label: string }> | null {
70
76
  const [first = "", second, ...rest] = prefix.trimStart().split(/\s+/);
@@ -73,7 +79,7 @@ function completions(prefix: string): Array<{ value: string; label: string }> |
73
79
  const options = FIRST_TOKENS.filter((option) => option.startsWith(first));
74
80
  return options.length > 0 ? options.map((option) => ({ value: `${option} `, label: option })) : null;
75
81
  }
76
- if (first === "settings" || first === "refresh" || first === "update") return null;
82
+ if (first === "settings" || first === "skills" || first === "refresh" || first === "update") return null;
77
83
  const options = (first === "doctor" ? [...Object.keys(REQUIREMENTS), "refresh"] : ["refresh"]).filter((option) =>
78
84
  option.startsWith(second),
79
85
  );
@@ -94,7 +100,7 @@ interface PanelArgs {
94
100
  refresh: boolean;
95
101
  }
96
102
 
97
- /** Parse `/bytefly [doctor] [settings] [update] [extension] [refresh]`; extra tokens are ignored. */
103
+ /** Parse `/bytefly [doctor] [settings] [skills] [update] [extension] [refresh]`; extra tokens are ignored. */
98
104
  function parseArgs(args: string): PanelArgs {
99
105
  const parts = args.trim().split(/\s+/).filter(Boolean);
100
106
  const refresh = parts.includes("refresh");
@@ -104,6 +110,7 @@ function parseArgs(args: string): PanelArgs {
104
110
  // beside it is accepted and ignored rather than refused.
105
111
  if (rest[0] === "update") return { mode: "update", section: "extensions", refresh };
106
112
  if (rest[0] === "settings") return { mode: "screen", section: "settings", name: rest[1], refresh };
113
+ if (rest[0] === "skills") return { mode: "screen", section: "skills", name: rest[1], refresh };
107
114
  return { mode: "screen", section: "extensions", name: rest[0], refresh };
108
115
  }
109
116
 
@@ -115,14 +122,14 @@ function notifyExtension(
115
122
  ctx: ExtensionCommandContext,
116
123
  name: string,
117
124
  reports: readonly ExtensionReport[],
118
- runtime: ExtensionsPickerRuntime,
125
+ runtime: PickerRuntime,
119
126
  ): void {
120
127
  const report = reports.find((entry) => entry.extension === name);
121
128
  if (report) {
122
129
  ctx.ui.notify(formatExtension(report), hasProblems([report]) ? "warning" : "info");
123
130
  return;
124
131
  }
125
- const item = runtime.items().find((entry) => extensionName(entry.path) === name);
132
+ const item = runtime.items("extensions").find((entry) => extensionName(entry.path) === name);
126
133
  ctx.ui.notify(
127
134
  item
128
135
  ? `bytefly: ${sanitizeLine(item.path)} — no doctor report`
@@ -146,15 +153,16 @@ function settingsThemeNames(ctx: ExtensionCommandContext): string[] {
146
153
 
147
154
  /** Register the single `/bytefly` command. */
148
155
  export function registerPanel(pi: ExtensionAPI, deps: PanelCommandDeps): void {
149
- const discover = deps.discover ?? discoverExtensions;
156
+ const discover = deps.discover ?? discoverResources;
150
157
  const makeSettings = deps.settings ?? createSettingsRuntime;
158
+ const loadSkills = deps.skills ?? loadSkillIndex;
151
159
 
152
160
  const handler = async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
153
161
  const { mode, section, name, refresh } = parseArgs(args);
154
- // `/bytefly settings` has no extension argument; a stray word is a typo, not
155
- // a filter, so say so rather than ignoring it.
156
- if (section === "settings" && name !== undefined) {
157
- ctx.ui.notify(`bytefly: /bytefly settings takes no extension name (got "${sanitizeLine(name)}").`, "warning");
162
+ // Only the Extensions section takes an extension argument; a stray word is a
163
+ // typo, not a filter, so say so rather than ignoring it.
164
+ if (section !== "extensions" && name !== undefined) {
165
+ ctx.ui.notify(`bytefly: /bytefly ${section} takes no extension name (got "${sanitizeLine(name)}").`, "warning");
158
166
  return;
159
167
  }
160
168
  const cwd = ctx.cwd ?? process.cwd();
@@ -195,7 +203,7 @@ export function registerPanel(pi: ExtensionAPI, deps: PanelCommandDeps): void {
195
203
  try {
196
204
  discovery = await discover({ cwd, agentDir, trusted });
197
205
  } catch (error) {
198
- ctx.ui.notify(`Could not list extensions: ${error instanceof Error ? error.message : String(error)}`, "error");
206
+ ctx.ui.notify(`Could not list resources: ${error instanceof Error ? error.message : String(error)}`, "error");
199
207
  return;
200
208
  }
201
209
 
@@ -208,7 +216,7 @@ export function registerPanel(pi: ExtensionAPI, deps: PanelCommandDeps): void {
208
216
  onError: settingsNotice,
209
217
  });
210
218
 
211
- const runtime = createExtensionsPickerRuntime({
219
+ const runtime = createPickerRuntime({
212
220
  discovery,
213
221
  cwd,
214
222
  agentDir,
@@ -222,6 +230,18 @@ export function registerPanel(pi: ExtensionAPI, deps: PanelCommandDeps): void {
222
230
  return;
223
231
  }
224
232
 
233
+ // Skills outside `tui` print the same grouped listing, from the metadata Pi's
234
+ // loader reads; there is no report to probe for and no package to resolve.
235
+ if (section === "skills" && ctx.mode !== "tui") {
236
+ const index = loadSkills(resolvedSkillPaths(discovery), cwd, agentDir);
237
+ const model = buildSkillModel({ items: runtime.items("skills"), index, agentDir });
238
+ ctx.ui.notify(
239
+ formatListing(model, runtime.scope(), { noun: SKILL_NOUN, emptyLabel: "No skills found." }),
240
+ "info",
241
+ );
242
+ return;
243
+ }
244
+
225
245
  // Text modes print the report, so they probe now. The dock screen opens on
226
246
  // the session cache and probes on its first Extensions view instead, which
227
247
  // keeps `/bytefly` and `/bytefly settings` instant.
@@ -243,7 +263,7 @@ export function registerPanel(pi: ExtensionAPI, deps: PanelCommandDeps): void {
243
263
  ctx.ui.notify(formatReport(reports), hasProblems(reports) ? "warning" : "info");
244
264
  return;
245
265
  }
246
- const model = buildPanelModel({ items: runtime.items(), reports, agentDir });
266
+ const model = buildPanelModel({ items: runtime.items("extensions"), reports, agentDir });
247
267
  ctx.ui.notify(formatListing(model, runtime.scope()), "info");
248
268
  return;
249
269
  }
@@ -269,6 +289,7 @@ export function registerPanel(pi: ExtensionAPI, deps: PanelCommandDeps): void {
269
289
  initialRefresh: refresh,
270
290
  viewportRows: () => tui.terminal?.rows,
271
291
  requestRender: () => tui.requestRender(),
292
+ skills: () => loadSkills(resolvedSkillPaths(discovery), cwd, agentDir),
272
293
  refresh: async (force) => {
273
294
  reports = await deps.doctor.probeAll({ refresh: force === true });
274
295
  },
@@ -293,7 +314,7 @@ export function registerPanel(pi: ExtensionAPI, deps: PanelCommandDeps): void {
293
314
 
294
315
  pi.registerCommand("bytefly", {
295
316
  description:
296
- "Open the bytefly control center: /bytefly [extension] [refresh] lists extensions, /bytefly settings opens settings; /bytefly doctor [extension] [refresh] prints the doctor report",
317
+ "Open the bytefly control center: /bytefly [extension] [refresh] lists extensions, /bytefly skills lists skills, /bytefly settings opens settings; /bytefly doctor [extension] [refresh] prints the doctor report",
297
318
  getArgumentCompletions: completions,
298
319
  handler,
299
320
  });
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Every mode other than `tui` gets the same facts the screen shows, as plain
5
5
  * text: a headline with the scope and the counts, then the picker's grouped
6
- * listing with a health suffix on each row. The Settings listing uses the same
6
+ * listing with a verdict suffix on each row. The Settings listing uses the same
7
7
  * headline and groups with `Label: Value` rows. A single named extension prints
8
8
  * its full findings instead. Nothing here touches the host, and every string
9
9
  * from a probe or a settings file is sanitized before it is printed.
@@ -18,7 +18,7 @@ import { sanitizeLine } from "../../../lib/format.ts";
18
18
  import { GLYPHS } from "../../../lib/tui/glyphs.ts";
19
19
  import type { TuiRole } from "../../../lib/tui/tokens.ts";
20
20
  import { reportSeverity, reportSummary, severityGlyphChar, severityRole } from "../doctor/format.ts";
21
- import { groupViews, type SettingView } from "./settings.ts";
21
+ import type { SettingView } from "./settings.ts";
22
22
  import type { PanelModel, PanelRow, PanelSection } from "./types.ts";
23
23
 
24
24
  /** The settings scope the screen shows. */
@@ -32,17 +32,35 @@ export interface SummarySegment {
32
32
 
33
33
  /** Row and problem counts over a model. */
34
34
  interface PanelCounts {
35
- extensions: number;
35
+ rows: number;
36
36
  /** Rows switched off in settings (the picker's checkbox), not the env flag. */
37
37
  off: number;
38
38
  missing: number;
39
39
  degraded: number;
40
40
  }
41
41
 
42
+ /** How one row is counted in the headline, singular, plural, and abbreviated. */
43
+ export interface CountNoun {
44
+ one: string;
45
+ many: string;
46
+ /** The form a narrow terminal uses. */
47
+ compact: string;
48
+ }
49
+
50
+ /** Extensions are the default noun; Skills pass their own. */
51
+ export const EXTENSION_NOUN: CountNoun = { one: "extension", many: "extensions", compact: "ext" };
52
+
53
+ /** The Skills section's noun: `4 skills`, and the same word when narrowed. */
54
+ export const SKILL_NOUN: CountNoun = { one: "skill", many: "skills", compact: "skills" };
55
+
42
56
  /** Options for the plain-text headline and listing. */
43
57
  export interface PanelTextOptions {
44
58
  /** Abbreviate counts (`15 ext · 3 off`) for a narrow terminal. */
45
59
  compact?: boolean;
60
+ /** What one row is called; extensions by default. */
61
+ noun?: CountNoun;
62
+ /** What the listing says when it has no rows. */
63
+ emptyLabel?: string;
46
64
  }
47
65
 
48
66
  /** Options for the TUI headline, which can also name a narrowed extension. */
@@ -72,7 +90,7 @@ export function panelCounts(model: PanelModel): PanelCounts {
72
90
  missing += report.findings.filter((finding) => finding.severity === "missing").length;
73
91
  degraded += report.findings.filter((finding) => finding.severity === "degraded").length;
74
92
  }
75
- return { extensions: model.rows.length, off, missing, degraded };
93
+ return { rows: model.rows.length, off, missing, degraded };
76
94
  }
77
95
 
78
96
  /**
@@ -81,12 +99,13 @@ export function panelCounts(model: PanelModel): PanelCounts {
81
99
  */
82
100
  export function summarySegments(model: PanelModel, scope: PanelScope, options: SummaryOptions = {}): SummarySegment[] {
83
101
  const counts = panelCounts(model);
102
+ const forms = options.noun ?? EXTENSION_NOUN;
84
103
  const segments: SummarySegment[] = [{ text: scope === "global" ? "Global" : "Project", color: "accent" }];
85
104
  if (options.filter !== undefined) {
86
105
  segments.push({ text: `${options.filter} only`, color: "meta" });
87
106
  } else {
88
- const noun = options.compact ? "ext" : counts.extensions === 1 ? "extension" : "extensions";
89
- segments.push({ text: `${counts.extensions} ${noun}`, color: "meta" });
107
+ const noun = options.compact ? forms.compact : counts.rows === 1 ? forms.one : forms.many;
108
+ segments.push({ text: `${counts.rows} ${noun}`, color: "meta" });
90
109
  if (counts.off > 0) segments.push({ text: `${counts.off} off`, color: "meta" });
91
110
  }
92
111
  const problems: string[] = [];
@@ -98,15 +117,25 @@ export function summarySegments(model: PanelModel, scope: PanelScope, options: S
98
117
  return segments;
99
118
  }
100
119
 
120
+ /** The sections left to right, as the bar reads them. */
121
+ export const SECTION_ORDER: readonly PanelSection[] = ["extensions", "skills", "settings"];
122
+
123
+ /** One section's name in the bar. */
124
+ const SECTION_LABELS: Record<PanelSection, string> = {
125
+ extensions: "Extensions",
126
+ skills: "Skills",
127
+ settings: "Settings",
128
+ };
129
+
101
130
  /**
102
- * The section bar's two segments, in screen order. The active section takes the
103
- * accent and the other recedes, so the bar reads as a two-way switch.
131
+ * The section bar's segments, in screen order. The active section takes the
132
+ * accent and the others recede, so the bar reads as a left-to-right switch.
104
133
  */
105
134
  export function sectionSegments(active: PanelSection): SummarySegment[] {
106
- return [
107
- { text: "Extensions", color: active === "extensions" ? "accent" : "faint" },
108
- { text: "Settings", color: active === "settings" ? "accent" : "faint" },
109
- ];
135
+ return SECTION_ORDER.map((section) => ({
136
+ text: SECTION_LABELS[section],
137
+ color: active === section ? "accent" : "faint",
138
+ }));
110
139
  }
111
140
 
112
141
  /** The Settings section's counts: the scope and how many rows it shows. */
@@ -125,15 +154,10 @@ export function formatSettingsHeadline(count: number): string {
125
154
  return `bytefly: Settings · Global · ${count} settings`;
126
155
  }
127
156
 
128
- /** The grouped Settings listing for non-TUI modes. */
157
+ /** The Settings listing for non-TUI modes, one row per line, in screen order. */
129
158
  export function formatSettingsListing(views: readonly SettingView[]): string {
130
159
  const lines = [formatSettingsHeadline(views.length)];
131
- for (const group of groupViews(views)) {
132
- lines.push("", `${group.label} (${group.views.length})`);
133
- for (const view of group.views) {
134
- lines.push(` ${sanitizeLine(view.label)}: ${sanitizeLine(view.valueLabel)}`);
135
- }
136
- }
160
+ for (const view of views) lines.push(` ${sanitizeLine(view.label)}: ${sanitizeLine(view.valueLabel)}`);
137
161
  return lines.join("\n");
138
162
  }
139
163
 
@@ -156,15 +180,21 @@ interface RowBadge {
156
180
 
157
181
  /**
158
182
  * The row's badge. Settings is the enablement axis the checkbox already shows,
159
- * so an off extension gets one verdict (`off`) instead of a health word that
183
+ * so an off row gets one verdict (`off`) instead of a health word that
160
184
  * contradicts it. `PI_DISABLED_EXTENSIONS` is a different axis, so it is named
161
- * rather than sharing the word: `env disabled`. The glyph marks what needs
185
+ * rather than sharing the word: `env disabled`. A skill has no probed report, so
186
+ * its own file supplies the verdict: `not loaded` when Pi refused it, `hidden`
187
+ * when it is reachable only through `/skill:<name>`. The glyph marks what needs
162
188
  * attention — a warning, a miss, a disable — so a row with nothing to report
163
189
  * carries no glyph at all, and the column reads as one shape instead of a
164
190
  * checkmark that comes and goes.
165
191
  */
166
192
  export function rowBadge(row: PanelRow): RowBadge {
167
193
  if (row.item && !row.item.enabled) return { glyph: GLYPHS.disabled, role: "faint", text: "off" };
194
+ // A skill's verdict comes from its own file, not from a probed report: a file
195
+ // Pi refused is a warning, and one hidden from the model still loads by name.
196
+ if (row.skill?.problem !== undefined) return { glyph: GLYPHS.warn, role: "warn", text: "not loaded" };
197
+ if (row.skill?.modelHidden === true) return { role: "faint", text: "hidden" };
168
198
  const report = row.report;
169
199
  if (!report) return { role: "faint" };
170
200
  if (!report.enabled) return { glyph: GLYPHS.disabled, role: "faint", text: "env disabled" };
@@ -190,7 +220,7 @@ export function formatRow(row: PanelRow): string {
190
220
  export function formatListing(model: PanelModel, scope: PanelScope, options: PanelTextOptions = {}): string {
191
221
  const lines = [formatHeadline(model, scope, options)];
192
222
  if (model.rows.length === 0) {
193
- lines.push("", "No extensions found.");
223
+ lines.push("", options.emptyLabel ?? "No extensions found.");
194
224
  return lines.join("\n");
195
225
  }
196
226
  for (const group of model.groups) {
@@ -1,9 +1,10 @@
1
1
  /**
2
- * The merge behind the unified panel: one row per extension resource, joining
3
- * the picker's resolved items to the doctor's reports.
2
+ * The merge behind the unified panel: one row per resolved resource, joining the
3
+ * picker's items to whatever describes them — the doctor's report for an
4
+ * extension, the loaded `SKILL.md` metadata for a skill.
4
5
  *
5
- * The join key is the extension directory (`basename(dirname(path))`), not the
6
- * display name: a subfolder resource is shown as `web-access/index.ts` but its
6
+ * The join key for an extension is its directory (`basename(dirname(path))`), not
7
+ * the display name: a subfolder resource is shown as `web-access/index.ts` but its
7
8
  * report is named `web-access`. A directory name is only usable when it maps to
8
9
  * exactly one unique resource path — two unrelated extensions can share a
9
10
  * directory name (a user `plan` beside the package's `plan`) and every flat file
@@ -15,25 +16,21 @@
15
16
  * for a report with no item) so the cursor survives a rebuild even when two
16
17
  * rows share a directory name. Items keep the picker's grouping and order, and
17
18
  * a row never invents health: an item without a report carries no glyph and no
18
- * summary.
19
+ * summary. Skills skip the report half entirely and take their label and detail
20
+ * text from the skill index instead.
19
21
  */
20
22
 
21
23
  import { basename, dirname } from "node:path";
22
24
  import type { ExtensionReport } from "../../../lib/requirements.ts";
23
- import { buildEntries } from "../picker/resources.ts";
24
- import type { ResourceItem } from "../picker/types.ts";
25
- import type { PanelGroup, PanelModel, PanelRow } from "./types.ts";
25
+ import { buildEntries, resourceKey } from "../picker/resources.ts";
26
+ import type { ResourceItem, ResourceKind } from "../picker/types.ts";
27
+ import type { PanelGroup, PanelModel, PanelRow, SkillInfo } from "./types.ts";
26
28
 
27
29
  /** The extension directory name that joins a resolved item to its report. */
28
30
  export function extensionName(path: string): string {
29
31
  return basename(dirname(path));
30
32
  }
31
33
 
32
- /** Unique identity of a resolved resource, independent of the directory name. */
33
- export function resourceKey(item: ResourceItem): string {
34
- return `${item.metadata.source}:${item.path}`;
35
- }
36
-
37
34
  /** Row key for a report that has no resolved extension item. */
38
35
  function reportKey(name: string): string {
39
36
  return `report:${name}`;
@@ -49,11 +46,13 @@ export interface PanelJoinOptions {
49
46
  agentDir: string;
50
47
  /** Narrow to one extension directory or report name, or `undefined` for the full list. */
51
48
  filter?: string;
49
+ /** Which resource kind the list is, for labels and grouping. */
50
+ kind?: ResourceKind;
52
51
  }
53
52
 
54
53
  /** Build the merged model for the current items and reports. */
55
54
  export function buildPanelModel(options: PanelJoinOptions): PanelModel {
56
- const { items, reports, agentDir, filter } = options;
55
+ const { items, reports, agentDir, filter, kind = "extensions" } = options;
57
56
  const byName = new Map(reports.map((report) => [report.extension, report]));
58
57
 
59
58
  // A directory name may claim a report only when exactly one unique resource
@@ -73,7 +72,7 @@ export function buildPanelModel(options: PanelJoinOptions): PanelModel {
73
72
  const rows: PanelRow[] = [];
74
73
  let current: PanelGroup | undefined;
75
74
 
76
- for (const entry of buildEntries([...items], agentDir)) {
75
+ for (const entry of buildEntries([...items], agentDir, kind)) {
77
76
  if (entry.kind === "group") {
78
77
  current = { key: entry.key, label: entry.label, count: 0 };
79
78
  groups.push(current);
@@ -101,3 +100,25 @@ export function buildPanelModel(options: PanelJoinOptions): PanelModel {
101
100
 
102
101
  return { groups: groups.filter((group) => group.count > 0), rows };
103
102
  }
103
+
104
+ /**
105
+ * The Skills model: the grouped list an extension screen shows, labelled by the
106
+ * skill's declared name and carrying what its detail pane needs. There is no
107
+ * doctor report for a skill, so the only join is to the loaded metadata; a file
108
+ * Pi refused keeps the name its path gives it and carries the reason.
109
+ */
110
+ export function buildSkillModel(options: {
111
+ items: readonly ResourceItem[];
112
+ index: ReadonlyMap<string, SkillInfo>;
113
+ agentDir: string;
114
+ }): PanelModel {
115
+ const model = buildPanelModel({ items: options.items, reports: [], agentDir: options.agentDir, kind: "skills" });
116
+ return {
117
+ groups: model.groups,
118
+ rows: model.rows.map((row) => {
119
+ const skill = row.item ? options.index.get(row.item.path) : undefined;
120
+ if (skill === undefined) return row;
121
+ return { ...row, label: skill.name ?? row.label, skill };
122
+ }),
123
+ };
124
+ }