@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,50 +1,48 @@
1
1
  /**
2
- * The unified bytefly panel: two sections around one docked list.
2
+ * The unified bytefly panel: three sections around one docked list.
3
3
  *
4
4
  * Extensions lists every resolved extension with its enable checkbox and its
5
- * health; Settings lists the curated preferences with their values. The
6
- * component owns the state — the cursors, the focused row key per section, the
7
- * expanded set, the pending write chain, and the active section — and the two
8
- * section modules own how their rows and detail panes are drawn. It is derived:
9
- * it rebuilds the model from the picker runtime, the reports, or the settings
10
- * surface on every render, keeps the focused row's key across rebuilds, and
11
- * requests a repaint only after an input changes state. The screen is drawn from
12
- * the design system — a label-first `titleRule`, one `listRow` grammar, one
5
+ * health; Skills lists every resolved skill the same way, with what its
6
+ * `SKILL.md` declares; Settings lists the curated preferences with their values.
7
+ * The component owns the section switch, the cursors, the focused row key per
8
+ * section, the expanded set, the pending toggle chain, and the notice; the
9
+ * section modules own their rows and detail panes, and the Settings section also
10
+ * owns its editor and JSON-pane state. It is derived: every render rebuilds the
11
+ * model from the picker runtime, the reports, the skill index, or the settings
12
+ * surface, and keeps the focused row's key across rebuilds. The screen is drawn
13
+ * from the design system — a label-first `titleRule`, one `listRow` grammar, one
13
14
  * `checkbox` — and its scroll math from `lib/tui/cursor.ts`.
14
15
  */
15
16
 
16
17
  import type { Theme } from "@earendil-works/pi-coding-agent";
17
18
  import { Key, matchesKey, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
18
- import type { ViewportRowsSource } from "../../../lib/tui/chrome.ts";
19
- import { dockListFrame } from "../../../lib/tui/chrome.ts";
20
- import { isCloseKey, ListCursor, navIntent, wheelDelta } from "../../../lib/tui/cursor.ts";
21
- import { emptyState } from "../../../lib/tui/rows.ts";
19
+ import type { ScreenHint, ViewportRowsSource } from "../../../lib/tui/chrome.ts";
20
+ import { isCloseKey, ListCursor, wheelDelta } from "../../../lib/tui/cursor.ts";
22
21
  import { titleRule } from "../../../lib/tui/rules.ts";
23
22
  import type { ExtensionReport } from "../../../lib/requirements.ts";
24
- import type { ExtensionsPickerRuntime } from "../picker/runtime.ts";
25
- import { buildPanelModel } from "./join.ts";
26
- import { SETTING_ROWS, UNTRUSTED_SETTINGS_NOTE } from "./settings.ts";
23
+ import type { PickerRuntime } from "../picker/runtime.ts";
24
+ import type { ResourceKind } from "../picker/types.ts";
25
+ import { SECTION_ORDER, SKILL_NOUN } from "./format.ts";
26
+ import { buildPanelModel, buildSkillModel } from "./join.ts";
27
+ import { UNTRUSTED_SETTINGS_NOTE } from "./settings.ts";
27
28
  import type { SettingsSurface } from "./settings-runtime.ts";
28
- import type { PanelModel, PanelSection } from "./types.ts";
29
+ import type { PanelModel, PanelRow, PanelSection, SkillInfo } from "./types.ts";
29
30
  import { entriesOf, entryKey, isSelectable, settingEntries, type PanelEntry } from "./tui/entries.ts";
30
- import { applyEditKey, type EditingState } from "./tui/editing.ts";
31
- import { clampSelection, firstRow, lastRow, moveRows, retainFocus } from "./tui/nav.ts";
32
- import { extensionDetail, extensionHints, extensionRow, extensionSummary } from "./tui/extensions-view.ts";
31
+ import { extensionDetail } from "./tui/extensions-view.ts";
33
32
  import { BODY_START, detailRows, groupLine, hintLine, noticeLine, panelFrame, sectionBar } from "./tui/frame.ts";
34
- import {
35
- editHints,
36
- projectNote,
37
- sectionJsonPane,
38
- settingDetail,
39
- settingRow,
40
- settingsHints,
41
- settingsSummary,
42
- } from "./tui/settings-view.ts";
33
+ import { resourceRow, resourceSummary, sectionHints } from "./tui/list-view.ts";
34
+ import { clampSelection, moveRows, navigateEntries, retainFocus } from "./tui/nav.ts";
35
+ import { editHints, settingsHints } from "./tui/settings-view.ts";
36
+ import { SettingsSection } from "./tui/settings-section.ts";
37
+ import { skillDetail } from "./tui/skills-view.ts";
38
+
39
+ /** A section that draws a resource list rather than the settings table. */
40
+ type ListSection = ResourceKind;
43
41
 
44
42
  /** Host seams for the panel screen, injected by the command runner. */
45
43
  export interface PanelComponentOptions {
46
44
  /** The session's picker runtime: items, scope, toggles, dirty flag. */
47
- runtime: ExtensionsPickerRuntime;
45
+ runtime: PickerRuntime;
48
46
  /** Current reports; read on every render so a refresh is picked up. */
49
47
  reports: () => readonly ExtensionReport[];
50
48
  /** The Settings section's rows, values, and writes. */
@@ -60,6 +58,8 @@ export interface PanelComponentOptions {
60
58
  /** Live terminal height, for the row budget. */
61
59
  viewportRows?: ViewportRowsSource;
62
60
  requestRender: () => void;
61
+ /** Read every resolved skill's metadata; called once, when Skills first needs it. */
62
+ skills: () => ReadonlyMap<string, SkillInfo>;
63
63
  /** The Extensions view probes on first show (no session cache, or the command asked to refresh). */
64
64
  needsProbe?: boolean;
65
65
  /** Force that first probe (`/bytefly … refresh`). */
@@ -69,14 +69,63 @@ export interface PanelComponentOptions {
69
69
  onClose: () => void;
70
70
  }
71
71
 
72
+ /** What a list section's detail pane receives; each section reads what it needs. */
73
+ interface DetailInput {
74
+ theme: Theme;
75
+ width: number;
76
+ row: PanelRow | undefined;
77
+ budget: number;
78
+ expanded: boolean;
79
+ dirty: boolean;
80
+ }
81
+
82
+ /** What a list section's hint row receives. */
83
+ interface HintInput {
84
+ expanded: boolean;
85
+ refreshing: boolean;
86
+ projectScope: boolean;
87
+ }
88
+
89
+ /** One list section's own vocabulary: what it calls empty, its pane, and its hints. */
90
+ interface ListView {
91
+ emptyLabel: string;
92
+ detail(input: DetailInput): string[];
93
+ hints(input: HintInput): ScreenHint[];
94
+ }
95
+
96
+ /**
97
+ * The two list sections. Everything else — the frame, the rows, the cursor, the
98
+ * keys, the scope — is shared, so a section only supplies what it alone can say.
99
+ */
100
+ const LIST_VIEWS: Record<ListSection, ListView> = {
101
+ extensions: {
102
+ emptyLabel: "No extensions found.",
103
+ detail: (input) => extensionDetail(input.theme, input.width, input.row, input.budget, input.expanded, input.dirty),
104
+ hints: (input) =>
105
+ sectionHints({ expanded: input.expanded, projectScope: input.projectScope, refreshing: input.refreshing }),
106
+ },
107
+ skills: {
108
+ emptyLabel: "No skills found.",
109
+ detail: (input) => skillDetail(input),
110
+ // A skill has no probe to re-run, so the section never offers the refresh key.
111
+ hints: (input) => sectionHints({ expanded: input.expanded, projectScope: input.projectScope }),
112
+ },
113
+ };
114
+
115
+ /** The resource kind a list section toggles, or `undefined` for Settings. */
116
+ function kindOf(section: PanelSection): ResourceKind | undefined {
117
+ return section === "settings" ? undefined : section;
118
+ }
119
+
72
120
  /** The docked bytefly panel. */
73
121
  export class PanelComponent implements Component {
74
122
  private readonly cursors: Record<PanelSection, ListCursor>;
75
- /** Serialises writes so a rapid double press applies twice, not once. */
123
+ /** Serialises toggles so a rapid double press applies twice, not once. */
76
124
  private chain: Promise<void> = Promise.resolve();
77
125
  /** Focused row key per section, so it survives a rebuild that reorders rows. */
78
126
  private readonly focusedKeys: Record<PanelSection, string | undefined> = {
79
127
  extensions: undefined,
128
+ skills: undefined,
80
129
  settings: undefined,
81
130
  };
82
131
  /** Row keys whose findings are expanded; Enter toggles only the focused row. */
@@ -85,21 +134,43 @@ export class PanelComponent implements Component {
85
134
  private refreshing = false;
86
135
  /** True once the Extensions view has asked for its first probe. */
87
136
  private probed = false;
137
+ /** Skill metadata, read once when the Skills section first needs it. */
138
+ private skillIndex: ReadonlyMap<string, SkillInfo> | undefined;
88
139
  /** Transient feedback (a refusal or a failed write), shown in the hint row. */
89
140
  private notice: string | undefined;
90
- /** The text row being edited, if any. */
91
- private editing: EditingState | undefined;
92
- /** True while the Settings detail pane shows the focused row's section as JSON. */
93
- private json = false;
94
141
  /** Whether the last Settings render kept the project note; the click mapping needs it. */
95
142
  private noteShown = false;
143
+ /** The Settings section's editor and JSON-pane state. */
144
+ private readonly settingsSection: SettingsSection;
96
145
  /** The visible section. */
97
146
  private section: PanelSection;
98
147
 
99
148
  constructor(private readonly options: PanelComponentOptions) {
100
149
  const requestRender = (): void => this.options.requestRender();
101
- this.cursors = { extensions: new ListCursor(requestRender), settings: new ListCursor(requestRender) };
150
+ this.cursors = {
151
+ extensions: new ListCursor(requestRender),
152
+ skills: new ListCursor(requestRender),
153
+ settings: new ListCursor(requestRender),
154
+ };
102
155
  this.section = options.section ?? "extensions";
156
+ this.settingsSection = new SettingsSection({
157
+ surface: options.settings,
158
+ theme: options.theme,
159
+ scope: () => this.options.runtime.scope(),
160
+ cursor: this.cursors.settings,
161
+ viewportRows: options.viewportRows,
162
+ cwd: options.cwd,
163
+ agentDir: options.agentDir,
164
+ retain: (entries) => this.retain(entries),
165
+ navigate: (data) => this.navigate(data),
166
+ switchScope: () => this.switchScope(),
167
+ notify: (message) => this.notify(message),
168
+ requestRender,
169
+ setNoteShown: (shown) => {
170
+ this.noteShown = shown;
171
+ },
172
+ hint: (width) => this.hintRow(width, { jsonOpen: this.settingsSection.jsonOpen }),
173
+ });
103
174
  }
104
175
 
105
176
  /** The active section's cursor. */
@@ -112,7 +183,7 @@ export class PanelComponent implements Component {
112
183
  return this.options.theme;
113
184
  }
114
185
 
115
- /** Whether anything has been written this session, either section. */
186
+ /** Whether anything has been written this session, in any section. */
116
187
  private get dirty(): boolean {
117
188
  return this.options.runtime.isDirty() || this.options.settings.isDirty();
118
189
  }
@@ -121,21 +192,36 @@ export class PanelComponent implements Component {
121
192
  // Everything is rebuilt from live state on each render.
122
193
  }
123
194
 
124
- /** The merged model for the current items and reports. */
125
- private model(): PanelModel {
195
+ /** The merged model for the current items of one list section. */
196
+ private model(kind: ListSection): PanelModel {
197
+ if (kind === "skills") {
198
+ return buildSkillModel({
199
+ items: this.options.runtime.items("skills"),
200
+ index: this.skills(),
201
+ agentDir: this.options.agentDir,
202
+ });
203
+ }
126
204
  return buildPanelModel({
127
- items: this.options.runtime.items(),
205
+ items: this.options.runtime.items("extensions"),
128
206
  reports: this.options.reports(),
129
207
  agentDir: this.options.agentDir,
130
208
  filter: this.options.filter,
131
209
  });
132
210
  }
133
211
 
212
+ /** Skill metadata, loaded on first use and kept for the screen's lifetime. */
213
+ private skills(): ReadonlyMap<string, SkillInfo> {
214
+ this.skillIndex ??= this.options.skills();
215
+ return this.skillIndex;
216
+ }
217
+
134
218
  /** The entries of the visible section. */
135
219
  private activeEntries(): PanelEntry[] {
136
- return this.section === "settings"
137
- ? settingEntries(this.options.settings.views(this.options.runtime.scope()))
138
- : entriesOf(this.model());
220
+ const section = this.section;
221
+ if (section === "settings") {
222
+ return settingEntries(this.options.settings.views(this.options.runtime.scope()));
223
+ }
224
+ return entriesOf(this.model(section));
139
225
  }
140
226
 
141
227
  /** Keep the focus on the same row key when the list shape changes. */
@@ -152,28 +238,18 @@ export class PanelComponent implements Component {
152
238
  clampSelection(entries, this.cursor);
153
239
  }
154
240
 
155
- private select(index: number | undefined): void {
156
- if (index === undefined) return;
157
- this.cursor.set(index, this.activeEntries().length);
158
- }
159
-
160
- /** Move `delta` rows, skipping group headers. */
161
- private move(delta: number): void {
162
- moveRows(this.activeEntries(), this.cursor, delta);
163
- }
164
-
165
241
  private switchScope(): void {
166
242
  const next = this.options.runtime.scope() === "global" ? "project" : "global";
167
243
  if (next === "project" && !this.options.runtime.projectAvailable()) {
168
244
  this.notify(
169
245
  this.section === "settings"
170
246
  ? UNTRUSTED_SETTINGS_NOTE
171
- : "Project is not trusted; manage extensions in Global scope only.",
247
+ : `Project is not trusted; manage ${this.section} in Global scope only.`,
172
248
  );
173
249
  return;
174
250
  }
175
251
  // The JSON pane names a file, so a scope switch has to close it.
176
- this.json = false;
252
+ this.settingsSection.reset();
177
253
  const focused = entryKey(this.activeEntries()[this.cursor.selected]);
178
254
  this.options.runtime.setScope(next);
179
255
  // Keep the setting under the cursor across the layer switch; the two scopes
@@ -187,6 +263,8 @@ export class PanelComponent implements Component {
187
263
  }
188
264
 
189
265
  private toggle(): void {
266
+ const kind = kindOf(this.section);
267
+ if (kind === undefined) return;
190
268
  const entry = this.activeEntries()[this.cursor.selected];
191
269
  if (entry?.kind !== "row") return;
192
270
  const item = entry.row.item;
@@ -197,13 +275,15 @@ export class PanelComponent implements Component {
197
275
  return;
198
276
  }
199
277
  this.chain = this.chain
200
- .then(() => this.options.runtime.toggle(item))
278
+ .then(() => this.options.runtime.toggle(kind, item))
201
279
  .catch(() => undefined)
202
280
  .then(() => this.options.requestRender());
203
281
  }
204
282
 
205
- /** Drop the focused extension's project override, restoring the inherited row. */
283
+ /** Drop the focused resource's project override, restoring the inherited row. */
206
284
  private clearOverride(): void {
285
+ const kind = kindOf(this.section);
286
+ if (kind === undefined) return;
207
287
  const entry = this.activeEntries()[this.cursor.selected];
208
288
  if (entry?.kind !== "row") return;
209
289
  const item = entry.row.item;
@@ -212,98 +292,24 @@ export class PanelComponent implements Component {
212
292
  return;
213
293
  }
214
294
  this.chain = this.chain
215
- .then(() => this.options.runtime.clearOverride(item))
295
+ .then(() => this.options.runtime.clearOverride(kind, item))
216
296
  .catch(() => undefined)
217
297
  .then(() => this.options.requestRender());
218
298
  }
219
299
 
220
- /** Change the focused setting: cycle it, or open the editor for a text row. */
221
- private changeSetting(): void {
222
- const entry = this.activeEntries()[this.cursor.selected];
223
- if (entry?.kind !== "setting") return;
224
- const view = entry.setting;
225
- if (view.kind === "text") {
226
- this.editing = { id: view.id, buffer: view.value, caret: view.value.length };
227
- this.options.requestRender();
228
- return;
229
- }
230
- const scope = this.options.runtime.scope();
231
- this.chain = this.chain
232
- .then(() => this.options.settings.change(scope, view.id))
233
- .catch((error: unknown) => this.notify(error instanceof Error ? error.message : String(error)))
234
- .then(() => this.options.requestRender());
235
- }
236
-
237
- /** Delete the focused setting's leaf, so its default applies again. */
238
- private resetSetting(): void {
239
- const entry = this.activeEntries()[this.cursor.selected];
240
- if (entry?.kind !== "setting") return;
241
- const scope = this.options.runtime.scope();
242
- this.chain = this.chain
243
- .then(() => this.options.settings.reset(scope, entry.setting.id))
244
- .catch((error: unknown) => this.notify(error instanceof Error ? error.message : String(error)))
245
- .then(() => this.options.requestRender());
246
- }
247
-
248
- /** Toggle the detail pane between the row and its whole section as stored. */
249
- private toggleJson(): void {
250
- const entry = this.activeEntries()[this.cursor.selected];
251
- if (entry?.kind !== "setting") return;
252
- if (!entry.setting.config) {
253
- // There is no section to show, so leave the mode off rather than keeping a
254
- // hidden toggle that would reopen the pane on another row.
255
- this.json = false;
256
- this.notify(`${entry.setting.label} is a Pi setting; bytefly.json has no section for it.`);
257
- return;
258
- }
259
- this.json = !this.json;
260
- this.options.requestRender();
261
- }
262
-
263
- /** Store the editor's buffer; a refusal keeps it open with the reason shown. */
264
- private commitEdit(): void {
265
- const editing = this.editing;
266
- if (!editing) return;
267
- const scope = this.options.runtime.scope();
268
- this.chain = this.chain
269
- .then(() => this.options.settings.setValue(scope, editing.id, editing.buffer))
270
- .then((saved) => {
271
- if (saved) this.editing = undefined;
272
- })
273
- .catch((error: unknown) => this.notify(error instanceof Error ? error.message : String(error)))
274
- .then(() => this.options.requestRender());
275
- }
276
-
277
300
  /**
278
- * Editor keys. `Esc`/`Ctrl+C` cancel the edit rather than closing the screen,
279
- * and everything else the screen binds is inert until the editor closes.
301
+ * Ask for the work the newly visible section needs. The Extensions view probes
302
+ * when the session had no cache — doing it at mount would delay `/bytefly
303
+ * settings`, which never reads the reports — and the Skills view reads its
304
+ * metadata in the same one-shot way, so a scope switch repaints with names.
280
305
  */
281
- private handleEditingInput(data: string): void {
282
- const editing = this.editing;
283
- if (!editing) return;
284
- const max = SETTING_ROWS.find((row) => row.id === editing.id)?.text?.maxLength;
285
- const action = applyEditKey(data, editing, max);
286
- if (action.kind === "commit") {
287
- void this.commitEdit();
288
- return;
289
- }
290
- if (action.kind === "cancel") {
291
- this.editing = undefined;
292
- this.options.requestRender();
306
+ prime(): void {
307
+ const section = this.section;
308
+ if (section === "skills") {
309
+ this.skills();
293
310
  return;
294
311
  }
295
- if (!action.changed) return;
296
- this.editing = action.state;
297
- this.options.requestRender();
298
- }
299
-
300
- /**
301
- * Ask for the first Extensions probe when the session had no cache. The first
302
- * time the section is visible is when the rows need health; doing it at mount
303
- * would delay `/bytefly settings`, which never reads the reports.
304
- */
305
- prime(): void {
306
- if (this.probed || this.options.needsProbe !== true || this.section !== "extensions") return;
312
+ if (this.probed || this.options.needsProbe !== true || section !== "extensions") return;
307
313
  this.probed = true;
308
314
  void this.refresh(this.options.initialRefresh === true);
309
315
  }
@@ -333,119 +339,67 @@ export class PanelComponent implements Component {
333
339
  }
334
340
  }
335
341
 
336
- /** Show one of the two sections; each keeps its own cursor and focus. */
337
- private switchSection(next: PanelSection): void {
338
- if (next === this.section) return;
342
+ /**
343
+ * Step one section left or right. The ends stop instead of wrapping, so the
344
+ * bar reads as the screen's order rather than a loop.
345
+ */
346
+ private stepSection(direction: -1 | 1): void {
347
+ const next = SECTION_ORDER[SECTION_ORDER.indexOf(this.section) + direction];
348
+ if (next === undefined || next === this.section) return;
339
349
  this.section = next;
340
- this.json = false;
350
+ this.settingsSection.reset();
341
351
  this.prime();
342
352
  this.options.requestRender();
343
353
  }
344
354
 
345
- /** Navigation shared by both sections. */
355
+ /** Read one navigation key against the visible section. */
346
356
  private navigate(data: string): void {
347
- if (data === "g") {
348
- this.select(firstRow(this.activeEntries()));
349
- return;
350
- }
351
- if (data === "G") {
352
- this.select(lastRow(this.activeEntries()));
353
- return;
354
- }
355
- // Only Extensions carries group headings; the Settings list is flat, so the
356
- // keys would move the cursor nowhere there.
357
- if (this.section === "extensions" && (data === "[" || data === "]")) {
358
- this.jumpGroup(data === "[" ? -1 : 1);
359
- return;
360
- }
361
- switch (navIntent(data)) {
362
- case "up":
363
- this.move(-1);
364
- return;
365
- case "down":
366
- this.move(1);
367
- return;
368
- case "pageUp":
369
- this.move(-Math.max(1, this.cursor.visible));
370
- return;
371
- case "pageDown":
372
- this.move(Math.max(1, this.cursor.visible));
373
- return;
374
- case "home":
375
- this.select(firstRow(this.activeEntries()));
376
- return;
377
- case "end":
378
- this.select(lastRow(this.activeEntries()));
379
- return;
380
- }
357
+ navigateEntries(this.activeEntries(), this.cursor, data, { grouped: this.section !== "settings" });
381
358
  }
382
359
 
383
- /** Move to the first row of the next (or previous) group. */
384
- private jumpGroup(direction: -1 | 1): void {
385
- const entries = this.activeEntries();
386
- const headers = entries.flatMap((entry, index) => (entry.kind === "group" ? [index] : []));
387
- const selected = this.cursor.selected;
388
- // Step past the focused row's own header: landing on it would not move.
389
- const own = headers.filter((index) => index < selected).at(-1);
390
- const target =
391
- direction === 1
392
- ? headers.find((index) => index > selected)
393
- : headers.filter((index) => index < (own ?? 0)).at(-1);
394
- if (target === undefined) return;
395
- for (let index = target + 1; index < entries.length; index += 1) {
396
- if (isSelectable(entries[index])) {
397
- this.select(index);
398
- return;
399
- }
400
- }
401
- }
402
-
403
- /** Settings keys: change, scope, navigation. `←`/`→` stay on sections. */
404
- private handleSettingsInput(data: string): void {
405
- this.clampSelection(this.activeEntries());
406
- if (matchesKey(data, Key.space) || matchesKey(data, Key.enter)) {
407
- this.changeSetting();
408
- return;
409
- }
410
- if (matchesKey(data, Key.tab)) {
411
- this.switchScope();
412
- return;
413
- }
414
- if (data === "u") {
415
- this.resetSetting();
416
- return;
417
- }
418
- if (data === "J") {
419
- this.toggleJson();
420
- return;
421
- }
422
- this.navigate(data);
360
+ /** Drop the hint row's notice; any key dismisses it and still acts. */
361
+ private dismissNotice(): void {
362
+ if (this.notice === undefined) return;
363
+ this.notice = undefined;
364
+ this.options.requestRender();
423
365
  }
424
366
 
425
367
  handleInput(data: string): void {
426
- if (this.editing) {
427
- this.handleEditingInput(data);
368
+ const section = this.section;
369
+ if (section === "settings") {
370
+ // The editor takes every key, `Esc` included: it cancels the edit rather
371
+ // than closing the screen.
372
+ if (this.settingsSection.isEditing) {
373
+ this.settingsSection.handleInput(data);
374
+ return;
375
+ }
376
+ if (isCloseKey(data)) {
377
+ this.options.onClose();
378
+ return;
379
+ }
380
+ this.dismissNotice();
381
+ if (matchesKey(data, Key.left)) {
382
+ this.stepSection(-1);
383
+ return;
384
+ }
385
+ if (matchesKey(data, Key.right)) {
386
+ this.stepSection(1);
387
+ return;
388
+ }
389
+ this.settingsSection.handleInput(data);
428
390
  return;
429
391
  }
430
392
  if (isCloseKey(data)) {
431
393
  this.options.onClose();
432
394
  return;
433
395
  }
434
- // A notice replaces the hint row; any key dismisses it and still acts.
435
- if (this.notice !== undefined) {
436
- this.notice = undefined;
437
- this.options.requestRender();
438
- }
396
+ this.dismissNotice();
439
397
  if (matchesKey(data, Key.left)) {
440
- this.switchSection("extensions");
398
+ this.stepSection(-1);
441
399
  return;
442
400
  }
443
401
  if (matchesKey(data, Key.right)) {
444
- this.switchSection("settings");
445
- return;
446
- }
447
- if (this.section === "settings") {
448
- this.handleSettingsInput(data);
402
+ this.stepSection(1);
449
403
  return;
450
404
  }
451
405
  // An input can arrive before the first render; put the focus on a row.
@@ -462,7 +416,8 @@ export class PanelComponent implements Component {
462
416
  this.switchScope();
463
417
  return;
464
418
  }
465
- if (matchesKey(data, "r")) {
419
+ // Re-probing is the Extensions view's job; a skill has nothing to probe.
420
+ if (section === "extensions" && matchesKey(data, "r")) {
466
421
  void this.refresh(true);
467
422
  return;
468
423
  }
@@ -478,14 +433,13 @@ export class PanelComponent implements Component {
478
433
  handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
479
434
  // A click while editing leaves the editor the way `Esc` does: nothing is
480
435
  // written behind the screen.
481
- if (this.editing) {
482
- this.editing = undefined;
483
- this.options.requestRender();
436
+ if (this.settingsSection.isEditing) {
437
+ this.settingsSection.cancelEdit();
484
438
  return { handled: true };
485
439
  }
486
440
  const delta = wheelDelta(event);
487
441
  if (delta !== undefined) {
488
- this.move(delta);
442
+ moveRows(this.activeEntries(), this.cursor, delta);
489
443
  return { handled: true };
490
444
  }
491
445
  if ((event.type !== "click" && event.type !== "press") || event.button !== "left") return undefined;
@@ -499,21 +453,21 @@ export class PanelComponent implements Component {
499
453
  if (line < 0 || line >= window.end - window.start) return undefined;
500
454
  const index = window.start + line;
501
455
  if (!isSelectable(entries[index])) return undefined;
502
- this.select(index);
456
+ this.cursor.set(index, entries.length);
503
457
  return { handled: true };
504
458
  }
505
459
 
506
460
  /** Key hints for the active section; whole hints drop as the width runs out. */
507
461
  private hint(width: number, options: { expanded?: boolean; jsonOpen?: boolean } = {}): string {
508
- if (this.editing) return hintLine(this.theme, width, editHints());
509
- const hints =
510
- this.section === "settings"
511
- ? settingsHints(options.jsonOpen === true)
512
- : extensionHints({
513
- expanded: options.expanded === true,
514
- refreshing: this.refreshing,
515
- projectScope: this.options.runtime.scope() === "project",
516
- });
462
+ if (this.settingsSection.isEditing) return hintLine(this.theme, width, editHints());
463
+ if (this.section === "settings") {
464
+ return hintLine(this.theme, width, settingsHints(options.jsonOpen === true));
465
+ }
466
+ const hints = LIST_VIEWS[this.section].hints({
467
+ expanded: options.expanded === true,
468
+ refreshing: this.refreshing,
469
+ projectScope: this.options.runtime.scope() === "project",
470
+ });
517
471
  return hintLine(this.theme, width, hints);
518
472
  }
519
473
 
@@ -538,101 +492,38 @@ export class PanelComponent implements Component {
538
492
  return entries[this.cursor.selected];
539
493
  }
540
494
 
541
- /** The Settings body: scope counts, the project note, rows, and detail. */
542
- private renderSettings(width: number, header: string): string[] {
543
- const scope = this.options.runtime.scope();
544
- const views = this.options.settings.views(scope);
545
- const summary = sectionBar(
546
- this.theme,
547
- width,
548
- this.section,
549
- settingsSummary(this.theme, width, scope, views.length),
550
- );
551
- const entries = settingEntries(views);
552
- const note = scope === "project" ? projectNote(this.theme, width) : undefined;
553
-
554
- const focusedEntry = this.retain(entries);
555
- const focused = focusedEntry?.kind === "setting" ? focusedEntry.setting : undefined;
556
- // A row with no section leaves the mode off, so moving away and back cannot
557
- // reopen a pane the user closed.
558
- if (focused && !focused.config) this.json = false;
559
- const jsonConfig = this.json ? focused?.config : undefined;
560
- const detail =
561
- jsonConfig && focused
562
- ? sectionJsonPane({
563
- theme: this.theme,
564
- width,
565
- section: jsonConfig.section,
566
- document: this.options.settings.sectionDocument(scope, jsonConfig.section),
567
- budget: detailRows(true, this.options.viewportRows),
568
- dirty: this.dirty,
569
- paths: { scope, cwd: this.options.cwd, agentDir: this.options.agentDir },
570
- })
571
- : settingDetail({
572
- theme: this.theme,
573
- width,
574
- view: focused,
575
- budget: detailRows(false, this.options.viewportRows),
576
- dirty: this.dirty,
577
- paths: { scope, cwd: this.options.cwd, agentDir: this.options.agentDir },
578
- });
579
-
580
- const { lines, noteShown } = panelFrame({
581
- theme: this.theme,
582
- viewportRows: this.options.viewportRows,
583
- cursor: this.cursor,
584
- width,
585
- header,
586
- summary,
587
- entries,
588
- note,
589
- detail,
590
- hint: this.hintRow(width, { jsonOpen: jsonConfig !== undefined }),
591
- draw: (entry, index) => {
592
- if (entry.kind === "group") return groupLine(this.theme, width, entry);
593
- if (entry.kind !== "setting") return undefined;
594
- const editing = this.editing?.id === entry.setting.id ? this.editing : undefined;
595
- return settingRow(this.theme, width, entry.setting, index === this.cursor.selected, editing);
596
- },
597
- });
598
- this.noteShown = noteShown;
599
- return lines;
600
- }
601
-
602
495
  render(width: number): string[] {
603
496
  const w = Math.max(1, width);
604
- const header = titleRule(this.theme, w, "bytefly");
605
- if (this.section === "settings") return this.renderSettings(w, header);
497
+ const header = titleRule(this.theme, w, "Bytefly");
498
+ const section = this.section;
499
+ if (section === "settings") return this.settingsSection.render(w, header);
606
500
 
607
- const model = this.model();
501
+ const model = this.model(section);
608
502
  const entries = entriesOf(model);
609
503
  const summary = sectionBar(
610
504
  this.theme,
611
505
  w,
612
- this.section,
613
- extensionSummary(this.theme, w, model, this.options.runtime.scope(), this.options.filter),
506
+ section,
507
+ resourceSummary(
508
+ this.theme,
509
+ w,
510
+ model,
511
+ this.options.runtime.scope(),
512
+ section === "skills" ? { noun: SKILL_NOUN } : { filter: this.options.filter },
513
+ ),
614
514
  );
615
515
 
616
- if (entries.length === 0) {
617
- return dockListFrame({
618
- header,
619
- summary,
620
- body: emptyState(this.theme, w, { label: "No extensions found." }),
621
- hint: this.hintRow(w),
622
- });
623
- }
624
-
625
516
  const focusedEntry = this.retain(entries);
626
517
  const focused = focusedEntry?.kind === "row" ? focusedEntry.row : undefined;
627
518
  const expanded = focused !== undefined && this.expanded.has(focused.key);
628
- const detail = extensionDetail(
629
- this.theme,
630
- w,
631
- focused,
632
- detailRows(expanded, this.options.viewportRows),
519
+ const detail = LIST_VIEWS[section].detail({
520
+ theme: this.theme,
521
+ width: w,
522
+ row: focused,
523
+ budget: detailRows(expanded, this.options.viewportRows),
633
524
  expanded,
634
- this.dirty,
635
- );
525
+ dirty: this.dirty,
526
+ });
636
527
 
637
528
  const { lines, noteShown } = panelFrame({
638
529
  theme: this.theme,
@@ -642,12 +533,13 @@ export class PanelComponent implements Component {
642
533
  header,
643
534
  summary,
644
535
  entries,
536
+ emptyLabel: LIST_VIEWS[section].emptyLabel,
645
537
  detail,
646
538
  hint: this.hintRow(w, { expanded }),
647
539
  draw: (entry, index) => {
648
540
  if (entry.kind === "group") return groupLine(this.theme, w, entry);
649
541
  if (entry.kind !== "row") return undefined;
650
- return extensionRow(this.theme, w, entry.row, index === this.cursor.selected);
542
+ return resourceRow(this.theme, w, entry.row, index === this.cursor.selected);
651
543
  },
652
544
  });
653
545
  this.noteShown = noteShown;