@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,35 +1,38 @@
1
1
  /**
2
- * The grouped list sections' shared pieces: the headline counts, one row, and
3
- * the key hints.
2
+ * The resource row: the one row grammar Extensions and Skills share.
4
3
  *
5
- * Extensions and Skills draw the same row grammar — a checkbox, a verdict, and
6
- * the resource's name — and offer the same keys, so the row, the summary, and
7
- * the hint row live here and each section supplies only what it can say about
8
- * the focused row and whether it has anything to refresh.
4
+ * A row is a checkbox, a status glyph, the resource's name, and one verdict word.
5
+ * A clean row carries no glyph and no word, so the column reads as one shape
6
+ * instead of a checkmark that comes and goes, and the eye finds the rows that
7
+ * need it.
8
+ *
9
+ * Pure: the theme, the width and the model row come in, one clipped line comes out.
9
10
  */
10
11
 
11
12
  import type { Theme } from "@earendil-works/pi-coding-agent";
12
13
  import { sanitizeLine } from "../../../../lib/format.ts";
13
- import type { ScreenHint } from "../../../../lib/tui/chrome.ts";
14
+ import { clip, padTo } from "../../../../lib/tui/columns.ts";
14
15
  import { checkbox, listRow } from "../../../../lib/tui/rows.ts";
15
- import { rowBadge, type PanelScope, summarySegments, type SummaryOptions } from "../format.ts";
16
- import type { PanelModel, PanelRow } from "../types.ts";
17
- import { countsLine } from "./frame.ts";
16
+ import { rowBadge } from "../format.ts";
17
+ import type { PanelRow } from "../types.ts";
18
+
19
+ /** Columns the verdict keeps before the name is clipped instead. */
20
+ const MIN_VERDICT_WIDTH = 9;
18
21
 
19
- /** The section's headline: the scope, the row count, and what is off. */
20
- export function resourceSummary(
21
- theme: Theme,
22
- width: number,
23
- model: PanelModel,
24
- scope: PanelScope,
25
- options: SummaryOptions = {},
26
- ): string {
27
- return countsLine(theme, width, summarySegments(model, scope, { ...options, compact: width < 72 }));
22
+ /** The widest verdict the visible rows carry, so the column lines up. */
23
+ export function verdictColumn(rows: readonly PanelRow[]): number {
24
+ return rows.reduce((columns, row) => Math.max(columns, (rowBadge(row).text ?? "").length), 0);
28
25
  }
29
26
 
30
- /** One resource row: its checkbox, verdict, and name. */
31
- export function resourceRow(theme: Theme, width: number, row: PanelRow, selected: boolean): string {
27
+ /** One resource row: its checkbox, verdict glyph, name, and verdict word. */
28
+ export function resourceRow(theme: Theme, width: number, row: PanelRow, selected: boolean, verdicts = 0): string {
32
29
  const badge = rowBadge(row);
30
+ const name = sanitizeLine(row.label);
31
+ // The verdict sits in its own column instead of against the name, so a column
32
+ // of rows can be read down rather than across; a long name loses its tail
33
+ // before the verdict loses its word.
34
+ const room = Math.max(MIN_VERDICT_WIDTH, Math.min(verdicts, Math.max(0, width - 22)));
35
+ const nameCell = verdicts > 0 ? padTo(clip(name, Math.max(1, width - 22)), room) : name;
33
36
  return listRow(theme, width, {
34
37
  selected,
35
38
  // A reported-only row has no enable pattern, but still reserves the box
@@ -37,36 +40,12 @@ export function resourceRow(theme: Theme, width: number, row: PanelRow, selected
37
40
  leading: row.item ? checkbox(theme, row.item.enabled) : " ",
38
41
  glyph: badge.glyph ?? " ",
39
42
  glyphRole: badge.role,
40
- label: sanitizeLine(row.label),
43
+ label: nameCell,
41
44
  labelRole: selected ? "accent" : row.item && !row.item.enabled ? "faint" : "label",
42
45
  trailing: badge.text,
43
46
  // The glyph carries the status colour; the word stays quiet unless it is a
44
47
  // problem, so a clean row does not shout.
45
48
  trailingRole: badge.role === "bad" || badge.role === "warn" ? badge.role : "faint",
46
- // The verdict sits beside the name instead of at the far edge of a wide
47
- // terminal, where a two-word verdict would be unreadable.
48
49
  align: "inline",
49
50
  });
50
51
  }
51
-
52
- export interface SectionHintOptions {
53
- expanded: boolean;
54
- projectScope: boolean;
55
- /** The refresh key's state; omitted by a section with nothing to refresh. */
56
- refreshing?: boolean;
57
- }
58
-
59
- /** The section's key hints, in the order they drop as the width runs out. */
60
- export function sectionHints({ expanded, projectScope, refreshing }: SectionHintOptions): ScreenHint[] {
61
- return [
62
- "Esc close",
63
- "←/→",
64
- { text: "Space toggle", color: "accent" },
65
- { text: expanded ? "Enter collapse" : "Enter detail", color: "accent" },
66
- "Tab scope",
67
- // Only Project scope can hold an override, so the key is not offered elsewhere.
68
- ...(projectScope ? ["x inherit"] : []),
69
- ...(refreshing === undefined ? [] : [refreshing ? "refreshing…" : "r refresh"]),
70
- "↑/↓ select",
71
- ];
72
- }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The two-pane split: where the side pane starts, how wide each side is, and how
3
+ * one row of each is laid on one terminal line.
4
+ *
5
+ * Above the breakpoint the focused row's facts sit beside the list; below it the
6
+ * frame keeps the single column and the facts move under the list. The side lines
7
+ * are *composed onto* the list's lines rather than appended, so the two panes cost
8
+ * exactly the rows the list already spends — the frame's row budget is the same at
9
+ * every width.
10
+ *
11
+ * Value-only: pure functions, the theme passed in.
12
+ */
13
+
14
+ import type { Theme } from "@earendil-works/pi-coding-agent";
15
+ import { clip, padTo } from "../../../../lib/tui/columns.ts";
16
+ import { GLYPHS } from "../../../../lib/tui/glyphs.ts";
17
+ import { paint } from "../../../../lib/tui/tokens.ts";
18
+
19
+ /** The narrowest terminal that gets a side pane. */
20
+ export const WIDE_MIN = 96;
21
+
22
+ /** The side pane's width bounds, in columns. */
23
+ export const SIDE_MIN = 36;
24
+ export const SIDE_MAX = 56;
25
+
26
+ /** Columns between the list and the side pane, on each side of the divider. */
27
+ const GAP = 1;
28
+
29
+ /** Whether this width gets a side pane. */
30
+ export function isWide(width: number): boolean {
31
+ return width >= WIDE_MIN;
32
+ }
33
+
34
+ /**
35
+ * The side pane's width: two fifths of the terminal, clamped. Reading the list
36
+ * and its facts at the same height is the point, so the side never grows past
37
+ * what keeps a row and its verdict readable on the left.
38
+ */
39
+ export function sideWidth(width: number): number {
40
+ return Math.max(SIDE_MIN, Math.min(SIDE_MAX, Math.floor(width * 0.42)));
41
+ }
42
+
43
+ /** The columns the list keeps, divider and gaps included in the accounting. */
44
+ export function listWidth(width: number): number {
45
+ return Math.max(1, width - sideWidth(width) - GAP * 2 - 1);
46
+ }
47
+
48
+ /**
49
+ * Compose one line pair: the list row padded to its column, the divider, and the
50
+ * side row. A pair with no side content still draws the divider, so the pane keeps
51
+ * one edge down the body instead of appearing and disappearing row by row.
52
+ */
53
+ export function composeRow(theme: Theme, width: number, list: string, side: string): string {
54
+ const left = padTo(clip(list, listWidth(width)), listWidth(width));
55
+ const divider = paint(theme, "rule", GLYPHS.divider);
56
+ const room = Math.max(0, width - listWidth(width) - GAP * 2 - 1);
57
+ return clip(`${left}${" ".repeat(GAP)}${divider}${" ".repeat(GAP)}${clip(side, room)}`, width);
58
+ }
59
+
60
+ /** Compose a body of list rows with a side pane, one line pair per row. */
61
+ export function composeRows(theme: Theme, width: number, list: readonly string[], side: readonly string[]): string[] {
62
+ return list.map((line, index) => composeRow(theme, width, line, side[index] ?? ""));
63
+ }
64
+
65
+ /**
66
+ * Cut the side block to the rows it has, keeping the reader informed: the last row
67
+ * says how many lines are left out, which is the same convention the JSON pane uses.
68
+ */
69
+ export function clipRows(rows: readonly string[], budget: number, width: number): string[] {
70
+ const room = Math.max(0, Math.floor(budget));
71
+ if (rows.length <= room) return [...rows];
72
+ if (room === 0) return [];
73
+ const kept = rows.slice(0, room - 1);
74
+ const hidden = rows.length - kept.length;
75
+ return [...kept, clip(`… ${hidden} more line${hidden === 1 ? "" : "s"}`, width)];
76
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * What the sections show, derived from the session's sources: the counts on the
3
+ * tabs, the one body height they share, the attention in the header, the filter
4
+ * chips, and the merged model the rows are drawn from.
5
+ *
6
+ * One module because these are one question asked five ways — "what is on this
7
+ * screen, and what does it add up to" — and because the component should be left
8
+ * with the state it mutates and the keys it reads, not the arithmetic over it.
9
+ *
10
+ * Pure: the source is handed in, no host is touched, and nothing is cached.
11
+ */
12
+
13
+ import type { ExtensionReport } from "../../../../lib/requirements.ts";
14
+ import { filterChips, isFiltering, rowMatches, type PanelFilter } from "../filter.ts";
15
+ import { attentionSegments, panelCounts, type SummarySegment } from "../format.ts";
16
+ import { buildPanelModel, buildSkillModel, groupedEntryCount } from "../join.ts";
17
+ import type { ResourceItem, ResourceKind, WriteScope } from "../../picker/types.ts";
18
+ import type { SettingsSurface } from "../settings-runtime.ts";
19
+ import type { PanelModel, PanelRow, PanelSection, SkillInfo } from "../types.ts";
20
+ import { settingEntries } from "./entries.ts";
21
+
22
+ /** Everything the screen derives its content from. */
23
+ export interface ViewSource {
24
+ /** The write scope the settings rows are read in. */
25
+ scope: WriteScope;
26
+ /** The resolved resources of one kind. */
27
+ items(kind: ResourceKind): readonly ResourceItem[];
28
+ /** The current doctor reports. */
29
+ reports(): readonly ExtensionReport[];
30
+ /** The settings surface, for its rows. */
31
+ settings: SettingsSurface;
32
+ /** The agent directory the resource groups are labelled from. */
33
+ agentDir: string;
34
+ /** What the reader narrowed the rows to. */
35
+ filter: PanelFilter;
36
+ /** The extension the command narrowed to, if it named one. */
37
+ commandFilter?: string;
38
+ /** Something was changed this session. */
39
+ dirty: boolean;
40
+ }
41
+
42
+ /**
43
+ * The merged model for a list section. `filtered` off skips the reader's filter,
44
+ * which is what the header's attention counts need: narrowing the list must not
45
+ * hide a problem.
46
+ */
47
+ export function modelFor(
48
+ source: ViewSource,
49
+ kind: ResourceKind,
50
+ skills: () => ReadonlyMap<string, SkillInfo>,
51
+ filtered = true,
52
+ ): PanelModel {
53
+ const where = filtered && isFiltering(source.filter) ? (row: PanelRow) => rowMatches(row, source.filter) : undefined;
54
+ if (kind === "skills") {
55
+ return buildSkillModel({ items: source.items("skills"), index: skills(), agentDir: source.agentDir, where });
56
+ }
57
+ return buildPanelModel({
58
+ items: source.items("extensions"),
59
+ reports: source.reports(),
60
+ agentDir: source.agentDir,
61
+ filter: source.commandFilter,
62
+ where,
63
+ });
64
+ }
65
+
66
+ /**
67
+ * The section sizes the tabs print: the resources each section holds. They count
68
+ * what the section has, not what a filter left, so a tab does not shrink while
69
+ * another section is being narrowed.
70
+ */
71
+ export function sectionCounts(source: ViewSource): Record<PanelSection, number> {
72
+ return {
73
+ extensions: source.items("extensions").length,
74
+ skills: source.items("skills").length,
75
+ settings: source.settings.views(source.scope).length,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * The tallest section's entry count, group headings included. All three sections
81
+ * pad their body to it, which is what keeps the dock from resizing when the tabs
82
+ * change. Counted through `groupedEntryCount`, so measuring Skills never reads a
83
+ * `SKILL.md`.
84
+ */
85
+ export function tallestBody(source: ViewSource): number {
86
+ const lists = (["extensions", "skills"] as const).map((kind) =>
87
+ groupedEntryCount(source.items(kind), source.agentDir, kind),
88
+ );
89
+ const settings = settingEntries(source.settings.views(source.scope)).length;
90
+ return Math.max(1, ...lists, settings);
91
+ }
92
+
93
+ /** The header's attention, over every extension rather than the filter. */
94
+ export function attentionFor(source: ViewSource): SummarySegment[] {
95
+ return attentionSegments(panelCounts(modelFor(source, "extensions", () => new Map(), false)), source.dirty);
96
+ }
97
+
98
+ /** The chips the tab row shows: the filter editor while open, else what applies. */
99
+ export function chipsFor(
100
+ source: ViewSource,
101
+ section: PanelSection,
102
+ editing: { buffer: string } | undefined,
103
+ ): SummarySegment[] {
104
+ if (editing !== undefined) return [{ text: `/${editing.buffer}▏`, color: "accent" }];
105
+ const chips = filterChips(source.filter, section === "settings" ? "settings" : "rows");
106
+ // The command's own narrowing (`/bytefly plan`) is a filter the reader did not
107
+ // ask for this time, so the tab row says why the list is short.
108
+ if (source.commandFilter !== undefined && section === "extensions") {
109
+ chips.unshift({ text: `${source.commandFilter} only`, color: "meta" });
110
+ }
111
+ return chips;
112
+ }
113
+
114
+ /** The line a section shows in place of its body. */
115
+ export function emptyLabelFor(filter: PanelFilter, section: PanelSection): string {
116
+ if (isFiltering(filter)) return "Nothing matches the filter.";
117
+ if (section === "skills") return "No skills found.";
118
+ if (section === "settings") return "No settings found.";
119
+ return "No extensions found.";
120
+ }
121
+
122
+ /** The group heading a row sits under, for the block's `Source` fact. */
123
+ export function groupLabelFor(model: PanelModel | undefined, row: PanelRow | undefined): string | undefined {
124
+ if (model === undefined || row === undefined) return undefined;
125
+ return model.groups.find((group) => group.key === row.group)?.label;
126
+ }
@@ -1,35 +1,30 @@
1
1
  /**
2
- * The Settings section: its own state (the editor and the JSON pane), its key
3
- * handling, and the screen it draws.
2
+ * The Settings section's own state: the text editor, the JSON pane, and the
3
+ * writes. Everything it draws comes from `tui.ts` and `side-pane.ts`, because all
4
+ * three sections draw one frame.
4
5
  *
5
- * The component owns the section switch, the write scope, and the cursor. This
6
- * class owns what only Settings has, so `tui.ts` stays the switch and the shared
7
- * list keys, and the section reads and writes through the same surface.
6
+ * The component owns the section switch, the write scope, the cursor, the filter
7
+ * and the keys the three sections share; this class owns what only Settings has.
8
8
  */
9
9
 
10
- import type { Theme } from "@earendil-works/pi-coding-agent";
11
- import { Key, matchesKey } from "@earendil-works/pi-tui";
12
- import type { ViewportRowsSource } from "../../../../lib/tui/chrome.ts";
13
10
  import type { ListCursor } from "../../../../lib/tui/cursor.ts";
14
11
  import type { WriteScope } from "../../picker/types.ts";
12
+ import { settingMatches, type PanelFilter } from "../filter.ts";
15
13
  import type { SettingsSurface } from "../settings-runtime.ts";
16
- import { SETTING_ROWS } from "../settings.ts";
14
+ import { SETTING_ROWS, type SettingView } from "../settings.ts";
17
15
  import { applyEditKey, type EditingState } from "./editing.ts";
18
16
  import { settingEntries, type PanelEntry } from "./entries.ts";
19
- import { detailRows, groupLine, panelFrame, sectionBar } from "./frame.ts";
20
17
  import { clampSelection } from "./nav.ts";
21
- import { projectNote, sectionJsonPane, settingDetail, settingRow, settingsSummary } from "./settings-view.ts";
22
18
 
23
19
  /** What the Settings section needs from the component that hosts it. */
24
20
  export interface SettingsSectionDeps {
25
21
  /** The rows, values, and writes the section reads and calls. */
26
22
  surface: SettingsSurface;
27
- theme: Theme;
23
+ /** The write scope the component owns. */
28
24
  scope(): WriteScope;
25
+ /** The filter the component owns. */
26
+ filter(): PanelFilter;
29
27
  cursor: ListCursor;
30
- viewportRows: ViewportRowsSource | undefined;
31
- cwd: string;
32
- agentDir: string;
33
28
  /** Keep the focused row across a rebuild and return it. */
34
29
  retain(entries: PanelEntry[]): PanelEntry | undefined;
35
30
  /** Panel navigation, for the keys this section does not own. */
@@ -38,13 +33,9 @@ export interface SettingsSectionDeps {
38
33
  switchScope(): void;
39
34
  notify(message: string): void;
40
35
  requestRender(): void;
41
- /** Keep the frame's project-note marker in step for the mouse mapping. */
42
- setNoteShown(shown: boolean): void;
43
- /** The hint row, already assembled (or the notice that replaces it). */
44
- hint(width: number): string;
45
36
  }
46
37
 
47
- /** The Settings section's state and screen. */
38
+ /** The Settings section's state. */
48
39
  export class SettingsSection {
49
40
  private chain: Promise<void> = Promise.resolve();
50
41
  private editing: EditingState | undefined;
@@ -52,16 +43,21 @@ export class SettingsSection {
52
43
 
53
44
  constructor(private readonly deps: SettingsSectionDeps) {}
54
45
 
55
- /** Whether a text row is open in the editor, so the hint row and mouse defer. */
46
+ /** Whether a text row is open in the editor, so the hint row and keys defer. */
56
47
  get isEditing(): boolean {
57
48
  return this.editing !== undefined;
58
49
  }
59
50
 
60
- /** Whether the detail pane shows the focused row's section as JSON. */
51
+ /** Whether the focused row's section is shown as JSON. */
61
52
  get jsonOpen(): boolean {
62
53
  return this.json;
63
54
  }
64
55
 
56
+ /** The editor's buffer and caret, for the row that is being typed into. */
57
+ get editingState(): EditingState | undefined {
58
+ return this.editing;
59
+ }
60
+
65
61
  /** Run `task` after the previous write, then repaint once. */
66
62
  private write(task: () => Promise<unknown>, onSettled?: (result: unknown) => void): void {
67
63
  this.chain = this.chain
@@ -71,16 +67,30 @@ export class SettingsSection {
71
67
  .then(() => this.deps.requestRender());
72
68
  }
73
69
 
74
- /** The rows for the active scope, as display entries. */
75
- private entries(): PanelEntry[] {
76
- return settingEntries(this.deps.surface.views(this.deps.scope()));
70
+ /** The rows the active scope shows, after the filter. */
71
+ views(): SettingView[] {
72
+ const filter = this.deps.filter();
73
+ return this.deps.surface.views(this.deps.scope()).filter((view) => settingMatches(view, filter));
74
+ }
75
+
76
+ /** Those rows as display entries, grouped under their headings. */
77
+ entries(): PanelEntry[] {
78
+ return settingEntries(this.views());
77
79
  }
78
80
 
79
- /** The focused entry, after clamping the cursor onto a row. */
80
- private focused(): PanelEntry | undefined {
81
+ /** The focused setting, after clamping the cursor onto a row. */
82
+ focused(): SettingView | undefined {
81
83
  const entries = this.entries();
82
84
  clampSelection(entries, this.deps.cursor);
83
- return entries[this.deps.cursor.selected];
85
+ const entry = entries[this.deps.cursor.selected];
86
+ return entry?.kind === "setting" ? entry.setting : undefined;
87
+ }
88
+
89
+ /** The stored document behind the focused row, when the JSON pane is open. */
90
+ jsonFor(view: SettingView | undefined): { section: string; document: unknown } | undefined {
91
+ if (!this.json || view?.config === undefined) return undefined;
92
+ const { section } = view.config;
93
+ return { section, document: this.deps.surface.sectionDocument(this.deps.scope(), section) };
84
94
  }
85
95
 
86
96
  /** Drop the editor and the JSON pane when another section takes over. */
@@ -96,10 +106,9 @@ export class SettingsSection {
96
106
  }
97
107
 
98
108
  /** Change the focused setting: cycle it, or open the editor for a text row. */
99
- private changeSetting(): void {
100
- const entry = this.focused();
101
- if (entry?.kind !== "setting") return;
102
- const view = entry.setting;
109
+ changeSetting(): void {
110
+ const view = this.focused();
111
+ if (view === undefined) return;
103
112
  if (view.kind === "text") {
104
113
  this.editing = { id: view.id, buffer: view.value, caret: view.value.length };
105
114
  this.deps.requestRender();
@@ -110,22 +119,22 @@ export class SettingsSection {
110
119
  }
111
120
 
112
121
  /** Delete the focused setting's leaf, so its default applies again. */
113
- private resetSetting(): void {
114
- const entry = this.focused();
115
- if (entry?.kind !== "setting") return;
122
+ resetSetting(): void {
123
+ const view = this.focused();
124
+ if (view === undefined) return;
116
125
  const scope = this.deps.scope();
117
- this.write(() => this.deps.surface.reset(scope, entry.setting.id));
126
+ this.write(() => this.deps.surface.reset(scope, view.id));
118
127
  }
119
128
 
120
- /** Toggle the detail pane between the row and its whole section as stored. */
121
- private toggleJson(): void {
122
- const entry = this.focused();
123
- if (entry?.kind !== "setting") return;
124
- if (!entry.setting.config) {
129
+ /** Toggle the focused row between its value and the whole section as stored. */
130
+ toggleJson(): void {
131
+ const view = this.focused();
132
+ if (view === undefined) return;
133
+ if (!view.config) {
125
134
  // There is no section to show, so leave the mode off rather than keeping a
126
135
  // hidden toggle that would reopen the pane on another row.
127
136
  this.json = false;
128
- this.deps.notify(`${entry.setting.label} is a Pi setting; bytefly.json has no section for it.`);
137
+ this.deps.notify(`${view.label} is a Pi setting; bytefly.json has no section for it.`);
129
138
  return;
130
139
  }
131
140
  this.json = !this.json;
@@ -133,7 +142,7 @@ export class SettingsSection {
133
142
  }
134
143
 
135
144
  /** Store the editor's buffer; a refusal keeps it open with the reason shown. */
136
- private commitEdit(): void {
145
+ commitEdit(): void {
137
146
  const editing = this.editing;
138
147
  if (!editing) return;
139
148
  const scope = this.deps.scope();
@@ -149,13 +158,13 @@ export class SettingsSection {
149
158
  * Editor keys. `Esc`/`Ctrl+C` cancel the edit rather than closing the screen,
150
159
  * and everything else the section binds is inert until the editor closes.
151
160
  */
152
- private handleEditingInput(data: string): void {
161
+ handleEditingInput(data: string): void {
153
162
  const editing = this.editing;
154
163
  if (!editing) return;
155
164
  const max = SETTING_ROWS.find((row) => row.id === editing.id)?.text?.maxLength;
156
165
  const action = applyEditKey(data, editing, max);
157
166
  if (action.kind === "commit") {
158
- void this.commitEdit();
167
+ this.commitEdit();
159
168
  return;
160
169
  }
161
170
  if (action.kind === "cancel") {
@@ -167,84 +176,4 @@ export class SettingsSection {
167
176
  this.editing = action.state;
168
177
  this.deps.requestRender();
169
178
  }
170
-
171
- /** One key: editor keys first, then change, scope, reset, JSON, navigation. */
172
- handleInput(data: string): void {
173
- if (this.editing) {
174
- this.handleEditingInput(data);
175
- return;
176
- }
177
- this.focused();
178
- if (matchesKey(data, Key.space) || matchesKey(data, Key.enter)) {
179
- this.changeSetting();
180
- return;
181
- }
182
- if (matchesKey(data, Key.tab)) {
183
- this.deps.switchScope();
184
- return;
185
- }
186
- if (data === "u") {
187
- this.resetSetting();
188
- return;
189
- }
190
- if (data === "J") {
191
- this.toggleJson();
192
- return;
193
- }
194
- this.deps.navigate(data);
195
- }
196
-
197
- /** The Settings body: scope counts, the project note, rows, and detail. */
198
- render(width: number, header: string): string[] {
199
- const { theme, surface, viewportRows, cwd, agentDir } = this.deps;
200
- const scope = this.deps.scope();
201
- const views = surface.views(scope);
202
- const entries = settingEntries(views);
203
- const focusedEntry = this.deps.retain(entries);
204
- const focused = focusedEntry?.kind === "setting" ? focusedEntry.setting : undefined;
205
- // A row with no section leaves the mode off, so moving away and back cannot
206
- // reopen a pane the user closed.
207
- if (focused && !focused.config) this.json = false;
208
- const jsonSection = this.json ? focused?.config : undefined;
209
- const paths = { scope, cwd, agentDir };
210
- const detail = jsonSection
211
- ? sectionJsonPane({
212
- theme,
213
- width,
214
- section: jsonSection.section,
215
- document: surface.sectionDocument(scope, jsonSection.section),
216
- budget: detailRows(true, viewportRows),
217
- dirty: surface.isDirty(),
218
- paths,
219
- })
220
- : settingDetail({
221
- theme,
222
- width,
223
- view: focused,
224
- budget: detailRows(false, viewportRows),
225
- dirty: surface.isDirty(),
226
- paths,
227
- });
228
-
229
- const { lines, noteShown } = panelFrame({
230
- theme,
231
- viewportRows,
232
- cursor: this.deps.cursor,
233
- width,
234
- header,
235
- summary: sectionBar(theme, width, "settings", settingsSummary(theme, width, scope, views.length)),
236
- entries,
237
- note: scope === "project" ? projectNote(theme, width) : undefined,
238
- detail,
239
- hint: this.deps.hint(width),
240
- draw: (entry, index) => {
241
- if (entry.kind === "group") return groupLine(theme, width, entry);
242
- if (entry.kind !== "setting") return undefined;
243
- const editing = this.editing?.id === entry.setting.id ? this.editing : undefined;
244
- return settingRow(theme, width, entry.setting, index === this.deps.cursor.selected, editing);
245
- },
246
- });
247
- this.deps.setNoteShown(noteShown);
248
- return lines;
249
- }
250
179
  }