@gavin-hu/my-pi-agent 0.18.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 (324) hide show
  1. package/README.md +66 -52
  2. package/README.zh-CN.md +56 -46
  3. package/docs/extensions/README.md +34 -0
  4. package/docs/extensions/board.md +507 -0
  5. package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +774 -574
  6. package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
  7. package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
  8. package/docs/extensions/file-browse.md +561 -0
  9. package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
  10. package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
  11. package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
  12. package/{extensions/job/README.md → docs/extensions/job.md} +62 -52
  13. package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
  14. package/{extensions/plan/README.md → docs/extensions/plan.md} +440 -322
  15. package/{extensions/question/README.md → docs/extensions/question.md} +54 -31
  16. package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
  17. package/docs/extensions/session-bus.md +298 -0
  18. package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
  19. package/docs/extensions/team.md +254 -0
  20. package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
  21. package/{extensions/watch/README.md → docs/extensions/watch.md} +54 -36
  22. package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
  23. package/{extensions/wechat/README.md → docs/extensions/wechat.md} +260 -163
  24. package/{extensions/worktree/README.md → docs/extensions/worktree.md} +137 -79
  25. package/extensions/board/commands.ts +105 -0
  26. package/extensions/board/config.ts +36 -0
  27. package/extensions/board/format.ts +170 -0
  28. package/extensions/board/index.ts +40 -0
  29. package/extensions/board/input.ts +54 -0
  30. package/extensions/board/join.ts +42 -0
  31. package/extensions/board/page.ts +388 -0
  32. package/extensions/board/result.ts +108 -0
  33. package/extensions/board/rows.ts +48 -0
  34. package/extensions/board/schema.ts +90 -0
  35. package/extensions/board/seat.ts +28 -0
  36. package/extensions/board/snapshot.ts +42 -0
  37. package/extensions/board/tools.ts +444 -0
  38. package/extensions/board/transport/router.ts +91 -0
  39. package/extensions/board/transport/server.ts +138 -0
  40. package/extensions/board/types.ts +121 -0
  41. package/extensions/board/view/client.ts +51 -0
  42. package/extensions/board/view/dialog.ts +43 -0
  43. package/extensions/board/view/keys.ts +125 -0
  44. package/extensions/board/view/live.ts +231 -0
  45. package/extensions/board/view/names.ts +91 -0
  46. package/extensions/board/view/page.ts +118 -0
  47. package/extensions/board/view/render.ts +519 -0
  48. package/extensions/board/view/styles.ts +228 -0
  49. package/extensions/board/view/team.ts +327 -0
  50. package/extensions/bytefly/footer/budget.ts +6 -5
  51. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  52. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  53. package/extensions/bytefly/footer/config.ts +19 -8
  54. package/extensions/bytefly/footer/price/price.ts +30 -8
  55. package/extensions/bytefly/footer/register.ts +5 -5
  56. package/extensions/bytefly/footer/render/format.ts +18 -0
  57. package/extensions/bytefly/footer/render/lines.ts +23 -7
  58. package/extensions/bytefly/footer/render/segments.ts +117 -31
  59. package/extensions/bytefly/footer/types.ts +29 -5
  60. package/extensions/bytefly/footer/unpriced.ts +42 -17
  61. package/extensions/bytefly/panel/commands.ts +67 -7
  62. package/extensions/bytefly/panel/filter.ts +68 -0
  63. package/extensions/bytefly/panel/format.ts +16 -15
  64. package/extensions/bytefly/panel/join.ts +56 -13
  65. package/extensions/bytefly/panel/prices.ts +43 -0
  66. package/extensions/bytefly/panel/settings-runtime.ts +0 -6
  67. package/extensions/bytefly/panel/settings.ts +75 -43
  68. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  69. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  70. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  71. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  72. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  73. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  74. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  75. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  76. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  77. package/extensions/bytefly/panel/tui.ts +306 -259
  78. package/extensions/bytefly/picker/discovery.ts +0 -2
  79. package/extensions/bytefly/picker/types.ts +0 -2
  80. package/extensions/bytefly/update/check.ts +39 -0
  81. package/extensions/bytefly/update/format.ts +15 -0
  82. package/extensions/bytefly/update/register.ts +73 -34
  83. package/extensions/bytefly/update/state.ts +6 -3
  84. package/extensions/check/commands.ts +2 -1
  85. package/extensions/context/tui.ts +1 -1
  86. package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
  87. package/extensions/file-browse/files/classify.ts +136 -0
  88. package/extensions/file-browse/files/files.ts +12 -0
  89. package/extensions/file-browse/files/index.ts +90 -0
  90. package/extensions/file-browse/files/list.ts +261 -0
  91. package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
  92. package/extensions/file-browse/files/tree.ts +101 -0
  93. package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
  94. package/extensions/file-browse/index.ts +4 -4
  95. package/extensions/file-browse/{live.ts → live/live.ts} +20 -69
  96. package/extensions/file-browse/{router.ts → transport/router.ts} +21 -17
  97. package/extensions/file-browse/transport/server.ts +110 -0
  98. package/extensions/file-browse/{chrome.ts → view/chrome.ts} +4 -4
  99. package/extensions/file-browse/{client.ts → view/client.ts} +1 -1
  100. package/extensions/file-browse/{diff-html.ts → view/diff-html.ts} +3 -3
  101. package/extensions/file-browse/view/emit.ts +51 -0
  102. package/extensions/file-browse/{html.ts → view/html.ts} +19 -18
  103. package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
  104. package/extensions/file-browse/{markdown.ts → view/markdown.ts} +18 -20
  105. package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +19 -14
  106. package/extensions/file-browse/{styles.ts → view/styles.ts} +29 -5
  107. package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
  108. package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
  109. package/extensions/file-read/schema.ts +2 -1
  110. package/extensions/job/runtime.ts +4 -4
  111. package/extensions/memory/store.ts +12 -5
  112. package/extensions/plan/blocks.ts +23 -2
  113. package/extensions/plan/commands.ts +21 -16
  114. package/extensions/plan/feedback.ts +185 -52
  115. package/extensions/plan/format.ts +10 -3
  116. package/extensions/plan/index.ts +23 -5
  117. package/extensions/plan/plan-files.ts +88 -0
  118. package/extensions/plan/plan-index.ts +43 -9
  119. package/extensions/plan/plan-text.ts +33 -23
  120. package/extensions/plan/plans.ts +27 -34
  121. package/extensions/plan/policy.ts +30 -17
  122. package/extensions/plan/review.ts +3 -27
  123. package/extensions/plan/runtime.ts +52 -11
  124. package/extensions/plan/sections.ts +112 -0
  125. package/extensions/plan/steps.ts +3 -22
  126. package/extensions/plan/todos.ts +51 -3
  127. package/extensions/plan/tools/exit.ts +20 -95
  128. package/extensions/plan/tools/todo-seed.ts +4 -2
  129. package/extensions/plan/tools/write.ts +12 -1
  130. package/extensions/plan/tui.ts +5 -34
  131. package/extensions/plan/types.ts +15 -8
  132. package/extensions/plan/web-blocks.ts +89 -0
  133. package/extensions/plan/web-chrome.ts +3 -5
  134. package/extensions/plan/web-client.ts +179 -330
  135. package/extensions/plan/web-collab.ts +3 -3
  136. package/extensions/plan/web-comments.ts +18 -8
  137. package/extensions/plan/web-composer.ts +80 -0
  138. package/extensions/plan/web-draft.ts +40 -0
  139. package/extensions/plan/web-keys.ts +134 -0
  140. package/extensions/plan/web-outline.ts +192 -0
  141. package/extensions/plan/web-page.ts +4 -8
  142. package/extensions/plan/web-plan.ts +150 -0
  143. package/extensions/plan/web-router.ts +3 -58
  144. package/extensions/plan/web-settle.ts +194 -0
  145. package/extensions/plan/web-share-session.ts +24 -30
  146. package/extensions/plan/web-streams.ts +14 -14
  147. package/extensions/plan/web-tokens.ts +2 -2
  148. package/extensions/plan/web.ts +66 -120
  149. package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
  150. package/extensions/rewind/{review.ts → commands/review.ts} +15 -14
  151. package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
  152. package/extensions/rewind/{config.ts → config/config.ts} +1 -1
  153. package/extensions/rewind/index.ts +3 -3
  154. package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
  155. package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
  156. package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
  157. package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
  158. package/extensions/rewind/{format.ts → text/format.ts} +3 -3
  159. package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
  160. package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
  161. package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
  162. package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
  163. package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
  164. package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
  165. package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
  166. package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
  167. package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
  168. package/extensions/rewind/{status.ts → view/status.ts} +4 -4
  169. package/extensions/session-bus/commands.ts +17 -69
  170. package/extensions/session-bus/format.ts +22 -45
  171. package/extensions/session-bus/index.ts +13 -7
  172. package/extensions/session-bus/join-request.ts +40 -0
  173. package/extensions/session-bus/mail-watch.ts +134 -0
  174. package/extensions/session-bus/notice.ts +32 -4
  175. package/{lib/claims → extensions/session-bus}/overlap.ts +11 -18
  176. package/extensions/session-bus/protocol.ts +19 -49
  177. package/extensions/session-bus/resolve.ts +67 -0
  178. package/extensions/session-bus/result.ts +16 -51
  179. package/extensions/session-bus/rows.ts +11 -5
  180. package/extensions/session-bus/runtime.ts +220 -267
  181. package/extensions/session-bus/store.ts +55 -7
  182. package/extensions/session-bus/tools.ts +31 -66
  183. package/extensions/session-bus/tui.ts +5 -5
  184. package/extensions/session-bus/types.ts +23 -14
  185. package/extensions/subagent/agents.ts +9 -8
  186. package/extensions/subagent/commands.ts +2 -1
  187. package/extensions/subagent/invocation.ts +4 -2
  188. package/extensions/team/commands.ts +145 -0
  189. package/extensions/team/dispatch.ts +133 -0
  190. package/extensions/team/format.ts +119 -0
  191. package/extensions/team/index.ts +34 -0
  192. package/extensions/team/schema.ts +29 -0
  193. package/extensions/team/tools.ts +267 -0
  194. package/extensions/team/types.ts +65 -0
  195. package/extensions/todo/index.ts +4 -1
  196. package/extensions/todo/nudge.ts +8 -2
  197. package/extensions/web-access/fetch/page.ts +4 -1
  198. package/extensions/web-access/fetch/structured.ts +447 -0
  199. package/extensions/web-access/fetch/tool.ts +2 -1
  200. package/extensions/web-access/search/tool.ts +2 -1
  201. package/extensions/wechat/bridge.ts +106 -251
  202. package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
  203. package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
  204. package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
  205. package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
  206. package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
  207. package/extensions/wechat/dialog/channels.ts +136 -0
  208. package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +37 -37
  209. package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +2 -2
  210. package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
  211. package/extensions/wechat/index.ts +10 -10
  212. package/extensions/wechat/intake.ts +245 -0
  213. package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
  214. package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
  215. package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +100 -22
  216. package/extensions/wechat/{presence.ts → peer/presence.ts} +8 -7
  217. package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +1 -1
  218. package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
  219. package/extensions/wechat/{state.ts → state/state.ts} +14 -2
  220. package/extensions/wechat/{format.ts → text/format.ts} +3 -3
  221. package/extensions/wechat/{notices.ts → text/notices.ts} +8 -0
  222. package/extensions/wechat/tools.ts +29 -12
  223. package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
  224. package/extensions/wechat/{deliver.ts → transport/deliver.ts} +72 -28
  225. package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
  226. package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
  227. package/extensions/wechat/transport/outbound.ts +142 -0
  228. package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
  229. package/extensions/wechat/types.ts +10 -1
  230. package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
  231. package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
  232. package/extensions/worktree/commands.ts +24 -17
  233. package/extensions/worktree/exit.ts +12 -6
  234. package/extensions/worktree/git.ts +22 -14
  235. package/extensions/worktree/guard.ts +18 -290
  236. package/extensions/worktree/handoff.ts +10 -2
  237. package/extensions/worktree/index.ts +2 -1
  238. package/extensions/worktree/lifecycle.ts +2 -0
  239. package/extensions/worktree/registry.ts +20 -11
  240. package/extensions/worktree/root-tools.ts +13 -3
  241. package/extensions/worktree/runtime.ts +8 -2
  242. package/extensions/worktree/screen.ts +15 -8
  243. package/extensions/worktree/shell.ts +303 -0
  244. package/extensions/worktree/state.ts +1 -1
  245. package/extensions/worktree/status.ts +42 -9
  246. package/extensions/worktree/tools.ts +19 -13
  247. package/lib/README.md +11 -8
  248. package/lib/board/card.ts +479 -0
  249. package/lib/board/links.ts +141 -0
  250. package/lib/board/page-pointer.ts +310 -0
  251. package/lib/board/round-text.ts +81 -0
  252. package/lib/board/round.ts +94 -0
  253. package/lib/board/rules.ts +437 -0
  254. package/lib/board/session.ts +105 -0
  255. package/lib/board/store.ts +497 -0
  256. package/lib/board/view.ts +234 -0
  257. package/lib/errors.ts +12 -4
  258. package/lib/format.ts +35 -0
  259. package/lib/http/guard.ts +112 -0
  260. package/lib/http/index.ts +16 -5
  261. package/lib/http/serve.ts +237 -0
  262. package/lib/http/server.ts +12 -10
  263. package/lib/http/sink.ts +135 -0
  264. package/lib/http/token.ts +38 -0
  265. package/lib/plan-entry.ts +11 -0
  266. package/lib/policy.ts +25 -2
  267. package/lib/requirements.ts +6 -7
  268. package/lib/session-label.ts +64 -0
  269. package/lib/session-roster.ts +201 -0
  270. package/lib/session-tool.ts +184 -0
  271. package/lib/spawn.ts +37 -3
  272. package/lib/stray-fields.ts +45 -0
  273. package/lib/tool-names.ts +38 -2
  274. package/lib/tui/glyphs.ts +3 -2
  275. package/lib/web/README.md +57 -4
  276. package/lib/web/client-runtime.ts +123 -0
  277. package/lib/web/components.ts +5 -0
  278. package/lib/web/index.ts +4 -2
  279. package/lib/web/markup.ts +88 -1
  280. package/lib/web/names.ts +42 -0
  281. package/lib/web/runtime.ts +7 -1
  282. package/lib/web/tokens.ts +25 -2
  283. package/package.json +26 -14
  284. package/skills/next/SKILL.md +4 -3
  285. package/skills/project-docs/SKILL.md +133 -0
  286. package/skills/project-docs/references/audit.md +131 -0
  287. package/skills/project-docs/references/changelog.md +105 -0
  288. package/skills/project-docs/references/discovery.md +127 -0
  289. package/skills/project-docs/references/readme.md +80 -0
  290. package/skills/project-docs/references/release.md +84 -0
  291. package/skills/project-docs/references/roadmap.md +93 -0
  292. package/skills/project-docs/scripts/state.ts +316 -0
  293. package/skills/refactor/SKILL.md +100 -125
  294. package/skills/refactor/references/handoff.md +35 -0
  295. package/skills/refactor/references/signals.md +54 -0
  296. package/skills/refactor/scripts/inventory.ts +587 -0
  297. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  298. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  299. package/extensions/file-browse/README.md +0 -506
  300. package/extensions/file-browse/files.ts +0 -564
  301. package/extensions/file-browse/server.ts +0 -141
  302. package/extensions/job/process.ts +0 -35
  303. package/extensions/plan/split.ts +0 -87
  304. package/extensions/session-bus/README.md +0 -247
  305. package/extensions/session-bus/team.ts +0 -102
  306. package/extensions/wechat/outbound.ts +0 -100
  307. package/lib/plans/sections.ts +0 -172
  308. package/lib/plans/setting.ts +0 -52
  309. package/lib/plans/split.ts +0 -215
  310. package/lib/plans/store.ts +0 -19
  311. package/lib/plans/team.ts +0 -151
  312. package/skills/changelog/SKILL.md +0 -148
  313. package/skills/parallel-dev/SKILL.md +0 -150
  314. /package/extensions/file-browse/{rev.ts → live/rev.ts} +0 -0
  315. /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
  316. /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
  317. /package/extensions/file-browse/{live-client.ts → view/live-client.ts} +0 -0
  318. /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
  319. /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
  320. /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
  321. /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
  322. /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
  323. /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
  324. /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
@@ -9,7 +9,7 @@
9
9
  * The catalogue is the shortlist a session changes, not the whole document: every
10
10
  * row is one a workflow differs on, and each states its own rule so a value the
11
11
  * screen accepts is a value the loader keeps. The catalogue order is the screen's
12
- * order, and it is capped so the section fits a 24-row terminal (`panel.test.ts`
12
+ * order, and it is capped so the section fits a 24-row terminal (`settings.test.ts`
13
13
  * holds the ceiling); a new row has to displace one. Everything else stays
14
14
  * `bytefly.json` — a list (`doctor.skip`), a table (`price.rates`), a schedule
15
15
  * (`price.peak`), and the tuning numbers and labels this screen deliberately does
@@ -25,13 +25,14 @@
25
25
  */
26
26
 
27
27
  import { configSection, configValueAt } from "../../../lib/config.ts";
28
- import { SPLIT_KEY, SPLIT_SECTION, SPLIT_SETTING_ID, SPLIT_SETTING_LABEL } from "../../../lib/plans/setting.ts";
29
28
  import { DEFAULT_CONFIG as DEFAULT_DOCTOR_CONFIG, normalizeConfig as normalizeDoctorConfig } from "../doctor/config.ts";
30
29
  import {
31
30
  CURRENCY_DEFAULTS,
31
+ CURRENCY_SYMBOLS,
32
32
  DEFAULT_BYTEFLY_CONFIG,
33
33
  DEFAULT_CURRENCY,
34
34
  MAX_BUDGET,
35
+ MAX_DECIMALS,
35
36
  normalizeByteflyConfig,
36
37
  } from "../footer/price/config.ts";
37
38
  import type { CurrencyCode } from "../footer/price/types.ts";
@@ -39,7 +40,7 @@ import { DEFAULT_NOTIFY_CONFIG, normalizeNotifyConfig } from "../notify/config.t
39
40
  import type { Settings, WriteScope } from "../picker/types.ts";
40
41
 
41
42
  /** Where a preference is stored. */
42
- type SettingSource = "app" | "bytefly" | "plan";
43
+ type SettingSource = "app" | "bytefly";
43
44
 
44
45
  /** The row control a preference uses. */
45
46
  type SettingKind = "boolean" | "enum" | "text";
@@ -47,6 +48,13 @@ type SettingKind = "boolean" | "enum" | "text";
47
48
  /** Where the effective value came from. */
48
49
  type SettingOrigin = "default" | "global" | "project" | "inherited";
49
50
 
51
+ /**
52
+ * The heading a row sits under. The screen draws one heading per group, so twelve
53
+ * rows read as four short lists: what a session starts with, what it tells you
54
+ * about, what it keeps, and what it costs.
55
+ */
56
+ export type SettingGroup = "Startup" | "Notifications" | "Session" | "Price";
57
+
50
58
  /** One curated value a row can take. */
51
59
  interface SettingOption {
52
60
  value: string;
@@ -62,7 +70,7 @@ interface TextRule {
62
70
  }
63
71
 
64
72
  /** The `bytefly.json` sections a row still reaches. */
65
- export type ConfigSectionName = "notify" | "doctor" | "price" | "rewind" | "job" | "plan";
73
+ export type ConfigSectionName = "notify" | "doctor" | "price" | "rewind" | "job";
66
74
 
67
75
  /** Where a `bytefly.json` row lives: a section, and the path to its leaf. */
68
76
  interface SettingConfig {
@@ -81,6 +89,8 @@ interface SettingLeaf {
81
89
  export interface SettingDescriptor {
82
90
  /** Stable identity, also the focus key. */
83
91
  id: string;
92
+ /** The heading this row is listed under. */
93
+ group: SettingGroup;
84
94
  label: string;
85
95
  description: string;
86
96
  source: SettingSource;
@@ -125,8 +135,6 @@ const ON_OFF: readonly SettingOption[] = [
125
135
  { value: "true", label: "On" },
126
136
  ];
127
137
 
128
- /** The notify section's delivery methods, labeled by the surface each reaches. */
129
-
130
138
  /**
131
139
  * A text rule for a money amount inside an inclusive range: a whole number or a
132
140
  * decimal with up to four places, matching the loader's clamp for `price.budget`.
@@ -148,15 +156,17 @@ function amountRule(min: number, max: number): TextRule {
148
156
  /**
149
157
  * The catalogue, in screen order.
150
158
  *
151
- * Global scope lists all thirteen; Project lists the eleven `bytefly.json` rows,
159
+ * Global scope lists all twelve; Project lists the ten `bytefly.json` rows,
152
160
  * because Pi's setters write the personal `settings.json` only. The list is
153
161
  * capped so the screen keeps fitting a 24-row terminal.
154
162
  */
155
163
  export const SETTING_ROWS: readonly SettingDescriptor[] = [
156
164
  {
157
165
  id: "byteflyUi",
158
- label: "Bytefly startup TUI",
159
- description: "Use bytefly's startup header in place of Pi's startup TUI.",
166
+ group: "Startup",
167
+ label: "Startup screen",
168
+ description:
169
+ "Show bytefly's header in place of Pi's own, so a session opens on the versions, the help keys, and the update marker.",
160
170
  source: "app",
161
171
  kind: "boolean",
162
172
  fallback: "false",
@@ -165,9 +175,10 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
165
175
  },
166
176
  {
167
177
  id: "theme",
178
+ group: "Startup",
168
179
  label: "Theme",
169
180
  description:
170
- "Pi's color theme, chosen from the palettes this package ships; `system` follows the terminal's colors. Takes effect after reload.",
181
+ "The palette every bytefly surface is drawn with; `system` follows Pi's own colors. Takes effect after reload.",
171
182
  source: "app",
172
183
  kind: "enum",
173
184
  fallback: "system",
@@ -176,8 +187,9 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
176
187
  },
177
188
  {
178
189
  id: "notify.enabled",
190
+ group: "Notifications",
179
191
  label: "Desktop notifications",
180
- description: "Raise an OS notification when a run or job finishes, or the agent asks for input.",
192
+ description: "The master switch for OS notifications; with it off, none of the four below is raised.",
181
193
  source: "bytefly",
182
194
  kind: "boolean",
183
195
  fallback: "false",
@@ -187,8 +199,9 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
187
199
  },
188
200
  {
189
201
  id: "notify.turnFinished",
190
- label: "Turn finished",
191
- description: "Announce it when a full agent run settles.",
202
+ group: "Notifications",
203
+ label: "When a turn ends",
204
+ description: "Raise a notification once the agent has finished a reply.",
192
205
  source: "bytefly",
193
206
  kind: "boolean",
194
207
  fallback: "true",
@@ -198,8 +211,9 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
198
211
  },
199
212
  {
200
213
  id: "notify.needsInput",
201
- label: "Needs input",
202
- description: "Announce it when the agent opens a prompt and waits for an answer.",
214
+ group: "Notifications",
215
+ label: "When the agent needs you",
216
+ description: "Raise a notification when a question or a permission prompt is waiting.",
203
217
  source: "bytefly",
204
218
  kind: "boolean",
205
219
  fallback: "true",
@@ -209,8 +223,9 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
209
223
  },
210
224
  {
211
225
  id: "notify.jobFinished",
212
- label: "Job finished",
213
- description: "Announce it when a background job or watcher settles.",
226
+ group: "Notifications",
227
+ label: "When a job ends",
228
+ description: "Raise a notification when a background job or a watcher settles.",
214
229
  source: "bytefly",
215
230
  kind: "boolean",
216
231
  fallback: "true",
@@ -220,8 +235,9 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
220
235
  },
221
236
  {
222
237
  id: "notify.matchFound",
223
- label: "Watcher matched",
224
- description: "Announce it when a `watch` whose `wake` is on matches, once the agent is idle.",
238
+ group: "Notifications",
239
+ label: "When a watcher matches",
240
+ description: "Raise a notification when a watched pattern appears, once the agent is idle.",
225
241
  source: "bytefly",
226
242
  kind: "boolean",
227
243
  fallback: "true",
@@ -231,8 +247,9 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
231
247
  },
232
248
  {
233
249
  id: "job.wakeOnFinish",
234
- label: "Wake when a job finishes",
235
- description: "Start one agent turn when any background job finishes.",
250
+ group: "Session",
251
+ label: "Resume after a job",
252
+ description: "Wake the agent once a background job finishes, so it can read the output.",
236
253
  source: "bytefly",
237
254
  kind: "boolean",
238
255
  fallback: "false",
@@ -242,8 +259,9 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
242
259
  },
243
260
  {
244
261
  id: "rewind.autoSnapshots",
245
- label: "Automatic snapshots",
246
- description: "Take one rewind snapshot per prompt, before the first mutating tool call.",
262
+ group: "Session",
263
+ label: "Snapshot before a turn",
264
+ description: "Keep one rewind point per prompt, so `/rewind` can take you back.",
247
265
  source: "bytefly",
248
266
  kind: "boolean",
249
267
  fallback: "true",
@@ -253,8 +271,9 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
253
271
  },
254
272
  {
255
273
  id: "doctor.proactive",
256
- label: "Proactive health check",
257
- description: "Probe enabled extensions at session start and warn about a missing requirement.",
274
+ group: "Session",
275
+ label: "Health check at startup",
276
+ description: "Probe what the extensions need before the first turn and say what is missing.",
258
277
  source: "bytefly",
259
278
  kind: "boolean",
260
279
  fallback: "true",
@@ -264,9 +283,9 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
264
283
  },
265
284
  {
266
285
  id: "price.currency.code",
286
+ group: "Price",
267
287
  label: "Currency",
268
- description:
269
- "CNY prices DeepSeek from its yuan rates; USD from its published dollars, falling back to Pi's recorded cost.",
288
+ description: "The currency the footer prices a session in, at that currency's published rates.",
270
289
  source: "bytefly",
271
290
  kind: "enum",
272
291
  fallback: DEFAULT_CURRENCY.code,
@@ -286,8 +305,9 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
286
305
  },
287
306
  {
288
307
  id: "price.budget",
308
+ group: "Price",
289
309
  label: "Session budget",
290
- description: "Warn once when the session's cost reaches this amount; 0 is off. It never stops a turn.",
310
+ description: "Where the footer's `spent/limit` warns: one notice and one notification at the limit; 0 is off.",
291
311
  source: "bytefly",
292
312
  kind: "text",
293
313
  fallback: "0",
@@ -296,20 +316,6 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
296
316
  text: amountRule(0, MAX_BUDGET),
297
317
  config: { section: "price", path: ["budget"] },
298
318
  },
299
- {
300
- // The split's switch, with the label, id, and default taken from the module
301
- // that reads it, so this row cannot describe a key nothing honours.
302
- id: SPLIT_SETTING_ID,
303
- label: SPLIT_SETTING_LABEL,
304
- description:
305
- "Let an approved plan carry a team of slices, which separate sessions join by name. Off keeps every plan one flow.",
306
- source: "plan",
307
- kind: "boolean",
308
- fallback: "false",
309
- json: "boolean",
310
- options: ON_OFF,
311
- config: { section: SPLIT_SECTION, path: [SPLIT_KEY] },
312
- },
313
319
  ];
314
320
 
315
321
  /** The one refusal for a Project write while the project is untrusted. */
@@ -346,6 +352,31 @@ function themeOptions(themes: readonly string[]): readonly SettingOption[] {
346
352
  return names.map((name) => ({ value: name, label: name }));
347
353
  }
348
354
 
355
+ /** The currency and precision a session prices in, through the price reader. */
356
+ function currencyOf(snapshot: SettingsSnapshot, scope: WriteScope): { code: CurrencyCode; decimals: number } {
357
+ const reader = SECTION_READERS.price;
358
+ const globalRaw = configSection(snapshot.global, "price");
359
+ const projectRaw = scope === "project" ? configSection(snapshot.project, "price") : undefined;
360
+ const code = reader?.(globalRaw, projectRaw, ["currency", "code"]);
361
+ const decimals = reader?.(globalRaw, projectRaw, ["currency", "decimals"]);
362
+ return {
363
+ code: typeof code === "string" && code in CURRENCY_SYMBOLS ? (code as CurrencyCode) : DEFAULT_CURRENCY.code,
364
+ decimals: typeof decimals === "number" ? Math.min(decimals, MAX_DECIMALS) : DEFAULT_CURRENCY.decimals,
365
+ };
366
+ }
367
+
368
+ /**
369
+ * The budget's display value: `Off` for zero, otherwise the amount in the currency
370
+ * the session prices in. A reader thinks in `¥5`, not in `5`, and in `Off` rather
371
+ * than in `0`.
372
+ */
373
+ function budgetLabel(raw: string, snapshot: SettingsSnapshot, scope: WriteScope): string {
374
+ const amount = Number(raw);
375
+ if (!Number.isFinite(amount) || amount <= 0) return "Off";
376
+ const { code, decimals } = currencyOf(snapshot, scope);
377
+ return `${CURRENCY_SYMBOLS[code]}${amount.toFixed(decimals)}`;
378
+ }
379
+
349
380
  /** The display label for `raw`, or the raw value when it is a valid custom one. */
350
381
  function labelFor(row: SettingDescriptor, raw: string): string {
351
382
  const option = row.options.find((entry) => entry.value === raw);
@@ -445,7 +476,8 @@ export function settingViews(snapshot: SettingsSnapshot, scope: WriteScope): Set
445
476
  const options = row.id === "theme" ? themeOptions(snapshot.themes) : row.options;
446
477
  const { value, origin } = row.source === "app" ? appValue(snapshot, row) : byteflyValue(snapshot, row, scope);
447
478
  const view = { ...row, options, value };
448
- return { ...view, valueLabel: labelFor(view, value), origin };
479
+ const valueLabel = row.id === "price.budget" ? budgetLabel(value, snapshot, scope) : labelFor(view, value);
480
+ return { ...view, valueLabel, origin };
449
481
  });
450
482
  }
451
483
 
@@ -1,10 +1,12 @@
1
1
  /**
2
- * The panel's entry model: one flat list of selectable rows per section.
2
+ * The panel's entry model: one flat list of display entries per section, made of
3
+ * group headings interleaved with the selectable rows under them.
3
4
  *
4
- * Extensions interleaves group headings with rows — a source group says where a
5
- * resource came from, which the row cannot — while Settings is flat, in catalogue
6
- * order. Both are built from a model or a set of views, so the component only has
7
- * to keep one cursor per section.
5
+ * Every section groups. A resource list groups by source — where an extension came
6
+ * from is something its row cannot say — and Settings groups by what the
7
+ * preference does, so twelve rows read as four short lists instead of one long
8
+ * one. Both are built from a model or a set of views, so the component only has to
9
+ * keep one cursor per section.
8
10
  *
9
11
  * Pure: the list is rebuilt from a model or a set of views, and navigation is
10
12
  * index arithmetic.
@@ -76,9 +78,25 @@ export function entriesOf(model: PanelModel): PanelEntry[] {
76
78
  }
77
79
 
78
80
  /**
79
- * The Settings entries: one row per view, in catalogue order, with no group
80
- * headings — the list is short enough to read whole.
81
+ * The Settings entries: one heading per group, in catalogue order, then its rows.
82
+ * The group key names the heading so a filter cannot collide a setting id with it.
81
83
  */
82
84
  export function settingEntries(views: readonly SettingView[]): PanelEntry[] {
83
- return views.map((setting) => ({ kind: "setting", setting }));
85
+ const entries: PanelEntry[] = [];
86
+ for (const [group, settings] of groupSettings(views)) {
87
+ entries.push({ kind: "group", key: `setting:${group}`, label: group, count: settings.length });
88
+ for (const setting of settings) entries.push({ kind: "setting", setting });
89
+ }
90
+ return entries;
91
+ }
92
+
93
+ /** The views by their group, in catalogue order of first appearance. */
94
+ function groupSettings(views: readonly SettingView[]): Map<string, SettingView[]> {
95
+ const groups = new Map<string, SettingView[]>();
96
+ for (const view of views) {
97
+ const list = groups.get(view.group) ?? [];
98
+ list.push(view);
99
+ groups.set(view.group, list);
100
+ }
101
+ return groups;
84
102
  }
@@ -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
  }