@quickchatai/quickreview 1.2.0 → 1.3.2

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 (350) hide show
  1. package/README.md +116 -53
  2. package/bin/qcr.js +1 -1
  3. package/config.example.toml +25 -7
  4. package/deploy/ansible/README.md +20 -12
  5. package/deploy/ansible/inventory.example.ini +1 -1
  6. package/deploy/collect-secrets.sh +1 -1
  7. package/deploy/qcr-update.sh +1 -1
  8. package/dist/extensions/cli/home.js +14 -0
  9. package/dist/extensions/cli/home.js.map +1 -0
  10. package/dist/extensions/cli/selfimprove.js +18 -0
  11. package/dist/extensions/cli/selfimprove.js.map +1 -0
  12. package/dist/extensions/home.js +350 -0
  13. package/dist/extensions/home.js.map +1 -0
  14. package/dist/extensions/login-route.js +1 -1
  15. package/dist/extensions/login-route.js.map +1 -1
  16. package/dist/extensions/onboarding.js +8 -7
  17. package/dist/extensions/onboarding.js.map +1 -1
  18. package/dist/extensions/selfimprove.js +49 -0
  19. package/dist/extensions/selfimprove.js.map +1 -0
  20. package/dist/extensions/setup-scan.js +23 -592
  21. package/dist/extensions/setup-scan.js.map +1 -1
  22. package/dist/extensions/setup.js +2 -47
  23. package/dist/extensions/setup.js.map +1 -1
  24. package/dist/extensions/vendor/openviking-pi/index.js +4 -0
  25. package/dist/extensions/vendor/openviking-pi/index.js.map +1 -1
  26. package/dist/extensions/vendor/openviking-pi/qcr-hooks.js +3 -1
  27. package/dist/extensions/vendor/openviking-pi/qcr-hooks.js.map +1 -1
  28. package/dist/extensions/vendor/openviking-pi/sync.js +81 -0
  29. package/dist/extensions/vendor/openviking-pi/sync.js.map +1 -1
  30. package/dist/src/archive/restore.js +18 -15
  31. package/dist/src/archive/restore.js.map +1 -1
  32. package/dist/src/botworker.js +13 -2
  33. package/dist/src/botworker.js.map +1 -1
  34. package/dist/src/channelfs.js +3 -26
  35. package/dist/src/channelfs.js.map +1 -1
  36. package/dist/src/cli.js +14 -50
  37. package/dist/src/cli.js.map +1 -1
  38. package/dist/src/config.js +16 -0
  39. package/dist/src/config.js.map +1 -1
  40. package/dist/src/daemon.js +33 -4
  41. package/dist/src/daemon.js.map +1 -1
  42. package/dist/src/debuground.js +1 -0
  43. package/dist/src/debuground.js.map +1 -1
  44. package/dist/src/debugroundpi.js +1 -0
  45. package/dist/src/debugroundpi.js.map +1 -1
  46. package/dist/src/engine/caserunner-sdk.js +15 -251
  47. package/dist/src/engine/caserunner-sdk.js.map +1 -1
  48. package/dist/src/home/adapters.js +349 -0
  49. package/dist/src/home/adapters.js.map +1 -0
  50. package/dist/src/home/advanced.js +318 -0
  51. package/dist/src/home/advanced.js.map +1 -0
  52. package/dist/src/home/advancedtools.js +57 -0
  53. package/dist/src/home/advancedtools.js.map +1 -0
  54. package/dist/src/home/autoreview.js +325 -0
  55. package/dist/src/home/autoreview.js.map +1 -0
  56. package/dist/src/home/daemonstatus.js +32 -0
  57. package/dist/src/home/daemonstatus.js.map +1 -0
  58. package/dist/src/home/devicelogin.js +61 -0
  59. package/dist/src/home/devicelogin.js.map +1 -0
  60. package/dist/src/home/launch.js +202 -0
  61. package/dist/src/home/launch.js.map +1 -0
  62. package/dist/src/home/loginowner.js +205 -0
  63. package/dist/src/home/loginowner.js.map +1 -0
  64. package/dist/src/home/memory.js +122 -0
  65. package/dist/src/home/memory.js.map +1 -0
  66. package/dist/src/home/model.js +811 -0
  67. package/dist/src/home/model.js.map +1 -0
  68. package/dist/src/home/panel.js +520 -0
  69. package/dist/src/home/panel.js.map +1 -0
  70. package/dist/src/home/ports.js +19 -0
  71. package/dist/src/home/ports.js.map +1 -0
  72. package/dist/src/home/review.js +163 -0
  73. package/dist/src/home/review.js.map +1 -0
  74. package/dist/src/home/reviews.js +478 -0
  75. package/dist/src/home/reviews.js.map +1 -0
  76. package/dist/src/home/setup.js +507 -0
  77. package/dist/src/home/setup.js.map +1 -0
  78. package/dist/src/home/setupadapters.js +214 -0
  79. package/dist/src/home/setupadapters.js.map +1 -0
  80. package/dist/src/home/setupcard.js +199 -0
  81. package/dist/src/home/setupcard.js.map +1 -0
  82. package/dist/src/home/setuptools.js +45 -0
  83. package/dist/src/home/setuptools.js.map +1 -0
  84. package/dist/src/home/spend.js +160 -0
  85. package/dist/src/home/spend.js.map +1 -0
  86. package/dist/src/home/switches.js +209 -0
  87. package/dist/src/home/switches.js.map +1 -0
  88. package/dist/src/home/switchon.js +439 -0
  89. package/dist/src/home/switchon.js.map +1 -0
  90. package/dist/src/home/toolnames.js +41 -0
  91. package/dist/src/home/toolnames.js.map +1 -0
  92. package/dist/src/home/tune.js +341 -0
  93. package/dist/src/home/tune.js.map +1 -0
  94. package/dist/src/home/words.js +7 -0
  95. package/dist/src/home/words.js.map +1 -0
  96. package/dist/src/homes.js +14 -0
  97. package/dist/src/homes.js.map +1 -1
  98. package/dist/src/install.js +1 -1
  99. package/dist/src/install.js.map +1 -1
  100. package/dist/src/ipc/workerspec.js +8 -0
  101. package/dist/src/ipc/workerspec.js.map +1 -1
  102. package/dist/src/ledgercheck.js +39 -10
  103. package/dist/src/ledgercheck.js.map +1 -1
  104. package/dist/src/live/publish.js +1 -1
  105. package/dist/src/live/publish.js.map +1 -1
  106. package/dist/src/livecli.js +1 -1
  107. package/dist/src/livecli.js.map +1 -1
  108. package/dist/src/loginrefresh/handover.js +121 -0
  109. package/dist/src/loginrefresh/handover.js.map +1 -0
  110. package/dist/src/loginrefresh/lock.js +54 -3
  111. package/dist/src/loginrefresh/lock.js.map +1 -1
  112. package/dist/src/loginrefresh/refresher.js +33 -5
  113. package/dist/src/loginrefresh/refresher.js.map +1 -1
  114. package/dist/src/loginrefresh/supervisor.js +18 -1
  115. package/dist/src/loginrefresh/supervisor.js.map +1 -1
  116. package/dist/src/npmverbs.js +42 -12
  117. package/dist/src/npmverbs.js.map +1 -1
  118. package/dist/src/onboard/choices.js +6 -3
  119. package/dist/src/onboard/choices.js.map +1 -1
  120. package/dist/src/onboard/engine.js +33 -292
  121. package/dist/src/onboard/engine.js.map +1 -1
  122. package/dist/src/onboard/steps/bringup.js +3 -2
  123. package/dist/src/onboard/steps/bringup.js.map +1 -1
  124. package/dist/src/onboard/steps/configfill.js +18 -7
  125. package/dist/src/onboard/steps/configfill.js.map +1 -1
  126. package/dist/src/onboard/steps/loginshare.js +213 -0
  127. package/dist/src/onboard/steps/loginshare.js.map +1 -0
  128. package/dist/src/onboard/steps/memory.js +14 -2
  129. package/dist/src/onboard/steps/memory.js.map +1 -1
  130. package/dist/src/onboard/steps/repoconfig.js +72 -0
  131. package/dist/src/onboard/steps/repoconfig.js.map +1 -0
  132. package/dist/src/onboard/toolnames.js +2 -2
  133. package/dist/src/onboard/toolnames.js.map +1 -1
  134. package/dist/src/piagentdir.js +13 -0
  135. package/dist/src/piagentdir.js.map +1 -0
  136. package/dist/src/pilogin.js +31 -16
  137. package/dist/src/pilogin.js.map +1 -1
  138. package/dist/src/qcrbin.js +16 -11
  139. package/dist/src/qcrbin.js.map +1 -1
  140. package/dist/src/reviewargs.js +122 -0
  141. package/dist/src/reviewargs.js.map +1 -0
  142. package/dist/src/reviewcli.js +7 -161
  143. package/dist/src/reviewcli.js.map +1 -1
  144. package/dist/src/reviewdetach.js +76 -11
  145. package/dist/src/reviewdetach.js.map +1 -1
  146. package/dist/src/scancli.js +39 -691
  147. package/dist/src/scancli.js.map +1 -1
  148. package/dist/src/selfimprove.js +66 -0
  149. package/dist/src/selfimprove.js.map +1 -0
  150. package/dist/src/session-command-supervisor.js +38 -9
  151. package/dist/src/session-command-supervisor.js.map +1 -1
  152. package/dist/src/setup/adapters.js +5 -223
  153. package/dist/src/setup/adapters.js.map +1 -1
  154. package/dist/src/setup/caserunner.js +15 -249
  155. package/dist/src/setup/caserunner.js.map +1 -1
  156. package/dist/src/setup/engine.js +32 -341
  157. package/dist/src/setup/engine.js.map +1 -1
  158. package/dist/src/setup/facts.js +3 -592
  159. package/dist/src/setup/facts.js.map +1 -1
  160. package/dist/src/setup/findings.js +3 -947
  161. package/dist/src/setup/findings.js.map +1 -1
  162. package/dist/src/setup/presentation.js +3 -8
  163. package/dist/src/setup/presentation.js.map +1 -1
  164. package/dist/src/setup/scan.js +3 -724
  165. package/dist/src/setup/scan.js.map +1 -1
  166. package/dist/src/setup/scanbuild.js +9 -92
  167. package/dist/src/setup/scanbuild.js.map +1 -1
  168. package/dist/src/setup/scanlease.js +3 -250
  169. package/dist/src/setup/scanlease.js.map +1 -1
  170. package/dist/src/setup/scanreport.js +3 -288
  171. package/dist/src/setup/scanreport.js.map +1 -1
  172. package/dist/src/setup/scanrun.js +3 -1732
  173. package/dist/src/setup/scanrun.js.map +1 -1
  174. package/dist/src/setup/scantree.js +3 -256
  175. package/dist/src/setup/scantree.js.map +1 -1
  176. package/dist/src/setup/stage2request.js +3 -258
  177. package/dist/src/setup/stage2request.js.map +1 -1
  178. package/dist/src/setup/unitcut.js +3 -436
  179. package/dist/src/setup/unitcut.js.map +1 -1
  180. package/dist/src/state.js +75 -9
  181. package/dist/src/state.js.map +1 -1
  182. package/dist/src/takeover.js +1 -0
  183. package/dist/src/takeover.js.map +1 -1
  184. package/dist/src/takeoverpi.js +2 -1
  185. package/dist/src/takeoverpi.js.map +1 -1
  186. package/dist/src/tui/alerts.js +4 -1
  187. package/dist/src/tui/alerts.js.map +1 -1
  188. package/dist/src/tui/board.js +2 -2
  189. package/dist/src/tui/board.js.map +1 -1
  190. package/dist/src/tui/companion.js +2 -1
  191. package/dist/src/tui/companion.js.map +1 -1
  192. package/dist/src/tui/deploytab.js +2 -2
  193. package/dist/src/tui/deploytab.js.map +1 -1
  194. package/dist/src/tui/index.js +9 -6
  195. package/dist/src/tui/index.js.map +1 -1
  196. package/dist/src/tui/memory.js +3 -16
  197. package/dist/src/tui/memory.js.map +1 -1
  198. package/dist/src/tui/memoryurl.js +22 -0
  199. package/dist/src/tui/memoryurl.js.map +1 -0
  200. package/dist/src/tui/remote.js +1 -1
  201. package/dist/src/tui/remote.js.map +1 -1
  202. package/dist/src/tui/route.js +2 -2
  203. package/dist/src/tui/route.js.map +1 -1
  204. package/dist/src/tui/setupstate.js +8 -5
  205. package/dist/src/tui/setupstate.js.map +1 -1
  206. package/dist/src/tui/sshtriage.js +1 -1
  207. package/dist/src/tui/sshtriage.js.map +1 -1
  208. package/dist/src/verdicts.js +12 -0
  209. package/dist/src/verdicts.js.map +1 -1
  210. package/dist/src/wire.js +9 -4
  211. package/dist/src/wire.js.map +1 -1
  212. package/dist/src/workerbreaker.js +32 -5
  213. package/dist/src/workerbreaker.js.map +1 -1
  214. package/dist/src/workerworld.js +4 -0
  215. package/dist/src/workerworld.js.map +1 -1
  216. package/docker/README.md +1 -1
  217. package/docker/entrypoint.sh +40 -6
  218. package/docker-compose.yml +16 -1
  219. package/extensions/cli/home.ts +14 -0
  220. package/extensions/cli/selfimprove.ts +18 -0
  221. package/extensions/home.ts +484 -0
  222. package/extensions/login-route.ts +1 -1
  223. package/extensions/onboarding.ts +8 -7
  224. package/extensions/selfimprove.ts +65 -0
  225. package/extensions/setup-scan.ts +79 -766
  226. package/extensions/setup.ts +14 -70
  227. package/extensions/vendor/openviking-pi/NOTICE +5 -0
  228. package/extensions/vendor/openviking-pi/README.md +39 -5
  229. package/extensions/vendor/openviking-pi/index.ts +4 -0
  230. package/extensions/vendor/openviking-pi/patches/0014-one-turn-sync-in-flight.patch +239 -0
  231. package/extensions/vendor/openviking-pi/qcr-hooks.ts +3 -1
  232. package/extensions/vendor/openviking-pi/sync.ts +80 -0
  233. package/extensions/vendor/openviking-pi/upstream.json +2 -1
  234. package/package.json +6 -4
  235. package/prompts/onboarding.md +8 -7
  236. package/schemas/review-result.v1.json +60 -0
  237. package/scripts/deploy.sh +8 -6
  238. package/scripts/qcr-host.sh +28 -5
  239. package/scripts/vm-ghcr-login.sh +7 -4
  240. package/src/archive/restore.ts +18 -21
  241. package/src/botworker.ts +13 -2
  242. package/src/channelfs.ts +3 -26
  243. package/src/cli.ts +13 -54
  244. package/src/config.ts +38 -0
  245. package/src/daemon.ts +34 -4
  246. package/src/debuground.ts +3 -0
  247. package/src/debugroundpi.ts +1 -0
  248. package/src/engine/caserunner-sdk.ts +29 -314
  249. package/src/home/adapters.ts +418 -0
  250. package/src/home/advanced.ts +421 -0
  251. package/src/home/advancedtools.ts +96 -0
  252. package/src/home/autoreview.ts +385 -0
  253. package/src/home/daemonstatus.ts +55 -0
  254. package/src/home/devicelogin.ts +107 -0
  255. package/src/home/launch.ts +257 -0
  256. package/src/home/loginowner.ts +248 -0
  257. package/src/home/memory.ts +176 -0
  258. package/src/home/model.ts +965 -0
  259. package/src/home/panel.ts +666 -0
  260. package/src/home/ports.ts +338 -0
  261. package/src/home/review.ts +206 -0
  262. package/src/home/reviews.ts +565 -0
  263. package/src/home/setup.ts +689 -0
  264. package/src/home/setupadapters.ts +217 -0
  265. package/src/home/setupcard.ts +288 -0
  266. package/src/home/setuptools.ts +57 -0
  267. package/src/home/spend.ts +194 -0
  268. package/src/home/switches.ts +293 -0
  269. package/src/home/switchon.ts +554 -0
  270. package/src/home/toolnames.ts +46 -0
  271. package/src/home/tune.ts +405 -0
  272. package/src/home/words.ts +8 -0
  273. package/src/homes.ts +17 -0
  274. package/src/install.ts +1 -1
  275. package/src/ipc/workerspec.ts +15 -0
  276. package/src/ledgercheck.ts +45 -9
  277. package/src/live/publish.ts +4 -1
  278. package/src/livecli.ts +6 -1
  279. package/src/loginrefresh/handover.ts +138 -0
  280. package/src/loginrefresh/lock.ts +72 -3
  281. package/src/loginrefresh/refresher.ts +34 -5
  282. package/src/loginrefresh/supervisor.ts +25 -2
  283. package/src/npmverbs.ts +51 -11
  284. package/src/onboard/choices.ts +6 -3
  285. package/src/onboard/engine.ts +64 -434
  286. package/src/onboard/steps/bringup.ts +3 -2
  287. package/src/onboard/steps/configfill.ts +23 -7
  288. package/src/onboard/steps/loginshare.ts +258 -0
  289. package/src/onboard/steps/memory.ts +18 -2
  290. package/src/onboard/steps/repoconfig.ts +85 -0
  291. package/src/onboard/toolnames.ts +2 -2
  292. package/src/piagentdir.ts +12 -0
  293. package/src/pilogin.ts +32 -16
  294. package/src/qcrbin.ts +16 -11
  295. package/src/reviewargs.ts +119 -0
  296. package/src/reviewcli.ts +6 -167
  297. package/src/reviewdetach.ts +107 -13
  298. package/src/scancli.ts +75 -826
  299. package/src/selfimprove.ts +90 -0
  300. package/src/session-command-supervisor.ts +41 -8
  301. package/src/setup/adapters.ts +13 -221
  302. package/src/setup/caserunner.ts +34 -273
  303. package/src/setup/engine.ts +62 -496
  304. package/src/setup/facts.ts +3 -627
  305. package/src/setup/findings.ts +3 -1264
  306. package/src/setup/presentation.ts +3 -18
  307. package/src/setup/scan.ts +3 -1033
  308. package/src/setup/scanbuild.ts +20 -114
  309. package/src/setup/scanlease.ts +3 -297
  310. package/src/setup/scanreport.ts +3 -373
  311. package/src/setup/scanrun.ts +3 -2268
  312. package/src/setup/scantree.ts +3 -259
  313. package/src/setup/stage2request.ts +3 -347
  314. package/src/setup/unitcut.ts +3 -523
  315. package/src/state.ts +126 -11
  316. package/src/takeover.ts +5 -0
  317. package/src/takeoverpi.ts +3 -2
  318. package/src/tui/alerts.ts +9 -2
  319. package/src/tui/board.ts +2 -2
  320. package/src/tui/companion.ts +2 -1
  321. package/src/tui/deploytab.ts +2 -2
  322. package/src/tui/index.ts +9 -6
  323. package/src/tui/memory.ts +3 -15
  324. package/src/tui/memoryurl.ts +22 -0
  325. package/src/tui/remote.ts +1 -1
  326. package/src/tui/route.ts +2 -2
  327. package/src/tui/setupstate.ts +20 -16
  328. package/src/tui/sshtriage.ts +1 -1
  329. package/src/verdicts.ts +13 -0
  330. package/src/wire.ts +9 -4
  331. package/src/workerbreaker.ts +41 -5
  332. package/src/workerworld.ts +9 -1
  333. package/dist/src/live/projection.js +0 -450
  334. package/dist/src/live/projection.js.map +0 -1
  335. package/dist/src/live/read.js +0 -60
  336. package/dist/src/live/read.js.map +0 -1
  337. package/dist/src/onboard/launch.js +0 -75
  338. package/dist/src/onboard/launch.js.map +0 -1
  339. package/dist/src/setup/ledger.js +0 -207
  340. package/dist/src/setup/ledger.js.map +0 -1
  341. package/dist/src/setup/stage2.js +0 -617
  342. package/dist/src/setup/stage2.js.map +0 -1
  343. package/dist/src/setup/textsniff.js +0 -89
  344. package/dist/src/setup/textsniff.js.map +0 -1
  345. package/src/live/projection.ts +0 -491
  346. package/src/live/read.ts +0 -86
  347. package/src/onboard/launch.ts +0 -103
  348. package/src/setup/ledger.ts +0 -324
  349. package/src/setup/stage2.ts +0 -926
  350. package/src/setup/textsniff.ts +0 -95
@@ -0,0 +1,666 @@
1
+ // The home's panel: a non-capturing overlay, a full-height column at the top
2
+ // right of the chat. It draws the HomeModel in
3
+ // pi's theme tokens and turns keys and the mouse into the model's actions; it
4
+ // owns no state of its own beyond the rows it last drew, which a click or a
5
+ // hover maps back to a pull request.
6
+ //
7
+ // ╭─ quickreview ───────────────── owner/name ─╮
8
+ // │ auto-review (+ what it picks up), memory + │
9
+ // │ spend, the logins │
10
+ // ├─ To review ─────────────────────────── n ─┤
11
+ // ├─ Reviewed ──────────────────────────── n ─┤
12
+ // │ (spare rows) │
13
+ // ├─ #n ──────────────────────────────────────┤ the selected PR: its price,
14
+ // │ its review's progress (the
15
+ // │ plan, the lenses, validate,
16
+ // │ write-up, a "now" line per
17
+ // │ running lens) or its findings
18
+ // ├───────────────────────────────────────────┤
19
+ // │ the notice, then the key hints │
20
+ // ╰───────────────────────────────────────────╯
21
+ import type { Theme } from "@earendil-works/pi-coding-agent";
22
+ import type { Component, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
23
+ import {
24
+ matchesKey,
25
+ truncateToWidth,
26
+ visibleWidth,
27
+ wrapTextWithAnsi,
28
+ } from "@earendil-works/pi-tui";
29
+ import { clockOf } from "@quickchatai/pi-quickreview/src/live/lensline.js";
30
+ import { ago, providerLabel, type HomeModel } from "./model.js";
31
+ import type { OpenPull, PrReview, ReviewQuote, Slot } from "./ports.js";
32
+ import { sevOf, shortWhere, whereOf, type Lens, type Run } from "./review.js";
33
+ import { pickUpPhrase, type Ask } from "./switches.js";
34
+ import { money, plural } from "./words.js";
35
+
36
+ type Fg = Parameters<Theme["fg"]>[0];
37
+
38
+ /** Where the panel sits: the reference placement. */
39
+ export const HOME_OVERLAY = {
40
+ anchor: "top-right",
41
+ width: "34%",
42
+ minWidth: 74,
43
+ margin: { top: 1, right: 1 },
44
+ nonCapturing: true,
45
+ } as const;
46
+
47
+ /** The rows the panel leaves under it for pi's editor and footer. */
48
+ export const EDITOR_ROWS = 8;
49
+ /** Each list shows this many rows before "… n more" wherever the column
50
+ * allows; the details area gives way before a list goes under it. */
51
+ export const LIST_ROWS = 10;
52
+ /** A notice wraps onto at most this many rows. */
53
+ const NOTICE_ROWS = 3;
54
+ /** The finished view lists this many findings before "… n more". */
55
+ const FINDING_ROWS = 8;
56
+ /** "now" lines for this many running lenses, then "and n more". */
57
+ const NOW_ROWS = 4;
58
+
59
+ export interface PanelHost {
60
+ model: HomeModel;
61
+ focused(): boolean;
62
+ /** The terminal's height. */
63
+ rows(): number;
64
+ /** Give the keyboard back to the editor. */
65
+ unfocus(): void;
66
+ /** A spinner frame counter. */
67
+ frame(): number;
68
+ /** How a switch started from a panel key asks the person; absent, the
69
+ * switch says it cannot ask. */
70
+ ask?(): Ask | undefined;
71
+ /** Sign in again (src/home/loginowner.ts Relogin); absent: the panel
72
+ * offers no re-login. */
73
+ relogin?(): void;
74
+ /** A re-login is in flight. */
75
+ signingAgain?(): boolean;
76
+ /** t: "Tune to this repo" (src/home/tune.ts). */
77
+ tune?(): void;
78
+ /** x: Advanced (src/home/advanced.ts). */
79
+ advanced?(): void;
80
+ }
81
+
82
+ const SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
83
+
84
+ /** How many pull requests each list may show, out of `room` rows for both
85
+ * lists (their titles aside). Each gets half, its "… n more" row included; a
86
+ * list that needs less gives the rest to the other. */
87
+ export function listCaps(
88
+ room: number,
89
+ need: { toReview: number; reviewed: number },
90
+ ): { toReview: number; reviewed: number } {
91
+ const base = Math.max(3, Math.floor((room - 2) / 2));
92
+ const rows = (n: number, cap: number): number => (n > cap ? cap + 1 : Math.max(1, n));
93
+ const toReview = Math.max(base, room - rows(need.reviewed, base) - 1);
94
+ return { toReview, reviewed: Math.max(base, room - rows(need.toReview, toReview) - 1) };
95
+ }
96
+
97
+ /** Would a list show fewer than LIST_ROWS while it has more to show? */
98
+ function cramped(
99
+ caps: { toReview: number; reviewed: number },
100
+ need: { toReview: number; reviewed: number },
101
+ ): boolean {
102
+ return (
103
+ (need.toReview > caps.toReview && caps.toReview < LIST_ROWS) ||
104
+ (need.reviewed > caps.reviewed && caps.reviewed < LIST_ROWS)
105
+ );
106
+ }
107
+
108
+ export class HomePanel implements Component {
109
+ /** rendered line → the pull request drawn on it, for clicks and hover */
110
+ private rowMap = new Map<number, number>();
111
+
112
+ constructor(
113
+ private readonly th: Theme,
114
+ private readonly host: PanelHost,
115
+ ) {}
116
+
117
+ private get m(): HomeModel {
118
+ return this.host.model;
119
+ }
120
+
121
+ private c(color: Fg, s: string): string {
122
+ return this.th.fg(color, s);
123
+ }
124
+
125
+ private spin(): string {
126
+ return SPIN[this.host.frame() % SPIN.length]!;
127
+ }
128
+
129
+ private edge(s: string): string {
130
+ return this.c(this.host.focused() ? "borderAccent" : "borderMuted", s);
131
+ }
132
+
133
+ /** One framed row, the right part pushed to the edge. */
134
+ private row(w: number, left: string, right = "", selected = false): string {
135
+ const inner = w - 4;
136
+ const r = right ? ` ${right}` : "";
137
+ const l = truncateToWidth(left, Math.max(1, inner - visibleWidth(r)), "…");
138
+ const gap = Math.max(0, inner - visibleWidth(l) - visibleWidth(r));
139
+ let text = ` ${l}${" ".repeat(gap)}${r} `;
140
+ if (selected) text = this.th.bg("selectedBg", text);
141
+ return `${this.edge("│")}${text}${this.edge("│")}`;
142
+ }
143
+
144
+ /** A divider, optionally titled, with a count on the right. */
145
+ private rule(w: number, title = "", count = ""): string {
146
+ const left = title
147
+ ? `${this.edge("├─ ")}${this.th.bold(this.c("text", title))} `
148
+ : this.edge("├");
149
+ const right = count ? ` ${this.c("muted", count)}${this.edge(" ─┤")}` : this.edge("┤");
150
+ const fill = Math.max(0, w - visibleWidth(left) - visibleWidth(right));
151
+ return `${left}${this.edge("─".repeat(fill))}${right}`;
152
+ }
153
+
154
+ private top(w: number): string {
155
+ const left = `${this.edge("╭─ ")}${this.th.bold(this.c("accent", "quickreview"))} `;
156
+ const repo = this.m.state.repo;
157
+ const name =
158
+ repo.kind === "known"
159
+ ? (repo.value ?? "no repository")
160
+ : repo.kind === "unknown"
161
+ ? "repository unknown"
162
+ : "…";
163
+ const right = ` ${this.c("muted", name)}${this.edge(" ─╮")}`;
164
+ const fill = Math.max(0, w - visibleWidth(left) - visibleWidth(right));
165
+ return `${left}${this.edge("─".repeat(fill))}${right}`;
166
+ }
167
+
168
+ private bottom(w: number): string {
169
+ return this.edge(`╰${"─".repeat(Math.max(0, w - 2))}╯`);
170
+ }
171
+
172
+ private checking(label: string): string {
173
+ return `${this.c("accent", this.spin())} ${label} ${this.c("dim", "checking…")}`;
174
+ }
175
+
176
+ /** The auto-review row, with the switch's key on the right where the home
177
+ * can switch it and the row says nothing there. */
178
+ private autoRow(): [string, string] {
179
+ const [left, right] = this.autoLine();
180
+ if (right !== "" || !this.m.canSwitch("autoReview")) return [left, right];
181
+ const s = this.m.state.autoReview;
182
+ if (s.kind === "pending") return [left, right];
183
+ const on = s.kind === "known" && s.value.state !== "off";
184
+ const key = this.host.focused() ? "a" : "alt+a";
185
+ return [left, this.c("dim", `${key} ${on ? "switches it off" : "turns it on"}`)];
186
+ }
187
+
188
+ /** What auto-review picks up, when the home knows where to read it. */
189
+ private triggerRow(): string | null {
190
+ const t = this.m.state.trigger;
191
+ if (t === null) return null;
192
+ if (t.kind === "pending") return this.c("dim", " picks up … checking");
193
+ return this.c("dim", ` picks up ${pickUpPhrase(t.kind === "known" ? t.value : null)}`);
194
+ }
195
+
196
+ private autoLine(): [string, string] {
197
+ const s = this.m.state.autoReview;
198
+ const c = (k: Fg, t: string): string => this.c(k, t);
199
+ if (s.kind === "pending") return [this.checking("Auto-review"), ""];
200
+ if (s.kind === "unknown")
201
+ return [
202
+ `${c("dim", "?")} Auto-review ${c("muted", "unknown")} ${c("dim", "· can't read its status")}`,
203
+ "",
204
+ ];
205
+ const when =
206
+ s.value.checkedAt === undefined
207
+ ? ""
208
+ : c("dim", `checked ${ago(s.value.checkedAt, this.m.now())} ago`);
209
+ switch (s.value.state) {
210
+ case "off":
211
+ return [`${c("dim", "○")} Auto-review ${c("muted", "off")}`, ""];
212
+ case "starting":
213
+ return [`${c("warning", this.spin())} Auto-review ${c("warning", "starting")}`, ""];
214
+ case "on-live":
215
+ return [
216
+ `${c("success", "●")} Auto-review ${c("success", "on")} ${c("muted", "· live")}`,
217
+ when,
218
+ ];
219
+ case "on-dry":
220
+ return [
221
+ `${c("accent", "●")} Auto-review ${c("accent", "on")} ${c("muted", "· dry-run")}`,
222
+ when,
223
+ ];
224
+ case "paused-login":
225
+ return [
226
+ `${c("warning", "‖")} Auto-review ${c("warning", "paused")} ${c("muted", `· ${s.value.cause ?? "login expired"}`)}`,
227
+ "",
228
+ ];
229
+ case "broken":
230
+ return [
231
+ `${c("error", "✗")} Auto-review ${c("error", "stopped")}${s.value.cause ? ` ${c("muted", `· ${s.value.cause}`)}` : ""}`,
232
+ "",
233
+ ];
234
+ }
235
+ }
236
+
237
+ private memLine(): [string, string] {
238
+ const s = this.m.state.memory;
239
+ const left =
240
+ s.kind === "pending"
241
+ ? this.checking("Memory")
242
+ : s.kind === "unknown"
243
+ ? `${this.c("dim", "?")} Memory ${this.c("muted", "unknown")}`
244
+ : s.value === "on"
245
+ ? `${this.c("success", "●")} Memory ${this.c("success", "on")}`
246
+ : s.value === "starting"
247
+ ? `${this.c("warning", this.spin())} Memory ${this.c("warning", "starting")}`
248
+ : `${this.c("dim", "○")} Memory ${this.c("muted", "off")}${this.memoryCost()}`;
249
+ const sp = this.m.state.spend;
250
+ const right =
251
+ sp.kind === "pending"
252
+ ? this.c("dim", "spend checking…")
253
+ : sp.kind === "unknown"
254
+ ? this.c("dim", "spend unknown")
255
+ : this.c("muted", `${money(sp.value.today)} today · ${money(sp.value.month)} this month`);
256
+ return [left, right];
257
+ }
258
+
259
+ /** Memory's RAM cost and key while it is off, where the home can switch it. */
260
+ private memoryCost(): string {
261
+ const control = this.m.memoryControl();
262
+ if (!control) return "";
263
+ const key = this.host.focused() ? "m" : "alt+m";
264
+ return this.c("dim", ` · ${control.ramCost()} · ${key}`);
265
+ }
266
+
267
+ private loginLine(): [string, string] {
268
+ const label = providerLabel(this.m.chatProvider());
269
+ const signed = this.m.signedIn();
270
+ // one login per box: an expired one says so, with the key that signs in again
271
+ const expired = this.m.loginExpired();
272
+ const again = this.host.relogin ? (this.host.focused() ? "l" : "alt+l") : null;
273
+ const login =
274
+ this.m.state.login.kind === "pending"
275
+ ? `${this.c("accent", this.spin())} ${this.c("muted", label)} ${this.c("dim", "checking…")}`
276
+ : this.host.signingAgain?.()
277
+ ? `${this.c("accent", this.spin())} ${this.c("muted", label)} ${this.c("dim", "signing in again…")}`
278
+ : expired !== null
279
+ ? `${this.c("error", "✗")} ${this.c("muted", label)} ${this.c("error", expired)}${again ? ` ${this.c("dim", "·")} ${this.c("accent", again)} ${this.c("muted", "sign in again")}` : ""}`
280
+ : signed === null
281
+ ? `${this.c("dim", "?")} ${this.c("muted", label)} unknown`
282
+ : signed
283
+ ? `${this.c("success", "✓")} ${this.c("muted", label)} ${label === "OpenAI" ? "ChatGPT" : "signed in"}`
284
+ : `${this.c("warning", "✗")} ${this.c("muted", label)} ${this.c("warning", "not signed in")}`;
285
+ const g = this.m.state.github;
286
+ const gh =
287
+ g.kind === "pending"
288
+ ? `${this.c("accent", this.spin())} ${this.c("muted", "GitHub")} ${this.c("dim", "checking…")}`
289
+ : g.kind === "unknown"
290
+ ? `${this.c("dim", "?")} ${this.c("muted", "GitHub")} unknown`
291
+ : g.value === null
292
+ ? `${this.c("warning", "✗")} ${this.c("muted", "GitHub")} ${this.c("warning", "not signed in")}`
293
+ : `${this.c("success", "✓")} ${this.c("muted", "GitHub")} @${g.value}`;
294
+ return [
295
+ `${login} ${gh}`,
296
+ this.c("dim", this.m.tools === "full" ? "all pi tools" : "reviewer tools"),
297
+ ];
298
+ }
299
+
300
+ /** "~$1.33 · stops at $2.66", "price unknown", a spinner while pricing,
301
+ * or nothing before it is asked for. */
302
+ private price(q: Slot<ReviewQuote> | null, long: boolean): string {
303
+ if (q === null) return "";
304
+ if (q.kind === "pending") return this.c("dim", `${this.spin()} pricing`);
305
+ if (q.kind === "unknown") return this.c("dim", "price unknown");
306
+ const cap = q.value.capUsd;
307
+ return this.c(
308
+ "dim",
309
+ `~${money(q.value.estimateUsd)}${long ? (cap === null ? " · no hard stop" : ` · stops at ${money(cap)}`) : ""}`,
310
+ );
311
+ }
312
+
313
+ /** A run's lens bar: done of planned, `cells` wide. */
314
+ private bar(run: Run, cells: number): string {
315
+ const done = run.lenses.filter((l) => l.state === "done" || l.state === "failed").length;
316
+ const filled = Math.round((done / Math.max(1, run.lenses.length)) * cells);
317
+ return `${this.c("accent", "▰".repeat(filled))}${this.c("dim", "▱".repeat(cells - filled))} ${this.c("muted", `${done}/${run.lenses.length || "?"}`)}`;
318
+ }
319
+
320
+ private prRow(w: number, p: OpenPull, review: PrReview | undefined, isSel: boolean): string {
321
+ let mark: string;
322
+ let right: string;
323
+ const run = this.m.runs.get(p.number);
324
+ if (run && this.m.isRunning(p.number)) {
325
+ mark = this.c("accent", this.spin());
326
+ right = this.bar(run, 8);
327
+ } else if (review) {
328
+ mark = review.high ? this.c("warning", "●") : this.c("success", "✓");
329
+ right =
330
+ review.findings === null
331
+ ? this.c("muted", "reviewed")
332
+ : review.findings === 0
333
+ ? this.c("success", "clean")
334
+ : this.c(review.high ? "warning" : "muted", plural(review.findings, "finding"));
335
+ right += this.c("dim", ` · ${review.by} · ${ago(review.at, this.m.now())}`);
336
+ } else {
337
+ mark = this.c("dim", "○");
338
+ const price = this.price(this.m.quoteOf(p), false);
339
+ right =
340
+ this.c("dim", `@${p.author} · ${ago(Date.parse(p.updatedAt), this.m.now())}`) +
341
+ (price && this.m.quoteOf(p)?.kind === "known" ? this.c("dim", " · ") + price : "");
342
+ }
343
+ const cursor = isSel ? this.c("accent", "▌") : " ";
344
+ const num = this.c(isSel ? "accent" : "muted", `#${p.number}`);
345
+ const title = `${isSel ? this.th.bold(p.title) : p.title}${p.isDraft ? ` ${this.c("dim", "draft")}` : ""}`;
346
+ return this.row(w, `${cursor}${mark} ${num} ${title}`, right, isSel);
347
+ }
348
+
349
+ /** One list's rows: up to `cap`, a window that keeps the selection in view,
350
+ * then "… n more". */
351
+ private listRows(
352
+ w: number,
353
+ out: string[],
354
+ rows: ReadonlyArray<{ pull: OpenPull; review?: PrReview }>,
355
+ cap: number,
356
+ ): void {
357
+ const sel = rows.findIndex((r) => r.pull.number === this.m.selected);
358
+ const start = rows.length <= cap || sel < cap ? 0 : Math.min(sel - cap + 1, rows.length - cap);
359
+ for (const r of rows.slice(start, start + cap)) {
360
+ this.rowMap.set(out.length, r.pull.number);
361
+ out.push(this.prRow(w, r.pull, r.review, r.pull.number === this.m.selected));
362
+ }
363
+ if (rows.length > cap) out.push(this.row(w, this.c("dim", `… ${rows.length - cap} more`)));
364
+ }
365
+
366
+ private pending(s: Slot<unknown>, w: number): string {
367
+ return s.kind === "unknown"
368
+ ? this.row(w, this.c("warning", `Unknown — ${s.why}.`))
369
+ : this.row(
370
+ w,
371
+ `${this.c("accent", this.spin())} ${this.c("dim", "reading the pull requests…")}`,
372
+ );
373
+ }
374
+
375
+ private need(): { toReview: number; reviewed: number } | null {
376
+ const l = this.m.lists();
377
+ return l && { toReview: l.toReview.length, reviewed: l.reviewed?.length ?? 1 };
378
+ }
379
+
380
+ private lists(w: number, out: string[], room: number): void {
381
+ const l = this.m.lists();
382
+ if (l === null) {
383
+ out.push(this.rule(w, "To review", "?"));
384
+ out.push(this.pending(this.m.state.pulls, w));
385
+ out.push(this.rule(w, "Reviewed", "?"));
386
+ out.push(this.pending(this.m.state.pulls, w));
387
+ return;
388
+ }
389
+ // auto-review's note, when its half is unknown, takes a row of the room
390
+ const note = l.reviewed !== null && this.m.autoUnknown() !== null ? 1 : 0;
391
+ const caps = listCaps(room - note, this.need()!);
392
+ out.push(this.rule(w, "To review", String(l.toReview.length)));
393
+ if (l.toReview.length === 0)
394
+ out.push(this.row(w, this.c("dim", "Nothing waiting. New pull requests show up here.")));
395
+ this.listRows(
396
+ w,
397
+ out,
398
+ l.toReview.map((pull) => ({ pull })),
399
+ caps.toReview,
400
+ );
401
+ const reviews = this.m.state.reviews;
402
+ out.push(this.rule(w, "Reviewed", l.reviewed === null ? "?" : String(l.reviewed.length)));
403
+ if (l.reviewed === null)
404
+ out.push(
405
+ this.row(
406
+ w,
407
+ this.c(
408
+ "warning",
409
+ `Unknown — ${reviews.kind === "unknown" ? reviews.why : "still reading"}.`,
410
+ ),
411
+ ),
412
+ );
413
+ else if (l.reviewed.length === 0)
414
+ out.push(this.row(w, this.c("dim", "Nothing reviewed here yet.")));
415
+ else this.listRows(w, out, l.reviewed, caps.reviewed);
416
+ const auto = this.m.autoUnknown();
417
+ if (l.reviewed !== null && auto !== null)
418
+ out.push(this.row(w, this.c("dim", `Auto-review's reviews unknown — ${auto}.`)));
419
+ }
420
+
421
+ private details(w: number): string[] {
422
+ const p = this.m.selectedPull();
423
+ if (!p) return [];
424
+ const c = (k: Fg, t: string): string => this.c(k, t);
425
+ const run = this.m.runs.get(p.number);
426
+ const running = run !== undefined && this.m.isRunning(p.number);
427
+ const out = [this.rule(w, `#${p.number}`, running && run.runId ? `run ${run.runId}` : "")];
428
+ out.push(this.row(w, this.th.bold(p.title)));
429
+ const size = ` · ${c("success", `+${p.additions}`)} ${c("error", `−${p.deletions}`)} in ${plural(p.changedFiles, "file")}`;
430
+ out.push(
431
+ this.row(
432
+ w,
433
+ c(
434
+ "muted",
435
+ `@${p.author} · updated ${ago(Date.parse(p.updatedAt), this.m.now())} ago · ${p.headRefName}`,
436
+ ) + size,
437
+ ),
438
+ );
439
+ const review = this.m.reviewOf(p.number);
440
+ if (running) out.push(...this.progress(w, run));
441
+ else {
442
+ out.push(this.row(w, ""));
443
+ // a failed or stopped run says why, and the PR stays reviewable
444
+ if (run?.stage === "failed" && (review === undefined || (run.ended ?? 0) >= review.at))
445
+ out.push(this.row(w, c("warning", `✗ The last review stopped: ${run.error ?? "unknown"}`)));
446
+ if (review) out.push(...this.finishedView(w, review));
447
+ else
448
+ out.push(
449
+ this.row(
450
+ w,
451
+ `${c("dim", "○")} Not reviewed yet. ${c("accent", this.host.focused() ? "⏎" : "alt+r")} ${c("muted", "reviews it now, in the background")}`,
452
+ this.price(this.m.quoteOf(p), true),
453
+ ),
454
+ );
455
+ }
456
+ out.push(
457
+ this.row(
458
+ w,
459
+ `${c("accent", this.host.focused() ? "o" : "alt+o")} ${c("muted", "opens it on GitHub")}`,
460
+ ),
461
+ );
462
+ return out;
463
+ }
464
+
465
+ /** A running review: the plan, the lens bar and chips, validate and
466
+ * write-up, then what each running lens is doing now. */
467
+ private progress(w: number, r: Run): string[] {
468
+ const c = (k: Fg, t: string): string => this.c(k, t);
469
+ const now = this.m.now();
470
+ const order: Run["stage"][] = ["plan", "lenses", "validate", "write", "done"];
471
+ const at = order.indexOf(r.stage);
472
+ const mark = (i: number): string =>
473
+ at > i ? c("success", "✓") : at === i ? c("accent", this.spin()) : c("dim", "○");
474
+ const price =
475
+ r.est === null
476
+ ? "price unknown"
477
+ : `~${money(r.est)}${r.cap === null ? "" : `, stops at ${money(r.cap)}`}`;
478
+ const plan =
479
+ r.lenses.length > 0
480
+ ? `${r.files === undefined ? "" : `${plural(r.files, "file")} → `}${r.lenses.length} lens${r.lenses.length === 1 ? "" : "es"}`
481
+ : (r.now ?? "planning");
482
+ const out = [
483
+ this.row(w, ""),
484
+ this.row(
485
+ w,
486
+ `${mark(0)} Plan ${c("muted", plan)}`,
487
+ c("dim", `${clockOf(now - r.started)} · ${price}`),
488
+ ),
489
+ this.row(w, `${mark(1)} Lenses ${this.bar(r, 12)}`),
490
+ ];
491
+ const chip = (l: Lens): string =>
492
+ l.state === "done"
493
+ ? `${c("success", "✓")} ${c("muted", l.name)}${c("dim", ` ${l.tools}t`)}${l.findings ? c("warning", ` ·${l.findings}`) : ""}`
494
+ : l.state === "run"
495
+ ? `${c("accent", this.spin())} ${l.name}${c("dim", ` ${clockOf(now - (l.since ?? now))}`)}`
496
+ : l.state === "failed"
497
+ ? `${c("error", "✗")} ${c("muted", l.name)}`
498
+ : `${c("dim", "○")} ${c("dim", l.name)}`;
499
+ const cell = Math.max(0, ...r.lenses.map((l) => l.name.length)) + 9;
500
+ const pad = (t: string): string => t + " ".repeat(Math.max(0, cell - visibleWidth(t)));
501
+ const chips = r.lenses.map(chip);
502
+ for (let i = 0; i < chips.length; i += 3)
503
+ out.push(
504
+ this.row(
505
+ w,
506
+ ` ${chips
507
+ .slice(i, i + 3)
508
+ .map(pad)
509
+ .join("")}`,
510
+ ),
511
+ );
512
+ out.push(
513
+ this.row(w, `${mark(2)} Validate ${c("muted", "checks every finding against the code")}`),
514
+ );
515
+ out.push(
516
+ this.row(w, `${mark(3)} Write-up ${c("muted", "the review, ready to read or post")}`),
517
+ );
518
+ out.push(this.row(w, ""));
519
+ // what the reviewer is doing right now: one line per running lens
520
+ const busy = r.lenses.filter((l) => l.state === "run");
521
+ if (busy.length === 0)
522
+ out.push(this.row(w, `${c("dim", "now")} ${c("muted", r.now ?? "starting")}`));
523
+ for (const l of busy.slice(0, NOW_ROWS)) {
524
+ const line = l.line ?? `${l.name} · ${clockOf(now - (l.since ?? now))}`;
525
+ const cut = line.indexOf(" · ");
526
+ const name = cut < 0 ? line : line.slice(0, cut);
527
+ const rest = cut < 0 ? "" : line.slice(cut + 3);
528
+ out.push(this.row(w, `${c("dim", "now")} ${c("accent", name)} ${c("muted", rest)}`));
529
+ }
530
+ if (busy.length > NOW_ROWS)
531
+ out.push(this.row(w, c("dim", ` and ${busy.length - NOW_ROWS} more`)));
532
+ return out;
533
+ }
534
+
535
+ /** A finished review: who, how many, the time and the real cost, then one
536
+ * line per finding. */
537
+ private finishedView(w: number, r: PrReview): string[] {
538
+ const c = (k: Fg, t: string): string => this.c(k, t);
539
+ const right = [
540
+ ...(r.tookMs !== undefined ? [`took ${clockOf(r.tookMs)}`] : []),
541
+ ...(r.costUsd !== undefined ? [r.costUsd === null ? "cost unknown" : money(r.costUsd)] : []),
542
+ ...(r.tookMs === undefined && r.costUsd === undefined
543
+ ? [`${ago(r.at, this.m.now())} ago`]
544
+ : []),
545
+ ].join(" · ");
546
+ const count =
547
+ r.findings === null ? "findings on the pull request" : plural(r.findings, "finding");
548
+ const out = [
549
+ this.row(
550
+ w,
551
+ `${c("success", "✓")} Reviewed by ${r.by === "auto" ? "auto-review" : "you"} · ${c(r.high ? "warning" : "text", count)}`,
552
+ c("dim", right),
553
+ ),
554
+ ];
555
+ const list = r.list ?? [];
556
+ for (const f of list.slice(0, FINDING_ROWS)) {
557
+ const sev = sevOf(f.severity);
558
+ out.push(
559
+ this.row(
560
+ w,
561
+ ` ${c(sev === "high" ? "warning" : sev === "medium" ? "text" : "muted", sev.padEnd(6))} ${c("dim", shortWhere(whereOf(f)))} ${f.title}`,
562
+ ),
563
+ );
564
+ }
565
+ if (list.length > FINDING_ROWS)
566
+ out.push(this.row(w, c("dim", ` … ${list.length - FINDING_ROWS} more`)));
567
+ if (r.list !== undefined && list.length === 0)
568
+ out.push(this.row(w, c("muted", " Nothing worth raising.")));
569
+ out.push(
570
+ this.row(
571
+ w,
572
+ ` ${c("accent", this.host.focused() ? "⏎" : "alt+r")} ${c("muted", "shows the review in the chat")}`,
573
+ ),
574
+ );
575
+ return out;
576
+ }
577
+
578
+ private hints(w: number): string {
579
+ const k = (key: string, label: string): string =>
580
+ `${this.c("accent", key)} ${this.c("muted", label)}`;
581
+ const p = this.m.selectedPull();
582
+ const running = p !== undefined && this.m.isRunning(p.number);
583
+ const enter =
584
+ p === undefined ? null : !running && this.m.reviewOf(p.number) ? "show review" : "review";
585
+ const f = this.host.focused();
586
+ const items = [
587
+ ...(enter !== null && !running ? [k(f ? "⏎" : "alt+r", enter)] : []),
588
+ k(f ? "j/k" : "alt+j/k", "move"),
589
+ k(f ? "o" : "alt+o", "open"),
590
+ ...(running ? [k(f ? "s" : "alt+s", "stop")] : []),
591
+ k(f ? "u" : "alt+u", "refresh"),
592
+ k(f ? "esc" : "f2", f ? "chat" : "focus"),
593
+ // alt+t and alt+x while typing: the hints keep to the column's width
594
+ ...(f && this.host.tune ? [k("t", "tune")] : []),
595
+ ...(f && this.host.advanced ? [k("x", "advanced")] : []),
596
+ ];
597
+ return this.row(w, items.join(" "));
598
+ }
599
+
600
+ render(width: number): string[] {
601
+ const w = Math.max(40, width);
602
+ this.rowMap.clear();
603
+ const target = Math.max(20, this.host.rows() - EDITOR_ROWS);
604
+ const [a, aRight] = this.autoRow();
605
+ const [mem, spend] = this.memLine();
606
+ const [login, tools] = this.loginLine();
607
+ const picks = this.triggerRow();
608
+ const body = [
609
+ this.top(w),
610
+ this.row(w, a, aRight),
611
+ ...(picks === null ? [] : [this.row(w, picks)]),
612
+ this.row(w, mem, spend),
613
+ this.row(w, login, tools),
614
+ ];
615
+ const foot = [this.rule(w)];
616
+ const n = this.m.notice;
617
+ if (n) {
618
+ const color: Fg = n.kind === "ok" ? "success" : n.kind === "warn" ? "warning" : "text";
619
+ for (const l of wrapTextWithAnsi(n.text, w - 4).slice(0, NOTICE_ROWS))
620
+ foot.push(this.row(w, this.c(color, l)));
621
+ }
622
+ foot.push(this.hints(w), this.bottom(w));
623
+ // the setup checklist is the card's (src/home/setupcard.ts), never the panel's
624
+ // two titles and the rows of both lists share what the rest leaves
625
+ const roomWith = (d: number): number => target - body.length - d - foot.length - 2;
626
+ let detail = this.details(w);
627
+ const need = this.need();
628
+ if (need && detail.length > 0 && cramped(listCaps(roomWith(detail.length), need), need))
629
+ detail = [];
630
+ this.lists(w, body, roomWith(detail.length));
631
+ // a tall details area (a running review's lenses, a finished one's
632
+ // findings) keeps to what the lists left: its head stays, its tail is cut
633
+ const left = target - body.length - foot.length;
634
+ if (detail.length > left)
635
+ detail = left >= 4 ? [...detail.slice(0, left - 1), this.row(w, this.c("dim", "…"))] : [];
636
+ const spare = Math.max(0, target - body.length - detail.length - foot.length);
637
+ const blank = Array.from({ length: spare }, () => this.row(w, ""));
638
+ return [...body, ...blank, ...detail, ...foot].map((l) => truncateToWidth(l, w));
639
+ }
640
+
641
+ handleInput(data: string): void {
642
+ const m = this.m;
643
+ if (matchesKey(data, "escape")) this.host.unfocus();
644
+ else if (data === "j" || matchesKey(data, "down")) m.move(1);
645
+ else if (data === "k" || matchesKey(data, "up")) m.move(-1);
646
+ else if (data === "o") void m.openPr();
647
+ else if (data === "u") void m.refresh();
648
+ else if (matchesKey(data, "enter")) void m.enter();
649
+ else if (data === "r") void m.review();
650
+ else if (data === "s") void m.stopReview();
651
+ else if (data === "a") void m.switchAutoReview(this.host.ask?.());
652
+ else if (data === "m") void m.switchMemory(this.host.ask?.());
653
+ else if (data === "l") this.host.relogin?.();
654
+ else if (data === "t") this.host.tune?.();
655
+ else if (data === "x") this.host.advanced?.();
656
+ }
657
+
658
+ handleMouse(e: TuiMouseEvent): TuiMouseEventResult | undefined {
659
+ if (e.type !== "click" && e.type !== "move") return undefined;
660
+ const pr = this.rowMap.get(e.y);
661
+ if (pr !== undefined) this.m.select(pr);
662
+ return e.type === "click" ? { focus: true } : undefined;
663
+ }
664
+
665
+ invalidate(): void {}
666
+ }