@gavin-hu/my-pi-agent 0.11.4 → 0.12.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 (81) hide show
  1. package/README.md +13 -8
  2. package/README.zh-CN.md +5 -3
  3. package/assets/chrome.png +0 -0
  4. package/assets/plan-review.png +0 -0
  5. package/extensions/bytefly/README.md +291 -207
  6. package/extensions/bytefly/doctor/runtime.ts +16 -0
  7. package/extensions/bytefly/doctor/tools.ts +1 -3
  8. package/extensions/bytefly/footer/price/config.ts +54 -15
  9. package/extensions/bytefly/footer/price/price.ts +20 -7
  10. package/extensions/bytefly/footer/price/types.ts +13 -7
  11. package/extensions/bytefly/footer/render/format.ts +3 -2
  12. package/extensions/bytefly/footer/render/segments.ts +3 -2
  13. package/extensions/bytefly/footer/types.ts +1 -0
  14. package/extensions/bytefly/header/component.ts +21 -6
  15. package/extensions/bytefly/header/config.ts +3 -0
  16. package/extensions/bytefly/header/register.ts +34 -3
  17. package/extensions/bytefly/index.ts +13 -3
  18. package/extensions/bytefly/panel/settings-runtime.ts +71 -28
  19. package/extensions/bytefly/panel/settings.ts +201 -38
  20. package/extensions/bytefly/panel/tui/entries.ts +58 -0
  21. package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
  22. package/extensions/bytefly/panel/tui/frame.ts +71 -0
  23. package/extensions/bytefly/panel/tui/nav.ts +75 -0
  24. package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
  25. package/extensions/bytefly/panel/tui.ts +205 -319
  26. package/extensions/bytefly/picker/runtime.ts +61 -2
  27. package/extensions/bytefly/picker/toggle.ts +56 -0
  28. package/extensions/bytefly/update/check.ts +59 -0
  29. package/extensions/bytefly/update/format.ts +32 -0
  30. package/extensions/bytefly/update/register.ts +72 -0
  31. package/extensions/bytefly/update/state.ts +28 -0
  32. package/extensions/bytefly/update/version.ts +83 -0
  33. package/extensions/document/README.md +2 -2
  34. package/extensions/document/tool.ts +1 -3
  35. package/extensions/file-open/README.md +30 -15
  36. package/extensions/file-open/index.ts +4 -3
  37. package/extensions/file-open/open.ts +52 -21
  38. package/extensions/goal/README.md +39 -13
  39. package/extensions/goal/clarify-prompt.ts +5 -36
  40. package/extensions/goal/clarify.ts +55 -86
  41. package/extensions/goal/commands.ts +41 -35
  42. package/extensions/goal/format.ts +12 -4
  43. package/extensions/goal/tools.ts +28 -8
  44. package/extensions/goal/tui.ts +1 -1
  45. package/extensions/memory/README.md +1 -1
  46. package/extensions/plan/README.md +24 -8
  47. package/extensions/plan/blocks.ts +1 -35
  48. package/extensions/plan/commands.ts +18 -12
  49. package/extensions/plan/plans.ts +1 -1
  50. package/extensions/plan/policy.ts +4 -3
  51. package/extensions/plan/review.ts +243 -0
  52. package/extensions/plan/runtime.ts +4 -0
  53. package/extensions/plan/steps.ts +34 -23
  54. package/extensions/plan/todos.ts +5 -58
  55. package/extensions/plan/tools.ts +66 -222
  56. package/extensions/plan/tui.ts +17 -9
  57. package/extensions/plan/types.ts +3 -0
  58. package/extensions/plan/web-client.ts +51 -18
  59. package/extensions/plan/web-page.ts +5 -0
  60. package/extensions/plan/web-router.ts +11 -3
  61. package/extensions/plan/web.ts +100 -36
  62. package/extensions/question/README.md +1 -1
  63. package/extensions/question/tools.ts +1 -1
  64. package/extensions/subagent/index.ts +0 -2
  65. package/extensions/todo/README.md +19 -11
  66. package/extensions/todo/index.ts +5 -3
  67. package/extensions/todo/nudge.ts +58 -9
  68. package/extensions/todo/runtime.ts +28 -17
  69. package/extensions/web-access/README.md +3 -3
  70. package/extensions/web-access/fetch/tool.ts +1 -3
  71. package/extensions/web-access/search/tool.ts +1 -3
  72. package/extensions/wechat/README.md +5 -4
  73. package/extensions/wechat/tools.ts +0 -2
  74. package/lib/README.md +2 -1
  75. package/lib/config.ts +43 -1
  76. package/lib/env.ts +12 -0
  77. package/lib/requirements.ts +76 -2
  78. package/lib/todo-state.ts +62 -0
  79. package/lib/tui/glyphs.ts +1 -0
  80. package/package.json +1 -1
  81. package/assets/bytefly.schema.json +0 -785
@@ -3,107 +3,51 @@
3
3
  *
4
4
  * Extensions lists every resolved extension with its enable checkbox and its
5
5
  * health; Settings lists the curated preferences with their values. The
6
- * component is derived: it rebuilds the model from the picker runtime, the
7
- * reports, or the settings surface on every render, keeps the focused row's key
8
- * across rebuilds, and requests a repaint only after an input changes state.
9
- * The screen is drawn from the design system — a label-first `titleRule`, one
10
- * `listRow` grammar, one `checkbox` — and its scroll math from
11
- * `lib/tui/cursor.ts`. A row without a report renders no glyph and no summary,
12
- * never a false `ok`.
6
+ * component owns the state — the cursors, the focused row key per section, the
7
+ * expanded set, the pending write chain, and the active section — and the two
8
+ * section modules own how their rows and detail panes are drawn. It is derived:
9
+ * it rebuilds the model from the picker runtime, the reports, or the settings
10
+ * surface on every render, keeps the focused row's key across rebuilds, and
11
+ * requests a repaint only after an input changes state. The screen is drawn from
12
+ * the design system — a label-first `titleRule`, one `listRow` grammar, one
13
+ * `checkbox` — and its scroll math from `lib/tui/cursor.ts`.
13
14
  */
14
15
 
15
- import { join } from "node:path";
16
16
  import type { Theme } from "@earendil-works/pi-coding-agent";
17
- import {
18
- Key,
19
- matchesKey,
20
- type Component,
21
- type TuiMouseEvent,
22
- type TuiMouseEventResult,
23
- wrapTextWithAnsi,
24
- } from "@earendil-works/pi-tui";
25
- import { sanitizeLine } from "../../../lib/format.ts";
26
- import { dockListFrame, screenHint, type ViewportRowsSource } from "../../../lib/tui/chrome.ts";
27
- import { clip, fitCells, splitRow } from "../../../lib/tui/columns.ts";
17
+ import { Key, matchesKey, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
18
+ import type { ViewportRowsSource } from "../../../lib/tui/chrome.ts";
19
+ import { dockListFrame } from "../../../lib/tui/chrome.ts";
28
20
  import { fitRows, formatRange, isCloseKey, ListCursor, navIntent, wheelDelta } from "../../../lib/tui/cursor.ts";
29
- import { BODY_INDENT } from "../../../lib/tui/indent.ts";
30
- import { checkbox, emptyState, listRow } from "../../../lib/tui/rows.ts";
21
+ import { emptyState } from "../../../lib/tui/rows.ts";
31
22
  import { titleRule } from "../../../lib/tui/rules.ts";
32
- import { paint } from "../../../lib/tui/tokens.ts";
33
23
  import type { ExtensionReport } from "../../../lib/requirements.ts";
34
- import { detailLines as reportDetailLines } from "../doctor/format.ts";
35
24
  import type { ExtensionsPickerRuntime } from "../picker/runtime.ts";
36
- import { rowBadge, sectionSegments, settingsSummarySegments, summarySegments, type SummarySegment } from "./format.ts";
37
25
  import { buildPanelModel } from "./join.ts";
38
- import { groupViews, type SettingView } from "./settings.ts";
39
- import { configPath, type SettingsSurface } from "./settings-runtime.ts";
40
- import type { PanelModel, PanelRow, PanelSection } from "./types.ts";
41
-
42
- /** Body rows when the terminal height is unknown. */
43
- const SCREEN_DEFAULT_ROWS = 16;
44
- /** Fixed rows around the body: header, summary, blank, blank, hint, blank. */
45
- const FRAME_ROWS = 6;
46
- /** Detail rows reserved for the focused row. */
47
- const DETAIL_ROWS = 4;
48
- /** Body rows that must survive before the detail pane collapses to its title. */
49
- const MIN_BODY_ROWS = 3;
50
- /** Detail rows when the terminal height is unknown and the focused row is expanded. */
51
- const EXPANDED_DETAIL_FALLBACK = 8;
52
- /** Pi's personal settings file, named in the detail pane. */
53
- const PI_SETTINGS_FILE = "settings.json";
54
- /** Fixed body offset in the frame: header, summary, blank line. */
55
- const BODY_START = 3;
56
-
57
- /** The body indent, shared by the note and the detail pane. */
58
- const INDENT = " ".repeat(BODY_INDENT);
59
-
60
- /** A flat display entry: a group header or a selectable row. */
61
- type PanelEntry =
62
- | { kind: "group"; key: string; label: string; count: number }
63
- | { kind: "row"; row: PanelRow }
64
- | { kind: "setting"; setting: SettingView };
65
-
66
- /** A selectable entry's stable key, or `undefined` for a group header. */
67
- function entryKey(entry: PanelEntry | undefined): string | undefined {
68
- if (entry?.kind === "row") return entry.row.key;
69
- if (entry?.kind === "setting") return entry.setting.id;
70
- return undefined;
71
- }
72
-
73
- /** Whether an entry can take the focus. */
74
- function isSelectable(entry: PanelEntry | undefined): boolean {
75
- return entryKey(entry) !== undefined;
76
- }
77
-
78
- /** The next selectable entry from `from` in `direction`, or `undefined`. */
79
- function nextRow(entries: PanelEntry[], from: number, direction: -1 | 1): number | undefined {
80
- for (let index = from + direction; index >= 0 && index < entries.length; index += direction) {
81
- if (isSelectable(entries[index])) return index;
82
- }
83
- return undefined;
84
- }
85
-
86
- /** Interleave the extension model's groups and rows into one display list. */
87
- function entriesOf(model: PanelModel): PanelEntry[] {
88
- const entries: PanelEntry[] = [];
89
- for (const group of model.groups) {
90
- entries.push({ kind: "group", key: group.key, label: group.label, count: group.count });
91
- for (const row of model.rows) {
92
- if (row.group === group.key) entries.push({ kind: "row", row });
93
- }
94
- }
95
- return entries;
96
- }
97
-
98
- /** Interleave the settings views into the same group/row display list. */
99
- function settingEntries(views: readonly SettingView[]): PanelEntry[] {
100
- const entries: PanelEntry[] = [];
101
- for (const group of groupViews(views)) {
102
- entries.push({ kind: "group", key: group.key, label: group.label, count: group.views.length });
103
- for (const view of group.views) entries.push({ kind: "setting", setting: view });
104
- }
105
- return entries;
106
- }
26
+ import type { SettingsSurface } from "./settings-runtime.ts";
27
+ import type { PanelModel, PanelSection } from "./types.ts";
28
+ import { entriesOf, entryKey, isSelectable, settingEntries, type PanelEntry } from "./tui/entries.ts";
29
+ import { clampSelection, firstRow, lastRow, moveRows, retainFocus } from "./tui/nav.ts";
30
+ import { extensionDetail, extensionHints, extensionRow, extensionSummary } from "./tui/extensions-view.ts";
31
+ import {
32
+ BODY_START,
33
+ DETAIL_ROWS,
34
+ EXPANDED_DETAIL_FALLBACK,
35
+ FRAME_ROWS,
36
+ groupLine,
37
+ hintLine,
38
+ MIN_BODY_ROWS,
39
+ noticeLine,
40
+ SCREEN_DEFAULT_ROWS,
41
+ sectionBar,
42
+ } from "./tui/frame.ts";
43
+ import {
44
+ editHints,
45
+ projectNote,
46
+ settingDetail,
47
+ settingRow,
48
+ settingsHints,
49
+ settingsSummary,
50
+ } from "./tui/settings-view.ts";
107
51
 
108
52
  /** Terminal rows, or `undefined` when the height is unknown. */
109
53
  function totalRows(source: ViewportRowsSource | undefined): number | undefined {
@@ -157,6 +101,8 @@ export class PanelComponent implements Component {
157
101
  private probed = false;
158
102
  /** Transient feedback (a refusal or a failed write), shown in the hint row. */
159
103
  private notice: string | undefined;
104
+ /** The text row being edited: its id, the buffer, and the caret offset. */
105
+ private editing: { id: string; buffer: string; caret: number } | undefined;
160
106
  /** The visible section. */
161
107
  private section: PanelSection;
162
108
 
@@ -176,6 +122,11 @@ export class PanelComponent implements Component {
176
122
  return this.options.theme;
177
123
  }
178
124
 
125
+ /** Whether anything has been written this session, either section. */
126
+ private get dirty(): boolean {
127
+ return this.options.runtime.isDirty() || this.options.settings.isDirty();
128
+ }
129
+
179
130
  invalidate(): void {
180
131
  // Everything is rebuilt from live state on each render.
181
132
  }
@@ -199,52 +150,16 @@ export class PanelComponent implements Component {
199
150
 
200
151
  /** Keep the focus on the same row key when the list shape changes. */
201
152
  private retainFocus(entries: PanelEntry[]): void {
202
- const key = entryKey(entries[this.cursor.selected]);
203
- if (key !== undefined) {
204
- this.focusedKeys[this.section] = key;
205
- return;
206
- }
207
- const focusedKey = this.focusedKeys[this.section];
208
- if (focusedKey === undefined) return;
209
- const index = entries.findIndex((candidate) => entryKey(candidate) === focusedKey);
210
- if (index >= 0) this.cursor.selected = index;
153
+ const before = entryKey(entries[this.cursor.selected]);
154
+ retainFocus(entries, this.cursor, this.focusedKeys[this.section]);
155
+ const after = entryKey(entries[this.cursor.selected]);
156
+ if (after !== undefined) this.focusedKeys[this.section] = after;
157
+ else if (before !== undefined) this.focusedKeys[this.section] = before;
211
158
  }
212
159
 
213
160
  /** Move onto the nearest selectable row when the focus is not already on one. */
214
161
  private clampSelection(entries: PanelEntry[]): void {
215
- if (entries.length === 0) return;
216
- const index = Math.min(Math.max(0, this.cursor.selected), entries.length - 1);
217
- if (isSelectable(entries[index])) {
218
- this.cursor.selected = index;
219
- return;
220
- }
221
- // Prefer the closest row above, then the closest below, so a scope switch
222
- // that drops the focused row leaves the cursor nearby instead of at the top.
223
- for (let back = index - 1; back >= 0; back -= 1) {
224
- if (isSelectable(entries[back])) {
225
- this.cursor.selected = back;
226
- return;
227
- }
228
- }
229
- for (let forward = index + 1; forward < entries.length; forward += 1) {
230
- if (isSelectable(entries[forward])) {
231
- this.cursor.selected = forward;
232
- return;
233
- }
234
- }
235
- }
236
-
237
- private firstRow(): number | undefined {
238
- const index = this.activeEntries().findIndex(isSelectable);
239
- return index >= 0 ? index : undefined;
240
- }
241
-
242
- private lastRow(): number | undefined {
243
- const entries = this.activeEntries();
244
- for (let index = entries.length - 1; index >= 0; index -= 1) {
245
- if (isSelectable(entries[index])) return index;
246
- }
247
- return undefined;
162
+ clampSelection(entries, this.cursor);
248
163
  }
249
164
 
250
165
  private select(index: number | undefined): void {
@@ -254,19 +169,7 @@ export class PanelComponent implements Component {
254
169
 
255
170
  /** Move `delta` rows, skipping group headers. */
256
171
  private move(delta: number): void {
257
- if (delta === 0) return;
258
- this.clampSelection(this.activeEntries());
259
- const entries = this.activeEntries();
260
- const direction = delta < 0 ? -1 : 1;
261
- let index = this.cursor.selected;
262
- let remaining = Math.abs(delta);
263
- while (remaining > 0) {
264
- const next = nextRow(entries, index, direction);
265
- if (next === undefined) break;
266
- index = next;
267
- remaining -= 1;
268
- }
269
- this.select(index);
172
+ moveRows(this.activeEntries(), this.cursor, delta);
270
173
  }
271
174
 
272
175
  private switchScope(): void {
@@ -299,18 +202,112 @@ export class PanelComponent implements Component {
299
202
  .then(() => this.options.requestRender());
300
203
  }
301
204
 
302
- /** Change the focused setting, if the section has one. */
205
+ /** Drop the focused extension's project override, restoring the inherited row. */
206
+ private clearOverride(): void {
207
+ const entry = this.activeEntries()[this.cursor.selected];
208
+ if (entry?.kind !== "row") return;
209
+ const item = entry.row.item;
210
+ if (!item) {
211
+ this.notify(`${entry.row.label} is not resolved; there is no override to clear.`);
212
+ return;
213
+ }
214
+ this.chain = this.chain
215
+ .then(() => this.options.runtime.clearOverride(item))
216
+ .catch(() => undefined)
217
+ .then(() => this.options.requestRender());
218
+ }
219
+
220
+ /** Change the focused setting: cycle it, or open the editor for a text row. */
303
221
  private changeSetting(): void {
304
222
  const entry = this.activeEntries()[this.cursor.selected];
305
223
  if (entry?.kind !== "setting") return;
224
+ const view = entry.setting;
225
+ if (view.kind === "text") {
226
+ this.editing = { id: view.id, buffer: view.value, caret: view.value.length };
227
+ this.options.requestRender();
228
+ return;
229
+ }
230
+ const scope = this.options.runtime.scope();
231
+ this.chain = this.chain
232
+ .then(() => this.options.settings.change(scope, view.id))
233
+ .catch((error: unknown) => this.notify(error instanceof Error ? error.message : String(error)))
234
+ .then(() => this.options.requestRender());
235
+ }
236
+
237
+ /** Store the editor's buffer; a refusal keeps it open with the reason shown. */
238
+ private commitEdit(): void {
239
+ const editing = this.editing;
240
+ if (!editing) return;
306
241
  const scope = this.options.runtime.scope();
307
- const id = entry.setting.id;
308
242
  this.chain = this.chain
309
- .then(() => this.options.settings.change(scope, id))
243
+ .then(() => this.options.settings.setValue(scope, editing.id, editing.buffer))
244
+ .then((saved) => {
245
+ if (saved) this.editing = undefined;
246
+ })
310
247
  .catch((error: unknown) => this.notify(error instanceof Error ? error.message : String(error)))
311
248
  .then(() => this.options.requestRender());
312
249
  }
313
250
 
251
+ /** The text row being edited, read live so a scope switch cannot point at a stale row. */
252
+ private editingRow(): { view: ReturnType<SettingsSurface["views"]>[number] } | undefined {
253
+ const editing = this.editing;
254
+ if (!editing) return undefined;
255
+ const scope = this.options.runtime.scope();
256
+ const view = this.options.settings.views(scope).find((entry) => entry.id === editing.id);
257
+ return view ? { view } : undefined;
258
+ }
259
+
260
+ /**
261
+ * Editor keys. `Esc` cancels the edit rather than closing the screen, and
262
+ * everything else the screen binds is inert until the editor closes.
263
+ */
264
+ private handleEditingInput(data: string): void {
265
+ const editing = this.editing;
266
+ if (!editing) return;
267
+ if (isCloseKey(data)) {
268
+ this.editing = undefined;
269
+ this.options.requestRender();
270
+ return;
271
+ }
272
+ if (matchesKey(data, Key.enter)) {
273
+ void this.commitEdit();
274
+ return;
275
+ }
276
+ const { buffer, caret } = editing;
277
+ if (matchesKey(data, Key.backspace)) {
278
+ if (caret > 0)
279
+ this.editing = { ...editing, buffer: buffer.slice(0, caret - 1) + buffer.slice(caret), caret: caret - 1 };
280
+ } else if (matchesKey(data, Key.delete)) {
281
+ if (caret < buffer.length)
282
+ this.editing = { ...editing, buffer: buffer.slice(0, caret) + buffer.slice(caret + 1) };
283
+ } else if (matchesKey(data, Key.left)) {
284
+ this.editing = { ...editing, caret: Math.max(0, caret - 1) };
285
+ } else if (matchesKey(data, Key.right)) {
286
+ this.editing = { ...editing, caret: Math.min(buffer.length, caret + 1) };
287
+ // `Ctrl+A`/`Ctrl+E` are the emacs spellings of Home/End, which pi-tui's
288
+ // `Key.home`/`Key.end` do not match; both are offered because a terminal
289
+ // may send either.
290
+ } else if (matchesKey(data, Key.home) || matchesKey(data, "ctrl+a")) {
291
+ this.editing = { ...editing, caret: 0 };
292
+ } else if (matchesKey(data, Key.end) || matchesKey(data, "ctrl+e")) {
293
+ this.editing = { ...editing, caret: buffer.length };
294
+ } else if (matchesKey(data, "ctrl+u")) {
295
+ this.editing = { ...editing, buffer: "", caret: 0 };
296
+ } else {
297
+ // Anything else must be printable; control sequences are not text.
298
+ const typed = [...data].filter((char) => char >= " " && char !== "\u007f").join("");
299
+ const max = this.editingRow()?.view.text?.maxLength;
300
+ if (typed && max !== undefined && buffer.length + typed.length <= max) {
301
+ this.editing = {
302
+ ...editing,
303
+ buffer: buffer.slice(0, caret) + typed + buffer.slice(caret),
304
+ caret: caret + typed.length,
305
+ };
306
+ }
307
+ }
308
+ this.options.requestRender();
309
+ }
310
+
314
311
  /**
315
312
  * Ask for the first Extensions probe when the session had no cache. The first
316
313
  * time the section is visible is when the rows need health; doing it at mount
@@ -358,11 +355,11 @@ export class PanelComponent implements Component {
358
355
  /** Navigation shared by both sections. */
359
356
  private navigate(data: string): void {
360
357
  if (data === "g") {
361
- this.select(this.firstRow());
358
+ this.select(firstRow(this.activeEntries()));
362
359
  return;
363
360
  }
364
361
  if (data === "G") {
365
- this.select(this.lastRow());
362
+ this.select(lastRow(this.activeEntries()));
366
363
  return;
367
364
  }
368
365
  switch (navIntent(data)) {
@@ -379,10 +376,10 @@ export class PanelComponent implements Component {
379
376
  this.move(Math.max(1, this.cursor.visible));
380
377
  return;
381
378
  case "home":
382
- this.select(this.firstRow());
379
+ this.select(firstRow(this.activeEntries()));
383
380
  return;
384
381
  case "end":
385
- this.select(this.lastRow());
382
+ this.select(lastRow(this.activeEntries()));
386
383
  return;
387
384
  }
388
385
  }
@@ -402,6 +399,10 @@ export class PanelComponent implements Component {
402
399
  }
403
400
 
404
401
  handleInput(data: string): void {
402
+ if (this.editing) {
403
+ this.handleEditingInput(data);
404
+ return;
405
+ }
405
406
  if (isCloseKey(data)) {
406
407
  this.options.onClose();
407
408
  return;
@@ -441,10 +442,23 @@ export class PanelComponent implements Component {
441
442
  void this.refresh(true);
442
443
  return;
443
444
  }
445
+ // Only Project scope can hold an override, so the key does not exist in
446
+ // Global scope and the hint row does not offer it there.
447
+ if (matchesKey(data, "x") && this.options.runtime.scope() === "project") {
448
+ this.clearOverride();
449
+ return;
450
+ }
444
451
  this.navigate(data);
445
452
  }
446
453
 
447
454
  handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
455
+ // A click while editing leaves the editor the way `Esc` does: nothing is
456
+ // written behind the screen.
457
+ if (this.editing) {
458
+ this.editing = undefined;
459
+ this.options.requestRender();
460
+ return { handled: true };
461
+ }
448
462
  const delta = wheelDelta(event);
449
463
  if (delta !== undefined) {
450
464
  this.move(delta);
@@ -464,167 +478,23 @@ export class PanelComponent implements Component {
464
478
  return { handled: true };
465
479
  }
466
480
 
467
- /** The themed section bar: both section names, the active one accented. */
468
- private sectionBar(width: number, counts: string): string {
469
- const separator = paint(this.theme, "faint", " · ");
470
- const left = ` ${sectionSegments(this.section)
471
- .map((segment) => paint(this.theme, segment.color, segment.text))
472
- .join(separator)}`;
473
- return clip(splitRow(left, counts, width), width);
474
- }
475
-
476
- /** One section's counts as whole segments, so a narrow screen drops a count. */
477
- private counts(segments: readonly SummarySegment[], width: number): string {
478
- const separator = paint(this.theme, "faint", " · ");
479
- const styled = segments.map((segment) => paint(this.theme, segment.color, segment.text));
480
- return fitCells(styled, Math.max(1, width - 2), separator);
481
- }
482
-
483
- /** The Extensions section's counts, next to the section bar. */
484
- private extensionCounts(width: number, model: PanelModel): string {
485
- return this.counts(
486
- summarySegments(model, this.options.runtime.scope(), {
487
- filter: this.options.filter,
488
- compact: width < 72,
489
- }),
490
- width,
491
- );
492
- }
493
-
494
481
  /** Key hints for the active section; whole hints drop as the width runs out. */
495
482
  private hint(width: number, expanded = false): string {
496
- const common = ["Esc close", "←/→"];
483
+ if (this.editing) return hintLine(this.theme, width, editHints());
497
484
  const hints =
498
485
  this.section === "settings"
499
- ? [...common, "Space change", "Tab scope", "↑/↓ select"]
500
- : [
501
- ...common,
502
- "Space toggle",
503
- expanded ? "Enter collapse" : "Enter detail",
504
- "Tab scope",
505
- this.refreshing ? "refreshing…" : "r refresh",
506
- "↑/↓ select",
507
- ];
508
- return screenHint(this.theme, width, hints);
509
- }
510
-
511
- /** The transient notice row, clipped and painted like the hint it replaces. */
512
- private noticeLine(width: number): string {
513
- return clip(`${INDENT}${paint(this.theme, "meta", sanitizeLine(this.notice ?? ""))}`, width);
486
+ ? settingsHints()
487
+ : extensionHints({
488
+ expanded,
489
+ refreshing: this.refreshing,
490
+ projectScope: this.options.runtime.scope() === "project",
491
+ });
492
+ return hintLine(this.theme, width, hints);
514
493
  }
515
494
 
516
495
  /** The key-hint row, or the notice that temporarily replaces it. */
517
496
  private hintRow(width: number, expanded = false): string {
518
- return this.notice !== undefined ? this.noticeLine(width) : this.hint(width, expanded);
519
- }
520
-
521
- private groupLine(entry: Extract<PanelEntry, { kind: "group" }>, width: number): string {
522
- const label = paint(this.theme, "meta", sanitizeLine(entry.label));
523
- const count = paint(this.theme, "faint", `(${entry.count})`);
524
- return clip(` ${label} ${count}`, width);
525
- }
526
-
527
- private rowLine(row: PanelRow, selected: boolean, width: number): string {
528
- const badge = rowBadge(row);
529
- return listRow(this.theme, width, {
530
- selected,
531
- // A reported-only row has no enable pattern, but still reserves the box
532
- // column so the list stays aligned.
533
- leading: row.item ? checkbox(this.theme, row.item.enabled) : " ",
534
- glyph: badge.glyph ?? " ",
535
- glyphRole: badge.role,
536
- label: sanitizeLine(row.label),
537
- labelRole: selected ? "accent" : row.item && !row.item.enabled ? "faint" : "label",
538
- trailing: badge.text,
539
- // The glyph carries the status colour; the word stays quiet unless it is a
540
- // problem, so a clean row does not shout.
541
- trailingRole: badge.role === "bad" || badge.role === "warn" ? badge.role : "faint",
542
- });
543
- }
544
-
545
- /** One setting row: a checkbox for booleans, the value right-aligned. */
546
- private settingLine(view: SettingView, selected: boolean, width: number): string {
547
- return listRow(this.theme, width, {
548
- selected,
549
- leading: view.kind === "boolean" ? checkbox(this.theme, view.value === "true") : " ",
550
- label: sanitizeLine(view.label),
551
- labelRole: selected ? "accent" : "label",
552
- trailing: sanitizeLine(view.valueLabel),
553
- trailingRole: "meta",
554
- });
555
- }
556
-
557
- /** The project note: app rows are absent because Pi has no project setter. */
558
- private noteLine(width: number): string {
559
- return clip(`${INDENT}${paint(this.theme, "faint", "App settings are Global only.")}`, width);
560
- }
561
-
562
- /** The saved-then-reload marker, shared by both sections. */
563
- private dirtyLine(width: number): string[] {
564
- if (!this.options.runtime.isDirty() && !this.options.settings.isDirty()) return [];
565
- return [clip(`${INDENT}${paint(this.theme, "warn", "Saved · reload to apply")}`, width)];
566
- }
567
-
568
- /** The origin and target file for a setting, shown in its detail pane. */
569
- private originLine(view: SettingView): string {
570
- const scope = this.options.runtime.scope();
571
- const path =
572
- view.source === "app"
573
- ? join(this.options.agentDir, PI_SETTINGS_FILE)
574
- : configPath(scope, this.options.cwd, this.options.agentDir);
575
- const file = sanitizeLine(path);
576
- switch (view.origin) {
577
- case "inherited":
578
- return `Inherited from Global · ${file}`;
579
- case "project":
580
- return `Project · ${file}`;
581
- case "global":
582
- return `Global · ${file}`;
583
- default:
584
- return `Default · ${file}`;
585
- }
586
- }
587
-
588
- /** The focused setting's source, description, origin, and pending changes. */
589
- private settingDetail(view: SettingView | undefined, width: number, budget: number): string[] {
590
- if (!view) return this.dirtyLine(width);
591
- const source = view.source === "app" ? "Pi setting" : "bytefly.json";
592
- const head = `${paint(this.theme, "meta", sanitizeLine(view.label))} ${paint(this.theme, "faint", "—")} ${paint(this.theme, "faint", source)}`;
593
- const headLine = clip(`${INDENT}${head}`, width);
594
- // The pending-changes marker survives the collapsed body: on a short terminal
595
- // the value still changed, and the screen has to say so.
596
- if (budget === 0) return [headLine, ...this.dirtyLine(width)];
597
-
598
- const lines = [headLine];
599
- const dirty = this.dirtyLine(width);
600
- const descriptionWidth = Math.max(1, width - BODY_INDENT * 2);
601
- const description = wrapTextWithAnsi(sanitizeLine(view.description), descriptionWidth).slice(
602
- 0,
603
- Math.max(0, Math.min(2, budget - 2 - dirty.length)),
604
- );
605
- for (const line of description) {
606
- lines.push(clip(`${INDENT}${paint(this.theme, "meta", line)}`, width));
607
- }
608
- lines.push(clip(`${INDENT}${paint(this.theme, "faint", this.originLine(view))}`, width));
609
- lines.push(...dirty);
610
- return lines;
611
- }
612
-
613
- /** The focused extension's label, findings, and the pending-changes note. */
614
- private detail(row: PanelRow | undefined, width: number, budget: number, expanded: boolean): string[] {
615
- const lines: string[] = [];
616
- const badge = row ? rowBadge(row) : undefined;
617
- if (row && badge?.text !== undefined) {
618
- const head = `${paint(this.theme, "meta", sanitizeLine(row.label))} ${paint(this.theme, "faint", "—")} ${paint(this.theme, badge.role, badge.text)}`;
619
- lines.push(clip(` ${head}`, width));
620
- } else if (row?.item) {
621
- lines.push(clip(` ${paint(this.theme, "meta", sanitizeLine(row.item.path))}`, width));
622
- }
623
- if (row?.report && budget > 0) {
624
- lines.push(...reportDetailLines(row.report, this.theme, width, budget, expanded));
625
- }
626
- lines.push(...this.dirtyLine(width));
627
- return lines;
497
+ return this.notice !== undefined ? noticeLine(this.theme, width, this.notice) : this.hint(width, expanded);
628
498
  }
629
499
 
630
500
  /**
@@ -653,12 +523,14 @@ export class PanelComponent implements Component {
653
523
  private renderSettings(width: number, header: string): string[] {
654
524
  const scope = this.options.runtime.scope();
655
525
  const views = this.options.settings.views(scope);
656
- const summary = this.sectionBar(
526
+ const summary = sectionBar(
527
+ this.theme,
657
528
  width,
658
- this.counts(settingsSummarySegments(scope, this.options.settings.count(scope)), width),
529
+ this.section,
530
+ settingsSummary(this.theme, width, scope, this.options.settings.count(scope)),
659
531
  );
660
532
  const entries = settingEntries(views);
661
- const note = scope === "project" ? this.noteLine(width) : undefined;
533
+ const note = scope === "project" ? projectNote(this.theme, width) : undefined;
662
534
 
663
535
  if (entries.length === 0) {
664
536
  return dockListFrame({
@@ -674,7 +546,14 @@ export class PanelComponent implements Component {
674
546
 
675
547
  const focusedEntry = entries[this.cursor.selected];
676
548
  const focused = focusedEntry?.kind === "setting" ? focusedEntry.setting : undefined;
677
- const detail = this.settingDetail(focused, width, this.detailRows(false));
549
+ const detail = settingDetail({
550
+ theme: this.theme,
551
+ width,
552
+ view: focused,
553
+ budget: this.detailRows(false),
554
+ dirty: this.dirty,
555
+ paths: { scope, cwd: this.options.cwd, agentDir: this.options.agentDir },
556
+ });
678
557
 
679
558
  this.cursor.visible = fitRows(
680
559
  this.options.viewportRows,
@@ -689,9 +568,11 @@ export class PanelComponent implements Component {
689
568
  if (note) body.push(note);
690
569
  for (let index = start; index < end; index += 1) {
691
570
  const entry = entries[index];
692
- if (entry.kind === "group") body.push(this.groupLine(entry, width));
693
- else if (entry.kind === "setting")
694
- body.push(this.settingLine(entry.setting, index === this.cursor.selected, width));
571
+ if (entry.kind === "group") body.push(groupLine(this.theme, width, entry));
572
+ else if (entry.kind === "setting") {
573
+ const editing = this.editing?.id === entry.setting.id ? this.editing : undefined;
574
+ body.push(settingRow(this.theme, width, entry.setting, index === this.cursor.selected, editing));
575
+ }
695
576
  }
696
577
 
697
578
  return dockListFrame({
@@ -713,7 +594,12 @@ export class PanelComponent implements Component {
713
594
 
714
595
  const model = this.model();
715
596
  const entries = entriesOf(model);
716
- const summary = this.sectionBar(w, this.extensionCounts(w, model));
597
+ const summary = sectionBar(
598
+ this.theme,
599
+ w,
600
+ this.section,
601
+ extensionSummary(this.theme, w, model, this.options.runtime.scope(), this.options.filter),
602
+ );
717
603
 
718
604
  if (entries.length === 0) {
719
605
  return dockListFrame({
@@ -730,7 +616,7 @@ export class PanelComponent implements Component {
730
616
  const focusedEntry = entries[this.cursor.selected];
731
617
  const focused = focusedEntry?.kind === "row" ? focusedEntry.row : undefined;
732
618
  const expanded = focused !== undefined && this.expanded.has(focused.key);
733
- const detail = this.detail(focused, w, this.detailRows(expanded), expanded);
619
+ const detail = extensionDetail(this.theme, w, focused, this.detailRows(expanded), expanded, this.dirty);
734
620
 
735
621
  this.cursor.visible = fitRows(
736
622
  this.options.viewportRows,
@@ -744,8 +630,8 @@ export class PanelComponent implements Component {
744
630
  const body: string[] = [];
745
631
  for (let index = start; index < end; index += 1) {
746
632
  const entry = entries[index];
747
- if (entry.kind === "group") body.push(this.groupLine(entry, w));
748
- else if (entry.kind === "row") body.push(this.rowLine(entry.row, index === this.cursor.selected, w));
633
+ if (entry.kind === "group") body.push(groupLine(this.theme, w, entry));
634
+ else if (entry.kind === "row") body.push(extensionRow(this.theme, w, entry.row, index === this.cursor.selected));
749
635
  }
750
636
 
751
637
  return dockListFrame({