@gavin-hu/my-pi-agent 0.19.0 → 0.20.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 (158) hide show
  1. package/README.md +17 -11
  2. package/README.zh-CN.md +13 -8
  3. package/docs/extensions/README.md +1 -1
  4. package/docs/extensions/board.md +286 -123
  5. package/docs/extensions/bytefly.md +381 -223
  6. package/docs/extensions/file-browse.md +14 -7
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +56 -11
  9. package/docs/extensions/session-bus.md +23 -10
  10. package/docs/extensions/team.md +45 -17
  11. package/docs/extensions/wechat.md +2 -1
  12. package/docs/extensions/worktree.md +30 -15
  13. package/extensions/board/commands.ts +23 -5
  14. package/extensions/board/format.ts +107 -9
  15. package/extensions/board/join.ts +1 -1
  16. package/extensions/board/page.ts +170 -94
  17. package/extensions/board/result.ts +9 -0
  18. package/extensions/board/schema.ts +22 -7
  19. package/extensions/board/seat.ts +9 -10
  20. package/extensions/board/snapshot.ts +1 -0
  21. package/extensions/board/tools.ts +186 -21
  22. package/extensions/board/transport/router.ts +27 -108
  23. package/extensions/board/transport/server.ts +18 -29
  24. package/extensions/board/types.ts +67 -0
  25. package/extensions/board/view/client.ts +23 -13
  26. package/extensions/board/view/dialog.ts +43 -0
  27. package/extensions/board/view/keys.ts +28 -24
  28. package/extensions/board/view/live.ts +5 -171
  29. package/extensions/board/view/names.ts +91 -0
  30. package/extensions/board/view/page.ts +48 -88
  31. package/extensions/board/view/render.ts +213 -190
  32. package/extensions/board/view/styles.ts +65 -48
  33. package/extensions/board/view/team.ts +63 -41
  34. package/extensions/bytefly/footer/budget.ts +6 -5
  35. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  36. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  37. package/extensions/bytefly/footer/config.ts +19 -8
  38. package/extensions/bytefly/footer/price/price.ts +30 -8
  39. package/extensions/bytefly/footer/register.ts +5 -5
  40. package/extensions/bytefly/footer/render/format.ts +18 -0
  41. package/extensions/bytefly/footer/render/lines.ts +23 -7
  42. package/extensions/bytefly/footer/render/segments.ts +117 -31
  43. package/extensions/bytefly/footer/types.ts +29 -5
  44. package/extensions/bytefly/footer/unpriced.ts +42 -17
  45. package/extensions/bytefly/panel/commands.ts +28 -1
  46. package/extensions/bytefly/panel/filter.ts +68 -0
  47. package/extensions/bytefly/panel/format.ts +16 -15
  48. package/extensions/bytefly/panel/join.ts +56 -13
  49. package/extensions/bytefly/panel/prices.ts +43 -0
  50. package/extensions/bytefly/panel/settings.ts +72 -23
  51. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  52. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  53. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  54. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  55. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  56. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  57. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  58. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  59. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  60. package/extensions/bytefly/panel/tui.ts +306 -259
  61. package/extensions/check/commands.ts +2 -1
  62. package/extensions/file-browse/files/classify.ts +136 -0
  63. package/extensions/file-browse/files/files.ts +8 -547
  64. package/extensions/file-browse/files/index.ts +90 -0
  65. package/extensions/file-browse/files/list.ts +261 -0
  66. package/extensions/file-browse/files/tree.ts +101 -0
  67. package/extensions/file-browse/transport/router.ts +0 -2
  68. package/extensions/file-browse/view/emit.ts +51 -0
  69. package/extensions/file-browse/view/html.ts +6 -7
  70. package/extensions/file-browse/view/markdown.ts +11 -13
  71. package/extensions/file-browse/view/raw-html.ts +18 -13
  72. package/extensions/job/runtime.ts +4 -4
  73. package/extensions/plan/feedback.ts +40 -16
  74. package/extensions/plan/format.ts +10 -3
  75. package/extensions/plan/index.ts +17 -1
  76. package/extensions/plan/plan-files.ts +88 -0
  77. package/extensions/plan/plans.ts +19 -23
  78. package/extensions/plan/policy.ts +10 -5
  79. package/extensions/plan/runtime.ts +31 -2
  80. package/extensions/plan/todos.ts +51 -3
  81. package/extensions/plan/tools/exit.ts +10 -1
  82. package/extensions/plan/tools/todo-seed.ts +4 -2
  83. package/extensions/plan/tools/write.ts +12 -1
  84. package/extensions/plan/types.ts +15 -0
  85. package/extensions/plan/web-blocks.ts +89 -0
  86. package/extensions/plan/web-chrome.ts +3 -5
  87. package/extensions/plan/web-client.ts +179 -330
  88. package/extensions/plan/web-collab.ts +3 -3
  89. package/extensions/plan/web-comments.ts +18 -8
  90. package/extensions/plan/web-composer.ts +80 -0
  91. package/extensions/plan/web-draft.ts +40 -0
  92. package/extensions/plan/web-keys.ts +134 -0
  93. package/extensions/plan/web-outline.ts +192 -0
  94. package/extensions/plan/web-page.ts +4 -1
  95. package/extensions/plan/web-plan.ts +150 -0
  96. package/extensions/plan/web-router.ts +0 -1
  97. package/extensions/plan/web-settle.ts +194 -0
  98. package/extensions/rewind/commands/review.ts +2 -1
  99. package/extensions/session-bus/rows.ts +7 -3
  100. package/extensions/session-bus/runtime.ts +100 -38
  101. package/extensions/session-bus/store.ts +14 -2
  102. package/extensions/session-bus/tui.ts +1 -1
  103. package/extensions/session-bus/types.ts +10 -1
  104. package/extensions/subagent/commands.ts +2 -1
  105. package/extensions/team/commands.ts +41 -8
  106. package/extensions/team/format.ts +14 -0
  107. package/extensions/team/index.ts +2 -1
  108. package/extensions/team/tools.ts +30 -9
  109. package/extensions/team/types.ts +17 -0
  110. package/extensions/todo/index.ts +4 -1
  111. package/extensions/todo/nudge.ts +8 -2
  112. package/extensions/wechat/bridge.ts +43 -172
  113. package/extensions/wechat/intake.ts +245 -0
  114. package/extensions/worktree/commands.ts +24 -17
  115. package/extensions/worktree/exit.ts +12 -6
  116. package/extensions/worktree/git.ts +22 -14
  117. package/extensions/worktree/guard.ts +15 -285
  118. package/extensions/worktree/handoff.ts +10 -2
  119. package/extensions/worktree/index.ts +2 -1
  120. package/extensions/worktree/lifecycle.ts +2 -0
  121. package/extensions/worktree/registry.ts +20 -11
  122. package/extensions/worktree/root-tools.ts +13 -3
  123. package/extensions/worktree/runtime.ts +8 -2
  124. package/extensions/worktree/screen.ts +15 -8
  125. package/extensions/worktree/shell.ts +303 -0
  126. package/extensions/worktree/state.ts +1 -1
  127. package/extensions/worktree/status.ts +42 -9
  128. package/extensions/worktree/tools.ts +19 -13
  129. package/lib/README.md +6 -5
  130. package/lib/board/card.ts +192 -3
  131. package/lib/board/links.ts +26 -2
  132. package/lib/board/page-pointer.ts +86 -32
  133. package/lib/board/round-text.ts +11 -2
  134. package/lib/board/round.ts +25 -20
  135. package/lib/board/rules.ts +144 -11
  136. package/lib/board/store.ts +239 -52
  137. package/lib/board/view.ts +94 -10
  138. package/lib/format.ts +22 -0
  139. package/lib/http/serve.ts +3 -0
  140. package/lib/http/sink.ts +34 -9
  141. package/lib/plan-entry.ts +11 -0
  142. package/lib/policy.ts +14 -1
  143. package/lib/session-roster.ts +74 -3
  144. package/lib/spawn.ts +37 -3
  145. package/lib/stray-fields.ts +45 -0
  146. package/lib/tool-names.ts +13 -0
  147. package/lib/tui/glyphs.ts +2 -3
  148. package/lib/web/README.md +52 -2
  149. package/lib/web/client-runtime.ts +123 -0
  150. package/lib/web/index.ts +4 -2
  151. package/lib/web/markup.ts +88 -1
  152. package/lib/web/names.ts +42 -0
  153. package/package.json +1 -1
  154. package/extensions/board/view/actions.ts +0 -306
  155. package/extensions/board/writes.ts +0 -99
  156. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  157. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  158. package/extensions/job/process.ts +0 -35
@@ -1,45 +1,62 @@
1
1
  /**
2
- * The unified bytefly panel: three sections around one docked list.
2
+ * The control center: three sections around one frame.
3
3
  *
4
4
  * Extensions lists every resolved extension with its enable checkbox and its
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.
5
+ * health, Skills the same with what its `SKILL.md` declares, and Settings the
6
+ * curated preferences with their values. All three are drawn by one frame —
7
+ * header, tabs, a shared body height, one focused-row block — so the screen does
8
+ * not change shape when the section does.
9
+ *
7
10
  * 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
14
- * `checkbox` — and its scroll math from `lib/tui/cursor.ts`.
11
+ * section, the filter, the help block, the pending toggle chain and the notice.
12
+ * It is derived: every render rebuilds from the picker runtime, the reports, the
13
+ * skill index, or the settings surface, and keeps the focused row's key across
14
+ * rebuilds.
15
15
  */
16
16
 
17
17
  import type { Theme } from "@earendil-works/pi-coding-agent";
18
- import { Key, matchesKey, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
19
- import type { ScreenHint, ViewportRowsSource } from "../../../lib/tui/chrome.ts";
20
- import { isCloseKey, ListCursor, wheelDelta } from "../../../lib/tui/cursor.ts";
21
- import { titleRule } from "../../../lib/tui/rules.ts";
18
+ import type { Component, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
19
+ import type { ViewportRowsSource } from "../../../lib/tui/chrome.ts";
20
+ import { ListCursor, wheelDelta } from "../../../lib/tui/cursor.ts";
22
21
  import type { ExtensionReport } from "../../../lib/requirements.ts";
23
22
  import type { PickerRuntime } from "../picker/runtime.ts";
24
23
  import type { ResourceKind } from "../picker/types.ts";
25
- import { SECTION_ORDER, SKILL_NOUN } from "./format.ts";
26
- import { buildPanelModel, buildSkillModel } from "./join.ts";
24
+ import { NO_FILTER, type PanelFilter } from "./filter.ts";
25
+ import { SECTION_ORDER } from "./format.ts";
27
26
  import { UNTRUSTED_SETTINGS_NOTE } from "./settings.ts";
28
27
  import type { SettingsSurface } from "./settings-runtime.ts";
29
- import type { PanelModel, PanelRow, PanelSection, SkillInfo } from "./types.ts";
30
- import { entriesOf, entryKey, isSelectable, settingEntries, type PanelEntry } from "./tui/entries.ts";
31
- import { extensionDetail } from "./tui/extensions-view.ts";
32
- import { BODY_START, detailRows, groupLine, hintLine, noticeLine, panelFrame, sectionBar } from "./tui/frame.ts";
33
- import { resourceRow, resourceSummary, sectionHints } from "./tui/list-view.ts";
28
+ import type { PanelModel, PanelSection, SkillInfo } from "./types.ts";
29
+ import { applyEditKey, type EditingState } from "./tui/editing.ts";
30
+ import { entriesOf, entryKey, isSelectable, type PanelEntry } from "./tui/entries.ts";
31
+ import { BODY_START, detailRows, groupLine, headerLine, panelFrame, tabsLine } from "./tui/frame.ts";
32
+ import { hintRow, keyFor } from "./tui/keys.ts";
33
+ import { resourceRow, verdictColumn } from "./tui/list-view.ts";
34
34
  import { clampSelection, moveRows, navigateEntries, retainFocus } from "./tui/nav.ts";
35
- import { editHints, settingsHints } from "./tui/settings-view.ts";
35
+ import { clipRows, isWide, sideWidth } from "./tui/panes.ts";
36
+ import {
37
+ attentionFor,
38
+ chipsFor,
39
+ emptyLabelFor,
40
+ groupLabelFor,
41
+ modelFor,
42
+ sectionCounts,
43
+ tallestBody,
44
+ type ViewSource,
45
+ } from "./tui/section-view.ts";
46
+ import { sideRows } from "./tui/side-pane.ts";
36
47
  import { SettingsSection } from "./tui/settings-section.ts";
37
- import { skillDetail } from "./tui/skills-view.ts";
48
+ import { projectNote, settingLabelColumn, settingRow } from "./tui/settings-view.ts";
38
49
 
39
50
  /** A section that draws a resource list rather than the settings table. */
40
51
  type ListSection = ResourceKind;
41
52
 
42
- /** Host seams for the panel screen, injected by the command runner. */
53
+ /** The filter editor's identity, so it can share the settings editor's key map. */
54
+ const FILTER_EDIT_ID = "filter";
55
+
56
+ /** The longest filter query the editor accepts. */
57
+ const FILTER_MAX = 60;
58
+
59
+ /** Host seams for the control center, injected by the command runner. */
43
60
  export interface PanelComponentOptions {
44
61
  /** The session's picker runtime: items, scope, toggles, dirty flag. */
45
62
  runtime: PickerRuntime;
@@ -53,7 +70,7 @@ export interface PanelComponentOptions {
53
70
  section?: PanelSection;
54
71
  theme: Theme;
55
72
  agentDir: string;
56
- /** The session working directory, for the config-file path in the detail pane. */
73
+ /** The session working directory, for the config-file path in the block. */
57
74
  cwd: string;
58
75
  /** Live terminal height, for the row budget. */
59
76
  viewportRows?: ViewportRowsSource;
@@ -66,58 +83,12 @@ export interface PanelComponentOptions {
66
83
  initialRefresh?: boolean;
67
84
  /** Re-probe; `force` ignores the session cache. Awaited before the busy state clears. */
68
85
  refresh: (force?: boolean) => void | Promise<void>;
86
+ /** Confirm and reload, so the changes on screen take effect. */
87
+ reload: () => void | Promise<void>;
69
88
  onClose: () => void;
70
89
  }
71
90
 
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
-
120
- /** The docked bytefly panel. */
91
+ /** The control center's screen. */
121
92
  export class PanelComponent implements Component {
122
93
  private readonly cursors: Record<PanelSection, ListCursor>;
123
94
  /** Serialises toggles so a rapid double press applies twice, not once. */
@@ -138,8 +109,16 @@ export class PanelComponent implements Component {
138
109
  private skillIndex: ReadonlyMap<string, SkillInfo> | undefined;
139
110
  /** Transient feedback (a refusal or a failed write), shown in the hint row. */
140
111
  private notice: string | undefined;
141
- /** Whether the last Settings render kept the project note; the click mapping needs it. */
112
+ /** What the reader narrowed the rows to. */
113
+ private filter: PanelFilter = NO_FILTER;
114
+ /** The filter editor, while `/` is open. */
115
+ private filterEditing: EditingState | undefined;
116
+ /** Whether the key table has taken the focused-row block. */
117
+ private helpOpen = false;
118
+ /** Whether the last render kept the project note; the click mapping needs it. */
142
119
  private noteShown = false;
120
+ /** The label column the last Settings render used, in columns. */
121
+ private labelColumn = 0;
143
122
  /** The Settings section's editor and JSON-pane state. */
144
123
  private readonly settingsSection: SettingsSection;
145
124
  /** The visible section. */
@@ -155,21 +134,14 @@ export class PanelComponent implements Component {
155
134
  this.section = options.section ?? "extensions";
156
135
  this.settingsSection = new SettingsSection({
157
136
  surface: options.settings,
158
- theme: options.theme,
159
137
  scope: () => this.options.runtime.scope(),
138
+ filter: () => this.filter,
160
139
  cursor: this.cursors.settings,
161
- viewportRows: options.viewportRows,
162
- cwd: options.cwd,
163
- agentDir: options.agentDir,
164
140
  retain: (entries) => this.retain(entries),
165
141
  navigate: (data) => this.navigate(data),
166
142
  switchScope: () => this.switchScope(),
167
143
  notify: (message) => this.notify(message),
168
144
  requestRender,
169
- setNoteShown: (shown) => {
170
- this.noteShown = shown;
171
- },
172
- hint: (width) => this.hintRow(width, { jsonOpen: this.settingsSection.jsonOpen }),
173
145
  });
174
146
  }
175
147
 
@@ -192,21 +164,23 @@ export class PanelComponent implements Component {
192
164
  // Everything is rebuilt from live state on each render.
193
165
  }
194
166
 
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
- }
204
- return buildPanelModel({
205
- items: this.options.runtime.items("extensions"),
206
- reports: this.options.reports(),
167
+ /** The sources the screen's content is derived from. */
168
+ private source(): ViewSource {
169
+ return {
170
+ scope: this.options.runtime.scope(),
171
+ items: (kind) => this.options.runtime.items(kind),
172
+ reports: () => this.options.reports(),
173
+ settings: this.options.settings,
207
174
  agentDir: this.options.agentDir,
208
- filter: this.options.filter,
209
- });
175
+ filter: this.filter,
176
+ commandFilter: this.options.filter,
177
+ dirty: this.dirty,
178
+ };
179
+ }
180
+
181
+ /** The merged model for a list section, filtered unless `filtered` is off. */
182
+ private model(kind: ListSection, filtered = true): PanelModel {
183
+ return modelFor(this.source(), kind, () => this.skills(), filtered);
210
184
  }
211
185
 
212
186
  /** Skill metadata, loaded on first use and kept for the screen's lifetime. */
@@ -217,25 +191,8 @@ export class PanelComponent implements Component {
217
191
 
218
192
  /** The entries of the visible section. */
219
193
  private activeEntries(): PanelEntry[] {
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));
225
- }
226
-
227
- /** Keep the focus on the same row key when the list shape changes. */
228
- private retainFocus(entries: PanelEntry[]): void {
229
- const before = entryKey(entries[this.cursor.selected]);
230
- retainFocus(entries, this.cursor, this.focusedKeys[this.section]);
231
- const after = entryKey(entries[this.cursor.selected]);
232
- if (after !== undefined) this.focusedKeys[this.section] = after;
233
- else if (before !== undefined) this.focusedKeys[this.section] = before;
234
- }
235
-
236
- /** Move onto the nearest selectable row when the focus is not already on one. */
237
- private clampSelection(entries: PanelEntry[]): void {
238
- clampSelection(entries, this.cursor);
194
+ if (this.section === "settings") return this.settingsSection.entries();
195
+ return entriesOf(this.model(this.section));
239
196
  }
240
197
 
241
198
  private switchScope(): void {
@@ -252,9 +209,9 @@ export class PanelComponent implements Component {
252
209
  this.settingsSection.reset();
253
210
  const focused = entryKey(this.activeEntries()[this.cursor.selected]);
254
211
  this.options.runtime.setScope(next);
255
- // Keep the setting under the cursor across the layer switch; the two scopes
256
- // do not share entry indices because Project drops the app rows, so the
257
- // render's focus retention and clamp land on the nearest row when it cannot.
212
+ // Keep the row under the cursor across the layer switch; the two scopes do
213
+ // not share entry indices because Project drops the app rows, so the render's
214
+ // focus retention and clamp land on the nearest row when it cannot.
258
215
  if (focused !== undefined) {
259
216
  const index = this.activeEntries().findIndex((entry) => entryKey(entry) === focused);
260
217
  if (index >= 0) this.cursor.selected = index;
@@ -263,9 +220,7 @@ export class PanelComponent implements Component {
263
220
  }
264
221
 
265
222
  private toggle(): void {
266
- const kind = kindOf(this.section);
267
- if (kind === undefined) return;
268
- const entry = this.activeEntries()[this.cursor.selected];
223
+ const entry = this.focusedEntry();
269
224
  if (entry?.kind !== "row") return;
270
225
  const item = entry.row.item;
271
226
  if (!item) {
@@ -274,6 +229,7 @@ export class PanelComponent implements Component {
274
229
  this.notify(`${entry.row.label} is not resolved; manage it with pi config.`);
275
230
  return;
276
231
  }
232
+ const kind = this.section === "skills" ? "skills" : "extensions";
277
233
  this.chain = this.chain
278
234
  .then(() => this.options.runtime.toggle(kind, item))
279
235
  .catch(() => undefined)
@@ -282,15 +238,14 @@ export class PanelComponent implements Component {
282
238
 
283
239
  /** Drop the focused resource's project override, restoring the inherited row. */
284
240
  private clearOverride(): void {
285
- const kind = kindOf(this.section);
286
- if (kind === undefined) return;
287
- const entry = this.activeEntries()[this.cursor.selected];
241
+ const entry = this.focusedEntry();
288
242
  if (entry?.kind !== "row") return;
289
243
  const item = entry.row.item;
290
244
  if (!item) {
291
245
  this.notify(`${entry.row.label} is not resolved; there is no override to clear.`);
292
246
  return;
293
247
  }
248
+ const kind = this.section === "skills" ? "skills" : "extensions";
294
249
  this.chain = this.chain
295
250
  .then(() => this.options.runtime.clearOverride(kind, item))
296
251
  .catch(() => undefined)
@@ -301,7 +256,7 @@ export class PanelComponent implements Component {
301
256
  * Ask for the work the newly visible section needs. The Extensions view probes
302
257
  * when the session had no cache — doing it at mount would delay `/bytefly
303
258
  * 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.
259
+ * metadata in the same one-shot way.
305
260
  */
306
261
  prime(): void {
307
262
  const section = this.section;
@@ -339,14 +294,25 @@ export class PanelComponent implements Component {
339
294
  }
340
295
  }
341
296
 
297
+ /** Hand the reload to the command, which confirms before it takes the session. */
298
+ private async reload(): Promise<void> {
299
+ if (!this.dirty) {
300
+ this.notify("Nothing to apply; change something first.");
301
+ return;
302
+ }
303
+ await this.options.reload();
304
+ }
305
+
342
306
  /**
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.
307
+ * Step one section left or right, wrapping. The tabs read as a ring, so the last
308
+ * one continues to the first instead of stopping with no explanation.
345
309
  */
346
310
  private stepSection(direction: -1 | 1): void {
347
- const next = SECTION_ORDER[SECTION_ORDER.indexOf(this.section) + direction];
311
+ const index = SECTION_ORDER.indexOf(this.section);
312
+ const next = SECTION_ORDER[(index + direction + SECTION_ORDER.length) % SECTION_ORDER.length];
348
313
  if (next === undefined || next === this.section) return;
349
314
  this.section = next;
315
+ this.helpOpen = false;
350
316
  this.settingsSection.reset();
351
317
  this.prime();
352
318
  this.options.requestRender();
@@ -354,82 +320,167 @@ export class PanelComponent implements Component {
354
320
 
355
321
  /** Read one navigation key against the visible section. */
356
322
  private navigate(data: string): void {
357
- navigateEntries(this.activeEntries(), this.cursor, data, { grouped: this.section !== "settings" });
323
+ navigateEntries(this.activeEntries(), this.cursor, data, { grouped: true });
358
324
  }
359
325
 
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;
326
+ /** Keep the focus on the same row key when the list shape changes. */
327
+ private retainFocus(entries: PanelEntry[]): void {
328
+ const before = entryKey(entries[this.cursor.selected]);
329
+ retainFocus(entries, this.cursor, this.focusedKeys[this.section]);
330
+ const after = entryKey(entries[this.cursor.selected]);
331
+ if (after !== undefined) this.focusedKeys[this.section] = after;
332
+ else if (before !== undefined) this.focusedKeys[this.section] = before;
333
+ }
334
+
335
+ /** Move onto the nearest selectable row when the focus is not already on one. */
336
+ private clampSelection(entries: PanelEntry[]): void {
337
+ clampSelection(entries, this.cursor);
338
+ }
339
+
340
+ /**
341
+ * The focused entry, with the cursor clamped onto a row first: a key can arrive
342
+ * before the first render, when the cursor still sits on a group heading.
343
+ */
344
+ private focusedEntry(): PanelEntry | undefined {
345
+ const entries = this.activeEntries();
346
+ this.clampSelection(entries);
347
+ return entries[this.cursor.selected];
348
+ }
349
+
350
+ /** Retain the focus across a rebuild and return the entry it landed on. */
351
+ private retain(entries: PanelEntry[]): PanelEntry | undefined {
352
+ this.retainFocus(entries);
353
+ this.clampSelection(entries);
354
+ return entries[this.cursor.selected];
355
+ }
356
+
357
+ /** Expand or collapse the focused row's findings. */
358
+ private toggleDetail(): void {
359
+ const key = entryKey(this.focusedEntry());
360
+ if (key === undefined) return;
361
+ if (this.expanded.has(key)) this.expanded.delete(key);
362
+ else this.expanded.add(key);
364
363
  this.options.requestRender();
365
364
  }
366
365
 
367
- handleInput(data: string): void {
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);
390
- return;
391
- }
392
- if (isCloseKey(data)) {
393
- this.options.onClose();
394
- return;
395
- }
396
- this.dismissNotice();
397
- if (matchesKey(data, Key.left)) {
398
- this.stepSection(-1);
399
- return;
400
- }
401
- if (matchesKey(data, Key.right)) {
402
- this.stepSection(1);
366
+ /** Open the filter editor, seeded with what is already applied. */
367
+ private openFilter(): void {
368
+ this.filterEditing = { id: FILTER_EDIT_ID, buffer: this.filter.query, caret: this.filter.query.length };
369
+ this.options.requestRender();
370
+ }
371
+
372
+ /**
373
+ * The filter editor's keys. The query applies as it is typed — a filter that
374
+ * only takes effect on `Enter` would make the reader wait to see it work — and
375
+ * `Esc` clears it rather than leaving a hidden narrowing behind.
376
+ */
377
+ private handleFilterInput(data: string): void {
378
+ const editing = this.filterEditing;
379
+ if (editing === undefined) return;
380
+ const action = applyEditKey(data, editing, FILTER_MAX);
381
+ if (action.kind === "commit") {
382
+ this.filterEditing = undefined;
383
+ this.options.requestRender();
403
384
  return;
404
385
  }
405
- // An input can arrive before the first render; put the focus on a row.
406
- this.clampSelection(this.activeEntries());
407
- if (matchesKey(data, Key.space)) {
408
- this.toggle();
386
+ if (action.kind === "cancel") {
387
+ this.filterEditing = undefined;
388
+ this.filter = { ...this.filter, query: "" };
389
+ this.options.requestRender();
409
390
  return;
410
391
  }
411
- if (matchesKey(data, Key.enter)) {
412
- this.toggleDetail();
392
+ if (!action.changed) return;
393
+ this.filterEditing = action.state;
394
+ this.filter = { ...this.filter, query: action.state.buffer };
395
+ this.options.requestRender();
396
+ }
397
+
398
+ /** One key, read against the section's table, with navigation as the fallback. */
399
+ handleInput(data: string): void {
400
+ const section = this.section;
401
+ // The editor takes every key, `Esc` included: it cancels the edit rather than
402
+ // closing the screen. The filter editor does the same with the query.
403
+ if (section === "settings" && this.settingsSection.isEditing) {
404
+ this.settingsSection.handleEditingInput(data);
413
405
  return;
414
406
  }
415
- if (matchesKey(data, Key.tab)) {
416
- this.switchScope();
407
+ if (this.filterEditing !== undefined) {
408
+ this.handleFilterInput(data);
417
409
  return;
418
410
  }
419
- // Re-probing is the Extensions view's job; a skill has nothing to probe.
420
- if (section === "extensions" && matchesKey(data, "r")) {
421
- void this.refresh(true);
422
- return;
411
+ // Any key dismisses the last notice and still does its own work.
412
+ if (this.notice !== undefined) {
413
+ this.notice = undefined;
414
+ this.options.requestRender();
423
415
  }
424
- // Only Project scope can hold an override, so the key does not exist in
425
- // Global scope and the hint row does not offer it there.
426
- if (matchesKey(data, "x") && this.options.runtime.scope() === "project") {
427
- this.clearOverride();
416
+ const key = keyFor(data, section);
417
+ if (key !== undefined) {
418
+ this.act(key);
428
419
  return;
429
420
  }
421
+ // An input can arrive before the first render; put the focus on a row.
422
+ this.clampSelection(this.activeEntries());
430
423
  this.navigate(data);
431
424
  }
432
425
 
426
+ /** Run the action a key table row names. */
427
+ private act(key: ReturnType<typeof keyFor>): void {
428
+ switch (key) {
429
+ case "close":
430
+ this.options.onClose();
431
+ return;
432
+ case "previousSection":
433
+ this.stepSection(-1);
434
+ return;
435
+ case "nextSection":
436
+ this.stepSection(1);
437
+ return;
438
+ case "scope":
439
+ this.switchScope();
440
+ return;
441
+ case "filter":
442
+ this.openFilter();
443
+ return;
444
+ case "attention":
445
+ this.filter = { ...this.filter, attentionOnly: !this.filter.attentionOnly };
446
+ this.options.requestRender();
447
+ return;
448
+ case "help":
449
+ this.helpOpen = !this.helpOpen;
450
+ this.options.requestRender();
451
+ return;
452
+ case "reload":
453
+ void this.reload();
454
+ return;
455
+ case "toggle":
456
+ if (this.section === "settings") this.settingsSection.changeSetting();
457
+ else this.toggle();
458
+ return;
459
+ case "change":
460
+ this.settingsSection.changeSetting();
461
+ return;
462
+ case "reset":
463
+ this.settingsSection.resetSetting();
464
+ return;
465
+ case "json":
466
+ this.settingsSection.toggleJson();
467
+ return;
468
+ case "detail":
469
+ this.toggleDetail();
470
+ return;
471
+ case "refresh":
472
+ void this.refresh(true);
473
+ return;
474
+ case "inherit":
475
+ // Only Project scope can hold an override, so the key does nothing
476
+ // elsewhere and the hint row does not offer it there.
477
+ if (this.options.runtime.scope() === "project") this.clearOverride();
478
+ return;
479
+ default:
480
+ return;
481
+ }
482
+ }
483
+
433
484
  handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
434
485
  // A click while editing leaves the editor the way `Esc` does: nothing is
435
486
  // written behind the screen.
@@ -445,10 +496,7 @@ export class PanelComponent implements Component {
445
496
  if ((event.type !== "click" && event.type !== "press") || event.button !== "left") return undefined;
446
497
  const entries = this.activeEntries();
447
498
  if (entries.length === 0) return undefined;
448
- // The Settings section prints the "Global only" note first in Project scope,
449
- // so the rows start one line lower there.
450
- const note = this.section === "settings" && this.noteShown ? 1 : 0;
451
- const line = event.y - (BODY_START + note);
499
+ const line = event.y - (BODY_START + (this.noteShown ? 1 : 0));
452
500
  const window = this.cursor.window(entries.length);
453
501
  if (line < 0 || line >= window.end - window.start) return undefined;
454
502
  const index = window.start + line;
@@ -457,92 +505,91 @@ export class PanelComponent implements Component {
457
505
  return { handled: true };
458
506
  }
459
507
 
460
- /** Key hints for the active section; whole hints drop as the width runs out. */
461
- private hint(width: number, options: { expanded?: boolean; jsonOpen?: boolean } = {}): string {
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
- });
471
- return hintLine(this.theme, width, hints);
472
- }
473
-
474
- /** The key-hint row, or the notice that temporarily replaces it. */
475
- private hintRow(width: number, options: { expanded?: boolean; jsonOpen?: boolean } = {}): string {
476
- return this.notice !== undefined ? noticeLine(this.theme, width, this.notice) : this.hint(width, options);
477
- }
478
-
479
- /** Expand or collapse the focused row's findings. */
480
- private toggleDetail(): void {
481
- const key = entryKey(this.activeEntries()[this.cursor.selected]);
482
- if (key === undefined) return;
483
- if (this.expanded.has(key)) this.expanded.delete(key);
484
- else this.expanded.add(key);
485
- this.options.requestRender();
486
- }
487
-
488
- /** Retain the focus across a rebuild and return the entry it landed on. */
489
- private retain(entries: PanelEntry[]): PanelEntry | undefined {
490
- this.retainFocus(entries);
491
- this.clampSelection(entries);
492
- return entries[this.cursor.selected];
493
- }
494
-
495
508
  render(width: number): string[] {
496
509
  const w = Math.max(1, width);
497
- const header = titleRule(this.theme, w, "Bytefly");
498
510
  const section = this.section;
499
- if (section === "settings") return this.settingsSection.render(w, header);
500
-
501
- const model = this.model(section);
502
- const entries = entriesOf(model);
503
- const summary = sectionBar(
504
- this.theme,
505
- w,
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
- ),
514
- );
515
-
511
+ const scope = this.options.runtime.scope();
512
+ const source = this.source();
513
+ const model = section === "settings" ? undefined : this.model(section);
514
+ const entries = model === undefined ? this.settingsSection.entries() : entriesOf(model);
516
515
  const focusedEntry = this.retain(entries);
517
- const focused = focusedEntry?.kind === "row" ? focusedEntry.row : undefined;
518
- const expanded = focused !== undefined && this.expanded.has(focused.key);
519
- const detail = LIST_VIEWS[section].detail({
516
+ const row = focusedEntry?.kind === "row" ? focusedEntry.row : undefined;
517
+ const setting = focusedEntry?.kind === "setting" ? focusedEntry.setting : undefined;
518
+ // The help block takes the focused row's place, and the block gets the whole
519
+ // free height while it is open: a key table clipped to three rows is no help.
520
+ const expanded = this.helpOpen || (row !== undefined && this.expanded.has(row.key));
521
+ const wide = isWide(w);
522
+ const blockWidth = wide ? sideWidth(w) : w;
523
+ const block = sideRows({
520
524
  theme: this.theme,
521
- width: w,
522
- row: focused,
523
- budget: detailRows(expanded, this.options.viewportRows),
525
+ width: blockWidth,
526
+ section,
527
+ row,
528
+ groupLabel: groupLabelFor(model, row),
529
+ setting,
530
+ help: this.helpOpen,
524
531
  expanded,
532
+ // Below the pane breakpoint the block shares the rows with a table, so it
533
+ // leaves out what the row above already says.
534
+ compact: !wide,
525
535
  dirty: this.dirty,
536
+ paths: { scope, cwd: this.options.cwd, agentDir: this.options.agentDir },
537
+ json: this.settingsSection.jsonFor(setting),
526
538
  });
527
539
 
540
+ if (section === "settings") this.labelColumn = settingLabelColumn(this.settingsSection.views(), w);
528
541
  const { lines, noteShown } = panelFrame({
529
542
  theme: this.theme,
530
543
  viewportRows: this.options.viewportRows,
531
544
  cursor: this.cursor,
532
545
  width: w,
533
- header,
534
- summary,
546
+ header: headerLine(this.theme, w, "Bytefly", attentionFor(source)),
547
+ tabs: tabsLine(this.theme, w, section, sectionCounts(source), {
548
+ chips: chipsFor(source, section, this.filterEditing),
549
+ scope: scope === "global" ? "Global" : "Project",
550
+ }),
535
551
  entries,
536
- emptyLabel: LIST_VIEWS[section].emptyLabel,
537
- detail,
538
- hint: this.hintRow(w, { expanded }),
539
- draw: (entry, index) => {
540
- if (entry.kind === "group") return groupLine(this.theme, w, entry);
541
- if (entry.kind !== "row") return undefined;
542
- return resourceRow(this.theme, w, entry.row, index === this.cursor.selected);
543
- },
552
+ note: section === "settings" && scope === "project" ? projectNote(this.theme, w) : undefined,
553
+ detail: wide ? [] : clipRows(block, detailRows(expanded, this.options.viewportRows), w),
554
+ hint: hintRow({
555
+ theme: this.theme,
556
+ width: w,
557
+ section,
558
+ editing: this.settingsSection.isEditing || this.filterEditing !== undefined,
559
+ notice: this.notice,
560
+ state: {
561
+ expanded,
562
+ refreshing: this.refreshing,
563
+ projectScope: scope === "project",
564
+ dirty: this.dirty,
565
+ help: this.helpOpen,
566
+ },
567
+ }),
568
+ emptyLabel: emptyLabelFor(this.filter, section),
569
+ tallest: tallestBody(source),
570
+ side: wide ? (rows) => clipRows(block, rows, blockWidth) : undefined,
571
+ draw: (entry, index) => this.drawRow(entry, index, w, section, model),
544
572
  });
545
573
  this.noteShown = noteShown;
546
574
  return lines;
547
575
  }
576
+
577
+ /** One body line, in the section's own row grammar. */
578
+ private drawRow(
579
+ entry: PanelEntry,
580
+ index: number,
581
+ width: number,
582
+ section: PanelSection,
583
+ model: PanelModel | undefined,
584
+ ): string | undefined {
585
+ const selected = index === this.cursor.selected;
586
+ if (entry.kind === "group") return groupLine(this.theme, width, entry);
587
+ if (entry.kind === "row") {
588
+ return resourceRow(this.theme, width, entry.row, selected, model === undefined ? 0 : verdictColumn(model.rows));
589
+ }
590
+ if (section !== "settings") return undefined;
591
+ const editing =
592
+ this.settingsSection.editingState?.id === entry.setting.id ? this.settingsSection.editingState : undefined;
593
+ return settingRow(this.theme, width, entry.setting, selected, editing, this.labelColumn);
594
+ }
548
595
  }