@gavin-hu/my-pi-agent 0.16.0 → 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 (135) hide show
  1. package/README.md +14 -8
  2. package/README.zh-CN.md +14 -9
  3. package/extensions/bytefly/README.md +174 -101
  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/panel/commands.ts +46 -25
  17. package/extensions/bytefly/panel/format.ts +49 -14
  18. package/extensions/bytefly/panel/join.ts +35 -9
  19. package/extensions/bytefly/panel/settings.ts +10 -27
  20. package/extensions/bytefly/panel/skills.ts +51 -0
  21. package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
  22. package/extensions/bytefly/panel/tui/frame.ts +9 -1
  23. package/extensions/bytefly/panel/tui/list-view.ts +72 -0
  24. package/extensions/bytefly/panel/tui/nav.ts +71 -3
  25. package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
  26. package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
  27. package/extensions/bytefly/panel/tui.ts +223 -331
  28. package/extensions/bytefly/panel/types.ts +26 -8
  29. package/extensions/bytefly/picker/discovery.ts +32 -27
  30. package/extensions/bytefly/picker/resources.ts +16 -8
  31. package/extensions/bytefly/picker/runtime.ts +62 -34
  32. package/extensions/bytefly/picker/toggle.ts +16 -10
  33. package/extensions/bytefly/picker/types.ts +31 -13
  34. package/extensions/bytefly/update/format.ts +66 -8
  35. package/extensions/bytefly/update/patch.ts +66 -0
  36. package/extensions/bytefly/update/register.ts +4 -11
  37. package/extensions/bytefly/update/release.ts +65 -0
  38. package/extensions/bytefly/update/state.ts +14 -0
  39. package/extensions/bytefly/update/suppress.ts +47 -29
  40. package/extensions/bytefly/update/types.ts +15 -5
  41. package/extensions/check/tui.ts +2 -0
  42. package/extensions/context/README.md +21 -11
  43. package/extensions/context/format.ts +10 -8
  44. package/extensions/context/report.ts +21 -8
  45. package/extensions/context/tui.ts +26 -10
  46. package/extensions/context/types.ts +5 -3
  47. package/extensions/file-browse/README.md +29 -1
  48. package/extensions/file-browse/client.ts +16 -463
  49. package/extensions/file-browse/html.ts +7 -5
  50. package/extensions/file-browse/palette.ts +224 -0
  51. package/extensions/file-browse/router.ts +2 -2
  52. package/extensions/file-browse/styles.ts +8 -3
  53. package/extensions/file-browse/tree.ts +381 -0
  54. package/extensions/file-read/README.md +4 -1
  55. package/extensions/file-read/extract/odt.ts +7 -5
  56. package/extensions/goal/tools.ts +6 -11
  57. package/extensions/job/index.ts +20 -45
  58. package/extensions/job/tools.ts +51 -77
  59. package/extensions/memory/tools.ts +4 -6
  60. package/extensions/memory/tui.ts +5 -1
  61. package/extensions/plan/README.md +88 -33
  62. package/extensions/plan/commands.ts +19 -4
  63. package/extensions/plan/config.ts +27 -0
  64. package/extensions/plan/plan-index.ts +3 -1
  65. package/extensions/plan/plans.ts +31 -13
  66. package/extensions/plan/review.ts +34 -14
  67. package/extensions/plan/runtime.ts +3 -1
  68. package/extensions/plan/steps.ts +43 -14
  69. package/extensions/plan/tools/cancel.ts +79 -0
  70. package/extensions/plan/tools/enter.ts +10 -10
  71. package/extensions/plan/tools/exit.ts +54 -43
  72. package/extensions/plan/tools/steps.ts +7 -19
  73. package/extensions/plan/tools/write.ts +22 -29
  74. package/extensions/plan/tools.ts +5 -2
  75. package/extensions/plan/tui.ts +9 -7
  76. package/extensions/plan/types.ts +8 -0
  77. package/extensions/plan/web-chrome.ts +101 -0
  78. package/extensions/plan/web-client.ts +98 -311
  79. package/extensions/plan/web-markdown.ts +222 -0
  80. package/extensions/plan/web-page.ts +28 -2
  81. package/extensions/plan/web-router.ts +2 -2
  82. package/extensions/rewind/checklist.ts +4 -0
  83. package/extensions/rewind/review-tui.ts +3 -1
  84. package/extensions/rewind/rewind-tui.ts +4 -0
  85. package/extensions/subagent/README.md +25 -12
  86. package/extensions/subagent/format.ts +72 -25
  87. package/extensions/subagent/index.ts +21 -47
  88. package/extensions/subagent/orchestrate.ts +18 -41
  89. package/extensions/subagent/render.ts +29 -42
  90. package/extensions/subagent/stream.ts +24 -18
  91. package/extensions/subagent/types.ts +7 -0
  92. package/extensions/todo/tools.ts +5 -6
  93. package/extensions/todo/tui.ts +5 -1
  94. package/extensions/watch/index.ts +10 -35
  95. package/extensions/watch/tools.ts +31 -59
  96. package/extensions/wechat/README.md +40 -18
  97. package/extensions/wechat/bridge.ts +38 -40
  98. package/extensions/wechat/client.ts +1 -4
  99. package/extensions/wechat/commands.ts +1 -1
  100. package/extensions/wechat/interaction.ts +25 -4
  101. package/extensions/wechat/login-screen.ts +2 -3
  102. package/extensions/wechat/notices.ts +7 -5
  103. package/extensions/wechat/prompt.ts +19 -4
  104. package/extensions/wechat/qrcode.ts +9 -9
  105. package/extensions/wechat/slash.ts +14 -0
  106. package/extensions/wechat/status-screen.ts +2 -2
  107. package/extensions/wechat/tools.ts +3 -11
  108. package/extensions/worktree/README.md +48 -12
  109. package/extensions/worktree/commands.ts +109 -65
  110. package/extensions/worktree/enter.ts +1 -1
  111. package/extensions/worktree/exit.ts +11 -1
  112. package/extensions/worktree/lifecycle.ts +9 -1
  113. package/extensions/worktree/screen.ts +263 -0
  114. package/extensions/worktree/status.ts +84 -38
  115. package/lib/README.md +7 -6
  116. package/lib/config.ts +12 -0
  117. package/lib/format.ts +6 -2
  118. package/lib/interaction.ts +18 -1
  119. package/lib/report.ts +75 -2
  120. package/lib/tool-result.ts +54 -4
  121. package/lib/tui/README.md +16 -8
  122. package/lib/tui/chrome.ts +27 -6
  123. package/lib/tui/dock-screen.ts +6 -2
  124. package/lib/tui/gauge.ts +9 -5
  125. package/lib/usage.ts +26 -0
  126. package/lib/web/README.md +131 -0
  127. package/lib/web/base.ts +19 -2
  128. package/lib/web/components.ts +10 -6
  129. package/lib/web/layout.ts +2 -0
  130. package/lib/web/markup.ts +2 -1
  131. package/lib/web/runtime.ts +103 -25
  132. package/lib/web/tokens.ts +23 -7
  133. package/package.json +3 -1
  134. package/skills/changelog/SKILL.md +57 -45
  135. package/skills/next/SKILL.md +184 -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.
@@ -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. */
@@ -151,15 +180,21 @@ interface RowBadge {
151
180
 
152
181
  /**
153
182
  * The row's badge. Settings is the enablement axis the checkbox already shows,
154
- * 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
155
184
  * contradicts it. `PI_DISABLED_EXTENSIONS` is a different axis, so it is named
156
- * 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
157
188
  * attention — a warning, a miss, a disable — so a row with nothing to report
158
189
  * carries no glyph at all, and the column reads as one shape instead of a
159
190
  * checkmark that comes and goes.
160
191
  */
161
192
  export function rowBadge(row: PanelRow): RowBadge {
162
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" };
163
198
  const report = row.report;
164
199
  if (!report) return { role: "faint" };
165
200
  if (!report.enabled) return { glyph: GLYPHS.disabled, role: "faint", text: "env disabled" };
@@ -185,7 +220,7 @@ export function formatRow(row: PanelRow): string {
185
220
  export function formatListing(model: PanelModel, scope: PanelScope, options: PanelTextOptions = {}): string {
186
221
  const lines = [formatHeadline(model, scope, options)];
187
222
  if (model.rows.length === 0) {
188
- lines.push("", "No extensions found.");
223
+ lines.push("", options.emptyLabel ?? "No extensions found.");
189
224
  return lines.join("\n");
190
225
  }
191
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,14 +16,15 @@
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
25
  import { buildEntries, resourceKey } from "../picker/resources.ts";
24
- import type { ResourceItem } from "../picker/types.ts";
25
- import type { PanelGroup, PanelModel, PanelRow } from "./types.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 {
@@ -44,11 +46,13 @@ export interface PanelJoinOptions {
44
46
  agentDir: string;
45
47
  /** Narrow to one extension directory or report name, or `undefined` for the full list. */
46
48
  filter?: string;
49
+ /** Which resource kind the list is, for labels and grouping. */
50
+ kind?: ResourceKind;
47
51
  }
48
52
 
49
53
  /** Build the merged model for the current items and reports. */
50
54
  export function buildPanelModel(options: PanelJoinOptions): PanelModel {
51
- const { items, reports, agentDir, filter } = options;
55
+ const { items, reports, agentDir, filter, kind = "extensions" } = options;
52
56
  const byName = new Map(reports.map((report) => [report.extension, report]));
53
57
 
54
58
  // A directory name may claim a report only when exactly one unique resource
@@ -68,7 +72,7 @@ export function buildPanelModel(options: PanelJoinOptions): PanelModel {
68
72
  const rows: PanelRow[] = [];
69
73
  let current: PanelGroup | undefined;
70
74
 
71
- for (const entry of buildEntries([...items], agentDir)) {
75
+ for (const entry of buildEntries([...items], agentDir, kind)) {
72
76
  if (entry.kind === "group") {
73
77
  current = { key: entry.key, label: entry.label, count: 0 };
74
78
  groups.push(current);
@@ -96,3 +100,25 @@ export function buildPanelModel(options: PanelJoinOptions): PanelModel {
96
100
 
97
101
  return { groups: groups.filter((group) => group.count > 0), rows };
98
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
+ }
@@ -18,7 +18,7 @@
18
18
  *
19
19
  * Three sections have a normalizer here, so the screen shows what the notifier,
20
20
  * the doctor, and the footer keep. The three owned by other extensions (`rewind`,
21
- * `job`, `watch`) are read as the file states them, because importing their
21
+ * `job`, `plan`) are read as the file states them, because importing their
22
22
  * normalizer would be one extension importing another's module; each such row
23
23
  * declares the loader's default, and `test/settings-coverage.test.ts` holds the
24
24
  * pair together.
@@ -61,7 +61,7 @@ interface TextRule {
61
61
  }
62
62
 
63
63
  /** The `bytefly.json` sections a row still reaches. */
64
- export type ConfigSectionName = "notify" | "doctor" | "price" | "rewind" | "job" | "watch";
64
+ export type ConfigSectionName = "notify" | "doctor" | "price" | "rewind" | "job" | "plan";
65
65
 
66
66
  /** Where a `bytefly.json` row lives: a section, and the path to its leaf. */
67
67
  interface SettingConfig {
@@ -125,14 +125,6 @@ const ON_OFF: readonly SettingOption[] = [
125
125
  ];
126
126
 
127
127
  /** The notify section's delivery methods, labeled by the surface each reaches. */
128
- const METHOD_OPTIONS: readonly SettingOption[] = [
129
- { value: "auto", label: "Auto" },
130
- { value: "osc777", label: "OSC 777" },
131
- { value: "osc99", label: "OSC 99" },
132
- { value: "toast", label: "Windows toast" },
133
- { value: "osascript", label: "macOS" },
134
- { value: "notify-send", label: "Linux" },
135
- ];
136
128
 
137
129
  /**
138
130
  * A text rule for a money amount inside an inclusive range: a whole number or a
@@ -155,8 +147,9 @@ function amountRule(min: number, max: number): TextRule {
155
147
  /**
156
148
  * The catalogue, in screen order.
157
149
  *
158
- * Global scope lists all fourteen; Project lists the twelve `bytefly.json` rows,
159
- * because Pi's setters write the personal `settings.json` only.
150
+ * Global scope lists all thirteen; Project lists the eleven `bytefly.json` rows,
151
+ * because Pi's setters write the personal `settings.json` only. The list is
152
+ * capped so the screen keeps fitting a 24-row terminal.
160
153
  */
161
154
  export const SETTING_ROWS: readonly SettingDescriptor[] = [
162
155
  {
@@ -235,16 +228,6 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
235
228
  options: ON_OFF,
236
229
  config: { section: "notify", path: ["matchFound"] },
237
230
  },
238
- {
239
- id: "notify.method",
240
- label: "Delivery method",
241
- description: "How a notification is delivered; Auto picks from the terminal, then the platform.",
242
- source: "bytefly",
243
- kind: "enum",
244
- fallback: "auto",
245
- options: METHOD_OPTIONS,
246
- config: { section: "notify", path: ["method"] },
247
- },
248
231
  {
249
232
  id: "job.wakeOnFinish",
250
233
  label: "Wake when a job finishes",
@@ -257,15 +240,15 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
257
240
  config: { section: "job", path: ["wakeOnFinish"] },
258
241
  },
259
242
  {
260
- id: "watch.wakeOnMatch",
261
- label: "Wake on a match",
262
- description: "Start one agent turn when a watcher matches while the agent is idle.",
243
+ id: "plan.goalPrompt",
244
+ label: "Offer the session goal",
245
+ description: "Ask before recording an approved plan as the session goal; off records it without asking.",
263
246
  source: "bytefly",
264
247
  kind: "boolean",
265
- fallback: "false",
248
+ fallback: "true",
266
249
  json: "boolean",
267
250
  options: ON_OFF,
268
- config: { section: "watch", path: ["wakeOnMatch"] },
251
+ config: { section: "plan", path: ["goalPrompt"] },
269
252
  },
270
253
  {
271
254
  id: "rewind.autoSnapshots",
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Skill metadata for the panel.
3
+ *
4
+ * One `loadSkills` call reads every resolved `SKILL.md` and hands back the
5
+ * declared name, description, and model-invocation flag, keyed by file path so
6
+ * the row model can join it. Pi's loader also reports why it refused a file, and
7
+ * that reason travels with the row so the screen can say `not loaded` instead of
8
+ * silently showing a name. Nothing here renders or writes; the panel reads the
9
+ * index when the Skills section first becomes visible.
10
+ */
11
+
12
+ import { loadSkills } from "@earendil-works/pi-coding-agent";
13
+ import type { Discovery } from "../picker/types.ts";
14
+ import type { SkillInfo } from "./types.ts";
15
+
16
+ /** Loaded skill metadata keyed by the resolved resource path. */
17
+ export type SkillIndex = ReadonlyMap<string, SkillInfo>;
18
+
19
+ /** Every skill file either discovery view resolves, once, for the loader. */
20
+ export function resolvedSkillPaths(discovery: Discovery): string[] {
21
+ const paths = [...discovery.skills.global, ...discovery.skills.effective].map((item) => item.path);
22
+ return [...new Set(paths)];
23
+ }
24
+
25
+ /**
26
+ * Read the metadata for every resolved skill path. A path may be a `SKILL.md`
27
+ * or a standalone Markdown skill; both are what the resolver hands the panel.
28
+ *
29
+ * A diagnostic names the file Pi refused, so it becomes that row's `problem`. A
30
+ * warning about a file that still loaded is dropped: the row's own name and
31
+ * description already come from the same loader, and `not loaded` would be a
32
+ * lie about a skill the model can use.
33
+ */
34
+ export function loadSkillIndex(paths: readonly string[], cwd: string, agentDir: string): SkillIndex {
35
+ if (paths.length === 0) return new Map();
36
+ const { skills, diagnostics } = loadSkills({ cwd, agentDir, skillPaths: [...paths], includeDefaults: false });
37
+ const index = new Map<string, SkillInfo>();
38
+ for (const skill of skills) {
39
+ index.set(skill.filePath, {
40
+ name: skill.name,
41
+ description: skill.description,
42
+ modelHidden: skill.disableModelInvocation,
43
+ });
44
+ }
45
+ for (const diagnostic of diagnostics) {
46
+ const path = diagnostic.path;
47
+ if (path === undefined || index.has(path)) continue;
48
+ index.set(path, { description: "", modelHidden: false, problem: diagnostic.message });
49
+ }
50
+ return index;
51
+ }
@@ -1,54 +1,19 @@
1
1
  /**
2
- * The Extensions section: its summary line, one row per resolved extension, and
3
- * the detail pane for the focused row.
2
+ * The Extensions section's focused-row pane.
4
3
  *
5
- * Pure functions over the merged model — the component owns the cursor, the
6
- * filter, and the probe state, and passes them in.
4
+ * The row, the summary, and the key hints are the shared list grammar; what is
5
+ * specific here is the doctor's findings, including the resource path when a row
6
+ * has no verdict.
7
7
  */
8
8
 
9
9
  import type { Theme } from "@earendil-works/pi-coding-agent";
10
- import type { ScreenHint } from "../../../../lib/tui/chrome.ts";
11
10
  import { sanitizeLine } from "../../../../lib/format.ts";
12
11
  import { clip } from "../../../../lib/tui/columns.ts";
13
- import { checkbox, listRow } from "../../../../lib/tui/rows.ts";
14
12
  import { paint } from "../../../../lib/tui/tokens.ts";
15
13
  import { detailLines as reportDetailLines } from "../../doctor/format.ts";
16
- import { rowBadge, summarySegments, type PanelScope } from "../format.ts";
17
- import type { PanelModel, PanelRow } from "../types.ts";
18
- import { countsLine, savedLine } from "./frame.ts";
19
-
20
- /** The section's hits, its scope, and how many need attention. */
21
- export function extensionSummary(
22
- theme: Theme,
23
- width: number,
24
- model: PanelModel,
25
- scope: PanelScope,
26
- filter: string | undefined,
27
- ): string {
28
- return countsLine(theme, width, summarySegments(model, scope, { filter, compact: width < 72 }));
29
- }
30
-
31
- /** One extension row: its checkbox, health glyph, name, and verdict. */
32
- export function extensionRow(theme: Theme, width: number, row: PanelRow, selected: boolean): string {
33
- const badge = rowBadge(row);
34
- return listRow(theme, width, {
35
- selected,
36
- // A reported-only row has no enable pattern, but still reserves the box
37
- // column so the list stays aligned.
38
- leading: row.item ? checkbox(theme, row.item.enabled) : " ",
39
- glyph: badge.glyph ?? " ",
40
- glyphRole: badge.role,
41
- label: sanitizeLine(row.label),
42
- labelRole: selected ? "accent" : row.item && !row.item.enabled ? "faint" : "label",
43
- trailing: badge.text,
44
- // The glyph carries the status colour; the word stays quiet unless it is a
45
- // problem, so a clean row does not shout.
46
- trailingRole: badge.role === "bad" || badge.role === "warn" ? badge.role : "faint",
47
- // The verdict sits beside the name instead of at the far edge of a wide
48
- // terminal, where a two-word verdict would be unreadable.
49
- align: "inline",
50
- });
51
- }
14
+ import { rowBadge } from "../format.ts";
15
+ import type { PanelRow } from "../types.ts";
16
+ import { savedLine } from "./frame.ts";
52
17
 
53
18
  /** The focused extension's findings and the pending-changes note. */
54
19
  export function extensionDetail(
@@ -73,22 +38,3 @@ export function extensionDetail(
73
38
  lines.push(...savedLine(theme, width, dirty));
74
39
  return lines;
75
40
  }
76
-
77
- /** The section's key hints, in the order they drop as the width runs out. */
78
- export function extensionHints(options: {
79
- expanded: boolean;
80
- refreshing: boolean;
81
- projectScope: boolean;
82
- }): ScreenHint[] {
83
- return [
84
- "Esc close",
85
- "←/→",
86
- { text: "Space toggle", color: "accent" },
87
- { text: options.expanded ? "Enter collapse" : "Enter detail", color: "accent" },
88
- "Tab scope",
89
- // Only Project scope can hold an override, so the key is not offered elsewhere.
90
- ...(options.projectScope ? ["x inherit"] : []),
91
- options.refreshing ? "refreshing…" : "r refresh",
92
- "↑/↓ select",
93
- ];
94
- }