@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,13 +1,17 @@
1
1
  /**
2
- * The chrome around the panel's body: the layout constants, the section bar with
3
- * its counts, a group heading, the hint row that a transient notice replaces, and
4
- * the frame the two sections share.
2
+ * The chrome around the panel's body: the header rule with the screen's
3
+ * attention, the section tabs with their counts and the write scope, a group
4
+ * heading, the hint row that a transient notice replaces, and the frame all three
5
+ * sections share.
5
6
  *
6
- * Every section draws its body through these, so the two views cannot drift on
7
- * spacing, roles, or the width arithmetic.
7
+ * Every section draws its body through {@link panelFrame}, so the three cannot
8
+ * drift on spacing, roles, width arithmetic, or height: the body is padded to one
9
+ * target measured across all three sections, which is what keeps the dock from
10
+ * resizing when the tabs change.
8
11
  */
9
12
 
10
13
  import type { Theme } from "@earendil-works/pi-coding-agent";
14
+ import { visibleWidth } from "@earendil-works/pi-tui";
11
15
  import { sanitizeLine } from "../../../../lib/format.ts";
12
16
  import {
13
17
  detailBlockRows,
@@ -17,51 +21,86 @@ import {
17
21
  type ViewportRowsSource,
18
22
  } from "../../../../lib/tui/chrome.ts";
19
23
  import { clip, fitCells, splitRow } from "../../../../lib/tui/columns.ts";
20
- import { fitRows, formatRange, type ListCursor } from "../../../../lib/tui/cursor.ts";
24
+ import { formatRange, type ListCursor } from "../../../../lib/tui/cursor.ts";
25
+ import { SEPARATORS } from "../../../../lib/tui/glyphs.ts";
21
26
  import { BODY_INDENT } from "../../../../lib/tui/indent.ts";
22
27
  import { emptyState } from "../../../../lib/tui/rows.ts";
28
+ import { titleRule } from "../../../../lib/tui/rules.ts";
23
29
  import { paint } from "../../../../lib/tui/tokens.ts";
24
- import { sectionSegments, type SummarySegment } from "../format.ts";
30
+ import { sectionLabel, type SummarySegment } from "../format.ts";
25
31
  import type { PanelSection } from "../types.ts";
26
32
  import { selectableRange, type PanelEntry } from "./entries.ts";
33
+ import { composeRows } from "./panes.ts";
27
34
 
28
35
  /** Body rows when the terminal height is unknown. */
29
36
  export const SCREEN_DEFAULT_ROWS = 16;
30
- /** Fixed rows around the body: header, summary, blank, blank, hint, closing rule. */
37
+ /** Fixed rows around the body: header, tabs, blank, blank, hint, closing rule. */
31
38
  export const FRAME_ROWS = 6;
32
- /** Detail rows reserved for the focused row: its head, one description line, and its origin. */
39
+ /** Detail rows reserved for the focused row in the single-column layout. */
33
40
  export const DETAIL_ROWS = 3;
34
- /** Body rows that must survive before the detail pane collapses to its title. */
41
+ /** Body rows that must survive before the bottom pane collapses to nothing. */
35
42
  export const MIN_BODY_ROWS = 3;
36
43
  /** Detail rows when the terminal height is unknown and the focused row is expanded. */
37
44
  export const EXPANDED_DETAIL_FALLBACK = 8;
38
- /** Fixed body offset in the frame: header, summary, blank line. */
45
+ /** Fixed body offset in the frame: header, tabs, blank line. */
39
46
  export const BODY_START = 3;
40
47
 
41
- /** The body indent, shared by the note and the detail pane. */
48
+ /** The body indent, shared by the note and any focused-row block. */
42
49
  export const INDENT = " ".repeat(BODY_INDENT);
43
50
 
44
- /** The themed section bar: both section names, the active one accented. */
45
- export function sectionBar(theme: Theme, width: number, active: PanelSection, counts: string): string {
46
- const separator = paint(theme, "faint", " · ");
47
- const left = ` ${sectionSegments(active)
48
- .map((segment) => paint(theme, segment.color, segment.text))
49
- .join(separator)}`;
50
- return clip(splitRow(left, counts, width), width);
51
+ /**
52
+ * The header rule: the screen's name first, the attention it found at the right
53
+ * edge. The right side is dropped whole on a narrow terminal rather than clipped
54
+ * into half a word; the tabs below carry the same facts.
55
+ */
56
+ export function headerLine(theme: Theme, width: number, title: string, right: readonly SummarySegment[]): string {
57
+ const w = Math.max(1, width);
58
+ const separator = paint(theme, "faint", SEPARATORS.item);
59
+ const text = right.map((segment) => paint(theme, segment.color, segment.text)).join(separator);
60
+ const rightWidth = visibleWidth(text);
61
+ if (text === "" || rightWidth + title.length + 4 > w) return titleRule(theme, w, title);
62
+ return `${titleRule(theme, w - rightWidth - 1, title)} ${text}`;
63
+ }
64
+
65
+ /**
66
+ * The tab row: every section with how many rows it holds, then any filter chips,
67
+ * and the scope a change is written to at the right edge. The active tab takes the
68
+ * accent; the counts stay faint so the names read first.
69
+ */
70
+ export function tabsLine(
71
+ theme: Theme,
72
+ width: number,
73
+ active: PanelSection,
74
+ counts: Record<PanelSection, number>,
75
+ options: { chips?: readonly SummarySegment[]; scope?: string } = {},
76
+ ): string {
77
+ const separator = paint(theme, "faint", SEPARATORS.item);
78
+ const tabs = (["extensions", "skills", "settings"] as const).map(
79
+ (section) =>
80
+ paint(theme, section === active ? "accent" : "faint", sectionLabel(section)) +
81
+ paint(theme, "faint", ` ${counts[section]}`),
82
+ );
83
+ const chips = options.chips ?? [];
84
+ const left = [
85
+ ` ${tabs.join(separator)}`,
86
+ ...(chips.length > 0 ? [` ${chips.map((chip) => paint(theme, chip.color, chip.text)).join(separator)}`] : []),
87
+ ].join("");
88
+ const right = options.scope === undefined ? "" : paint(theme, "meta", `writes ${options.scope}`);
89
+ return clip(splitRow(left, right, width), width);
51
90
  }
52
91
 
53
92
  /** One section's counts as whole segments, so a narrow screen drops a count. */
54
93
  export function countsLine(theme: Theme, width: number, segments: readonly SummarySegment[]): string {
55
- const separator = paint(theme, "faint", " · ");
94
+ const separator = paint(theme, "faint", SEPARATORS.item);
56
95
  const styled = segments.map((segment) => paint(theme, segment.color, segment.text));
57
96
  return fitCells(styled, Math.max(1, width - 2), separator);
58
97
  }
59
98
 
60
- /** One group heading: its label and how many rows sit under it. */
99
+ /** One group heading: its label, and how many rows sit under it. */
61
100
  export function groupLine(theme: Theme, width: number, entry: { label: string; count: number }): string {
62
101
  const label = paint(theme, "meta", sanitizeLine(entry.label));
63
- const count = paint(theme, "faint", `(${entry.count})`);
64
- return clip(` ${label} ${count}`, width);
102
+ const count = entry.count > 0 ? paint(theme, "faint", ` (${entry.count})`) : "";
103
+ return clip(` ${label}${count}`, width);
65
104
  }
66
105
 
67
106
  /** The key-hint row, or a section's notice that replaces it. */
@@ -74,7 +113,7 @@ export function noticeLine(theme: Theme, width: number, notice: string): string
74
113
  return clip(`${INDENT}${paint(theme, "meta", sanitizeLine(notice))}`, width);
75
114
  }
76
115
 
77
- /** The saved-then-reload marker, shared by both detail panes. */
116
+ /** The saved-then-reload marker, shared by every focused-row block. */
78
117
  export function savedLine(theme: Theme, width: number, dirty: boolean): string[] {
79
118
  if (!dirty) return [];
80
119
  return [clip(`${INDENT}${paint(theme, "warn", "Saved · reload to apply")}`, width)];
@@ -87,50 +126,18 @@ export function totalRows(source: ViewportRowsSource | undefined): number | unde
87
126
  }
88
127
 
89
128
  /**
90
- * Detail rows the focused row gets: the fixed pane, or the free height when
91
- * expanded. Zero when there is no free row for a pane at all, so a short
92
- * terminal keeps its body; the caller then drops optional rows if needed.
129
+ * The rows the bottom pane gets in the single-column layout: the fixed pane, or
130
+ * the free height when expanded. Zero when there is no free row for a pane at all,
131
+ * so a short terminal keeps its body.
93
132
  */
94
133
  export function detailRows(expanded: boolean, viewportRows: ViewportRowsSource | undefined): number {
95
134
  const total = totalRows(viewportRows);
96
135
  if (total === undefined) return expanded ? EXPANDED_DETAIL_FALLBACK : DETAIL_ROWS;
97
- // One row pays for the blank line the frame puts above the pane.
98
136
  const available = total - FRAME_ROWS - MIN_BODY_ROWS - 1;
99
137
  if (available < 1) return 0;
100
138
  return expanded ? available : Math.min(DETAIL_ROWS, available);
101
139
  }
102
140
 
103
- /**
104
- * Drop optional rows until the frame fits the viewport: the range row first,
105
- * then the project note, then the blank above the detail pane, then the pane.
106
- * The header, summary, one body row, and the hint always survive; a terminal
107
- * shorter than those is left to the host.
108
- */
109
- export function fitOptionalLines(options: {
110
- viewportRows: ViewportRowsSource | undefined;
111
- body: string[];
112
- note: string | undefined;
113
- range: string | undefined;
114
- detail: string[];
115
- }): { body: string[]; range: string | undefined; detail: string[]; noteShown: boolean; detailBlank: boolean } {
116
- const total = totalRows(options.viewportRows);
117
- let { body, range, detail } = options;
118
- let noteShown = options.note !== undefined && body[0] === options.note;
119
- if (total === undefined) return { body, range, detail, noteShown, detailBlank: true };
120
- const fits = (blank: boolean): boolean =>
121
- FRAME_ROWS + body.length + (range === undefined ? 0 : 1) + detailBlockRows(detail, blank) <= total;
122
- if (!fits(true)) range = undefined;
123
- if (!fits(true) && noteShown) {
124
- body = body.slice(1);
125
- noteShown = false;
126
- }
127
- // The separator goes before the pane does: the focused row's findings are
128
- // worth more than the blank above them.
129
- const detailBlank = fits(true) || !fits(false);
130
- if (!fits(false)) detail = [];
131
- return { body, range, detail, noteShown, detailBlank };
132
- }
133
-
134
141
  /** What {@link panelFrame} draws: the window, the body, and the chrome around it. */
135
142
  export interface PanelFrameInput {
136
143
  theme: Theme;
@@ -138,59 +145,89 @@ export interface PanelFrameInput {
138
145
  cursor: ListCursor;
139
146
  width: number;
140
147
  header: string;
141
- summary: string;
148
+ tabs: string;
142
149
  entries: readonly PanelEntry[];
143
150
  note?: string;
151
+ /** The focused row's block, drawn under the list below the pane breakpoint. */
144
152
  detail: string[];
145
153
  hint: string;
146
154
  /** The line a section shows in place of its body when it has no entries. */
147
155
  emptyLabel?: string;
156
+ /**
157
+ * The tallest section's row count. The body is padded to it (bounded by the
158
+ * viewport), so every section draws the same height.
159
+ */
160
+ tallest: number;
161
+ /** The side pane's rows, in the wide layout only. */
162
+ side?: (rows: number) => string[];
148
163
  draw: (entry: PanelEntry, index: number) => string | undefined;
149
164
  }
150
165
 
166
+ /** The frame both sections share, and the state the caller has to remember. */
167
+ export interface PanelFrameResult {
168
+ lines: string[];
169
+ /** Whether the project note survived the row budget, for the click mapping. */
170
+ noteShown: boolean;
171
+ /** Body rows drawn, so a caller can map a click to a row. */
172
+ bodyRows: number;
173
+ }
174
+
151
175
  /**
152
- * The frame both sections share: the cursor window, the visible body rows, the
153
- * range row, the optional rows that give way on a short terminal, and the
154
- * chrome around them. Each section supplies its summary, its detail pane, its
155
- * hint row, and how one entry is drawn.
176
+ * The frame every section draws: the cursor window, the padded body, the range
177
+ * row when the list scrolls, the optional rows that give way on a short terminal,
178
+ * and the chrome around them.
179
+ *
180
+ * The height comes from one place. With a side pane the body is the whole free
181
+ * height, capped by the tallest section; without one the bottom pane is paid for
182
+ * first and the body gets what is left. Either way `target` is the same for all
183
+ * three sections at a given terminal size.
156
184
  */
157
- export function panelFrame(input: PanelFrameInput): { lines: string[]; noteShown: boolean } {
158
- const { theme, viewportRows, cursor, width, entries, note, detail, hint, draw } = input;
159
- cursor.visible = fitRows(
160
- viewportRows,
161
- entries.length,
162
- FRAME_ROWS + detailBlockRows(detail) + (note ? 1 : 0),
163
- SCREEN_DEFAULT_ROWS,
164
- );
185
+ export function panelFrame(input: PanelFrameInput): PanelFrameResult {
186
+ const { theme, viewportRows, cursor, width, entries, note, hint } = input;
187
+ const total = totalRows(viewportRows);
188
+ const wide = input.side !== undefined;
189
+
190
+ // Costs that a short terminal can shed, in the order they go: the bottom pane
191
+ // first (its facts move nowhere, but the list matters more), then the note.
192
+ let detail = wide ? [] : input.detail;
193
+ let noteShown = note !== undefined;
194
+ const budgetFor = (): number => {
195
+ if (total === undefined) return Math.max(MIN_BODY_ROWS, input.tallest);
196
+ return total - FRAME_ROWS - detailBlockRows(wide ? [] : detail) - (noteShown ? 1 : 0);
197
+ };
198
+ if (budgetFor() < MIN_BODY_ROWS && !wide) detail = [];
199
+ if (budgetFor() < MIN_BODY_ROWS && noteShown) noteShown = false;
200
+
201
+ const target = Math.max(1, Math.min(budgetFor(), Math.max(1, input.tallest)));
202
+ const capacity = Math.max(1, target - (noteShown && note !== undefined ? 1 : 0));
203
+ const scrolling = entries.length > capacity;
204
+ cursor.visible = Math.max(1, capacity - (scrolling ? 1 : 0));
165
205
  cursor.sync(entries.length);
166
206
  const { start, end } = cursor.window(entries.length);
167
207
 
168
208
  const body: string[] = [];
169
- if (note) body.push(note);
209
+ if (note !== undefined && noteShown) body.push(note);
170
210
  if (entries.length === 0 && input.emptyLabel !== undefined) {
171
211
  body.push(...emptyState(theme, width, { label: input.emptyLabel }));
172
212
  }
173
213
  for (let index = start; index < end; index += 1) {
174
- const line = draw(entries[index], index);
214
+ const line = input.draw(entries[index], index);
175
215
  if (line !== undefined) body.push(line);
176
216
  }
177
-
178
- const range = cursor.clipped(entries.length)
179
- ? formatRange(theme, width, selectableRange(entries, { start, end }))
180
- : undefined;
181
- const fitted = fitOptionalLines({ viewportRows, body, note, range, detail });
182
- return {
183
- lines: dockListFrame({
184
- theme,
185
- width,
186
- header: input.header,
187
- summary: input.summary,
188
- body: fitted.body,
189
- range: fitted.range,
190
- detail: fitted.detail.length > 0 ? fitted.detail : undefined,
191
- detailBlank: fitted.detailBlank,
192
- hint,
193
- }),
194
- noteShown: fitted.noteShown,
195
- };
217
+ if (scrolling) {
218
+ body.push(formatRange(theme, width, selectableRange(entries, { start, end })));
219
+ }
220
+ while (body.length < target) body.push("");
221
+
222
+ const side = input.side === undefined ? undefined : input.side(target);
223
+ const lines = dockListFrame({
224
+ theme,
225
+ width,
226
+ header: input.header,
227
+ summary: input.tabs,
228
+ body: side === undefined ? body : composeRows(theme, width, body, side),
229
+ detail: detail.length > 0 ? detail : undefined,
230
+ hint,
231
+ });
232
+ return { lines, noteShown, bodyRows: target };
196
233
  }
@@ -0,0 +1,352 @@
1
+ /**
2
+ * The control center's key table: the binding, the hint row it appears in, and
3
+ * the help block `?` prints.
4
+ *
5
+ * One table, so a help line cannot drift from a binding: the component dispatches
6
+ * through {@link keyFor}, the hint row is {@link hintsFor}, and the help block is
7
+ * {@link helpRows}. Movement is not here — `lib/tui/cursor.ts` owns the arrows,
8
+ * `j`/`k`, PgUp/PgDn and Home/End for every screen in the package — so the help
9
+ * block names movement in its own row and the table stays the actions this screen
10
+ * alone defines.
11
+ *
12
+ * Value-only: pure functions, the theme passed in.
13
+ */
14
+
15
+ import type { Theme } from "@earendil-works/pi-coding-agent";
16
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
17
+ import type { ScreenHint } from "../../../../lib/tui/chrome.ts";
18
+ import { clip, padTo } from "../../../../lib/tui/columns.ts";
19
+ import { isCloseKey } from "../../../../lib/tui/cursor.ts";
20
+ import { BODY_INDENT } from "../../../../lib/tui/indent.ts";
21
+ import { paint } from "../../../../lib/tui/tokens.ts";
22
+ import type { PanelSection } from "../types.ts";
23
+ import { hintLine, noticeLine } from "./frame.ts";
24
+ import { editHints } from "./settings-view.ts";
25
+
26
+ /** One action the screen binds. */
27
+ export type PanelKeyId =
28
+ | "close"
29
+ | "previousSection"
30
+ | "nextSection"
31
+ | "toggle"
32
+ | "change"
33
+ | "detail"
34
+ | "scope"
35
+ | "filter"
36
+ | "attention"
37
+ | "refresh"
38
+ | "reload"
39
+ | "reset"
40
+ | "json"
41
+ | "inherit"
42
+ | "help";
43
+
44
+ /** One binding, its hint label, and what the help block says about it. */
45
+ export interface PanelKey {
46
+ id: PanelKeyId;
47
+ /** The keys as the hint row and the help block print them. */
48
+ label: string;
49
+ /** The sections that offer it. A key absent from a section is not hinted there. */
50
+ sections: readonly PanelSection[];
51
+ /** One plain sentence for the help block. */
52
+ help: string;
53
+ /** Whether `data` is this key. */
54
+ match(data: string): boolean;
55
+ }
56
+
57
+ /** Every section, and the two resource lists that share a set of keys. */
58
+ const ALL: readonly PanelSection[] = ["extensions", "skills", "settings"];
59
+ const LISTS: readonly PanelSection[] = ["extensions", "skills"];
60
+
61
+ /** Shift+Tab arrives as this sequence on terminals without the Kitty protocol. */
62
+ const SHIFT_TAB = "\u001b[Z";
63
+
64
+ /** The screen's keys, in help order. */
65
+ export const PANEL_KEYS: readonly PanelKey[] = [
66
+ {
67
+ id: "close",
68
+ label: "Esc",
69
+ sections: ALL,
70
+ help: "Close the screen",
71
+ match: (data) => isCloseKey(data),
72
+ },
73
+ {
74
+ id: "previousSection",
75
+ label: "←",
76
+ sections: ALL,
77
+ help: "Previous section",
78
+ match: (data) =>
79
+ matchesKey(data, Key.left) || data === "h" || matchesKey(data, Key.shift("tab")) || data === SHIFT_TAB,
80
+ },
81
+ {
82
+ id: "nextSection",
83
+ label: "→",
84
+ sections: ALL,
85
+ help: "Next section",
86
+ match: (data) => matchesKey(data, Key.right) || data === "l" || matchesKey(data, Key.tab),
87
+ },
88
+ {
89
+ id: "toggle",
90
+ label: "Space",
91
+ sections: LISTS,
92
+ help: "Enable or disable, and re-probe when it changes",
93
+ match: (data) => matchesKey(data, Key.space),
94
+ },
95
+ {
96
+ id: "detail",
97
+ label: "Enter",
98
+ sections: LISTS,
99
+ help: "Show or hide the focused row's findings",
100
+ match: (data) => matchesKey(data, Key.enter),
101
+ },
102
+ {
103
+ id: "change",
104
+ label: "Space",
105
+ sections: ["settings"],
106
+ help: "Change the focused value",
107
+ match: (data) => matchesKey(data, Key.space) || matchesKey(data, Key.enter),
108
+ },
109
+ {
110
+ id: "reset",
111
+ label: "u",
112
+ sections: ["settings"],
113
+ help: "Reset the focused value to its default",
114
+ match: (data) => data === "u",
115
+ },
116
+ {
117
+ id: "json",
118
+ label: "J",
119
+ sections: ["settings"],
120
+ help: "Show the whole `bytefly.json` section the value lives in",
121
+ match: (data) => data === "J",
122
+ },
123
+ {
124
+ id: "scope",
125
+ label: "s",
126
+ sections: ALL,
127
+ help: "Switch where a change is written: Global or Project",
128
+ match: (data) => data === "s",
129
+ },
130
+ {
131
+ id: "filter",
132
+ label: "/",
133
+ sections: ALL,
134
+ help: "Filter the rows by text; Esc clears it",
135
+ match: (data) => matchesKey(data, Key.slash),
136
+ },
137
+ {
138
+ id: "attention",
139
+ label: "!",
140
+ sections: ALL,
141
+ help: "Show only the rows that need attention",
142
+ match: (data) => matchesKey(data, Key.exclamation),
143
+ },
144
+ {
145
+ id: "refresh",
146
+ label: "r",
147
+ sections: ["extensions"],
148
+ help: "Re-probe every extension's requirements",
149
+ match: (data) => data === "r",
150
+ },
151
+ {
152
+ id: "inherit",
153
+ label: "x",
154
+ sections: LISTS,
155
+ help: "Drop the Project override, so Global applies again",
156
+ match: (data) => data === "x",
157
+ },
158
+ {
159
+ id: "reload",
160
+ label: "R",
161
+ sections: ALL,
162
+ help: "Reload now, to apply what you changed",
163
+ match: (data) => data === "R",
164
+ },
165
+ {
166
+ id: "help",
167
+ label: "?",
168
+ sections: ALL,
169
+ help: "Show every key, in place of the focused row's facts",
170
+ match: (data) => matchesKey(data, Key.question),
171
+ },
172
+ ];
173
+
174
+ /** The binding for `id`; a table with no row would be a bug, not a key state. */
175
+ export function keyOf(id: PanelKeyId): PanelKey {
176
+ const key = PANEL_KEYS.find((entry) => entry.id === id);
177
+ if (key === undefined) throw new Error(`No panel key "${id}"`);
178
+ return key;
179
+ }
180
+
181
+ /** The action `data` asks for in `section`, or `undefined` for anything else. */
182
+ export function keyFor(data: string, section: PanelSection): PanelKeyId | undefined {
183
+ for (const key of PANEL_KEYS) {
184
+ if (key.sections.includes(section) && key.match(data)) return key.id;
185
+ }
186
+ return undefined;
187
+ }
188
+
189
+ /** What the hint row needs to know about the screen's state. */
190
+ export interface HintState {
191
+ /** The focused row's findings are expanded (lists only). */
192
+ expanded: boolean;
193
+ /** A probe is in flight, so the refresh key is not offered. */
194
+ refreshing: boolean;
195
+ /** Project scope can hold an override, Global cannot (lists only). */
196
+ projectScope: boolean;
197
+ /** Something was changed this session, so the reload key applies. */
198
+ dirty: boolean;
199
+ /** The help block is open. */
200
+ help: boolean;
201
+ }
202
+
203
+ /** One pale, dim hint, or an accented primary one. */
204
+ function hint(text: string, primary = false): ScreenHint {
205
+ return primary ? { text, color: "accent" } : text;
206
+ }
207
+
208
+ /**
209
+ * The hint row for a section, most useful first: whole hints drop from the right
210
+ * as the width runs out, so the close and section keys survive on a narrow
211
+ * terminal and `?` is the one that leaves first.
212
+ */
213
+ export function hintsFor(section: PanelSection, state: HintState): ScreenHint[] {
214
+ const hints: ScreenHint[] = [
215
+ hint("Esc close"),
216
+ hint("←/→ section"),
217
+ section === "settings" ? hint("Space change", true) : hint("Space toggle", true),
218
+ ...(section === "settings" ? [] : [hint(state.expanded ? "Enter collapse" : "Enter detail", true)]),
219
+ hint("/ filter"),
220
+ ];
221
+ if (section === "settings") {
222
+ hints.push(hint("u reset"), hint(state.help ? "? hide" : "? keys"));
223
+ return hints;
224
+ }
225
+ hints.push(hint(state.help ? "? hide" : "? keys"));
226
+ if (section === "extensions") hints.push(hint(state.refreshing ? "refreshing…" : "r refresh"));
227
+ if (state.dirty) hints.push(hint("R reload", true));
228
+ hints.push(hint("s scope"));
229
+ if (state.projectScope) hints.push(hint("x inherit"));
230
+ hints.push(hint("↑/↓ select"));
231
+ return hints;
232
+ }
233
+
234
+ /**
235
+ * The hint row a section shows: an editor's own keys while one is open, else the
236
+ * transient notice, else the section's hints. The three cases are exclusive, so a
237
+ * notice never shares the row with a hint that could not work.
238
+ */
239
+ export function hintRow(options: {
240
+ theme: Theme;
241
+ width: number;
242
+ section: PanelSection;
243
+ state: HintState;
244
+ /** A settings row or the filter is being typed into. */
245
+ editing: boolean;
246
+ /** The last refusal or failure, shown until the next key. */
247
+ notice?: string;
248
+ }): string {
249
+ const { theme, width, section, state, editing, notice } = options;
250
+ if (editing) return hintLine(theme, width, editHints());
251
+ if (notice !== undefined) return noticeLine(theme, width, notice);
252
+ return hintLine(theme, width, hintsFor(section, state));
253
+ }
254
+
255
+ /**
256
+ * One help row: the keys it names, and what they do.
257
+ *
258
+ * A row naming one binding takes that binding's own sentence, so the two cannot
259
+ * disagree; a row that explains a verb for this section — `!` narrows settings to
260
+ * what changed, not to what is broken — states its own.
261
+ */
262
+ interface HelpRow {
263
+ /** Table bindings this row documents; empty for a row over shared movement keys. */
264
+ ids: readonly PanelKeyId[];
265
+ /** The keys as printed; joined from the bindings unless the row sets its own. */
266
+ label?: string;
267
+ /** What the keys do here; the single binding's own sentence when omitted. */
268
+ help?: string;
269
+ }
270
+
271
+ /** Rows over the movement keys, which `lib/tui/cursor.ts` owns for every screen. */
272
+ const MOVEMENT: readonly HelpRow[] = [
273
+ { ids: [], label: "↑/↓ j/k", help: "Move to the next or previous row" },
274
+ { ids: [], label: "g G", help: "Jump to the first or last row" },
275
+ { ids: [], label: "[ ]", help: "Jump to the next or previous group" },
276
+ ];
277
+
278
+ /** The help block per section: the table's own rows, plus movement and the close pair. */
279
+ const HELP: Record<PanelSection, readonly HelpRow[]> = {
280
+ extensions: [
281
+ { ids: ["close", "previousSection", "nextSection"], help: "Close, or change section" },
282
+ ...MOVEMENT,
283
+ { ids: ["toggle"], help: "Enable or disable the focused extension" },
284
+ { ids: ["detail"], help: "Show or hide its findings" },
285
+ { ids: ["attention"], help: "Show only the rows that need attention" },
286
+ { ids: ["filter"], help: "Filter the rows by text; Esc clears it" },
287
+ { ids: ["scope"], help: "Switch where a change is written" },
288
+ { ids: ["refresh"], help: "Re-probe every extension's requirements" },
289
+ { ids: ["inherit"], help: "Drop the Project override" },
290
+ { ids: ["reload"], help: "Reload to apply what you changed" },
291
+ { ids: ["help"], help: "Show or hide this key list" },
292
+ ],
293
+ skills: [
294
+ { ids: ["close", "previousSection", "nextSection"], help: "Close, or change section" },
295
+ ...MOVEMENT,
296
+ { ids: ["toggle"], help: "Enable or disable the focused skill" },
297
+ { ids: ["detail"], help: "Show or hide what its `SKILL.md` declares" },
298
+ { ids: ["attention"], help: "Show only the rows that need attention" },
299
+ { ids: ["filter"], help: "Filter the rows by text; Esc clears it" },
300
+ { ids: ["scope"], help: "Switch where a change is written" },
301
+ { ids: ["inherit"], help: "Drop the Project override" },
302
+ { ids: ["reload"], help: "Reload to apply what you changed" },
303
+ { ids: ["help"], help: "Show or hide this key list" },
304
+ ],
305
+ settings: [
306
+ { ids: ["close", "previousSection", "nextSection"], help: "Close, or change section" },
307
+ ...MOVEMENT,
308
+ { ids: ["change"], help: "Change the focused value" },
309
+ { ids: ["reset"], help: "Reset it to its default" },
310
+ { ids: ["json"], help: "Show the section it lives in" },
311
+ { ids: ["attention"], help: "Show only the values you changed" },
312
+ { ids: ["filter"], help: "Filter the rows by text; Esc clears it" },
313
+ { ids: ["scope"], help: "Switch where a change is written" },
314
+ { ids: ["reload"], help: "Reload to apply what you changed" },
315
+ { ids: ["help"], help: "Show or hide this key list" },
316
+ ],
317
+ };
318
+
319
+ /** The bindings a section's help block documents, for the drift test. */
320
+ export function helpKeyIds(section: PanelSection): PanelKeyId[] {
321
+ return HELP[section].flatMap((row) => [...row.ids]);
322
+ }
323
+
324
+ /** The keys a help row prints, from the bindings it names. */
325
+ function helpLabel(row: HelpRow): string {
326
+ if (row.label !== undefined) return row.label;
327
+ return row.ids.map((id) => keyOf(id).label).join(" ");
328
+ }
329
+
330
+ /** What a help row says: its own sentence, or the single binding's. */
331
+ function helpText(row: HelpRow): string {
332
+ if (row.help !== undefined) return row.help;
333
+ return row.ids.map((id) => keyOf(id).help).join("; ");
334
+ }
335
+
336
+ /**
337
+ * The help block for a section: one `keys action` line per row, the key column
338
+ * padded to the widest label so the sentences line up. Every binding the section
339
+ * offers has a row, and every row names a binding the section offers —
340
+ * `keys.test.ts` holds both directions.
341
+ */
342
+ export function helpRows(theme: Theme, width: number, section: PanelSection): string[] {
343
+ const rows = HELP[section].map((row) => ({ label: helpLabel(row), help: helpText(row) }));
344
+ const column = Math.max(...rows.map((row) => row.label.length)) + 2;
345
+ const indent = " ".repeat(BODY_INDENT);
346
+ return rows.map((row) =>
347
+ clip(
348
+ `${indent}${paint(theme, "faint", padTo(row.label, column))}${paint(theme, "meta", row.help)}`,
349
+ Math.max(1, width),
350
+ ),
351
+ );
352
+ }