@gavin-hu/my-pi-agent 0.13.1 → 0.15.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 (237) hide show
  1. package/README.md +66 -41
  2. package/README.zh-CN.md +53 -36
  3. package/extensions/bytefly/README.md +321 -186
  4. package/extensions/bytefly/block/component.ts +7 -15
  5. package/extensions/bytefly/block/register.ts +6 -1
  6. package/extensions/bytefly/doctor/config.ts +4 -1
  7. package/extensions/bytefly/doctor/register.ts +24 -5
  8. package/extensions/bytefly/doctor/tools.ts +4 -0
  9. package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
  10. package/extensions/bytefly/footer/config.ts +10 -6
  11. package/extensions/bytefly/footer/price/config.ts +3 -2
  12. package/extensions/bytefly/footer/price/price.ts +5 -3
  13. package/extensions/bytefly/footer/price/rates.ts +63 -26
  14. package/extensions/bytefly/footer/render/status.ts +3 -3
  15. package/extensions/bytefly/header/component.ts +33 -10
  16. package/extensions/bytefly/header/config.ts +22 -0
  17. package/extensions/bytefly/header/register.ts +2 -1
  18. package/extensions/bytefly/index.ts +6 -6
  19. package/extensions/bytefly/notice/component.ts +8 -3
  20. package/extensions/bytefly/notify/config.ts +7 -12
  21. package/extensions/bytefly/notify/dispatch.ts +13 -5
  22. package/extensions/bytefly/panel/commands.ts +22 -2
  23. package/extensions/bytefly/panel/format.ts +17 -21
  24. package/extensions/bytefly/panel/settings-runtime.ts +86 -22
  25. package/extensions/bytefly/panel/settings.ts +279 -46
  26. package/extensions/bytefly/panel/themes.ts +32 -0
  27. package/extensions/bytefly/panel/tui/editing.ts +77 -0
  28. package/extensions/bytefly/panel/tui/entries.ts +24 -0
  29. package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
  30. package/extensions/bytefly/panel/tui/frame.ts +121 -4
  31. package/extensions/bytefly/panel/tui/settings-view.ts +98 -13
  32. package/extensions/bytefly/panel/tui.ts +176 -166
  33. package/extensions/bytefly/picker/discovery.ts +1 -0
  34. package/extensions/bytefly/picker/types.ts +2 -0
  35. package/extensions/bytefly/separator/component.ts +17 -6
  36. package/extensions/bytefly/separator/config.ts +15 -0
  37. package/extensions/bytefly/separator/format.ts +60 -6
  38. package/extensions/bytefly/separator/register.ts +68 -9
  39. package/extensions/bytefly/separator/types.ts +8 -0
  40. package/extensions/bytefly/update/cache.ts +58 -19
  41. package/extensions/bytefly/update/check.ts +35 -0
  42. package/extensions/bytefly/update/format.ts +76 -35
  43. package/extensions/bytefly/update/register.ts +144 -55
  44. package/extensions/bytefly/update/state.ts +4 -2
  45. package/extensions/bytefly/update/suppress.ts +66 -0
  46. package/extensions/bytefly/update/types.ts +15 -0
  47. package/extensions/bytefly/working/config.ts +4 -5
  48. package/extensions/bytefly/working/format.ts +24 -7
  49. package/extensions/bytefly/working/register.ts +34 -3
  50. package/extensions/check/README.md +253 -0
  51. package/extensions/check/commands.ts +128 -0
  52. package/extensions/check/config.ts +39 -0
  53. package/extensions/check/detect.ts +269 -0
  54. package/extensions/check/failures.ts +230 -0
  55. package/extensions/check/format.ts +135 -0
  56. package/extensions/check/index.ts +26 -0
  57. package/extensions/check/render.ts +185 -0
  58. package/extensions/check/runner.ts +176 -0
  59. package/extensions/check/runtime.ts +244 -0
  60. package/extensions/check/source.ts +80 -0
  61. package/extensions/check/tools.ts +173 -0
  62. package/extensions/check/tui.ts +195 -0
  63. package/extensions/check/types.ts +99 -0
  64. package/extensions/document/README.md +1 -1
  65. package/extensions/document/extract/pdf.ts +8 -16
  66. package/extensions/document/formats.ts +2 -2
  67. package/extensions/file-browse/README.md +66 -32
  68. package/extensions/file-browse/client.ts +95 -32
  69. package/extensions/file-browse/config.ts +9 -9
  70. package/extensions/file-browse/git.ts +37 -47
  71. package/extensions/file-browse/html.ts +39 -58
  72. package/extensions/file-browse/markdown.ts +110 -92
  73. package/extensions/file-browse/raw-html.ts +478 -0
  74. package/extensions/file-browse/router.ts +32 -70
  75. package/extensions/file-browse/server.ts +18 -56
  76. package/extensions/file-browse/urls.ts +80 -0
  77. package/extensions/goal/README.md +103 -47
  78. package/extensions/goal/clarify-parse.ts +4 -9
  79. package/extensions/goal/clarify.ts +27 -4
  80. package/extensions/goal/commands.ts +24 -11
  81. package/extensions/goal/format.ts +41 -10
  82. package/extensions/goal/index.ts +39 -16
  83. package/extensions/goal/plan-route.ts +51 -0
  84. package/extensions/goal/schema.ts +6 -23
  85. package/extensions/goal/state.ts +39 -57
  86. package/extensions/goal/tools.ts +26 -20
  87. package/extensions/goal/types.ts +11 -1
  88. package/extensions/job/README.md +20 -6
  89. package/extensions/job/format.ts +18 -68
  90. package/extensions/job/index.ts +26 -41
  91. package/extensions/job/log-writer.ts +208 -0
  92. package/extensions/job/logs.ts +3 -2
  93. package/extensions/job/process.ts +6 -99
  94. package/extensions/job/runtime.ts +42 -181
  95. package/extensions/job/schema.ts +1 -4
  96. package/extensions/job/tools.ts +1 -15
  97. package/extensions/job/tui.ts +117 -307
  98. package/extensions/job/ui.ts +7 -33
  99. package/extensions/memory/README.md +3 -1
  100. package/extensions/memory/errors.ts +5 -6
  101. package/extensions/memory/tui.ts +14 -24
  102. package/extensions/plan/README.md +106 -18
  103. package/extensions/plan/commands.ts +9 -5
  104. package/extensions/plan/format.ts +87 -0
  105. package/extensions/plan/index.ts +31 -15
  106. package/extensions/plan/plans.ts +2 -3
  107. package/extensions/plan/policy.ts +0 -6
  108. package/extensions/plan/runtime.ts +4 -2
  109. package/extensions/plan/steps.ts +37 -8
  110. package/extensions/plan/todos.ts +44 -3
  111. package/extensions/plan/tools/enter.ts +94 -0
  112. package/extensions/plan/tools/exit.ts +296 -0
  113. package/extensions/plan/tools/nested.ts +21 -0
  114. package/extensions/plan/tools/steps.ts +136 -0
  115. package/extensions/plan/tools/todo-seed.ts +76 -0
  116. package/extensions/plan/tools/write.ts +127 -0
  117. package/extensions/plan/tools.ts +18 -703
  118. package/extensions/plan/web-client.ts +6 -22
  119. package/extensions/plan/web-page.ts +19 -30
  120. package/extensions/plan/web-router.ts +34 -71
  121. package/extensions/plan/web.ts +13 -40
  122. package/extensions/question/answers.ts +3 -8
  123. package/extensions/question/tui.ts +6 -6
  124. package/extensions/rewind/README.md +95 -22
  125. package/extensions/rewind/checklist.ts +185 -0
  126. package/extensions/rewind/commands.ts +8 -0
  127. package/extensions/rewind/config.ts +3 -11
  128. package/extensions/rewind/format.ts +220 -2
  129. package/extensions/rewind/git.ts +74 -7
  130. package/extensions/rewind/hunk-picker.ts +114 -0
  131. package/extensions/rewind/patch.ts +88 -0
  132. package/extensions/rewind/path-picker.ts +67 -100
  133. package/extensions/rewind/paths.ts +93 -19
  134. package/extensions/rewind/restore.ts +53 -10
  135. package/extensions/rewind/review-tui.ts +320 -0
  136. package/extensions/rewind/review.ts +397 -0
  137. package/extensions/rewind/rewind-tui.ts +4 -3
  138. package/extensions/rewind/runtime.ts +94 -17
  139. package/extensions/subagent/README.md +118 -8
  140. package/extensions/subagent/background-tui.ts +208 -0
  141. package/extensions/subagent/background.ts +470 -0
  142. package/extensions/subagent/commands.ts +80 -0
  143. package/extensions/subagent/config.ts +62 -0
  144. package/extensions/subagent/format.ts +24 -1
  145. package/extensions/subagent/index.ts +116 -1
  146. package/extensions/subagent/orchestrate.ts +16 -3
  147. package/extensions/subagent/render.ts +25 -0
  148. package/extensions/subagent/run.ts +113 -49
  149. package/extensions/subagent/schema.ts +12 -0
  150. package/extensions/subagent/transcripts.ts +202 -0
  151. package/extensions/subagent/types.ts +23 -0
  152. package/extensions/subagent/ui.ts +50 -0
  153. package/extensions/todo/README.md +18 -13
  154. package/extensions/todo/format.ts +3 -2
  155. package/extensions/todo/runtime.ts +3 -2
  156. package/extensions/todo/schema.ts +8 -84
  157. package/extensions/todo/state.ts +5 -46
  158. package/extensions/todo/tools.ts +2 -1
  159. package/extensions/todo/types.ts +6 -15
  160. package/extensions/watch/README.md +16 -6
  161. package/extensions/watch/child.ts +150 -0
  162. package/extensions/watch/format.ts +8 -48
  163. package/extensions/watch/index.ts +42 -58
  164. package/extensions/watch/runtime.ts +49 -128
  165. package/extensions/watch/schema.ts +1 -4
  166. package/extensions/watch/tools.ts +1 -11
  167. package/extensions/watch/tui.ts +103 -273
  168. package/extensions/watch/ui.ts +5 -32
  169. package/extensions/web-access/README.md +1 -1
  170. package/extensions/web-access/fetch/page.ts +1 -1
  171. package/extensions/web-access/fetch/tool.ts +6 -2
  172. package/extensions/web-access/search/search.ts +2 -5
  173. package/extensions/wechat/README.md +3 -1
  174. package/extensions/wechat/client.ts +23 -0
  175. package/extensions/wechat/commands.ts +57 -42
  176. package/extensions/wechat/deliver.ts +5 -9
  177. package/extensions/wechat/errors.ts +3 -6
  178. package/extensions/wechat/inbound.ts +2 -5
  179. package/extensions/wechat/index.ts +2 -8
  180. package/extensions/wechat/outbound.ts +2 -4
  181. package/extensions/wechat/peer-worker.ts +3 -8
  182. package/extensions/wechat/poll.ts +10 -9
  183. package/extensions/wechat/presence.ts +35 -62
  184. package/extensions/wechat/reply-pipeline.ts +3 -1
  185. package/extensions/wechat/status-screen.ts +1 -1
  186. package/extensions/wechat/timer.ts +57 -0
  187. package/extensions/worktree/config.ts +8 -20
  188. package/extensions/worktree/enter.ts +7 -12
  189. package/extensions/worktree/git.ts +38 -69
  190. package/extensions/worktree/index.ts +5 -4
  191. package/extensions/worktree/registry.ts +7 -7
  192. package/extensions/worktree/root-tools.ts +2 -2
  193. package/extensions/worktree/status.ts +3 -2
  194. package/lib/README.md +22 -8
  195. package/lib/config.ts +83 -12
  196. package/lib/errors.ts +12 -0
  197. package/lib/format.ts +56 -0
  198. package/lib/git/read.ts +8 -0
  199. package/lib/goal-state.ts +23 -8
  200. package/lib/http/headers.ts +54 -0
  201. package/lib/http/index.ts +13 -0
  202. package/lib/http/response.ts +46 -0
  203. package/lib/http/router.ts +52 -0
  204. package/lib/http/server.ts +91 -0
  205. package/lib/lines.ts +77 -0
  206. package/lib/pdf.ts +82 -0
  207. package/lib/plan-entry.ts +71 -0
  208. package/lib/plan-state.ts +34 -0
  209. package/lib/report.ts +99 -0
  210. package/lib/requirements.ts +25 -19
  211. package/lib/spawn.ts +170 -0
  212. package/lib/todo-state.ts +110 -23
  213. package/lib/tool-names.ts +7 -0
  214. package/lib/tool-result.ts +33 -0
  215. package/lib/tui/README.md +68 -3
  216. package/lib/tui/chrome.ts +28 -4
  217. package/lib/tui/columns.ts +6 -1
  218. package/lib/tui/confirm.ts +65 -0
  219. package/lib/tui/dock-screen.ts +410 -0
  220. package/lib/tui/glyphs.ts +2 -1
  221. package/lib/tui/indent.ts +1 -1
  222. package/lib/tui/index.ts +2 -0
  223. package/lib/tui/rows.ts +21 -0
  224. package/lib/tui/status-controller.ts +35 -1
  225. package/lib/tui/tool.ts +59 -1
  226. package/lib/web/components.ts +20 -1
  227. package/lib/web/markup.ts +32 -0
  228. package/lib/web/runtime.ts +50 -0
  229. package/package.json +23 -5
  230. package/skills/init/SKILL.md +165 -0
  231. package/skills/init/references/agents-md-quality.md +103 -0
  232. package/skills/init/references/interview-questions.md +81 -0
  233. package/skills/refactor/SKILL.md +141 -0
  234. package/themes/bytefly-dark.json +2 -3
  235. package/themes/bytefly-light.json +2 -3
  236. package/extensions/watch/process.ts +0 -63
  237. package/extensions/web-access/fetch/pdf.ts +0 -45
@@ -17,44 +17,30 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
17
17
  import { Key, matchesKey, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
18
18
  import type { ViewportRowsSource } from "../../../lib/tui/chrome.ts";
19
19
  import { dockListFrame } from "../../../lib/tui/chrome.ts";
20
- import { fitRows, formatRange, isCloseKey, ListCursor, navIntent, wheelDelta } from "../../../lib/tui/cursor.ts";
20
+ import { isCloseKey, ListCursor, navIntent, wheelDelta } from "../../../lib/tui/cursor.ts";
21
21
  import { emptyState } from "../../../lib/tui/rows.ts";
22
22
  import { titleRule } from "../../../lib/tui/rules.ts";
23
23
  import type { ExtensionReport } from "../../../lib/requirements.ts";
24
24
  import type { ExtensionsPickerRuntime } from "../picker/runtime.ts";
25
25
  import { buildPanelModel } from "./join.ts";
26
+ import { SETTING_ROWS, UNTRUSTED_SETTINGS_NOTE } from "./settings.ts";
26
27
  import type { SettingsSurface } from "./settings-runtime.ts";
27
28
  import type { PanelModel, PanelSection } from "./types.ts";
28
29
  import { entriesOf, entryKey, isSelectable, settingEntries, type PanelEntry } from "./tui/entries.ts";
30
+ import { applyEditKey, type EditingState } from "./tui/editing.ts";
29
31
  import { clampSelection, firstRow, lastRow, moveRows, retainFocus } from "./tui/nav.ts";
30
32
  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";
33
+ import { BODY_START, detailRows, groupLine, hintLine, noticeLine, panelFrame, sectionBar } from "./tui/frame.ts";
43
34
  import {
44
35
  editHints,
45
36
  projectNote,
37
+ sectionJsonPane,
46
38
  settingDetail,
47
39
  settingRow,
48
40
  settingsHints,
49
41
  settingsSummary,
50
42
  } from "./tui/settings-view.ts";
51
43
 
52
- /** Terminal rows, or `undefined` when the height is unknown. */
53
- function totalRows(source: ViewportRowsSource | undefined): number | undefined {
54
- const rows = typeof source === "function" ? source() : source;
55
- return rows !== undefined && Number.isFinite(rows) && rows > 0 ? rows : undefined;
56
- }
57
-
58
44
  /** Host seams for the panel screen, injected by the command runner. */
59
45
  export interface PanelComponentOptions {
60
46
  /** The session's picker runtime: items, scope, toggles, dirty flag. */
@@ -101,8 +87,12 @@ export class PanelComponent implements Component {
101
87
  private probed = false;
102
88
  /** Transient feedback (a refusal or a failed write), shown in the hint row. */
103
89
  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;
90
+ /** The text row being edited, if any. */
91
+ private editing: EditingState | undefined;
92
+ /** True while the Settings detail pane shows the focused row's section as JSON. */
93
+ private json = false;
94
+ /** Whether the last Settings render kept the project note; the click mapping needs it. */
95
+ private noteShown = false;
106
96
  /** The visible section. */
107
97
  private section: PanelSection;
108
98
 
@@ -177,12 +167,22 @@ export class PanelComponent implements Component {
177
167
  if (next === "project" && !this.options.runtime.projectAvailable()) {
178
168
  this.notify(
179
169
  this.section === "settings"
180
- ? "Project is not trusted; settings can only be changed in Global scope."
170
+ ? UNTRUSTED_SETTINGS_NOTE
181
171
  : "Project is not trusted; manage extensions in Global scope only.",
182
172
  );
183
173
  return;
184
174
  }
175
+ // The JSON pane names a file, so a scope switch has to close it.
176
+ this.json = false;
177
+ const focused = entryKey(this.activeEntries()[this.cursor.selected]);
185
178
  this.options.runtime.setScope(next);
179
+ // Keep the setting under the cursor across the layer switch; the two scopes
180
+ // do not share entry indices because Project drops the app rows, so the
181
+ // render's focus retention and clamp land on the nearest row when it cannot.
182
+ if (focused !== undefined) {
183
+ const index = this.activeEntries().findIndex((entry) => entryKey(entry) === focused);
184
+ if (index >= 0) this.cursor.selected = index;
185
+ }
186
186
  this.options.requestRender();
187
187
  }
188
188
 
@@ -234,6 +234,32 @@ export class PanelComponent implements Component {
234
234
  .then(() => this.options.requestRender());
235
235
  }
236
236
 
237
+ /** Delete the focused setting's leaf, so its default applies again. */
238
+ private resetSetting(): void {
239
+ const entry = this.activeEntries()[this.cursor.selected];
240
+ if (entry?.kind !== "setting") return;
241
+ const scope = this.options.runtime.scope();
242
+ this.chain = this.chain
243
+ .then(() => this.options.settings.reset(scope, entry.setting.id))
244
+ .catch((error: unknown) => this.notify(error instanceof Error ? error.message : String(error)))
245
+ .then(() => this.options.requestRender());
246
+ }
247
+
248
+ /** Toggle the detail pane between the row and its whole section as stored. */
249
+ private toggleJson(): void {
250
+ const entry = this.activeEntries()[this.cursor.selected];
251
+ if (entry?.kind !== "setting") return;
252
+ if (!entry.setting.config) {
253
+ // There is no section to show, so leave the mode off rather than keeping a
254
+ // hidden toggle that would reopen the pane on another row.
255
+ this.json = false;
256
+ this.notify(`${entry.setting.label} is a Pi setting; bytefly.json has no section for it.`);
257
+ return;
258
+ }
259
+ this.json = !this.json;
260
+ this.options.requestRender();
261
+ }
262
+
237
263
  /** Store the editor's buffer; a refusal keeps it open with the reason shown. */
238
264
  private commitEdit(): void {
239
265
  const editing = this.editing;
@@ -248,63 +274,26 @@ export class PanelComponent implements Component {
248
274
  .then(() => this.options.requestRender());
249
275
  }
250
276
 
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
277
  /**
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.
278
+ * Editor keys. `Esc`/`Ctrl+C` cancel the edit rather than closing the screen,
279
+ * and everything else the screen binds is inert until the editor closes.
263
280
  */
264
281
  private handleEditingInput(data: string): void {
265
282
  const editing = this.editing;
266
283
  if (!editing) return;
267
- if (isCloseKey(data)) {
268
- this.editing = undefined;
269
- this.options.requestRender();
270
- return;
271
- }
272
- if (matchesKey(data, Key.enter)) {
284
+ const max = SETTING_ROWS.find((row) => row.id === editing.id)?.text?.maxLength;
285
+ const action = applyEditKey(data, editing, max);
286
+ if (action.kind === "commit") {
273
287
  void this.commitEdit();
274
288
  return;
275
289
  }
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
- }
290
+ if (action.kind === "cancel") {
291
+ this.editing = undefined;
292
+ this.options.requestRender();
293
+ return;
307
294
  }
295
+ if (!action.changed) return;
296
+ this.editing = action.state;
308
297
  this.options.requestRender();
309
298
  }
310
299
 
@@ -348,6 +337,7 @@ export class PanelComponent implements Component {
348
337
  private switchSection(next: PanelSection): void {
349
338
  if (next === this.section) return;
350
339
  this.section = next;
340
+ this.json = false;
351
341
  this.prime();
352
342
  this.options.requestRender();
353
343
  }
@@ -362,6 +352,14 @@ export class PanelComponent implements Component {
362
352
  this.select(lastRow(this.activeEntries()));
363
353
  return;
364
354
  }
355
+ if (data === "[") {
356
+ this.jumpGroup(-1);
357
+ return;
358
+ }
359
+ if (data === "]") {
360
+ this.jumpGroup(1);
361
+ return;
362
+ }
365
363
  switch (navIntent(data)) {
366
364
  case "up":
367
365
  this.move(-1);
@@ -384,6 +382,26 @@ export class PanelComponent implements Component {
384
382
  }
385
383
  }
386
384
 
385
+ /** Move to the first row of the next (or previous) group. */
386
+ private jumpGroup(direction: -1 | 1): void {
387
+ const entries = this.activeEntries();
388
+ const headers = entries.flatMap((entry, index) => (entry.kind === "group" ? [index] : []));
389
+ const selected = this.cursor.selected;
390
+ // Step past the focused row's own header: landing on it would not move.
391
+ const own = headers.filter((index) => index < selected).at(-1);
392
+ const target =
393
+ direction === 1
394
+ ? headers.find((index) => index > selected)
395
+ : headers.filter((index) => index < (own ?? 0)).at(-1);
396
+ if (target === undefined) return;
397
+ for (let index = target + 1; index < entries.length; index += 1) {
398
+ if (isSelectable(entries[index])) {
399
+ this.select(index);
400
+ return;
401
+ }
402
+ }
403
+ }
404
+
387
405
  /** Settings keys: change, scope, navigation. `←`/`→` stay on sections. */
388
406
  private handleSettingsInput(data: string): void {
389
407
  this.clampSelection(this.activeEntries());
@@ -395,6 +413,14 @@ export class PanelComponent implements Component {
395
413
  this.switchScope();
396
414
  return;
397
415
  }
416
+ if (data === "u") {
417
+ this.resetSetting();
418
+ return;
419
+ }
420
+ if (data === "J") {
421
+ this.toggleJson();
422
+ return;
423
+ }
398
424
  this.navigate(data);
399
425
  }
400
426
 
@@ -469,23 +495,24 @@ export class PanelComponent implements Component {
469
495
  if (entries.length === 0) return undefined;
470
496
  // The Settings section prints the "Global only" note first in Project scope,
471
497
  // so the rows start one line lower there.
472
- const note = this.section === "settings" && this.options.runtime.scope() === "project" ? 1 : 0;
498
+ const note = this.section === "settings" && this.noteShown ? 1 : 0;
473
499
  const line = event.y - (BODY_START + note);
474
- if (line < 0) return undefined;
475
- const index = this.cursor.window(entries.length).start + line;
500
+ const window = this.cursor.window(entries.length);
501
+ if (line < 0 || line >= window.end - window.start) return undefined;
502
+ const index = window.start + line;
476
503
  if (!isSelectable(entries[index])) return undefined;
477
504
  this.select(index);
478
505
  return { handled: true };
479
506
  }
480
507
 
481
508
  /** Key hints for the active section; whole hints drop as the width runs out. */
482
- private hint(width: number, expanded = false): string {
509
+ private hint(width: number, options: { expanded?: boolean; jsonOpen?: boolean } = {}): string {
483
510
  if (this.editing) return hintLine(this.theme, width, editHints());
484
511
  const hints =
485
512
  this.section === "settings"
486
- ? settingsHints()
513
+ ? settingsHints(options.jsonOpen === true)
487
514
  : extensionHints({
488
- expanded,
515
+ expanded: options.expanded === true,
489
516
  refreshing: this.refreshing,
490
517
  projectScope: this.options.runtime.scope() === "project",
491
518
  });
@@ -493,21 +520,8 @@ export class PanelComponent implements Component {
493
520
  }
494
521
 
495
522
  /** The key-hint row, or the notice that temporarily replaces it. */
496
- private hintRow(width: number, expanded = false): string {
497
- return this.notice !== undefined ? noticeLine(this.theme, width, this.notice) : this.hint(width, expanded);
498
- }
499
-
500
- /**
501
- * Detail rows the focused row gets: the fixed pane when collapsed, the free
502
- * height when expanded. Zero when the terminal cannot hold one body row next to
503
- * the pane, so the hint row is never pushed off the screen.
504
- */
505
- private detailRows(expanded: boolean): number {
506
- const total = totalRows(this.options.viewportRows);
507
- if (total === undefined) return expanded ? EXPANDED_DETAIL_FALLBACK : DETAIL_ROWS;
508
- const available = total - FRAME_ROWS - MIN_BODY_ROWS;
509
- if (available < DETAIL_ROWS + 1) return 0;
510
- return expanded ? Math.max(DETAIL_ROWS, available) : DETAIL_ROWS;
523
+ private hintRow(width: number, options: { expanded?: boolean; jsonOpen?: boolean } = {}): string {
524
+ return this.notice !== undefined ? noticeLine(this.theme, width, this.notice) : this.hint(width, options);
511
525
  }
512
526
 
513
527
  /** Expand or collapse the focused row's findings. */
@@ -519,6 +533,13 @@ export class PanelComponent implements Component {
519
533
  this.options.requestRender();
520
534
  }
521
535
 
536
+ /** Retain the focus across a rebuild and return the entry it landed on. */
537
+ private retain(entries: PanelEntry[]): PanelEntry | undefined {
538
+ this.retainFocus(entries);
539
+ this.clampSelection(entries);
540
+ return entries[this.cursor.selected];
541
+ }
542
+
522
543
  /** The Settings body: scope counts, the project note, rows, and detail. */
523
544
  private renderSettings(width: number, header: string): string[] {
524
545
  const scope = this.options.runtime.scope();
@@ -527,64 +548,57 @@ export class PanelComponent implements Component {
527
548
  this.theme,
528
549
  width,
529
550
  this.section,
530
- settingsSummary(this.theme, width, scope, this.options.settings.count(scope)),
551
+ settingsSummary(this.theme, width, scope, views.length),
531
552
  );
532
553
  const entries = settingEntries(views);
533
554
  const note = scope === "project" ? projectNote(this.theme, width) : undefined;
534
555
 
535
- if (entries.length === 0) {
536
- return dockListFrame({
537
- header,
538
- summary,
539
- body: emptyState(this.theme, width, { label: "No settings found." }),
540
- hint: this.hintRow(width),
541
- });
542
- }
543
-
544
- this.retainFocus(entries);
545
- this.clampSelection(entries);
546
-
547
- const focusedEntry = entries[this.cursor.selected];
556
+ const focusedEntry = this.retain(entries);
548
557
  const focused = focusedEntry?.kind === "setting" ? focusedEntry.setting : undefined;
549
- const detail = settingDetail({
558
+ // A row with no section leaves the mode off, so moving away and back cannot
559
+ // reopen a pane the user closed.
560
+ if (focused && !focused.config) this.json = false;
561
+ const jsonConfig = this.json ? focused?.config : undefined;
562
+ const detail =
563
+ jsonConfig && focused
564
+ ? sectionJsonPane({
565
+ theme: this.theme,
566
+ width,
567
+ section: jsonConfig.section,
568
+ document: this.options.settings.sectionDocument(scope, jsonConfig.section),
569
+ budget: detailRows(true, this.options.viewportRows),
570
+ dirty: this.dirty,
571
+ paths: { scope, cwd: this.options.cwd, agentDir: this.options.agentDir },
572
+ })
573
+ : settingDetail({
574
+ theme: this.theme,
575
+ width,
576
+ view: focused,
577
+ budget: detailRows(false, this.options.viewportRows),
578
+ dirty: this.dirty,
579
+ paths: { scope, cwd: this.options.cwd, agentDir: this.options.agentDir },
580
+ });
581
+
582
+ const { lines, noteShown } = panelFrame({
550
583
  theme: this.theme,
584
+ viewportRows: this.options.viewportRows,
585
+ cursor: this.cursor,
551
586
  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
- });
557
-
558
- this.cursor.visible = fitRows(
559
- this.options.viewportRows,
560
- entries.length,
561
- FRAME_ROWS + detail.length + (note ? 1 : 0),
562
- SCREEN_DEFAULT_ROWS,
563
- );
564
- this.cursor.sync(entries.length);
565
- const { start, end } = this.cursor.window(entries.length);
566
-
567
- const body: string[] = [];
568
- if (note) body.push(note);
569
- for (let index = start; index < end; index += 1) {
570
- const entry = entries[index];
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
- }
576
- }
577
-
578
- return dockListFrame({
579
587
  header,
580
588
  summary,
581
- body,
582
- range: this.cursor.clipped(entries.length)
583
- ? formatRange(this.theme, width, { start, end, total: entries.length })
584
- : undefined,
585
- detail: detail.length > 0 ? detail : undefined,
586
- hint: this.hintRow(width),
589
+ entries,
590
+ note,
591
+ detail,
592
+ hint: this.hintRow(width, { jsonOpen: jsonConfig !== undefined }),
593
+ draw: (entry, index) => {
594
+ if (entry.kind === "group") return groupLine(this.theme, width, entry);
595
+ if (entry.kind !== "setting") return undefined;
596
+ const editing = this.editing?.id === entry.setting.id ? this.editing : undefined;
597
+ return settingRow(this.theme, width, entry.setting, index === this.cursor.selected, editing);
598
+ },
587
599
  });
600
+ this.noteShown = noteShown;
601
+ return lines;
588
602
  }
589
603
 
590
604
  render(width: number): string[] {
@@ -610,39 +624,35 @@ export class PanelComponent implements Component {
610
624
  });
611
625
  }
612
626
 
613
- this.retainFocus(entries);
614
- this.clampSelection(entries);
615
-
616
- const focusedEntry = entries[this.cursor.selected];
627
+ const focusedEntry = this.retain(entries);
617
628
  const focused = focusedEntry?.kind === "row" ? focusedEntry.row : undefined;
618
629
  const expanded = focused !== undefined && this.expanded.has(focused.key);
619
- const detail = extensionDetail(this.theme, w, focused, this.detailRows(expanded), expanded, this.dirty);
620
-
621
- this.cursor.visible = fitRows(
622
- this.options.viewportRows,
623
- entries.length,
624
- FRAME_ROWS + detail.length,
625
- SCREEN_DEFAULT_ROWS,
630
+ const detail = extensionDetail(
631
+ this.theme,
632
+ w,
633
+ focused,
634
+ detailRows(expanded, this.options.viewportRows),
635
+ expanded,
636
+ this.dirty,
626
637
  );
627
- this.cursor.sync(entries.length);
628
- const { start, end } = this.cursor.window(entries.length);
629
638
 
630
- const body: string[] = [];
631
- for (let index = start; index < end; index += 1) {
632
- const entry = entries[index];
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));
635
- }
636
-
637
- return dockListFrame({
639
+ const { lines, noteShown } = panelFrame({
640
+ theme: this.theme,
641
+ viewportRows: this.options.viewportRows,
642
+ cursor: this.cursor,
643
+ width: w,
638
644
  header,
639
645
  summary,
640
- body,
641
- range: this.cursor.clipped(entries.length)
642
- ? formatRange(this.theme, w, { start, end, total: entries.length })
643
- : undefined,
644
- detail: detail.length > 0 ? detail : undefined,
645
- hint: this.hintRow(w, expanded),
646
+ entries,
647
+ detail,
648
+ hint: this.hintRow(w, { expanded }),
649
+ draw: (entry, index) => {
650
+ if (entry.kind === "group") return groupLine(this.theme, w, entry);
651
+ if (entry.kind !== "row") return undefined;
652
+ return extensionRow(this.theme, w, entry.row, index === this.cursor.selected);
653
+ },
646
654
  });
655
+ this.noteShown = noteShown;
656
+ return lines;
647
657
  }
648
658
  }
@@ -50,6 +50,7 @@ export function createSettingsStore(manager: SettingsManager): SettingsStore {
50
50
  setQuietStartup: (quiet) => manager.setQuietStartup(quiet),
51
51
  setOutputPad: (padding) => manager.setOutputPad(padding),
52
52
  setShowHardwareCursor: (enabled) => manager.setShowHardwareCursor(enabled),
53
+ setTheme: (theme) => manager.setTheme(theme),
53
54
  isProjectTrusted: () => manager.isProjectTrusted(),
54
55
  flush: () => manager.flush(),
55
56
  drainErrors: () => manager.drainErrors(),
@@ -37,6 +37,8 @@ export interface SettingsStore {
37
37
  setQuietStartup(quiet: QuietStartup): void;
38
38
  setOutputPad(padding: 0 | 1): void;
39
39
  setShowHardwareCursor(enabled: boolean): void;
40
+ /** Pi's theme setting is a personal-file preference. */
41
+ setTheme(theme: string): void;
40
42
  isProjectTrusted(): boolean;
41
43
  flush(): Promise<void>;
42
44
  drainErrors(): Array<{ error: Error }>;
@@ -1,27 +1,38 @@
1
1
  /**
2
2
  * The transcript component that draws one separator entry.
3
3
  *
4
- * It is stateless: the line is derived from the stored turn number and the
5
- * width passed to `render`, so there is nothing to invalidate.
4
+ * `render` runs on every frame Pi draws, and a session holds one separator per
5
+ * turn, so the line is a pure function of the stored entry data, the width, and
6
+ * the theme it was built with. `centeredRule` paints and measures a full-width
7
+ * rule, so the placed line is memoized on the width (`RenderCache`); without it
8
+ * a long session rebuilds every turn's rule on every frame. Pi rebuilds the
9
+ * component from the renderer on a theme change (its
10
+ * `CustomEntryComponent.invalidate` re-runs it), so the captured theme cannot go
11
+ * stale; `invalidate` drops the memo anyway, in case that changes.
6
12
  */
7
13
 
8
14
  import type { Theme } from "@earendil-works/pi-coding-agent";
9
15
  import type { Component } from "@earendil-works/pi-tui";
16
+ import { RenderCache } from "../../../lib/tui/render-cache.ts";
10
17
  import { separatorLine } from "./format.ts";
18
+ import type { TurnSeparatorData } from "./types.ts";
11
19
 
12
- /** Renders one separator entry; `turn` comes from the stored entry data. */
20
+ /** Renders one separator entry; the data comes from the stored entry. */
13
21
  export class SeparatorComponent implements Component {
22
+ private readonly cache = new RenderCache();
23
+
14
24
  constructor(
15
- private readonly turn: number,
25
+ private readonly data: TurnSeparatorData,
16
26
  private readonly theme: Theme,
17
27
  private readonly pad = 0,
18
28
  ) {}
19
29
 
20
30
  invalidate(): void {
21
- // Stateless: the line is derived from `turn` and the width passed to render.
31
+ this.cache.invalidate();
22
32
  }
23
33
 
24
34
  render(width: number): string[] {
25
- return separatorLine(this.turn, width, this.theme, this.pad);
35
+ const w = Math.max(1, width);
36
+ return this.cache.lines(w, undefined, () => separatorLine(this.data, w, this.theme, this.pad));
26
37
  }
27
38
  }
@@ -22,6 +22,16 @@ export const CONFIG = {
22
22
  dashRole: "ruleStrong",
23
23
  /** Role for the label. */
24
24
  labelRole: "meta",
25
+ /** Role for a turn with a failed tool. */
26
+ failureRole: "bad",
27
+ /** Role for an aborted turn; escape is a warning, not an error. */
28
+ abortedRole: "warn",
29
+ /** Smallest run duration printed on the receipt; shorter turns keep the label quiet. */
30
+ minDurationMs: 1000,
31
+ /** Word appended when the run was aborted. */
32
+ abortedLabel: "aborted",
33
+ /** Glyph before a failed tool count. */
34
+ failureGlyph: GLYPHS.cross,
25
35
  } as const satisfies {
26
36
  customType: string;
27
37
  glyph: string;
@@ -29,4 +39,9 @@ export const CONFIG = {
29
39
  pad: number;
30
40
  dashRole: TuiRole;
31
41
  labelRole: TuiRole;
42
+ failureRole: TuiRole;
43
+ abortedRole: TuiRole;
44
+ minDurationMs: number;
45
+ abortedLabel: string;
46
+ failureGlyph: string;
32
47
  };
@@ -1,14 +1,67 @@
1
1
  /**
2
2
  * Pure formatting for bytefly's turn separator line.
3
3
  *
4
- * The centered `╌╌╌ turn N ╌╌╌` layout comes from the design system's
5
- * `centeredRule`, so this module only names the turn; the shared rule owns the
6
- * centering, the degradation, and the colors.
4
+ * The line is a receipt for the settled run: the turn number, then as much of
5
+ * the duration, the top-level tool count, and the outcome as the width can
6
+ * carry. The centered `╌╌╌ turn N ╌╌╌` layout comes from the design system's
7
+ * `centeredRule`, so this module only builds the label and picks its role; the
8
+ * shared rule owns the centering and the dashes.
7
9
  */
8
10
 
9
11
  import type { Theme } from "@earendil-works/pi-coding-agent";
12
+ import { visibleWidth } from "@earendil-works/pi-tui";
13
+ import { formatDuration, formatToolCount } from "../../../lib/format.ts";
14
+ import { SEPARATORS } from "../../../lib/tui/glyphs.ts";
10
15
  import { centeredRule } from "../../../lib/tui/rules.ts";
16
+ import type { TuiRole } from "../../../lib/tui/tokens.ts";
11
17
  import { CONFIG } from "./config.ts";
18
+ import type { TurnSeparatorData } from "./types.ts";
19
+
20
+ /** Whether the label and `centeredRule`'s two pads leave room for a dash. */
21
+ function fits(label: string, width: number): boolean {
22
+ return visibleWidth(label) + CONFIG.pad * 2 < width;
23
+ }
24
+
25
+ /**
26
+ * The widest receipt that fits, shed one field at a time.
27
+ *
28
+ * The tool count goes first, then the duration; the outcome stays beside the
29
+ * turn as long as it fits, because a failed or aborted run is the part the rule
30
+ * should not hide. The turn number itself is never dropped here — a terminal
31
+ * too narrow even for that falls back to `centeredRule`'s plain dashed rule.
32
+ */
33
+ export function receiptLabel(data: TurnSeparatorData, width: number): string {
34
+ const turn = `${CONFIG.labelPrefix} ${data.turn}`;
35
+ const duration =
36
+ data.durationMs !== undefined && data.durationMs >= CONFIG.minDurationMs
37
+ ? formatDuration(data.durationMs)
38
+ : undefined;
39
+ const tools = data.tools !== undefined && data.tools > 0 ? formatToolCount(data.tools) : undefined;
40
+ const outcome = data.aborted
41
+ ? CONFIG.abortedLabel
42
+ : data.failures !== undefined && data.failures > 0
43
+ ? `${CONFIG.failureGlyph}${data.failures}`
44
+ : undefined;
45
+
46
+ const steps: Array<Array<string | undefined>> = [
47
+ [turn, duration, tools, outcome],
48
+ [turn, duration, outcome],
49
+ [turn, outcome],
50
+ [turn],
51
+ ];
52
+ for (const parts of steps) {
53
+ const label = parts.filter((part): part is string => part !== undefined).join(SEPARATORS.item);
54
+ if (fits(label, width)) return label;
55
+ }
56
+ return turn;
57
+ }
58
+
59
+ /** The label's role: an aborted turn warns, a failed tool is an error. */
60
+ export function receiptRole(data: TurnSeparatorData): TuiRole {
61
+ if (data.aborted) return CONFIG.abortedRole;
62
+ if (data.failures !== undefined && data.failures > 0) return CONFIG.failureRole;
63
+ return CONFIG.labelRole;
64
+ }
12
65
 
13
66
  /**
14
67
  * One rendered line: the centered turn rule, colored by role.
@@ -17,14 +70,15 @@ import { CONFIG } from "./config.ts";
17
70
  * component raw, so the rule insets itself symmetrically by that many columns,
18
71
  * keeping its left edge on the transcript's margin.
19
72
  */
20
- export function separatorLine(turn: number, width: number, theme: Theme, pad = 0): string[] {
73
+ export function separatorLine(data: TurnSeparatorData, width: number, theme: Theme, pad = 0): string[] {
21
74
  const w = Math.max(1, Math.floor(width));
22
75
  const inset = Math.max(0, Math.min(Math.floor(pad), Math.floor((w - 1) / 2)));
23
- const line = centeredRule(theme, Math.max(1, w - inset * 2), `${CONFIG.labelPrefix} ${turn}`, {
76
+ const ruleWidth = Math.max(1, w - inset * 2);
77
+ const line = centeredRule(theme, ruleWidth, receiptLabel(data, ruleWidth), {
24
78
  glyph: CONFIG.glyph,
25
79
  pad: CONFIG.pad,
26
80
  fillRole: CONFIG.dashRole,
27
- labelRole: CONFIG.labelRole,
81
+ labelRole: receiptRole(data),
28
82
  });
29
83
  return [`${" ".repeat(inset)}${line}`];
30
84
  }