quoxcli 0.0.0-stage → 0.3.1

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 (516) hide show
  1. package/LICENSE +35 -0
  2. package/README.md +144 -2
  3. package/bin/quox.js +265 -0
  4. package/bin/quoxon.js +8 -0
  5. package/lib/api/endpoints/admin.js +151 -0
  6. package/lib/api/endpoints/aee.js +38 -0
  7. package/lib/api/endpoints/agent-attach.js +126 -0
  8. package/lib/api/endpoints/agent-schedules.js +26 -0
  9. package/lib/api/endpoints/agents.js +63 -0
  10. package/lib/api/endpoints/assistants.js +62 -0
  11. package/lib/api/endpoints/audit.js +14 -0
  12. package/lib/api/endpoints/auth.js +50 -0
  13. package/lib/api/endpoints/awareness.js +34 -0
  14. package/lib/api/endpoints/away.js +15 -0
  15. package/lib/api/endpoints/backup-schedules.js +20 -0
  16. package/lib/api/endpoints/backups.js +86 -0
  17. package/lib/api/endpoints/brain.js +140 -0
  18. package/lib/api/endpoints/budgets.js +62 -0
  19. package/lib/api/endpoints/calendarq.js +71 -0
  20. package/lib/api/endpoints/catalog.js +14 -0
  21. package/lib/api/endpoints/chat.js +61 -0
  22. package/lib/api/endpoints/consent.js +26 -0
  23. package/lib/api/endpoints/contactsq.js +68 -0
  24. package/lib/api/endpoints/conversations.js +46 -0
  25. package/lib/api/endpoints/custom-roles.js +92 -0
  26. package/lib/api/endpoints/divisions.js +56 -0
  27. package/lib/api/endpoints/domains.js +38 -0
  28. package/lib/api/endpoints/engagement.js +231 -0
  29. package/lib/api/endpoints/engine.js +11 -0
  30. package/lib/api/endpoints/entity.js +50 -0
  31. package/lib/api/endpoints/executions.js +45 -0
  32. package/lib/api/endpoints/fetchAllowlist.js +27 -0
  33. package/lib/api/endpoints/file.js +20 -0
  34. package/lib/api/endpoints/files.js +40 -0
  35. package/lib/api/endpoints/github.js +21 -0
  36. package/lib/api/endpoints/governance.js +43 -0
  37. package/lib/api/endpoints/hat.js +66 -0
  38. package/lib/api/endpoints/inbox.js +52 -0
  39. package/lib/api/endpoints/inboxq.js +120 -0
  40. package/lib/api/endpoints/index.js +242 -0
  41. package/lib/api/endpoints/invites.js +19 -0
  42. package/lib/api/endpoints/llm-policy.js +43 -0
  43. package/lib/api/endpoints/lucid.js +89 -0
  44. package/lib/api/endpoints/members.js +32 -0
  45. package/lib/api/endpoints/memory.js +94 -0
  46. package/lib/api/endpoints/mfa.js +32 -0
  47. package/lib/api/endpoints/mind.js +13 -0
  48. package/lib/api/endpoints/mon.js +184 -0
  49. package/lib/api/endpoints/notesq.js +43 -0
  50. package/lib/api/endpoints/objectives.js +68 -0
  51. package/lib/api/endpoints/orgs.js +38 -0
  52. package/lib/api/endpoints/plugins.js +152 -0
  53. package/lib/api/endpoints/powers.js +75 -0
  54. package/lib/api/endpoints/presence.js +33 -0
  55. package/lib/api/endpoints/prompts.js +69 -0
  56. package/lib/api/endpoints/qlarity.js +122 -0
  57. package/lib/api/endpoints/quoxbox.js +55 -0
  58. package/lib/api/endpoints/quoxchat.js +54 -0
  59. package/lib/api/endpoints/quoxflow.js +32 -0
  60. package/lib/api/endpoints/quoxplan.js +121 -0
  61. package/lib/api/endpoints/quoxseo.js +114 -0
  62. package/lib/api/endpoints/reach.js +47 -0
  63. package/lib/api/endpoints/router.js +17 -0
  64. package/lib/api/endpoints/runs.js +38 -0
  65. package/lib/api/endpoints/safety.js +53 -0
  66. package/lib/api/endpoints/scim.js +46 -0
  67. package/lib/api/endpoints/scopes.js +14 -0
  68. package/lib/api/endpoints/security.js +69 -0
  69. package/lib/api/endpoints/setup.js +90 -0
  70. package/lib/api/endpoints/skills.js +200 -0
  71. package/lib/api/endpoints/sso.js +67 -0
  72. package/lib/api/endpoints/stepup.js +20 -0
  73. package/lib/api/endpoints/tags.js +32 -0
  74. package/lib/api/endpoints/teams.js +56 -0
  75. package/lib/api/endpoints/telephony.js +46 -0
  76. package/lib/api/endpoints/tg.js +56 -0
  77. package/lib/api/endpoints/tools.js +50 -0
  78. package/lib/api/endpoints/traces.js +157 -0
  79. package/lib/api/endpoints/triggers.js +96 -0
  80. package/lib/api/endpoints/user-library.js +41 -0
  81. package/lib/api/endpoints/users.js +20 -0
  82. package/lib/api/endpoints/v1.js +585 -0
  83. package/lib/api/endpoints/vault.js +192 -0
  84. package/lib/api/endpoints/voice.js +22 -0
  85. package/lib/api/endpoints/volt.js +84 -0
  86. package/lib/api/endpoints/ward.js +47 -0
  87. package/lib/api/endpoints/webhooks.js +46 -0
  88. package/lib/api/endpoints/workflows.js +169 -0
  89. package/lib/api/endpoints/x.js +32 -0
  90. package/lib/api/endpoints.js +15 -0
  91. package/lib/commands/actions.js +61 -0
  92. package/lib/commands/admin.js +375 -0
  93. package/lib/commands/aee.js +99 -0
  94. package/lib/commands/agent.js +337 -0
  95. package/lib/commands/agentAttach.js +1528 -0
  96. package/lib/commands/approvals.js +119 -0
  97. package/lib/commands/aside.js +24 -0
  98. package/lib/commands/ask.js +144 -0
  99. package/lib/commands/assistant.js +169 -0
  100. package/lib/commands/auth.js +464 -0
  101. package/lib/commands/awareness.js +88 -0
  102. package/lib/commands/backup.js +377 -0
  103. package/lib/commands/brain.js +1085 -0
  104. package/lib/commands/budget.js +241 -0
  105. package/lib/commands/build.js +170 -0
  106. package/lib/commands/calendar.js +216 -0
  107. package/lib/commands/capabilities.js +34 -0
  108. package/lib/commands/chat.js +295 -0
  109. package/lib/commands/claude.js +380 -0
  110. package/lib/commands/config-cmd.js +106 -0
  111. package/lib/commands/consent.js +84 -0
  112. package/lib/commands/contacts.js +1016 -0
  113. package/lib/commands/contactsSelftest.js +132 -0
  114. package/lib/commands/conversation.js +105 -0
  115. package/lib/commands/dashboard.js +43 -0
  116. package/lib/commands/department.js +96 -0
  117. package/lib/commands/dev.js +620 -0
  118. package/lib/commands/devResident.js +326 -0
  119. package/lib/commands/devSupervisor.js +1062 -0
  120. package/lib/commands/discord.js +132 -0
  121. package/lib/commands/doctor.js +181 -0
  122. package/lib/commands/domain.js +114 -0
  123. package/lib/commands/done.js +347 -0
  124. package/lib/commands/engagement.js +597 -0
  125. package/lib/commands/entity.js +119 -0
  126. package/lib/commands/facebook.js +212 -0
  127. package/lib/commands/fetchAllowlist.js +71 -0
  128. package/lib/commands/file.js +165 -0
  129. package/lib/commands/fleet.js +1189 -0
  130. package/lib/commands/governance.js +647 -0
  131. package/lib/commands/gripe.js +355 -0
  132. package/lib/commands/hat.js +374 -0
  133. package/lib/commands/helpers.js +96 -0
  134. package/lib/commands/hyperion.js +58 -0
  135. package/lib/commands/inbox.js +173 -0
  136. package/lib/commands/instagram.js +264 -0
  137. package/lib/commands/integration.js +100 -0
  138. package/lib/commands/inventory.js +115 -0
  139. package/lib/commands/invites.js +45 -0
  140. package/lib/commands/licence.js +139 -0
  141. package/lib/commands/linkedin.js +178 -0
  142. package/lib/commands/llm-policy.js +117 -0
  143. package/lib/commands/llm.js +45 -0
  144. package/lib/commands/lucid.js +597 -0
  145. package/lib/commands/mail.js +531 -0
  146. package/lib/commands/memory.js +361 -0
  147. package/lib/commands/mind.js +71 -0
  148. package/lib/commands/mon.js +581 -0
  149. package/lib/commands/monitoring.js +86 -0
  150. package/lib/commands/motion.js +22 -0
  151. package/lib/commands/notes.js +1246 -0
  152. package/lib/commands/notesBatch.js +160 -0
  153. package/lib/commands/notesBridges.js +56 -0
  154. package/lib/commands/notesLinks.js +60 -0
  155. package/lib/commands/notification.js +57 -0
  156. package/lib/commands/nucleus.js +123 -0
  157. package/lib/commands/objective.js +240 -0
  158. package/lib/commands/offload.js +89 -0
  159. package/lib/commands/onboard.js +329 -0
  160. package/lib/commands/open.js +56 -0
  161. package/lib/commands/org.js +276 -0
  162. package/lib/commands/peek.js +28 -0
  163. package/lib/commands/plan.js +944 -0
  164. package/lib/commands/plugin-doctor.js +1091 -0
  165. package/lib/commands/plugin.js +649 -0
  166. package/lib/commands/policy.js +78 -0
  167. package/lib/commands/powers.js +296 -0
  168. package/lib/commands/presence.js +89 -0
  169. package/lib/commands/prompt.js +350 -0
  170. package/lib/commands/provider.js +159 -0
  171. package/lib/commands/qlarity.js +706 -0
  172. package/lib/commands/quoxbox.js +270 -0
  173. package/lib/commands/quoxchat.js +275 -0
  174. package/lib/commands/quoxseo.js +378 -0
  175. package/lib/commands/reach.js +159 -0
  176. package/lib/commands/role.js +286 -0
  177. package/lib/commands/router.js +77 -0
  178. package/lib/commands/rows.js +358 -0
  179. package/lib/commands/run.js +187 -0
  180. package/lib/commands/safety.js +107 -0
  181. package/lib/commands/scim.js +88 -0
  182. package/lib/commands/scope.js +58 -0
  183. package/lib/commands/security.js +157 -0
  184. package/lib/commands/server.js +292 -0
  185. package/lib/commands/service.js +89 -0
  186. package/lib/commands/settings.js +99 -0
  187. package/lib/commands/setup.js +569 -0
  188. package/lib/commands/skillManifest.js +113 -0
  189. package/lib/commands/skillRightsFormat.js +38 -0
  190. package/lib/commands/skills.js +687 -0
  191. package/lib/commands/slot.js +1451 -0
  192. package/lib/commands/sso.js +229 -0
  193. package/lib/commands/status.js +137 -0
  194. package/lib/commands/tag.js +91 -0
  195. package/lib/commands/team.js +142 -0
  196. package/lib/commands/tg.js +240 -0
  197. package/lib/commands/tiktok.js +218 -0
  198. package/lib/commands/tips.js +23 -0
  199. package/lib/commands/tool.js +304 -0
  200. package/lib/commands/trace.js +530 -0
  201. package/lib/commands/trigger.js +223 -0
  202. package/lib/commands/update.js +46 -0
  203. package/lib/commands/vault.js +1447 -0
  204. package/lib/commands/vm.js +237 -0
  205. package/lib/commands/voice.js +213 -0
  206. package/lib/commands/volt.js +570 -0
  207. package/lib/commands/walkthrough.js +32 -0
  208. package/lib/commands/watch.js +167 -0
  209. package/lib/commands/webhook.js +137 -0
  210. package/lib/commands/welcome.js +14 -0
  211. package/lib/commands/workflow.js +660 -0
  212. package/lib/commands/x.js +448 -0
  213. package/lib/core/actingAs.js +136 -0
  214. package/lib/core/activeSummary.js +186 -0
  215. package/lib/core/aee.js +53 -0
  216. package/lib/core/agentAlias.js +163 -0
  217. package/lib/core/approvalContext.js +61 -0
  218. package/lib/core/aside.js +54 -0
  219. package/lib/core/askDecision.js +60 -0
  220. package/lib/core/auth.js +501 -0
  221. package/lib/core/banner.js +143 -0
  222. package/lib/core/brainPrime.js +90 -0
  223. package/lib/core/callerSource.js +129 -0
  224. package/lib/core/claudeLs.js +94 -0
  225. package/lib/core/cliBuild.js +62 -0
  226. package/lib/core/client.js +693 -0
  227. package/lib/core/commandRegistrars.js +121 -0
  228. package/lib/core/completions.js +163 -0
  229. package/lib/core/config.js +669 -0
  230. package/lib/core/confirm.js +37 -0
  231. package/lib/core/customHats.js +168 -0
  232. package/lib/core/deeplinks.js +76 -0
  233. package/lib/core/doneEngineFetch.js +227 -0
  234. package/lib/core/errors.js +441 -0
  235. package/lib/core/global-flags.js +190 -0
  236. package/lib/core/governedSession.js +402 -0
  237. package/lib/core/hyperion.js +102 -0
  238. package/lib/core/inboxResolution.js +65 -0
  239. package/lib/core/lazyCommands.js +122 -0
  240. package/lib/core/liveSessionsReport.js +112 -0
  241. package/lib/core/offload.js +504 -0
  242. package/lib/core/org-override.js +52 -0
  243. package/lib/core/orgResolve.js +70 -0
  244. package/lib/core/output.js +239 -0
  245. package/lib/core/peek.js +126 -0
  246. package/lib/core/prodEnv.js +7 -0
  247. package/lib/core/profileUsers.js +51 -0
  248. package/lib/core/promptCancel.js +18 -0
  249. package/lib/core/quoxglyph.js +158 -0
  250. package/lib/core/repoPath.js +11 -0
  251. package/lib/core/residentAlarm.js +282 -0
  252. package/lib/core/residentAuth.js +243 -0
  253. package/lib/core/residentPump.js +229 -0
  254. package/lib/core/residentSeed.js +106 -0
  255. package/lib/core/rootOptions.js +39 -0
  256. package/lib/core/router.js +168 -0
  257. package/lib/core/selfUpdate.js +188 -0
  258. package/lib/core/sessionHeartbeat.js +192 -0
  259. package/lib/core/sessionsOverview.js +224 -0
  260. package/lib/core/slotKeeper.js +527 -0
  261. package/lib/core/slotWake.js +120 -0
  262. package/lib/core/supervisorEngine.js +933 -0
  263. package/lib/core/systemdInstaller.js +256 -0
  264. package/lib/core/version.js +87 -0
  265. package/lib/core/vm.js +361 -0
  266. package/lib/core/vmTui.js +86 -0
  267. package/lib/core/walkthrough.js +35 -0
  268. package/lib/hyperion/deck.js +29 -0
  269. package/lib/lucid/LucidCard.jsx +68 -0
  270. package/lib/lucid/LucidIntro.jsx +92 -0
  271. package/lib/lucid/blind.js +71 -0
  272. package/lib/lucid/cards.js +86 -0
  273. package/lib/lucid/deck.js +32 -0
  274. package/lib/lucid/fmt.js +139 -0
  275. package/lib/lucid/markdown.js +81 -0
  276. package/lib/lucid/motion.js +56 -0
  277. package/lib/lucid/sitrep.js +173 -0
  278. package/lib/plugins/loader.js +93 -0
  279. package/lib/quoxon/activity.js +116 -0
  280. package/lib/quoxon/aocl.js +117 -0
  281. package/lib/quoxon/api.js +952 -0
  282. package/lib/quoxon/app.js +4032 -0
  283. package/lib/quoxon/approvalFacts.js +94 -0
  284. package/lib/quoxon/approvalsView.js +325 -0
  285. package/lib/quoxon/asideView.js +40 -0
  286. package/lib/quoxon/ask.js +121 -0
  287. package/lib/quoxon/away.js +52 -0
  288. package/lib/quoxon/bang.js +224 -0
  289. package/lib/quoxon/bulkRisk.js +17 -0
  290. package/lib/quoxon/capabilities.js +82 -0
  291. package/lib/quoxon/cells.js +53 -0
  292. package/lib/quoxon/commandShort.js +112 -0
  293. package/lib/quoxon/commands.js +479 -0
  294. package/lib/quoxon/connect.js +654 -0
  295. package/lib/quoxon/connectCard.js +45 -0
  296. package/lib/quoxon/consent.js +86 -0
  297. package/lib/quoxon/context.js +68 -0
  298. package/lib/quoxon/copy.js +34 -0
  299. package/lib/quoxon/countdownLine.js +11 -0
  300. package/lib/quoxon/demo.autoplay.js +29 -0
  301. package/lib/quoxon/demo.fixtures.js +24 -0
  302. package/lib/quoxon/demo.js +76 -0
  303. package/lib/quoxon/devObserver.js +20 -0
  304. package/lib/quoxon/dividerAocl.js +191 -0
  305. package/lib/quoxon/dividerRule.js +57 -0
  306. package/lib/quoxon/dragonModes.js +150 -0
  307. package/lib/quoxon/dragonView.js +41 -0
  308. package/lib/quoxon/earlyInput.js +40 -0
  309. package/lib/quoxon/echo.js +15 -0
  310. package/lib/quoxon/egg.js +36 -0
  311. package/lib/quoxon/events.js +677 -0
  312. package/lib/quoxon/feedback.js +50 -0
  313. package/lib/quoxon/gallery.js +237 -0
  314. package/lib/quoxon/handoffOffer.js +78 -0
  315. package/lib/quoxon/hatSwitch.js +37 -0
  316. package/lib/quoxon/headless.js +161 -0
  317. package/lib/quoxon/hostUrls.js +41 -0
  318. package/lib/quoxon/indicator.js +197 -0
  319. package/lib/quoxon/intent.js +52 -0
  320. package/lib/quoxon/keys.js +164 -0
  321. package/lib/quoxon/launcher.js +103 -0
  322. package/lib/quoxon/main.js +176 -0
  323. package/lib/quoxon/markdown.js +341 -0
  324. package/lib/quoxon/markdownSettle.js +19 -0
  325. package/lib/quoxon/matrixDomains.json +46 -0
  326. package/lib/quoxon/mentions.js +90 -0
  327. package/lib/quoxon/mind.js +103 -0
  328. package/lib/quoxon/mode.js +46 -0
  329. package/lib/quoxon/model.js +311 -0
  330. package/lib/quoxon/modelPrefs.js +345 -0
  331. package/lib/quoxon/motion/clock.js +70 -0
  332. package/lib/quoxon/motion/color.js +35 -0
  333. package/lib/quoxon/motion/frame.js +14 -0
  334. package/lib/quoxon/motion/level.js +91 -0
  335. package/lib/quoxon/motion/reveal.js +54 -0
  336. package/lib/quoxon/motion/ring.js +79 -0
  337. package/lib/quoxon/mouse.js +211 -0
  338. package/lib/quoxon/noai.js +57 -0
  339. package/lib/quoxon/objects/agent.js +61 -0
  340. package/lib/quoxon/objects/away.js +72 -0
  341. package/lib/quoxon/objects/crystal.js +100 -0
  342. package/lib/quoxon/objects/fetch.js +182 -0
  343. package/lib/quoxon/objects/fleet.js +149 -0
  344. package/lib/quoxon/objects/health.js +186 -0
  345. package/lib/quoxon/objects/index.js +106 -0
  346. package/lib/quoxon/objects/ladder.js +71 -0
  347. package/lib/quoxon/objects/memory.js +44 -0
  348. package/lib/quoxon/objects/mirror.js +137 -0
  349. package/lib/quoxon/objects/model.js +118 -0
  350. package/lib/quoxon/objects/peek.js +17 -0
  351. package/lib/quoxon/objects/proof.js +110 -0
  352. package/lib/quoxon/objects/prove.js +130 -0
  353. package/lib/quoxon/objects/replay.js +125 -0
  354. package/lib/quoxon/objects/sitrep.js +58 -0
  355. package/lib/quoxon/objects/skill.js +105 -0
  356. package/lib/quoxon/objects/view.js +87 -0
  357. package/lib/quoxon/objects/workflow.js +119 -0
  358. package/lib/quoxon/observers.js +42 -0
  359. package/lib/quoxon/onboarding/copy.js +263 -0
  360. package/lib/quoxon/onboarding/credential.js +108 -0
  361. package/lib/quoxon/onboarding/detect.js +22 -0
  362. package/lib/quoxon/onboarding/dockerOffer.js +45 -0
  363. package/lib/quoxon/onboarding/failureCause.js +33 -0
  364. package/lib/quoxon/onboarding/finish.js +134 -0
  365. package/lib/quoxon/onboarding/index.js +94 -0
  366. package/lib/quoxon/onboarding/installRunner.js +284 -0
  367. package/lib/quoxon/onboarding/intent.js +53 -0
  368. package/lib/quoxon/onboarding/journey.js +73 -0
  369. package/lib/quoxon/onboarding/preflight.js +156 -0
  370. package/lib/quoxon/onboarding/probe.js +65 -0
  371. package/lib/quoxon/onboarding/runner.js +58 -0
  372. package/lib/quoxon/onboarding/view.js +682 -0
  373. package/lib/quoxon/orgLabel.js +10 -0
  374. package/lib/quoxon/orgSwitch.js +33 -0
  375. package/lib/quoxon/ownBroadcast.js +22 -0
  376. package/lib/quoxon/palette.js +43 -0
  377. package/lib/quoxon/print.js +98 -0
  378. package/lib/quoxon/profileSwitch.js +53 -0
  379. package/lib/quoxon/promptHistory.js +41 -0
  380. package/lib/quoxon/providers.js +48 -0
  381. package/lib/quoxon/qart.js +348 -0
  382. package/lib/quoxon/qdragon.js +410 -0
  383. package/lib/quoxon/qdragonFly.js +61 -0
  384. package/lib/quoxon/qdragonScenes.js +282 -0
  385. package/lib/quoxon/qeyes.js +213 -0
  386. package/lib/quoxon/qglyph.js +143 -0
  387. package/lib/quoxon/qhello.js +58 -0
  388. package/lib/quoxon/qmotion.js +132 -0
  389. package/lib/quoxon/render.js +1167 -0
  390. package/lib/quoxon/resend.js +72 -0
  391. package/lib/quoxon/roster.js +307 -0
  392. package/lib/quoxon/route.js +39 -0
  393. package/lib/quoxon/rows.js +711 -0
  394. package/lib/quoxon/rowsStore.js +80 -0
  395. package/lib/quoxon/rules.js +130 -0
  396. package/lib/quoxon/screenContext.js +150 -0
  397. package/lib/quoxon/screenFrames.js +254 -0
  398. package/lib/quoxon/session.js +238 -0
  399. package/lib/quoxon/sessionDead.js +17 -0
  400. package/lib/quoxon/signin.js +125 -0
  401. package/lib/quoxon/skills.js +30 -0
  402. package/lib/quoxon/slash/agents.js +112 -0
  403. package/lib/quoxon/slash/argSources.js +65 -0
  404. package/lib/quoxon/slash/args.js +65 -0
  405. package/lib/quoxon/slash/automate.js +89 -0
  406. package/lib/quoxon/slash/broadcast.js +140 -0
  407. package/lib/quoxon/slash/broadcastRun.js +108 -0
  408. package/lib/quoxon/slash/card.js +30 -0
  409. package/lib/quoxon/slash/challenge.js +114 -0
  410. package/lib/quoxon/slash/cliCommand.js +53 -0
  411. package/lib/quoxon/slash/cliLeaves.json +948 -0
  412. package/lib/quoxon/slash/compact.js +90 -0
  413. package/lib/quoxon/slash/confirm.js +30 -0
  414. package/lib/quoxon/slash/crystallize.js +69 -0
  415. package/lib/quoxon/slash/dashboard.js +136 -0
  416. package/lib/quoxon/slash/draft.js +54 -0
  417. package/lib/quoxon/slash/hatWalk.js +206 -0
  418. package/lib/quoxon/slash/inverses.js +77 -0
  419. package/lib/quoxon/slash/killer.js +24 -0
  420. package/lib/quoxon/slash/killerApi.js +16 -0
  421. package/lib/quoxon/slash/ledger.js +88 -0
  422. package/lib/quoxon/slash/listView.js +30 -0
  423. package/lib/quoxon/slash/modelCmd.js +261 -0
  424. package/lib/quoxon/slash/palette.js +118 -0
  425. package/lib/quoxon/slash/paletteController.js +169 -0
  426. package/lib/quoxon/slash/panels.js +116 -0
  427. package/lib/quoxon/slash/picker.js +56 -0
  428. package/lib/quoxon/slash/restart.js +202 -0
  429. package/lib/quoxon/slash/resume.js +59 -0
  430. package/lib/quoxon/slash/rowCmd.js +157 -0
  431. package/lib/quoxon/slash/rowWalk.js +175 -0
  432. package/lib/quoxon/slash/settings.js +60 -0
  433. package/lib/quoxon/slash/spawn.js +177 -0
  434. package/lib/quoxon/slash/stepUp.js +274 -0
  435. package/lib/quoxon/slash/stepUpCard.js +60 -0
  436. package/lib/quoxon/slash/stepUpCmds.js +78 -0
  437. package/lib/quoxon/slash/undoApi.js +38 -0
  438. package/lib/quoxon/slash/undoCmd.js +49 -0
  439. package/lib/quoxon/slash/undoWish.js +15 -0
  440. package/lib/quoxon/slash/watch.js +164 -0
  441. package/lib/quoxon/slash/wish.js +23 -0
  442. package/lib/quoxon/slash/wishCmd.js +32 -0
  443. package/lib/quoxon/slotChat.js +300 -0
  444. package/lib/quoxon/slotFormat.js +195 -0
  445. package/lib/quoxon/slotRun.js +131 -0
  446. package/lib/quoxon/sound/index.js +186 -0
  447. package/lib/quoxon/sound/mapping.js +120 -0
  448. package/lib/quoxon/sound/output.js +52 -0
  449. package/lib/quoxon/sound/synth.js +122 -0
  450. package/lib/quoxon/stray.js +22 -0
  451. package/lib/quoxon/suggestCommand.js +59 -0
  452. package/lib/quoxon/term.js +141 -0
  453. package/lib/quoxon/textBuffer.js +416 -0
  454. package/lib/quoxon/theme.js +286 -0
  455. package/lib/quoxon/tips.js +433 -0
  456. package/lib/quoxon/toolError.js +41 -0
  457. package/lib/quoxon/transcript.js +188 -0
  458. package/lib/quoxon/web/qface.js +89 -0
  459. package/lib/quoxon/welcome.js +52 -0
  460. package/lib/quoxon/whatsnew.js +136 -0
  461. package/lib/quoxon/whatsnew.jsonl +89 -0
  462. package/lib/quoxon/windowOrg.js +113 -0
  463. package/lib/server/diag.js +129 -0
  464. package/lib/server/doctor.js +71 -0
  465. package/lib/server/install.js +149 -0
  466. package/lib/server/pluginEnable.js +161 -0
  467. package/lib/server/skew.js +56 -0
  468. package/lib/server/snapshot.js +131 -0
  469. package/lib/server/status.js +128 -0
  470. package/lib/server/sys.js +65 -0
  471. package/lib/server/uninstall.js +63 -0
  472. package/lib/server/update.js +234 -0
  473. package/lib/toolOutputShape.js +315 -0
  474. package/lib/tui/app.jsx +289 -0
  475. package/lib/tui/components/Breadcrumb.jsx +42 -0
  476. package/lib/tui/components/ChatInput.jsx +68 -0
  477. package/lib/tui/components/Confirm.jsx +32 -0
  478. package/lib/tui/components/DataTable.jsx +270 -0
  479. package/lib/tui/components/DetailPanel.jsx +241 -0
  480. package/lib/tui/components/HelpOverlay.jsx +67 -0
  481. package/lib/tui/components/Layout.jsx +129 -0
  482. package/lib/tui/components/Palette.jsx +141 -0
  483. package/lib/tui/components/SearchInput.jsx +125 -0
  484. package/lib/tui/components/Sparkline.jsx +55 -0
  485. package/lib/tui/components/StatusBar.jsx +133 -0
  486. package/lib/tui/components/Toast.jsx +52 -0
  487. package/lib/tui/hooks/useApi.js +44 -0
  488. package/lib/tui/hooks/useHealth.js +90 -0
  489. package/lib/tui/hooks/usePolledApi.js +86 -0
  490. package/lib/tui/hooks/useTerminalSize.js +46 -0
  491. package/lib/tui/index.js +56 -0
  492. package/lib/tui/intro/Intro.jsx +332 -0
  493. package/lib/tui/jsx-loader.js +32 -0
  494. package/lib/tui/keys.js +87 -0
  495. package/lib/tui/registry.js +49 -0
  496. package/lib/tui/theme.js +103 -0
  497. package/lib/tui/views/AdminView.jsx +398 -0
  498. package/lib/tui/views/AgentView.jsx +204 -0
  499. package/lib/tui/views/BackupView.jsx +225 -0
  500. package/lib/tui/views/BrainView.jsx +223 -0
  501. package/lib/tui/views/ChatView.jsx +468 -0
  502. package/lib/tui/views/FilesView.jsx +218 -0
  503. package/lib/tui/views/FleetView.jsx +234 -0
  504. package/lib/tui/views/GovernanceView.jsx +453 -0
  505. package/lib/tui/views/HealthView.jsx +216 -0
  506. package/lib/tui/views/HyperionDeck.jsx +79 -0
  507. package/lib/tui/views/InboxView.jsx +228 -0
  508. package/lib/tui/views/LucidDeck.jsx +719 -0
  509. package/lib/tui/views/MainMenu.jsx +123 -0
  510. package/lib/tui/views/MemoryView.jsx +377 -0
  511. package/lib/tui/views/ObjectivesView.jsx +230 -0
  512. package/lib/tui/views/OrgView.jsx +229 -0
  513. package/lib/tui/views/ToolView.jsx +221 -0
  514. package/lib/tui/views/VaultView.jsx +422 -0
  515. package/lib/tui/views/WorkflowView.jsx +329 -0
  516. package/package.json +75 -4
@@ -0,0 +1,1167 @@
1
+ /**
2
+ * QUOXON view layer (Ink 6, React.createElement). Width-aware everywhere,
3
+ * horizontal rules only (no side borders), semantic colour, mono-safe.
4
+ */
5
+ import React from 'react';
6
+ import chalk from 'chalk';
7
+ import { Box, Text, useStdout } from 'ink';
8
+ import stringWidth from 'string-width';
9
+ import { graphemes, lineCol, selection, composerFrom } from './textBuffer.js';
10
+
11
+ const h = React.createElement;
12
+ /** Coloured runs (from theme.paintLine/paintRuns) as Text children; the characters are unchanged. */
13
+ const runs = (rs) => rs.map((r, i) => (r.role ? h(Text, { key: i, color: c(r.role) }, r.text) : r.text));
14
+
15
+ import { COLORS, MODE_COLOR, MIN_COLS, mono, c, clampCols, clip, paintLine, paintRuns, agentColor, proto, toneText, stripMarks, providerOf, providerColor } from './theme.js';
16
+ import { reducedMotion, motionAllowed } from './motion/clock.js';
17
+ import { ruleColors, ringStep, ringMode } from './motion/ring.js';
18
+ import { useFrame } from './motion/frame.js';
19
+ import { rgbStr, hexRgb, mix, clamp01, mixOk, pulse01 } from './motion/color.js';
20
+ import { revealCells, TIMING } from './motion/reveal.js';
21
+ import { handoffFrame } from './motion/level.js';
22
+ import { dividerRuleEl } from './dividerRule.js';
23
+ import * as themeNs from './theme.js';
24
+ import { qArt, qArtFrame } from '../core/banner.js';
25
+ import { cmd } from './palette.js';
26
+ import { themeRole, rowParts, clipParts, labelWidth } from './rows.js';
27
+ import { Markdown, wrapCell, markdownRows, wrapRows, WText, Heading } from './markdown.js';
28
+ import { parseAsk, keysLabel, askText } from './ask.js';
29
+ import { parseAsideNote } from './asideView.js';
30
+ import { formatSlotBody, inlineable, codeRows } from './slotFormat.js';
31
+ import { termRows } from './term.js';
32
+ import { cellWidth, ambiguousWide } from './cells.js';
33
+ import { welcomeModel } from './welcome.js';
34
+ import { Q_GLYPH, qColor, stateWord, indicatorState, fmtTokens, wordFrame, wordColor } from './indicator.js';
35
+
36
+ import { qSize, qDims, orbitFrame, qLines, labSize, introPlan, qCells, cellsToAnsi, INTRO_MS } from './qart.js';
37
+ import { glyphStill, glyphMode, colorDepth } from './qglyph.js';
38
+ export { INTRO_MS };
39
+ import { QGlyph, cellNodes } from './qmotion.js';
40
+ import { CLIPS as DRAGON_CLIPS, clipCells } from './qdragonScenes.js';
41
+ import { dragonLayout } from './qdragon.js';
42
+ import { ObjectView } from './objects/view.js';
43
+ import { aoclLineSegs, aoclTableLines } from './aocl.js';
44
+ export { COLORS, MODE_COLOR, MIN_COLS, clampCols, clip, reducedMotion };
45
+
46
+ // ---- working indicator (the one animated line) -------------------------------------------------
47
+ export const KANA = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン';
48
+ export const RAIN = 4;
49
+ const G0 = [56, 189, 248], G1 = [168, 85, 247];
50
+
51
+ /** Pure frame: array of { ch, rgb, bold } for tick t. rand is injectable for tests. */
52
+ export function rainFrame(t, rand = Math.random) {
53
+ return Array.from({ length: RAIN }, (_, i) => {
54
+ const ch = KANA[Math.floor(rand() * KANA.length)];
55
+ const k = ((t + i * 3) % 16) / 15;
56
+ const m = k < 0.5 ? k * 2 : 2 - k * 2;
57
+ return { ch, rgb: G0.map((v, j) => Math.round(v + (G1[j] - v) * m)), bold: i === RAIN - 1 };
58
+ });
59
+ }
60
+ export const STATIC_RAIN = 'クオック'.slice(0, RAIN);
61
+
62
+ export function describeActivity(state) {
63
+ const running = state.blocks.filter(b => b.kind === 'tool' && b.status === 'running');
64
+ if (state.approvals.length) return 'Waiting for approval';
65
+ if (running.length) { const r = running[running.length - 1]; return r.agentName ? `Delegating to ${r.agentName}` : `Running ${r.tool}`; }
66
+ if (state.turn?.status === 'interrupting') return 'Stopping';
67
+ return state.turn?.stage || 'Working';
68
+ }
69
+
70
+ /**
71
+ * The one animated status line. It owns no timer: the app's motion clock re-renders it (frames while motion
72
+ * is on, once a second otherwise) and passes `now`. `motion` false = static word and colour (reduced motion);
73
+ * colour is separately off under NO_COLOR.
74
+ */
75
+ export const Working = ({ activity, since, interrupting, escLabel, state, failing = false, tokens = 0, act = null, now: nowProp, motion = !reducedMotion(), handoff = false }) => {
76
+ const now = useFrame(nowProp);
77
+ const quiet = !motion;
78
+ const t = Math.floor(now / 120); // the indicator's colour periods are in 120 ms ticks
79
+ const st = state || (act?.tone === 'wait' ? 'waiting' : indicatorState(act ? act.word : activity, failing));
80
+ // A new word decodes in over DECODE_MS; remember when the current word first appeared.
81
+ const wordKey = act ? act.word : null;
82
+ const seenRef = React.useRef({ key: wordKey, at: Date.now() });
83
+ if (seenRef.current.key !== wordKey) seenRef.current = { key: wordKey, at: now };
84
+ const flashing = Boolean(act?.flash) && now - act.flash < 800;
85
+ const secs = Math.max(0, Math.floor((now - since) / 1000));
86
+ const q = h(QGlyph, { key: 'q', state: flashing ? 'failing' : st, ms: now, since: act?.flash ?? null, quiet }); // the perpetual Q (qglyph.js), driven by the shared clock
87
+ // The word is ALL colour: one gradient sweeps across it (sky/violet/teal thinking, green writing, amber
88
+ // waiting, red failed). While working it rains through itself (indicator.wordFrame): each letter on its own
89
+ // clock, ~30% scrambled at a time, cascades of 2..4 half-width katakana resolving with a flash. Reduced
90
+ // motion: a still, coloured, plain word. NO_COLOR: the motion stays, the colour goes. Elapsed, tokens and
91
+ // hints are never scrambled.
92
+ // Silent wait (FW1): after 3 s on one activity with no answer text, say what it is waiting on, live.
93
+ const actSecs = act ? Math.max(0, Math.floor((now - act.since) / 1000)) : 0;
94
+ const longWait = Boolean(act) && st === 'working' && actSecs >= 3 && act.word !== 'Writing';
95
+ const waitTool = longWait && act.tool ? act.tool : null;
96
+ const stale = longWait && !act.tool && (act.word === 'Sending' || act.word === 'Routing');
97
+ const plain = clip(stateWord(st, stale ? 'Thinking' : (act ? act.word : activity)), 40);
98
+ const scramble = st === 'working' && !quiet;
99
+ const cst = st === 'working' && (act ? act.word : activity) === 'Writing' ? 'writing' : st;
100
+ const cells = scramble ? wordFrame(plain, now, { since: seenRef.current.at }) : [...plain].map(ch => ({ ch, glyph: false, flash: false }));
101
+ const n = cells.length;
102
+ const wordNodes = cells.map((g, i) => h(Text, {
103
+ key: i, bold: g.flash,
104
+ color: mono() ? undefined : `rgb(${wordColor(cst, i, n, now, g.flash ? 'flash' : g.glyph ? 'glyph' : 'letter', quiet).join(',')})`
105
+ }, g.ch));
106
+ const ell = st === 'failing' ? null : h(Text, { key: 'e', color: mono() ? undefined : `rgb(${wordColor(cst, n, n + 1, now, 'letter', quiet).join(',')})` }, '…');
107
+ const detail = waitTool ? clip(`waiting on ${waitTool}`, 40) : (act?.detail && st !== 'failing' ? clip(act.detail, 28) : '');
108
+ // Expressive: while delegating, a dot travels from Quox to the agent it is handing to (motion/level.js).
109
+ const delegating = handoff && !quiet && st === 'working' && (act ? act.word : activity) === 'Delegating';
110
+ const track = delegating ? h(Text, { key: 'ho' }, ' ', ...handoffFrame(now, 5, { mono: mono() }).map((cl, i) => h(Text, { key: i, color: cl.rgb ? rgbStr(cl.rgb) : undefined }, cl.ch))) : null;
111
+ const word = h(Text, { wrap: 'truncate-end' }, h(Text, null, ...wordNodes, ell), track,
112
+ detail ? h(Text, { color: c('dim') }, ` ${delegating ? '' : '· '}${detail}`) : '');
113
+ const { stdout: so } = useStdout();
114
+ const narrow = (so?.columns || 100) < 60; // keep the line on one row: short hint, tokens still shown
115
+ const tok = tokens > 0 ? ` · ↓ ${fmtTokens(tokens)} tokens` : '';
116
+ const sep = st === 'failing' ? '' : (detail ? ' · ' : ' '); // the separator lives in the tail so a squeezed word never swallows it
117
+ const tail = st === 'failing' ? '' : `${sep}${waitTool ? actSecs : secs}s${tok} · ${interrupting ? 'stopping; waiting for the server to confirm' : (narrow ? 'Esc' : (escLabel || 'Esc to stop'))}`;
118
+ return h(Box, { paddingLeft: 2 }, q, h(Text, null, ' '), word, h(Text, { color: c('dim'), wrap: 'truncate-end' }, tail));
119
+ };
120
+
121
+ // ---- drop-in reveals (motion/reveal.js) ------------------------------------------------------------
122
+ const KANA_RGB = [hexRgb('#38bdf8'), [58, 66, 82]];
123
+ const GLOW_RGB = [110, 231, 183];
124
+ /**
125
+ * Render segments [{ t, color, bold }] as Text nodes. With `fx` = { e, timing } (seconds since the item
126
+ * landed) the whole row decodes in along one edge; without it the output is exactly the plain segments.
127
+ */
128
+ export function fxNodes(segs, fx, salt = 1) {
129
+ const live = segs.filter(x => x && x.t);
130
+ if (!fx || fx.e == null) return live.map((x, i) => h(Text, { key: i, color: x.color ? c(x.color) : undefined, bold: x.bold }, x.t));
131
+ const total = live.reduce((n, x) => n + graphemes(x.t).length, 0);
132
+ const runs = [];
133
+ let offset = 0;
134
+ for (const x of live) {
135
+ const resolved = x.color ? c(x.color) : undefined;
136
+ const base = typeof resolved === 'string' && /^#[0-9a-f]{6}$/i.test(resolved) ? hexRgb(resolved) : null; // palette role, hex or ansi256
137
+ for (const cell of revealCells(x.t, fx.e, fx.timing, { offset, total, salt })) {
138
+ let color, bold = false;
139
+ if (cell.kind === 'kana') color = mono() ? undefined : rgbStr(mix(KANA_RGB[1], KANA_RGB[0], 0.35 + 0.65 * cell.g));
140
+ else if (cell.kind === 'glow') { color = base ? rgbStr(mix(base, GLOW_RGB, 0.5 * cell.g)) : undefined; bold = cell.bold || Boolean(x.bold); }
141
+ else if (cell.kind === 'plain') { color = x.color ? c(x.color) : undefined; bold = Boolean(x.bold); }
142
+ const last = runs[runs.length - 1];
143
+ if (last && last.color === color && last.bold === bold) last.t += cell.ch; else runs.push({ t: cell.ch, color, bold });
144
+ }
145
+ offset += graphemes(x.t).length;
146
+ }
147
+ return runs.map((r, i) => h(Text, { key: i, color: r.color, bold: r.bold }, r.t));
148
+ }
149
+
150
+ // ---- rows ------------------------------------------------------------------------------------
151
+ const STATUS_GLYPH = { running: '◐', ok: '✔', failed: '✖', denied: '⊘', effect_unknown: '⚠', unknown: '?' };
152
+ const STATUS_TONE = { running: 'sky', ok: 'ok', failed: 'fail', denied: 'warn', effect_unknown: 'warn', unknown: 'dim' };
153
+ const PROTO = { aee: ['⛓', 'AEE'], aocl: ['▤', 'AOCL'], volt: ['▣', 'VOLT'], ward: ['◉', 'WARD'] };
154
+
155
+ export const fmtDuration = (ms) => (ms == null ? '' : ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`);
156
+
157
+ export const Row = ({ glyph, tone, name, body, tail, cols }) => {
158
+ const nameW = cols >= 70 ? 14 : 10;
159
+ const used = 2 + 2 + nameW + 3;
160
+ const room = Math.max(8, cols - used);
161
+ const tailStr = tail ? clip(tail, Math.max(8, Math.floor(room / 2))) : '';
162
+ const bodyStr = clip(body, Math.max(4, room - (tailStr ? [...tailStr].length + 3 : 0)));
163
+ return h(Box, null,
164
+ h(Text, null, ' '), h(Text, { color: c(tone) }, glyph), h(Text, null, ' '),
165
+ h(Text, { bold: true, color: c('text') }, clip(name, nameW - 1).padEnd(nameW - 1)), h(Text, { color: c('dim') }, ' │ '),
166
+ h(Text, { color: c('text') }, bodyStr),
167
+ tailStr ? h(Text, { color: c('dim') }, ' │ ') : null,
168
+ tailStr ? h(Text, { color: c(tone) }, tailStr) : null);
169
+ };
170
+
171
+ // Tool ids are `<family>_<verb words>`. The family is the short label in the QuoxGlyph grammar
172
+ // (`✔ SEO │ summary │ 30 crawls · 2.4s`), never the whole id upper-cased.
173
+ const FAMILY = { quoxseo: 'SEO', quoxplan: 'PLAN', quoxsignal: 'SIGNAL', quoxflow: 'FLOW', lucid: 'LUCID', fleet: 'FLEET', memory: 'MEMORY', bastion: 'BASTION' };
174
+ export function toolLabel(id) {
175
+ const t = String(id || 'tool');
176
+ const i = t.indexOf('_');
177
+ const pre = (i > 0 ? t.slice(0, i) : t).toLowerCase();
178
+ const verb = i > 0 ? t.slice(i + 1).replace(/_+/g, ' ').trim() : '';
179
+ const family = FAMILY[pre] || (pre.startsWith('quox') && pre.length > 4 ? pre.slice(4) : pre).toUpperCase();
180
+ return { family, verb: verb || t };
181
+ }
182
+
183
+ const gw = (s) => graphemes(s).length;
184
+ /** Clip free text at a word or comma boundary; a single over-long token becomes just the ellipsis. */
185
+ function clipWords(text, max) {
186
+ const t = String(text ?? '').replace(/\s+/g, ' ').trim();
187
+ if (gw(t) <= max) return t;
188
+ if (max < 2) return '';
189
+ const gs = graphemes(t).slice(0, max - 1).map(g => g.text).join('');
190
+ const nextIsBreak = /[\s,]/.test(graphemes(t)[max - 1]?.text || '');
191
+ const cut = nextIsBreak ? gs : gs.replace(/[^\s,]*$/, '');
192
+ const kept = cut.replace(/[\s,]+$/, '');
193
+ return kept ? kept + '…' : '…';
194
+ }
195
+
196
+ /**
197
+ * A server result_summary fitted to `max` columns. `key: value, key: value` summaries read as
198
+ * `value key` (numbers) or `key: value`; `k_total` supersedes `k` (k is the page, the total is the truth);
199
+ * whole pairs are dropped from the end to fit (all of them when none fits), never cut mid-token.
200
+ * Other text clips at a word boundary.
201
+ */
202
+ export function fitResult(summary, max) {
203
+ const raw = String(summary ?? '').replace(/\s+/g, ' ').trim();
204
+ const parts = raw.split(/,\s*/);
205
+ const pairs = parts.map(p => p.match(/^([A-Za-z_][\w ]*?):\s*(.+)$/));
206
+ if (!raw || !pairs.every(Boolean)) return clipWords(raw, max);
207
+ const map = new Map(pairs.map(m => [m[1].trim(), m[2].trim()]));
208
+ const items = [];
209
+ for (const [k, v] of map) {
210
+ if (map.has(`${k}_total`)) continue;
211
+ const key = k.replace(/_total$/, '').replace(/_+/g, ' ');
212
+ items.push(/^-?\d+(\.\d+)?$/.test(v) ? `${v} ${key}` : `${key}: ${v}`);
213
+ }
214
+ for (let n = items.length; n > 0; n--) {
215
+ const joined = items.slice(0, n).join(', ');
216
+ if (gw(joined) <= max) return joined;
217
+ }
218
+ return ''; // not even one whole pair fits: show none rather than a value without its key
219
+ }
220
+
221
+ /**
222
+ * The ignition glow's colour (expressive motion): `hex` flared by g 0..1. Dark terminals flare towards a warm
223
+ * white, light ones towards amber (white would vanish on a light background). g 0 returns hex untouched.
224
+ */
225
+ export function igniteColor(col, g, light = themeNs.isLight()) {
226
+ const rgb = Array.isArray(col) ? col : typeof col === 'string' && /^#[0-9a-f]{6}$/i.test(col) ? hexRgb(col) : null;
227
+ if (!(g > 0) || !rgb) return Array.isArray(col) ? rgbStr(col) : col;
228
+ return rgbStr(mix(rgb, light ? [217, 119, 6] : [255, 244, 214], Math.min(1, g) * (light ? 0.7 : 0.85)));
229
+ }
230
+
231
+ /** Pure: the delegation row text. body while running names the target and the task; the result names the outcome. */
232
+ export function delegationText(b) {
233
+ const who = b.agentName;
234
+ const body = b.status === 'running' ? `Delegating to ${who}${b.taskGist ? ` \u00b7 ${b.taskGist}` : ''}` : `${who}${b.taskGist ? ` \u00b7 ${b.taskGist}` : ''}`;
235
+ if (b.status === 'running') return { body, result: '', warn: false };
236
+ if (b.status === 'ok' && Number.isFinite(b.toolsUsed)) {
237
+ return b.toolsUsed === 0
238
+ ? { body, result: `${who} did nothing (0 tools)`, warn: true }
239
+ : { body, result: `${who} finished \u00b7 used ${b.toolsUsed} tool${b.toolsUsed === 1 ? '' : 's'}`, warn: false };
240
+ }
241
+ return { body, result: '', warn: false };
242
+ }
243
+
244
+ export function ToolRow({ b, cols, fx = null, pulse = null, ignite = 0 }) {
245
+ const glyph = STATUS_GLYPH[b.status] || '◐';
246
+ const tone = STATUS_TONE[b.status] || 'sky';
247
+ const dur = fmtDuration(b.durationMs);
248
+ const { family, verb } = toolLabel(b.tool);
249
+ const name = clip(family, 10);
250
+ // QUOXON-FW3: a delegation names WHO ran it, what it was for, and whether it did anything.
251
+ const deleg = b.agentName ? delegationText(b) : null;
252
+ const body0 = deleg ? deleg.body : b.summary && b.summary !== b.tool ? b.summary : verb;
253
+ const room = Math.max(4, cols - 2 - 2 - gw(name) - 3); // indent, glyph + space, family, ' │ '
254
+ const running = b.status === 'running';
255
+ // the facts after the result: duration, then witnessing state
256
+ let after = [];
257
+ if (!running && b.receipt) {
258
+ const v = b.receipt.verified;
259
+ after = [dur, v === true ? 'witnessed' : v === false ? 'unverified' : 'verification pending', `receipt ward:${b.receipt.ward_seq}`].filter(Boolean);
260
+ } else if (!running) {
261
+ after = [dur, b.envelopeId ? `envelope ${String(b.envelopeId).slice(0, 8)}` : '', b.envelopeId ? 'witnessing pending' : ''].filter(Boolean);
262
+ }
263
+ const result = running ? '' : deleg && deleg.result ? deleg.result : (b.resultSummary ? `${b.status === 'unknown' ? 'unknown: ' : ''}${b.resultSummary}` : b.status);
264
+ const bodyStr = clipWords(body0, running || !result ? room : Math.max(6, Math.min(gw(body0), Math.floor(room / 2))));
265
+ const tailRoom = room - gw(bodyStr) - 3;
266
+ let tail = '';
267
+ if (!running && tailRoom > 0) {
268
+ const facts = after.join(' · ');
269
+ const resRoom = tailRoom - (facts ? gw(facts) + 3 : 0);
270
+ const res = resRoom >= 4 ? fitResult(result, resRoom) : '';
271
+ tail = clipWords([res, facts].filter(Boolean).join(' · '), tailRoom);
272
+ }
273
+ const verified = b.receipt?.verified === true;
274
+ const glyphColor = b.receipt && !running ? (verified ? 'ok' : 'warn') : tone;
275
+ // Running: one glyph whose colour pulses sky <-> violet in OKLab (never a spinner of different glyphs).
276
+ const runRgb = running && pulse != null && !mono() ? mixOk(hexRgb(COLORS.sky), hexRgb(COLORS.violet), pulse01(pulse, 1200)) : null;
277
+ const runColor = runRgb ? rgbStr(runRgb) : null;
278
+ const tailRuns = tail ? paintRuns(tail, deleg && deleg.warn ? 'warn' : tone === 'ok' ? 'value' : tone).map(r => (/^(witnessed)$/.test(r.text.trim()) ? { ...r, role: 'ok' } : r)) : [];
279
+ // Expressive: the row ignites as the tool starts (a short warm flare on the glyph and the family, motion/level.js).
280
+ const lit = running && ignite > 0 && !mono();
281
+ const glyphC = lit ? igniteColor(runRgb || c(glyphColor) || COLORS.sky, ignite) : runColor || glyphColor;
282
+ const head = [{ t: ' ' }, { t: glyph, color: glyphC, bold: lit && ignite > 0.5 }, { t: ' ' }, { t: name, color: lit ? igniteColor(agentColor(name), ignite * 0.7) : agentColor(name), bold: true }, { t: ' │ ', color: 'muted' }, { t: bodyStr, color: 'text' }];
283
+ const tailSegs = tail ? [{ t: ' │ ', color: 'muted' }, ...tailRuns.map(r => ({ t: r.text, color: r.role }))] : [];
284
+ // Settle (running -> result): the glyph flashes once and the result decodes in behind it.
285
+ if (fx?.settle != null && !running) {
286
+ const flash = fx.settle < 0.12 && !mono();
287
+ if (flash) head[1] = { t: glyph, color: '#ffffff', bold: true };
288
+ return h(Box, null, ...fxNodes(head, null), ...fxNodes(tailSegs, { e: fx.settle, timing: TIMING.settle }, 7));
289
+ }
290
+ return h(Box, null, ...fxNodes([...head, ...tailSegs], fx, 3));
291
+ }
292
+
293
+ /** Compact protocol detail: drop the quox.chat./quox. intent prefix and keep only the tail of long correlation ids, so the line fits instead of clipping. */
294
+ export function compactTrace(text) {
295
+ return String(text || '')
296
+ .replace(/\bquox\.(chat\.)?/g, '')
297
+ .replace(/\bcorr\s+([A-Za-z0-9_-]{10,})/g, (m, id) => `corr …${id.slice(-6)}`)
298
+ .replace(/\s+/g, ' ').trim();
299
+ }
300
+
301
+ // Protocol stage colours: the palette's proto(name) role when theme.js provides one, else these.
302
+ const PROTO_FALLBACK = { aee: '#06b6d4', aocl: '#a855f7', volt: '#f59e0b', ward: '#4ade80' };
303
+ export const protoColor = (name) => (typeof themeNs.proto === 'function' && themeNs.proto(name)) || PROTO_FALLBACK[name] || COLORS.dim;
304
+ const BG_DIM = [58, 66, 82];
305
+
306
+ /**
307
+ * The protocol icon's colour. Icons never change glyph, only colour (owner 2026-10-12): `pulse` (clock ms) while
308
+ * this is the active stage = a smooth OKLab pulse in the stage colour; `fx` (arrival) = brightening in, with a
309
+ * one-frame glint for the WARD seal; otherwise the steady stage colour.
310
+ */
311
+ function protoIconColor(protocol, { pulse = null, fx = null } = {}) {
312
+ if (mono()) return undefined;
313
+ const raw = protoColor(protocol);
314
+ if (!/^#[0-9a-f]{6}$/i.test(String(raw))) return raw; // 256-colour fallback: steady, no pulse
315
+ const col = hexRgb(raw);
316
+ if (pulse != null) return rgbStr(mixOk(mixOk(col, BG_DIM, 0.55), mixOk(col, [255, 255, 255], 0.3), pulse01(pulse, 1200)));
317
+ if (fx && fx.e != null && fx.e < 0.36) {
318
+ if (protocol === 'ward' && fx.e >= 0.3) return '#ffffff';
319
+ return rgbStr(mixOk(BG_DIM, col, clamp01(fx.e / 0.3)));
320
+ }
321
+ return rgbStr(col);
322
+ }
323
+
324
+ /**
325
+ * Pure: a protocol row body wrapped into at most `max` lines of `room` cells, breaking only at spaces (a word
326
+ * longer than a whole line is the one thing ever cut). Overflow ends the last line with " …" after a whole word.
327
+ */
328
+ export function wrapTrace(body, room, max = 4) {
329
+ const words = String(body || '').split(' ').filter(Boolean);
330
+ const w = (s) => [...s].length;
331
+ const lines = [];
332
+ let cur = '';
333
+ let i = 0;
334
+ for (; i < words.length && lines.length < max; i++) {
335
+ let word = words[i];
336
+ if (!cur && w(word) > room) { // longer than a line: the only hard break
337
+ while (w(word) > room && lines.length < max) { lines.push([...word].slice(0, room).join('')); word = [...word].slice(room).join(''); }
338
+ if (lines.length >= max) { i++; break; }
339
+ cur = word; continue;
340
+ }
341
+ if (!cur) { cur = word; continue; }
342
+ if (w(cur) + 1 + w(word) <= room) { cur += ' ' + word; continue; }
343
+ lines.push(cur); cur = '';
344
+ i--; // place this word on the next line
345
+ }
346
+ if (cur && lines.length < max) lines.push(cur);
347
+ if (i < words.length) { // more than fits: end on a whole word plus an ellipsis
348
+ let last = lines.length ? lines[lines.length - 1] : '';
349
+ while (last && w(last) + 2 > room) last = last.includes(' ') ? last.slice(0, last.lastIndexOf(' ')) : [...last].slice(0, Math.max(0, room - 2)).join('');
350
+ lines[Math.max(0, lines.length - 1)] = `${last} …`;
351
+ }
352
+ return lines;
353
+ }
354
+
355
+ export function TraceRow({ b, cols, fx = null, pulse = null }) {
356
+ const [g, label] = PROTO[b.protocol] || ['·', String(b.protocol || 'TRACE').toUpperCase()];
357
+ const ref = b.ref != null ? ` · ${String(b.ref).slice(0, 8)}` : '';
358
+ const body = compactTrace(`${b.label || ''}${b.detail ? ' · ' + b.detail : ''}`) + ref;
359
+ const head = ` ${g} ${label.padEnd(5)}│ `;
360
+ const afterIcon = head.slice(2 + g.length);
361
+ const room = Math.max(10, cols - [...head].length - 1);
362
+ // icon (colour only, never another glyph) + label in the protocol's own shade, the rest muted
363
+ const icon = h(Text, { color: PROTO[b.protocol] ? protoIconColor(b.protocol, { pulse, fx }) : c('muted') }, g);
364
+ const labelSeg = ' ' + label;
365
+ const labelNode = h(Text, { color: proto(b.protocol) }, labelSeg);
366
+ if ([...body].length <= room) {
367
+ if (!fx) return h(Box, null, h(Text, null, ' ', icon, labelNode, h(Text, { color: c('muted') }, afterIcon.slice(labelSeg.length) + body)));
368
+ return h(Box, null, h(Text, null, ' '), icon, ...fxNodes([{ t: labelSeg, color: 'proto:' + b.protocol }, { t: afterIcon.slice(labelSeg.length) + body, color: 'muted' }], fx, 5));
369
+ }
370
+ // Too long even when compacted: wrap at word boundaries onto indented lines (at most 4) rather than hiding the end.
371
+ const pad = ' '.repeat([...head].length);
372
+ const lines = wrapTrace(body, room, 4).map((l, i) => (i ? pad : afterIcon) + l);
373
+ return h(Box, { flexDirection: 'column' }, ...lines.map((l, i) => (i === 0
374
+ ? h(Text, { key: i }, ' ', icon, labelNode, h(Text, { color: c('muted') }, l.slice(labelSeg.length)))
375
+ : h(Text, { key: i, color: c('muted') }, l))));
376
+ }
377
+
378
+ export function HelpView({ model, cols }) {
379
+ // TUI-UX #6: the key column is sized from the usage text (capped), always followed by a two-space gutter. A usage that
380
+ // does not fit its column goes on its own line with the description underneath, so the two never run together.
381
+ const keyW = Math.min(24, Math.max(...model.filter(m => m.key).map(m => [...m.key].length), 8) + 2);
382
+ return h(Box, { flexDirection: 'column', marginTop: 1, width: cols }, ...model.map((m, i) => {
383
+ if (m.kind === 'gap') return h(Text, { key: i }, ' ');
384
+ if (m.kind === 'head') return m.dim ? h(Text, { key: i, bold: true, color: c('muted') }, m.text) : h(Heading, { key: i, text: m.text });
385
+ if (m.kind === 'row' || (m.kind === 'dim' && m.key)) {
386
+ const dim = m.kind === 'dim';
387
+ const keyRole = (r) => (r.role === 'muted' || r.role === 'key' ? r : { ...r, role: /^\s*(just type|paste|status line)\s*$/.test(m.key) ? 'label' : 'key' });
388
+ const keyNode = (txt) => h(Text, { color: c('muted') }, ...(dim ? [txt] : runs(paintRuns(txt, 'key').map(keyRole))));
389
+ const descNode = (txt) => h(Text, { color: c(dim ? 'muted' : 'text') }, ...(dim ? [txt] : runs(paintRuns(txt, 'text'))));
390
+ // Both columns are wrapped here, by words, so no continuation line starts with a stray space and none collides.
391
+ const descLines = wrapCell(m.text, Math.max(10, cols - keyW - 4));
392
+ if ([...m.key].length + 2 <= keyW) { // usage plus a two-space gutter fits the key column
393
+ return h(Box, { key: i, flexDirection: 'column' }, ...descLines.map((d, k) => h(Box, { key: k },
394
+ h(Box, { width: keyW + 2, flexShrink: 0 }, k === 0 ? h(Text, null, ' ', keyNode(m.key)) : h(Text, null, ' ')),
395
+ h(Box, { flexGrow: 1, flexShrink: 1 }, descNode(d)))));
396
+ }
397
+ const keyLines = wrapCell(m.key, Math.max(10, cols - 8)).map((l, k) => (k ? ' ' : ' ') + l);
398
+ return h(Box, { key: i, flexDirection: 'column' },
399
+ ...keyLines.map((l, k) => h(Text, { key: `k${k}` }, keyNode(l))),
400
+ ...descLines.map((d, k) => h(Box, { key: `d${k}`, paddingLeft: keyW + 2 }, descNode(d))));
401
+ }
402
+ return h(Text, { key: i, color: c(m.kind === 'dim' ? 'muted' : 'text') }, ...runs(paintRuns(m.text, m.kind === 'dim' ? 'muted' : 'text')));
403
+ }));
404
+ }
405
+
406
+ export function WelcomeView({ version, cols }) {
407
+ const LABEL = 10;
408
+ return h(Box, { flexDirection: 'column', marginTop: 1, width: cols },
409
+ ...welcomeModel(version).map((m, i) => {
410
+ switch (m.kind) {
411
+ case 'title': return h(Heading, { key: i, text: m.text });
412
+ case 'sub': return h(Text, { key: i, color: c('muted') }, ...runs(paintRuns(m.text, 'muted')));
413
+ case 'rule': return h(Text, { key: i, color: c('dim') }, '─'.repeat(cols));
414
+ case 'field': if (m.link && [...m.value].length > cols - LABEL) { // a URL is never split: stack it under its label
415
+ return h(Box, { key: i, flexDirection: 'column' }, h(Text, { color: c('label') }, m.label), h(Text, { color: c('link'), underline: true }, m.value));
416
+ }
417
+ return h(Box, { key: i },
418
+ h(Box, { width: LABEL, flexShrink: 0 }, h(Text, { color: c('label') }, m.label)),
419
+ h(Box, { flexGrow: 1, flexShrink: 1 }, h(WText, { color: c(m.link ? 'link' : 'value'), underline: Boolean(m.link) && !mono() }, m.value)));
420
+ case 'hint': return h(WText, { key: i, color: c('muted') }, ...runs(paintRuns(m.text, 'muted')));
421
+ default: return h(WText, { key: i, color: c('text') }, m.text);
422
+ }
423
+ }));
424
+ }
425
+
426
+ export const INTRO_FRAMES = 18; // INTRO_MS (900) at 50 ms frames on the shared clock: Orbit & Lock on the lab's timing
427
+ /** Animate the intro? Only on a real terminal, never under reduced motion or QUOX_TUI_LITE. NO_COLOR animates in mono. */
428
+ export const introAnimates = (isTTY) => motionAllowed(isTTY);
429
+
430
+ /**
431
+ * Text rows of the Q for a terminal size and progress (0..1 of the plan; 1 = settled). Lab sizing (labSize: the
432
+ * compact particle Q under 60 cols or 12 rows) and the lab timeline (introPlan: Orbit & Lock in INTRO_MS, then
433
+ * the dragon greeting when `dragon`). Exported so tests can assert on frames.
434
+ */
435
+ export function introArt(cols, rows, progress = 1, color = !mono(), { dragon = false, theme = themeNs.isLight() ? 'light' : 'dark' } = {}) {
436
+ const size = labSize(cols, rows);
437
+ const plan = introPlan(size, { color });
438
+ const ms = dragon ? INTRO_MS + DRAGON_CLIPS.greeting.dur + 300 : plan.ms;
439
+ const t = progress >= 1 ? Infinity : Math.max(0, progress) * ms;
440
+ if (dragon && t !== Infinity) {
441
+ // Cinematic: the settled Q opens its eyes, looks at you and winks (the dragon greeting, qdragonScenes.js). The
442
+ // dragon is drawn at the Q's OWN size (the compact dragon under 60 cols) on the Q's left edge, and the box keeps
443
+ // the dragon's height and width for the whole sequence (Orbit & Lock frames are padded), so nothing jumps.
444
+ const L = dragonLayout(qDims(size).cols + 6, { size, lm: 0 }), w = Math.max(qDims(size).cols, L.cols);
445
+ if (t >= INTRO_MS) return { size, w, ms, lines: cellsToAnsi(clipCells('greeting', t - INTRO_MS, { cols: L.cols, size, lm: 0, theme, mono: !color, label: false })) };
446
+ const q = cellsToAnsi(plan.at(Math.min(t, plan.ms)));
447
+ return { size, w, ms, lines: [...q, ...Array.from({ length: Math.max(0, L.rows - q.length) }, () => '')] };
448
+ }
449
+ return { size, w: qDims(size).cols, ms, lines: cellsToAnsi(t === Infinity ? qCells(size, { color }) : plan.at(Math.min(t, plan.ms))) };
450
+ }
451
+
452
+ export const INTRO_HEAD = "Hi, I'm Quox!";
453
+ export const INTRO_LINES = ['Tell me what you want. Watch me work.', '/help for commands, or just ask me anything.'];
454
+
455
+ export function Intro({ item, cols, progress = 1, rows, dragon = false }) {
456
+ const { stdout } = useStdout();
457
+ const art = introArt(cols, rows ?? termRows(stdout) ?? 40, progress, !mono(), { dragon });
458
+ // Beside the art only when the longest line fits there; otherwise the text goes under the art, left-aligned and wrapped, never clipped.
459
+ const need = Math.max(...[INTRO_HEAD, ...INTRO_LINES, item.text].map(l => [...l].length));
460
+ const wide = cols >= 60 && cols - 2 - art.w - 2 >= need;
461
+ const info = h(Box, { flexDirection: 'column', justifyContent: 'center', marginLeft: wide ? 2 : 0, marginTop: wide ? 0 : 1, flexShrink: 1, ...(wide ? {} : { width: Math.max(10, cols - 4) }) },
462
+ h(Grad, { text: INTRO_HEAD }),
463
+ ...INTRO_LINES.map((l, k) => h(Text, { key: k, color: c('text'), wrap: 'wrap' }, l)),
464
+ h(Text, { color: c('label'), wrap: 'wrap' }, item.text)); // version/build line: quieter (normal weight, soft brand tint), never grey
465
+ return h(Box, { flexDirection: wide ? 'row' : 'column', marginTop: 1, marginBottom: 2, marginLeft: 2 }, h(Box, { flexDirection: 'column' }, ...art.lines.map((l, k) => h(Text, { key: k, wrap: 'truncate' }, l))), info);
466
+ }
467
+
468
+ /** Reveal timing per item kind (null = never revealed: user input, answer text, intro, help, welcome). */
469
+ export function revealTimingFor(item) {
470
+ switch (item?.kind) {
471
+ case 'tool': return TIMING.row;
472
+ case 'trace': return TIMING.dim;
473
+ case 'note': case 'summary': case 'error': return TIMING.dim;
474
+ case 'object': return TIMING.title; // inline object cards decode in on the shared clock (objects/view.js)
475
+ default: return null;
476
+ }
477
+ }
478
+
479
+ // QUOXON-AOCL: ONE line per turn ("▤ AOCL 11/11 ✔ · 9 passed · 2 bypassed · 34 ms"), red naming the layer
480
+ // on a reject; with table:true, the /aocl per-layer table. White on dark, never grey (owner rule).
481
+ const AOCL_TONE = { ok: 'ok', fail: 'fail', warn: 'warn', text: 'input', error: 'fail', accent: 'sky' };
482
+ export function AoclRow({ item, cols }) {
483
+ const icon = h(Text, { color: protoIconColor('aocl') }, '▤');
484
+ const label = h(Text, { color: proto('aocl'), bold: true }, ' AOCL ');
485
+ if (item.table) {
486
+ const rows = aoclTableLines(item.sum);
487
+ return h(Box, { paddingLeft: 2, flexDirection: 'column', width: cols }, ...rows.map(([t, tone], i) => h(Text, { key: i, color: c(AOCL_TONE[tone] || 'input') }, clip(i === 0 ? t : ' ' + t, cols - 4))));
488
+ }
489
+ let room = Math.max(10, cols - 10);
490
+ const segs = [];
491
+ for (const sg of aoclLineSegs(item.sum).segs) {
492
+ if (room <= 0) break;
493
+ const t = clip(sg.t, room);
494
+ room -= [...t].length;
495
+ segs.push(h(Text, { key: segs.length, color: c(AOCL_TONE[sg.tone] || 'input') }, t));
496
+ }
497
+ return h(Box, { width: cols }, h(Text, null, ' ', icon, label, ...segs));
498
+ }
499
+
500
+ /**
501
+ * Visual rhythm, decided in ONE place (the history renderer), never per producer. Blank rows BEFORE an item given
502
+ * the item above it (an item never carries a trailing blank, so two blanks in a row cannot happen):
503
+ * - a blank row between EVERY distinct block (user message, tool group, answer, notice group, AOCL footer, tip
504
+ * frame, bang block); tight only inside a block: a run of tool rows, a run of notices, the AOCL + Thought
505
+ * footer lines. A blank follows the prompt, separates the answer from the footer, and follows the footer.
506
+ * - items that draw their own leading blank (intro, welcome, help, object cards) add none here
507
+ * - under 20 terminal rows nothing is padded: only a new turn keeps its blank
508
+ */
509
+ const SELF_SPACED = new Set(['intro', 'welcome', 'help', 'object']);
510
+ const GROUP = { tool: 'tools', trace: 'tools', note: 'notice', error: 'notice', aocl: 'footer', summary: 'footer' };
511
+ const FOOTER = new Set(['aocl', 'summary']);
512
+ export function historyGap(prev, item, rows = null) {
513
+ if (!item || SELF_SPACED.has(item.kind)) return 0;
514
+ if (item.kind === 'user') return 1;
515
+ if (!prev) return item.kind === 'text' ? 1 : 0;
516
+ if (rows != null && rows < 20) return 0;
517
+ if (FOOTER.has(item.kind) && FOOTER.has(prev.kind)) return 0; // the AOCL line and the Thought line are one footer block
518
+ const g = GROUP[item.kind];
519
+ if (g && g === GROUP[prev.kind]) return 0; // a run of tool rows, or of notices, is one block
520
+ return 1; // every other pair of distinct blocks (each tip frame is its own block)
521
+ }
522
+
523
+ export function HistoryItem({ item, cols, live = false, fx = null, pulse = null, ignite = 0, gap }) {
524
+ if (gap !== undefined && gap > 0 && !['user', 'text'].includes(item.kind)) {
525
+ return h(Box, { marginTop: gap, flexDirection: 'column' }, h(HistoryItem, { item, cols, live, fx, pulse, ignite, gap: 0 }));
526
+ }
527
+ switch (item.kind) {
528
+ case 'intro': return h(Intro, { item, cols });
529
+ case 'user': return h(Box, { marginTop: gap ?? 1, width: cols }, // gutter box + wrapping text box = hanging indent
530
+ h(Box, { width: 2, flexShrink: 0 }, h(Text, { color: c('sky'), bold: true }, item.queued ? '» ' : '› ')),
531
+ h(Box, { flexGrow: 1, flexShrink: 1 }, h(WText, { color: c('input') }, item.text)));
532
+ case 'welcome': return h(WelcomeView, { version: item.version, cols });
533
+ case 'help': return h(HelpView, { model: item.model, cols });
534
+ case 'object': return h(ObjectView, { item, cols, fx });
535
+ case 'summary': { // the closing line wraps at its ' · ' separators: no fact (model, receipt id) is ever cut off
536
+ const ls = noteLines(item.text, cols - 4).map(l => clip(l, cols - 4));
537
+ const segs = (l) => paintRuns(l, 'muted').map(r => ({ t: r.text, color: r.role || 'muted' }));
538
+ return h(Box, { paddingLeft: 2, flexDirection: 'column' }, ...ls.map((l, i) => (fx
539
+ ? h(Text, { key: i }, ...fxNodes(segs(l), { e: fx.e - i * 0.05, timing: fx.timing }, 11 + i))
540
+ : h(Text, { key: i, color: c('dim') }, ...runs(paintRuns(l, 'muted'))))));
541
+ }
542
+ case 'text': return h(Box, { marginTop: gap ?? 1, paddingLeft: 2, flexDirection: 'column' }, h(Markdown, { text: item.text, cols: cols - 2, live }));
543
+ case 'tool': return h(ToolRow, { b: item, cols, fx, pulse, ignite });
544
+ case 'trace': return h(TraceRow, { b: item, cols, fx, pulse });
545
+ case 'aocl': return h(AoclRow, { item, cols });
546
+ case 'error': return h(ErrorView, { item, cols, fx });
547
+ case 'note': return h(NoteView, { item, cols, fx });
548
+ case 'slotmsg': return h(SlotMsgView, { item, cols });
549
+ case 'tip': return h(TipView, { item, cols });
550
+ case 'whatsnew': return h(WhatsNewView, { item, cols });
551
+ case 'bcard': return h(BroadcastCardView, { item, cols });
552
+ case 'bang': return h(Box, { paddingLeft: 2, flexDirection: 'column', width: cols }, ...String(item.text).split('\n').map((l, i) => h(Text, { key: i, color: c('text') }, l || ' ')));
553
+ default: return null;
554
+ }
555
+ }
556
+
557
+ // ---- notes ---------------------------------------------------------------------------------------
558
+ const SEP = ' · ';
559
+ /** Lines of a note at `width`: one line when it fits; a ' · ' list breaks only at separators. */
560
+ export function noteLines(text, width) {
561
+ const t = String(text ?? '');
562
+ if (!t.includes(SEP) || t.includes('\n') || wrapRows(stripMarks(t), width) === 1) return [t];
563
+ const lines = [];
564
+ let cur = '';
565
+ for (const seg of t.split(SEP)) {
566
+ const next = cur ? cur + SEP + seg : seg;
567
+ if (!cur || graphemes(stripMarks(next)).length <= width) cur = next;
568
+ else { lines.push(cur); cur = seg; }
569
+ }
570
+ lines.push(cur);
571
+ return lines;
572
+ }
573
+ /** A note line that is one bare URL longer than the row: never hard-wrapped (a break inside a token breaks copy and click). */
574
+ const longUrl = (l, width) => { const t = stripMarks(l); return /^https?:\/\/\S+$/.test(t) && graphemes(t).length > width; };
575
+ export const noteRows = (text, cols, tone) => noteLines(toneText(text, tone), cols - 2).reduce((n, l) => n + (longUrl(l, cols - 2) ? 1 : wrapRows(stripMarks(l), cols - 2)), 0);
576
+
577
+ /**
578
+ * Z2H-7B3: a failure message can be several lines (what ran, what did not, the advice). Every line shows,
579
+ * wrapped at the row width, never clipped to one: the first line leads with a red ✖, the rest indent under it.
580
+ */
581
+ export const errorRows = (text, cols) => String(text ?? '').split('\n').reduce((n, l) => n + Math.max(1, wrapRows(stripMarks(l), Math.max(10, cols - 4))), 0);
582
+ function ErrorView({ item, cols, fx }) {
583
+ const raw = String(item.text ?? '');
584
+ const lines = raw.split('\n');
585
+ const paint = (l) => paintRuns(l, 'prose').map(r => ({ t: r.text, color: r.role || 'prose' }));
586
+ if (lines.length === 1 && wrapRows(stripMarks(raw), Math.max(10, cols - 4)) === 1) {
587
+ const parts = [{ t: '✖ ', color: 'error' }, ...paint(raw)];
588
+ return h(Box, { paddingLeft: 2 }, fx ? h(Text, null, ...fxNodes(parts, fx, 13)) : h(Text, null, ...parts.map((p, i) => h(Text, { key: i, color: c(p.color) }, p.t))));
589
+ }
590
+ return h(Box, { paddingLeft: 2, flexDirection: 'column', width: cols },
591
+ ...lines.map((l, i) => h(Box, { key: i },
592
+ h(Box, { width: 2, flexShrink: 0 }, h(Text, { color: c('error') }, i === 0 ? '✖ ' : ' ')),
593
+ h(Box, { flexGrow: 1, flexShrink: 1 }, h(WText, { color: c('prose') }, ...runs(paintRuns(l || ' ', 'prose')))))));
594
+ }
595
+
596
+ /**
597
+ * /aside: a read-only side answer, drawn apart from the conversation: a violet ┃ bar down the left, the question
598
+ * as its header, the answer as markdown, a quiet read-only footer. Rows: margin + header + body + footer (asideRows).
599
+ */
600
+ function AsideView({ item, cols }) {
601
+ const { head, body, foot } = parseAsideNote(item.text);
602
+ const w = Math.max(10, cols - 5);
603
+ return h(Box, { marginLeft: 2, marginTop: 1, flexDirection: 'column', width: cols - 2, borderStyle: 'bold', borderTop: false, borderRight: false, borderBottom: false, borderLeftColor: c('violet'), paddingLeft: 1 },
604
+ h(Text, { color: c('violet'), bold: true }, clip(head, w)),
605
+ h(Markdown, { text: body.join('\n') || '(no answer)', cols: w }),
606
+ ...(foot ? [h(Text, { key: 'f', color: c('quiet') }, clip(foot, w))] : []));
607
+ }
608
+ export const asideRows = (text, cols) => { const { body, foot } = parseAsideNote(text); return 2 + Math.max(1, markdownRows(body.join('\n') || '(no answer)', Math.max(10, cols - 5))) + (foot ? 1 : 0); };
609
+
610
+ function NoteView({ item, cols, fx = null }) {
611
+ if (item.tone === 'ask') return h(AskCard, { text: item.text, cols }); // QUOXON-ASK: every ask is the approval card
612
+ if (item.tone === 'aside') return h(AsideView, { item, cols });
613
+ // RECO-HIGHLIGHT: the recommended option of a decision card: bold, accent colour, never grey.
614
+ if (item.tone === 'reco') return h(Box, { paddingLeft: 2, width: cols }, h(WText, { color: c('sky'), bold: true }, stripMarks(item.text)));
615
+ // Tone shows as a glyph (⚠ ✖ ✔) in front; the sentence stays prose so commands and values keep their own colours.
616
+ const color = c('prose');
617
+ const text = toneText(item.text, item.tone);
618
+ // While it reveals, a line that fits decodes in; a line that wraps keeps plain wrapping (never mis-measured).
619
+ return h(Box, { paddingLeft: 2, flexDirection: 'column', width: cols }, ...noteLines(text, cols - 2).map((l, i) => longUrl(l, cols - 2)
620
+ ? h(Box, { key: i, width: graphemes(stripMarks(l)).length, flexShrink: 0 }, h(Text, { color: c('link'), wrap: 'truncate-end' }, stripMarks(l)))
621
+ : (fx && wrapRows(stripMarks(l), cols - 2) === 1
622
+ ? h(Text, { key: i }, ...fxNodes(paintLine(l, item.tone).map(r => (typeof r === 'string' ? { t: r, color: 'prose' } : { t: r.text, color: r.role || 'prose' })), { e: fx.e - i * 0.05, timing: fx.timing }, 17 + i))
623
+ : h(WText, { key: i, color, bold: item.tone === 'ask' }, ...runs(paintLine(l, item.tone))))));
624
+ }
625
+
626
+ /**
627
+ * QUOXON-MESH: a slot chat line. Incoming "◆ quox2 (Adam) 04:56 hello" (violet: another session speaking),
628
+ * outgoing "→ quox2 04:56 hello ✔" (sky: you). A file shows its name, three lines and a /slot log pointer;
629
+ * /slot log prints the whole file (`full`).
630
+ */
631
+ function SlotMsgView({ item, cols }) {
632
+ const l = item.line || {};
633
+ if (l.roomLine) { // QUOXON-ROOMS: `#group-chat QUOXON: text`, a stable colour per sender
634
+ return h(Box, { paddingLeft: 2, width: cols },
635
+ h(WText, null,
636
+ h(Text, { color: c('label') }, `#${l.room} `),
637
+ h(Text, { color: c(l.color || 'sky'), bold: true }, `${l.who}: `),
638
+ h(Text, { color: c('text') }, String(l.text ?? ''))));
639
+ }
640
+ const role = l.out ? 'sky' : l.urgent ? 'error' : 'violet';
641
+ const body = String(l.text ?? '');
642
+ // SLOT-RUN A (owner 2026-10-16 "messy needs a format"): one tidy header line (sender, no-reply marker, time);
643
+ // a short plain line stays on it, anything longer or with markdown renders below as formatted rows.
644
+ const time = l.time ? ` ${l.time}` : '';
645
+ const headLen = graphemes(`${l.glyph || '◆'} ${l.who || ''}${!l.out && l.noReply ? ' (no reply address)' : ''}${time} `).length;
646
+ const run = !l.out && l.run ? l.run : null;
647
+ const inline = !run && (Boolean(l.file) || l.out || inlineable(body, cols - 2 - headLen - 2));
648
+ const segRow = (segs, key) => h(Box, { key, paddingLeft: 4, width: cols }, segs.length ? h(Text, null, ...segs.map((sg, i) => h(Text, { key: i, color: c(sg.role || 'text'), bold: sg.bold || undefined }, sg.t))) : h(Text, null, ' '));
649
+ const out = [h(Box, { key: 'h', paddingLeft: 2, width: cols },
650
+ h(WText, null,
651
+ h(Text, { color: c(role), bold: true }, `${l.glyph || '◆'} ${l.who || ''}`),
652
+ !l.out && l.noReply ? h(Text, { color: c('label') }, ' (no reply address)') : null,
653
+ h(Text, { color: c('label') }, `${time} `),
654
+ inline ? h(Text, { color: c(l.urgent ? 'error' : 'text') }, body) : null,
655
+ l.out && l.delivered ? h(Text, { color: c('ok') }, ' ✔') : null))];
656
+ if (!inline && !l.file && !run && body.trim()) formatSlotBody(body, cols - 6, { role: l.urgent ? 'error' : 'text' }).forEach((segs, i) => out.push(segRow(segs, `b${i}`)));
657
+ // SLOT-RUN B: a run request shows the shared ask, the FULL command (never clipped), and its answer line.
658
+ // ASK-SETTLE: an answered or expired run request is one line (what was asked and the answer), never a live card.
659
+ if (run && run.settled) out.push(h(Box, { key: 'run', paddingLeft: 4, width: cols }, h(WText, { color: c('prose') }, ...runs(paintLine(toneText(run.settled, run.settledTone), run.settledTone)))));
660
+ else if (run) out.push(h(Box, { key: 'run' }, h(NeedsYouCard, { // QUOXON-ASK: a run request is the approval card too
661
+ cols, title: `⚠ ${run.who} asks you to run this here`, rows: run.why ? [['why', run.why]] : [], block: codeRows([run.cmd], cols - 4),
662
+ notes: run.runnable ? [] : [`✖ Not offered: ${run.refuse}. Copy it yourself only if you trust it.`],
663
+ footer: run.danger ? `▶ ${run.danger}. Type run to run it, n to skip? run · n no · c chat` : '▶ Run it? y yes · n no · c chat',
664
+ arming: run.runnable ? null : h(Text, null, '') })));
665
+ if (l.file && !l.out) {
666
+ const lines = l.full ? String(l.file.body).split('\n') : l.file.preview;
667
+ lines.forEach((t, i) => out.push(h(Box, { key: `f${i}`, paddingLeft: 6, width: cols }, h(Text, { color: c('text') }, clip(t || ' ', cols - 8)))));
668
+ if (!l.full && l.file.lines > lines.length) out.push(h(Box, { key: 'more', paddingLeft: 6 }, h(Text, { color: c('prose') }, `… ${l.file.lines - lines.length} more line${l.file.lines - lines.length === 1 ? '' : 's'}: `), h(Text, { color: c('command') }, '/slot log')));
669
+ else if (!l.full) out.push(h(Box, { key: 'more', paddingLeft: 6 }, h(Text, { color: c('prose') }, 'full file: '), h(Text, { color: c('command') }, '/slot log')));
670
+ }
671
+ return h(Box, { flexDirection: 'column' }, ...out);
672
+ }
673
+
674
+ export const TIP_HINT = `${cmd('/tip')} for another · ${cmd('/tip-settings off')} to stop`;
675
+ /** Word-wrap plain text to `width` columns (a word longer than the width is hard-split). */
676
+ function wrapPlain(text, width) {
677
+ const out = []; let line = '';
678
+ for (const w0 of String(text ?? '').split(/\s+/).filter(Boolean)) {
679
+ let w = w0;
680
+ while ([...w].length > width) { if (line) { out.push(line); line = ''; } out.push([...w].slice(0, width).join('')); w = [...w].slice(width).join(''); }
681
+ if (line && [...line].length + 1 + [...w].length > width) { out.push(line); line = w; } else line = line ? `${line} ${w}` : w;
682
+ }
683
+ if (line) out.push(line);
684
+ return out.length ? out : [''];
685
+ }
686
+
687
+ /**
688
+ * A tip in a rounded brand-accent frame: width min(cols - 4, 76), 1-col padding, wrapped text, "Try:" on its own
689
+ * line in the command colour, the /tip hint as the last line inside. Under 40 cols or NO_COLOR: a plain "Tip:" block.
690
+ */
691
+ function TipView({ item, cols }) {
692
+ if (mono() || cols < 40) {
693
+ const w = Math.max(10, cols - 2);
694
+ const lines = [...wrapPlain(`Tip: ${item.text}`, w), `Try: ${item.cmd}`, ...wrapPlain(stripMarks(TIP_HINT), w)];
695
+ return h(Box, { paddingLeft: 2, flexDirection: 'column', width: cols }, ...lines.map((l, i) => h(Text, { key: i }, l)));
696
+ }
697
+ const W = Math.min(cols - 4, 76), inner = W - 4, edge = { color: c('brand') };
698
+ const row = (key, kids, len) => h(Box, { key }, h(Text, edge, '│ '), h(Text, null, ...kids), h(Text, null, ' '.repeat(Math.max(0, inner - len))), h(Text, edge, ' │'));
699
+ const body = wrapPlain(item.text, inner).map((l, i) => row(`t${i}`, [h(Text, { key: 0, color: c('prose') }, l)], [...l].length));
700
+ const cmdText = clip(item.cmd, inner - 5);
701
+ const tryRow = row('try', [h(Text, { key: 0, color: c('prose'), bold: true }, 'Try: '), h(Text, { key: 1, color: c('command') }, cmdText)], 5 + [...cmdText].length);
702
+ const hintRow = row('hint', runs(paintRuns(TIP_HINT, 'quiet')), [...stripMarks(TIP_HINT)].length);
703
+ return h(Box, { paddingLeft: 2, flexDirection: 'column' },
704
+ h(Text, edge, '╭─ ', h(Text, { bold: true }, 'Tip'), ' ', '─'.repeat(W - 8), '╮'),
705
+ ...body, tryRow, hintRow,
706
+ h(Text, edge, '╰', '─'.repeat(W - 2), '╯'));
707
+ }
708
+
709
+ /**
710
+ * The startup "What's new since you last ran Quox" box: same frame as a tip, one title row (+ a "Try:" row) per entry,
711
+ * then ALWAYS a "Try: /whatsnew" footer ("+N more. Try: /whatsnew" when more are unseen), so the way to the full list
712
+ * never depends on which entries happen to be newest. The compact head variant (item.head, the new-commands line)
713
+ * already ends "/whatsnew for more" and gets no footer. Under 40 cols or NO_COLOR: plain lines, no frame.
714
+ */
715
+ function WhatsNewView({ item, cols }) {
716
+ const HEAD = item.head || "What's new since you last ran Quox";
717
+ const lead = item.more > 0 ? `+${item.more} more. ` : '';
718
+ const more = item.head ? '' : `${lead}Try: /whatsnew`;
719
+ if (mono() || cols < 40) {
720
+ const w = Math.max(10, cols - 2);
721
+ const lines = [HEAD, ...item.items.flatMap(it => [`- ${it.title}`, ...(it.cmd ? [` Try: ${it.cmd}`] : [])]), ...(more ? [more] : [])].flatMap(l => wrapPlain(l, w));
722
+ return h(Box, { paddingLeft: 2, flexDirection: 'column', width: cols }, ...lines.map((l, i) => h(Text, { key: i }, l)));
723
+ }
724
+ const W = Math.min(cols - 4, 76), inner = W - 4, edge = { color: c('brand') };
725
+ const row = (key, kids, len) => h(Box, { key }, h(Text, edge, '│ '), h(Text, null, ...kids), h(Text, null, ' '.repeat(Math.max(0, inner - len))), h(Text, edge, ' │'));
726
+ const rows = item.items.flatMap((it, i) => {
727
+ const t = clip(it.title, inner - 2);
728
+ const out = [row(`t${i}`, [h(Text, { key: 0, color: c('prose'), bold: true }, `• ${t}`)], 2 + [...t].length)];
729
+ if (it.cmd) { const k = clip(it.cmd, inner - 7); out.push(row(`c${i}`, [h(Text, { key: 0, color: c('prose') }, ' Try: '), h(Text, { key: 1, color: c('command') }, k)], 7 + [...k].length)); }
730
+ return out;
731
+ });
732
+ if (more) rows.push(row('more', [h(Text, { key: 0, color: c('prose') }, lead), h(Text, { key: 1, color: c('prose'), bold: true }, 'Try: '), h(Text, { key: 2, color: c('command') }, '/whatsnew')], [...more].length));
733
+ if (item.pad) { rows.unshift(row('p0', [], 0)); rows.push(row('p1', [], 0)); }
734
+ return h(Box, { paddingLeft: 2, flexDirection: 'column' },
735
+ h(Text, edge, '╭─ ', h(Text, { bold: true }, HEAD), ' ', '─'.repeat(Math.max(1, W - 5 - HEAD.length)), '╮'),
736
+ ...rows,
737
+ h(Text, edge, '╰', '─'.repeat(W - 2), '╯'));
738
+ }
739
+
740
+ /**
741
+ * QUOXON-BROADCAST: the sender's delivery card. Rounded frame in the card's tone (green all in, gold pending, red a
742
+ * real failure), one row per fact, the id and the status hint in the bottom edge. Widths are measured in cells so the
743
+ * emoji does not skew the edge. Under 50 cols: the model's one-line form.
744
+ */
745
+ function BroadcastCardView({ item, cols }) {
746
+ const m = item.card;
747
+ if (cols < 50) return h(Box, { paddingLeft: 1, width: cols }, h(WText, { color: c('prose') }, m.narrow));
748
+ const W = Math.min(cols - 4, 76), inner = W - 4, edge = { color: c(m.tone) };
749
+ const cut = (t, room) => { let o = ''; for (const ch of [...t]) { if (stringWidth(o + ch) > room - 1) return o + '…'; o += ch; } return o; };
750
+ const fit = (t, room) => (stringWidth(t) > room ? cut(t, room) : t);
751
+ const rows = m.rows.map((segs, i) => {
752
+ let left = inner;
753
+ const kids = segs.map(([t, role], k) => { const x = fit(t, left); left -= stringWidth(x); return h(Text, { key: k, color: c(role) }, x); });
754
+ return h(Box, { key: i }, h(Text, edge, '│ '), h(Text, null, ...kids), h(Text, null, ' '.repeat(Math.max(0, left))), h(Text, edge, ' │'));
755
+ });
756
+ const title = fit(m.title, W - 5);
757
+ const foot = fit(m.foot, W - 5);
758
+ return h(Box, { paddingLeft: 2, flexDirection: 'column' },
759
+ h(Text, edge, '╭─ ', h(Text, { bold: true, color: c('prose') }, title), ' ', '─'.repeat(Math.max(1, W - 5 - stringWidth(title))), '╮'),
760
+ ...rows,
761
+ h(Text, edge, '╰─ ', h(Text, { color: c('quiet') }, foot), ' ', '─'.repeat(Math.max(1, W - 5 - stringWidth(foot))), '╯'));
762
+ }
763
+
764
+ /** Rows a live block takes as HistoryItem renders it (sizes the live region; keep in step with HistoryItem). */
765
+ export function liveRows(blocks, cols) {
766
+ return blocks.reduce((n, b) => {
767
+ switch (b.kind) {
768
+ case 'text': return n + 1 + markdownRows(b.text, cols - 2, true);
769
+ case 'note': return n + (b.tone === 'ask' ? askCardRows(b.text, cols) : b.tone === 'aside' ? asideRows(b.text, cols) : noteRows(b.text, cols, b.tone));
770
+ case 'summary': return n + noteLines(b.text, cols - 4).length;
771
+ case 'error': return n + errorRows(b.text, cols);
772
+ case 'slotmsg': return n + (b.line?.run && !b.line.run.settled ? 9 + Math.ceil(String(b.line.run.cmd || '').length / Math.max(10, cols - 8)) : 1); // ASK-SETTLE: an armed run card is held live
773
+ default: return n + 1;
774
+ }
775
+ }, 0);
776
+ }
777
+
778
+ // ---- composer --------------------------------------------------------------------------------
779
+ export const Grad = ({ text }) => {
780
+ if (mono()) return h(Text, { bold: true }, text);
781
+ const chars = [...text];
782
+ return h(Text, { bold: true }, ...chars.map((ch, i) => {
783
+ const k = chars.length > 1 ? i / (chars.length - 1) : 0;
784
+ return h(Text, { key: i, color: `rgb(${G0.map((v, j) => Math.round(v + (G1[j] - v) * k)).join(',')})` }, ch);
785
+ }));
786
+ };
787
+
788
+ /**
789
+ * Pure: the model as the composer's bottom-rule label (`──── anthropic claude-sonnet-5-5 ─`). Degrades to fit,
790
+ * never wraps: full, then a shortened provider (`anth claude-sonnet-5-5`), then the model alone, then the
791
+ * model clipped. Leaves at least four rule cells on the left. Below 30 cols, or no model: ''.
792
+ */
793
+ export function modelRuleLabel(cols, model) {
794
+ const full = String(model || '').trim();
795
+ if (!full || cols < 30) return '';
796
+ const room = cols - 7; // ' label ' + one trailing dash + four leading dashes
797
+ const fits = (t) => cellWidth(t) <= room; // cells under the ambiguous-width policy (cells.js)
798
+ if (fits(full)) return full;
799
+ const segs = full.split(' · ');
800
+ const tailSeg = segs[segs.length - 1];
801
+ const words = (segs.length > 1 ? segs[0] : full).split(' ');
802
+ const name = segs.length > 1 ? tailSeg : words.slice(1).join(' ') || words[0];
803
+ const provider = segs.length > 1 ? segs[0] : words.length > 1 ? words[0] : '';
804
+ if (provider) { const short = `${provider.slice(0, 4)} ${name}`; if (fits(short)) return short; }
805
+ if (fits(name)) return name;
806
+ return clip(name, room);
807
+ }
808
+
809
+ /** Pure: the rule split around a right-aligned session name: `───── name ─`. Below 30 cols, or no name: plain. */
810
+ export function ruleParts(cols, name) {
811
+ // TUI-FOOTER: '─' is East Asian Ambiguous; where the terminal draws it two cells wide, half as many fill the row
812
+ const d = cellWidth('─');
813
+ if (!name || cols < 30) return { left: '─'.repeat(Math.floor(cols / d)), label: '', right: '' };
814
+ const label = ` ${clip(name, cols - 8)} `;
815
+ return { left: '─'.repeat(Math.max(1, Math.floor((cols - cellWidth(label) - d) / d))), label, right: '─' };
816
+ }
817
+
818
+ /**
819
+ * A ring rule segment as ONE pre-coloured string (not a Text per cell): the gradient is quantised to steps of
820
+ * RING_Q per channel so neighbouring cells share a colour run. Cuts the escapes Ink must tokenise per frame.
821
+ */
822
+ const RING_Q = 12;
823
+ function ringString(str, rgbs, from = 0) {
824
+ let out = '', run = '', key = null;
825
+ const flush = () => { if (run) out += chalk.rgb(...key.split(',').map(Number))(run); run = ''; };
826
+ [...str].forEach((ch, i) => {
827
+ const k = rgbs[from + i].map(v => Math.max(0, Math.min(255, Math.round(v / RING_Q) * RING_Q))).join(',');
828
+ if (k !== key) { flush(); key = k; }
829
+ run += ch;
830
+ });
831
+ flush();
832
+ return out;
833
+ }
834
+
835
+ /**
836
+ * A composer rule. `ring` (motion only, never under NO_COLOR) = { state, top }: the rule becomes half of the
837
+ * light ring (motion/ring.js); the session name stays bright on top of it.
838
+ */
839
+ export const Rule = ({ cols, color, name, ring = null, labelColor = null }) => {
840
+ const p = ruleParts(cols, name);
841
+ // `labelColor` (the model on the bottom rule): its own colour, not bold, so it reads as context, not a title
842
+ const lab = labelColor ? { color: labelColor } : null;
843
+ if (ring && ring.state && ring.state.k > 0 && !mono()) {
844
+ const rgbs = ruleColors(cols, ring.top, ring.state, COLORS[color] || color);
845
+ if (!p.label) return h(Text, null, ringString(p.left, rgbs));
846
+ const ls = [...p.left].length, lb = [...p.label].length;
847
+ return h(Text, null, ringString(p.left, rgbs), h(Text, lab || { color: c('violet'), bold: true }, p.label), ringString(p.right, rgbs, ls + lb));
848
+ }
849
+ if (!p.label) return h(Text, { color: c(color) }, p.left);
850
+ return h(Text, null, h(Text, { color: c(color) }, p.left), h(Text, lab || { color: c('label'), bold: true }, p.label), h(Text, { color: c(color) }, p.right));
851
+ };
852
+
853
+ /** Split the buffer into visible lines with an inverse cursor cell. */
854
+ export function composerLines(buf, maxLines = 8) {
855
+ const lines = buf.text.split('\n');
856
+ const { line: cl, col } = lineCol(buf.text, buf.cursor);
857
+ const from = composerFrom(buf, maxLines);
858
+ const sel = selection(buf);
859
+ const starts = []; { let o = 0; for (const l of lines) { starts.push(o); o += l.length + 1; } }
860
+ return lines.slice(from, from + maxLines).map((text, i) => {
861
+ const idx = from + i;
862
+ if (sel) { // QUOXON-MOUSE: the selected span is drawn inverse; no cursor cell while a selection shows
863
+ const a = Math.max(0, Math.min(text.length, sel.from - starts[idx])); const b = Math.max(0, Math.min(text.length, sel.to - starts[idx]));
864
+ return a < b ? { key: idx, pre: text.slice(0, a), sel: text.slice(a, b), post: text.slice(b) } : { text, cursor: null, key: idx };
865
+ }
866
+ if (idx !== cl) return { text, cursor: null, key: idx };
867
+ const gs = graphemes(text);
868
+ return { key: idx, before: gs.slice(0, col).map(g => g.text).join(''), at: gs[col] ? gs[col].text : ' ', after: gs.slice(col + 1).map(g => g.text).join('') };
869
+ });
870
+ }
871
+
872
+ /**
873
+ * The composer with its light ring. `ring` = { ref, active, mode } (motion + colour only): this wrapper steps
874
+ * the ring on every clock frame itself, so a frame re-renders the composer, not the app.
875
+ */
876
+ export function RingComposer({ ring: ringIn, ...props }) {
877
+ const now = useFrame();
878
+ const ring = ringIn ? (ringIn.ref.current = ringStep(ringIn.ref.current, { active: ringIn.active, mode: ringIn.mode, now })) : null;
879
+ return h(Composer, { ...props, ring, now });
880
+ }
881
+
882
+ /** `ring` = a ring state from motion/ring.js, or null for the static rules. Hook-free (pure render). */
883
+ // TUI-DIVIDER: `divider` = { d, slow } (the AOCL strip state and the per-turn slow-redraw ref), drawn only on the
884
+ // ring path (motion + colour), so NO_COLOR, reduced motion and the lite TUI keep the plain rule.
885
+ /** Does the block directly above the composer close with a rule? Then no spacer and no second rule (one rule only). */
886
+ export function endsInRule({ card = null, asks = [], background = 0, pending = [], steering = [], countdown = false }) {
887
+ if (background > 0 || pending.length || steering.length || countdown) return false;
888
+ if (asks.length) return !askCardModel(asks[asks.length - 1].text).plain && askCardRows(asks[asks.length - 1].text, 80) > 0;
889
+ return Boolean(card);
890
+ }
891
+
892
+ export function Composer({ buf, cols, mode, busy, placeholder, name, model = null, ring = null, divider = null, now = 0, topRule = true }) {
893
+ const empty = buf.text === '';
894
+ const lines = composerLines(buf);
895
+ const body = empty
896
+ ? [h(Box, { key: 'p' }, h(Text, { color: c('sky'), bold: true }, '› '), h(Text, { inverse: true }, ' '), h(Text, { color: c('placeholder') }, clip(placeholder, cols - 5)))]
897
+ : lines.map((l, i) => h(Box, { key: l.key },
898
+ h(Box, { flexShrink: 0 }, h(Text, { color: c('sky'), bold: true }, i === 0 ? '› ' : ' ')), // COMPOSER-EDIT: never squeezed, so wrapped rows are cols - 2 wide
899
+ l.sel !== undefined
900
+ ? h(Text, { color: c('input') }, l.pre, h(Text, { inverse: true }, l.sel), l.post)
901
+ : l.at === undefined
902
+ ? h(Text, { color: c('input') }, l.text || ' ')
903
+ : h(Text, { color: c('input') }, l.before, h(Text, { inverse: true }, l.at), l.after)));
904
+ return h(Box, { flexDirection: 'column', width: cols },
905
+ topRule ? h(Rule, { cols, color: MODE_COLOR[mode] || 'sky', name, ring: ring ? { state: ring, top: true } : null }) : null,
906
+ ...body,
907
+ (ring && divider && dividerRuleEl({ cols, label: modelRuleLabel(cols, model), divider: divider.d, now, ruleColor: c('dim'), labelColor: modelColor(model), mono: mono(), reduced: cellWidth('─') !== 1, slow: divider.slow }))
908
+ || h(Rule, { cols, color: 'dim', name: modelRuleLabel(cols, model), labelColor: modelColor(model), ring: ring ? { state: ring, top: false } : null }));
909
+ }
910
+
911
+ /** The model label's colour: its provider's brand colour, else the label role (never a grey). */
912
+ function modelColor(model) {
913
+ if (mono()) return undefined;
914
+ const [prov, ...rest] = String(model || '').split(' ');
915
+ const pc = providerOf(prov, rest.join(' ')) ? providerColor(prov, rest.join(' ')) : null;
916
+ return pc || c('label');
917
+ }
918
+
919
+ export function Pending({ pending, steering, cols }) {
920
+ if (!pending.length && !steering.length) return null;
921
+ return h(Box, { flexDirection: 'column', paddingLeft: 2 },
922
+ ...steering.map(s => h(Text, { key: 's' + s.id, color: c('sky') }, `↳ steering: ${clip(s.text, cols - 16)}`)),
923
+ ...pending.map((p, i) => h(Text, { key: 'q' + p.id, color: c('dim') }, `» queued ${i + 1}: ${clip(p.text, cols - 16)}`)),
924
+ pending.length ? h(Text, { key: 'hint', color: c('dim') }, 'Up edits the last queued message') : null);
925
+ }
926
+
927
+ /** A rule drawing itself in left to right over `dur` seconds (approval card arrival). Same width every frame. */
928
+ function DrawRule({ cols, color, e, dur = 0.25 }) {
929
+ const n = Math.round(clamp01(e / dur) * cols);
930
+ if (n >= cols) return h(Rule, { cols, color });
931
+ const edge = n > 0 && !mono() ? h(Text, { color: '#ffffff' }, '─') : (n > 0 ? h(Text, null, '─') : null);
932
+ return h(Text, null, h(Text, { color: c(color) }, '─'.repeat(Math.max(0, n - 1))), edge, ' '.repeat(cols - n));
933
+ }
934
+
935
+ export const ARM_BAR = 6;
936
+ /** `motionE` = seconds since the card appeared, when motion is on: the rule draws in, the title decodes, the arming bar fills. */
937
+ /** Approvals raised by ANOTHER session: a notice line only. It never takes focus or keys; answer through /approvals. */
938
+ export function WaitingNotice({ items = [], cols }) {
939
+ if (!items.length) return null;
940
+ const from = [...new Set(items.map(i => i.origin || 'another session'))].slice(0, 2).join(', ');
941
+ const n = items.length;
942
+ return h(Text, { color: c('ask'), bold: true }, ' ' + clip(`▶ ${n} approval${n === 1 ? '' : 's'} waiting from ${from}${items.some(i => i.privileged) ? ' (privileged)' : ''}: /approvals`, cols - 2));
943
+ }
944
+
945
+ /**
946
+ * QUOXON-ASK (owner 2026-10-16 "every ask should look like an approval card"): THE card frame for anything that needs
947
+ * the human. Approval cards and every other ask render through this one function, so they cannot drift apart:
948
+ * a rule, a bold title saying who is asking, label/value rows (what a yes does, in full, and why), then the
949
+ * "▶ question? keys" footer in the ask colour, and a closing rule. `arming` replaces the footer until keys are live.
950
+ */
951
+ export function NeedsYouCard({ cols, title, rows = [], block = [], notes = [], footer, arming = null, motionE = null }) {
952
+ const e = motionE;
953
+ return h(Box, { flexDirection: 'column', width: cols, marginTop: 1 },
954
+ e == null ? h(Rule, { cols, color: 'warn' }) : h(DrawRule, { cols, color: 'warn', e }),
955
+ e == null ? h(Text, { color: c('warn'), bold: true, wrap: 'wrap' }, title) : h(Text, null, ...fxNodes([{ t: title, color: 'warn', bold: true }], { e: e - 0.1, timing: TIMING.title }, 23)),
956
+ // APPROVAL-WHAT: wrap at word boundaries under the label instead of cutting the action or target off
957
+ ...rows.flatMap(([k, v]) => wrapPlain(String(v), Math.max(10, cols - 10)).slice(0, 8).map((line, i) => h(Text, { key: `${k}${i}`, color: c('text') }, ' ', h(Text, { color: c('label') }, (i ? '' : k).padEnd(7)), h(Text, { color: c(k === 'risk' ? 'warn' : 'value') }, line)))),
958
+ // block: pre-shaped segment rows shown verbatim (a command to run: never word-wrapped, never clipped)
959
+ ...block.map((segs, i) => h(Box, { key: `b${i}`, paddingLeft: 2, width: cols }, h(Text, null, ...segs.map((sg, j) => h(Text, { key: j, color: c(sg.role || 'text'), bold: sg.bold || undefined }, sg.t))))),
960
+ ...notes.map((n, i) => h(Text, { key: `n${i}`, color: c('warn'), bold: true, wrap: 'wrap' }, ` ${n}`)),
961
+ arming || h(Text, { color: c('prose'), bold: true, wrap: 'wrap' }, ' ', ...runs(paintLine(footer, 'ask'))),
962
+ e == null ? h(Rule, { cols, color: 'warn' }) : h(DrawRule, { cols, color: 'warn', e }));
963
+ }
964
+
965
+ /** The card's fields for an ask line (from askText): who asks, what a yes does, why, and the footer. */
966
+ export function askCardModel(text) {
967
+ const a = parseAsk(text);
968
+ const rows = [['do', a.action], ['why', a.reason]].filter(r => r[1]);
969
+ if (a.plain) return { plain: true, title: '', rows: [], footer: String(text).trim() }; // one quiet line, no box (the walkthrough's prompt)
970
+ return { title: a.title ? `⚠ ${a.title}` : `⚠ ${a.who || 'This terminal'} asks: ${a.question}`, rows, footer: `▶ ${a.question} ${keysLabel(a.keys)}`.trim() };
971
+ }
972
+
973
+ /** Any ask (y/N/c and friends) as the approval card frame. `text` is the askText line. */
974
+ export function AskCard({ text, cols }) {
975
+ if (!String(text ?? '').replace(/^\s*▶?\s*/, '').trim()) return null; // an empty ask draws nothing: no rules, no blank line
976
+ const m = askCardModel(text);
977
+ if (m.plain) return h(Box, { width: cols, marginTop: 1 }, h(Text, { color: c('prose'), bold: true, wrap: 'wrap' }, ' ', ...runs(paintLine(m.footer, 'ask'))));
978
+ return h(NeedsYouCard, { cols, title: m.title, rows: m.rows, footer: m.footer });
979
+ }
980
+ /** Rows an AskCard takes (sizes the live region). */
981
+ export const askCardRows = (text, cols) => {
982
+ if (!String(text ?? '').replace(/^\s*▶?\s*/, '').trim()) return 0;
983
+ const m = askCardModel(text);
984
+ if (m.plain) return 1 + wrapRows(m.footer, cols - 2);
985
+ return 4 + wrapRows(m.title, cols) + m.rows.reduce((n, [, v]) => n + Math.min(8, wrapPlain(String(v), Math.max(10, cols - 10)).length), 0) + wrapRows(m.footer, cols - 2);
986
+ };
987
+
988
+ export function ApprovalCard({ card, org, cols, armed, draftHint = null, motionE = null, confirm = false, position = null }) {
989
+ const when = card.askedAt ? String(card.askedAt).replace('T', ' ').slice(0, 16) + ' UTC' : null;
990
+ const rows = card.undescribable ? [['what', card.what], ['from', card.askedBy || card.origin], ['asked', when], ['org', org]].filter(r => r[1]) : [
991
+ ['what', card.what && card.what !== card.tool ? card.what : null], ['tool', card.tool], ['target', card.target], ['scope', card.scope], ['risk', card.risk], ['from', card.foreign ? card.origin : null], ['org', org]
992
+ ].filter(r => r[1]);
993
+ if (card.privileged) rows.push(['grants', `PRIVILEGED: ${[card.tool, card.target].filter(Boolean).join(' ')}`]);
994
+ // APPROVAL-WWWH: what exactly (verbatim), where, who, why, when, read-only or write. The command is a block: never word-wrapped.
995
+ const f = card.facts || null; const block = [];
996
+ if (f && !card.undescribable) {
997
+ if (f.where && /^\d+ (hosts|targets):/.test(f.where)) { for (const k of ['target', 'what']) { const i = rows.findIndex(r => r[0] === k && (k === 'target' || String(r[1]).includes(f.hostList[1]))); if (i >= 0) rows.splice(i, 1); } }
998
+ if (f.command) { block.push([{ t: f.command.kind === 'input' ? 'input:' : 'command:', role: 'label' }]); block.push(...codeRows(f.command.shown.split('\n'), cols - 4)); }
999
+ for (const r of [['where', f.where], ['who', f.who], ['why', f.why], ['asked', [f.asked && `"${f.asked}"`, f.when].filter(Boolean).join(' · ') || null], ['class', f.access]]) if (r[1]) rows.push(r);
1000
+ }
1001
+ const e = motionE;
1002
+ const title = `⚠ Approval needed${position && position.n > 1 ? ` (${position.i} of ${position.n})` : ''}: execution is held`;
1003
+ const filled = e == null ? 0 : Math.min(ARM_BAR, Math.floor(clamp01(e / 0.3) * ARM_BAR));
1004
+ const arming = e == null || mono()
1005
+ ? h(Text, { color: c('dim') }, e == null ? ' arming…' : ` arming… ${'▰'.repeat(filled)}${'▱'.repeat(ARM_BAR - filled)}`)
1006
+ : h(Text, null, h(Text, { color: c('dim') }, ' arming… '), h(Text, { color: c('warn') }, '▰'.repeat(filled)), h(Text, { color: c('faint') }, '▱'.repeat(ARM_BAR - filled)));
1007
+ const footer = card.privileged && confirm ? `▶ Grant ${[card.tool, card.target].filter(Boolean).join(' ')}? press y again to confirm, any other key cancels`
1008
+ : card.undescribable && confirm ? '▶ Quox could not describe this action. Approve it anyway? press y again to confirm, any other key cancels'
1009
+ : draftHint === 'focus' ? askText('Approve it?', { keys: 'y approve · n deny · card focused (Esc returns to your draft)' }) // RESTORE-TUI: every y/n goes through askText (same text)
1010
+ : draftHint === 'draft' ? askText('Approve it?', { keys: 'y/n answer once your draft is empty · Tab to answer the approval card (your draft is kept)' })
1011
+ : '▶ Approve it? y approve · n deny · c chat · Tab later · /approvals all';
1012
+ return h(NeedsYouCard, { cols, title, rows, block, notes: card.undescribable && !confirm ? ['Quox could not describe this action'] : [], footer, arming: armed ? null : arming, motionE: e });
1013
+ }
1014
+
1015
+ /** Columns the status line's Q takes: 2 for the Braille glyph, 1 for the Ⓠ fallback (2 where Ambiguous is wide). */
1016
+ export const statusGlyphWidth = () => cellWidth(glyphStill('resting', { mono: mono(), mode: glyphMode(process.env) }).text);
1017
+
1018
+ /** The composer mode in plain words on the status line's context row. */
1019
+ export const MODE_WORDS = { ask: 'ask first', auto: 'auto, acts without asking', plan: 'plan only' };
1020
+
1021
+ /**
1022
+ * The status as ONE continuous line that wraps (owner 2026-10-17 19:47: "should just make it one long line that wraps
1023
+ * to second or third line if needed ... so much space, looks wrong"; "should say agent: none, when nothing attached").
1024
+ * `● agent X · ⬢ slot S · ⬢ groups a 2, b · profile P · org O · mode ask first · rules · ↻ auto-restart`, wrapped
1025
+ * onto as few rows as it needs, evened out so no item sits alone, broken only between items, every further row at
1026
+ * the same left edge (never inside an item; a
1027
+ * single value too long for a row is split across rows). The key hints are separate: always their own row at that
1028
+ * same left edge, so nothing is right-aligned and no row carries a gap. The model sits on the composer's bottom rule
1029
+ * (modelRuleLabel). Widths are terminal cells under the ambiguous-width policy (cells.js).
1030
+ * Pure: rows of { text, segs, hint, gap, lead } where segs are [text, kind, extra].
1031
+ */
1032
+ export function statusRows({ cols, slot, agent, groups = [], parts, hint, qw = statusGlyphWidth(), wide = ambiguousWide() }) {
1033
+ const len = (t) => cellWidth(t, { wide });
1034
+ const W = Math.max(12, cols - 1);
1035
+ const lead = 1 + qw + len(' ✦ '); // ' ' + Q + ' ✦ '
1036
+ const room = Math.max(4, W - lead);
1037
+ // a token: segs to draw, joined to the previous token on the same row by `pre`
1038
+ const tokens = [];
1039
+ tokens.push(agent
1040
+ ? { pre: ' · ', segs: [['● ', 'agentDot', agent], ['agent ', 'label'], [agent, 'agent', agent]] }
1041
+ : { pre: ' · ', segs: [['○ ', 'icon'], ['agent: ', 'label'], ['none', 'value']] });
1042
+ tokens.push({ pre: ' · ', segs: slot ? [['⬢ ', 'icon'], ['slot ', 'label'], [slot, 'value']] : [['⬡ no slot', 'label']] });
1043
+ (groups || []).filter(g => g?.name).forEach((g, i) => tokens.push({ pre: i ? ', ' : ' · ',
1044
+ segs: [...(i ? [] : [['⬢ ', 'icon'], ['groups ', 'label']]), [g.name, 'value'], ...(g.unread ? [[' ' + g.unread, 'number']] : [])] }));
1045
+ const p = parts || {};
1046
+ // every context item says what it is (owner: "profile moo .. ask?? what?"); /help status explains each in a line
1047
+ const word = { profile: 'profile ', org: 'org ', mode: 'mode ' };
1048
+ const val = (k) => k === 'mode' ? (MODE_WORDS[p.mode] || null) : k === 'auto' ? '↻ auto-restart' : String(p[k]);
1049
+ for (const k of ['profile', 'org', 'mode', 'rules', 'auto'].filter(k => p[k])) tokens.push({ pre: ' · ',
1050
+ segs: word[k] && val(k) ? [[word[k], 'label'], [val(k), k === 'mode' && p.mode === 'plan' ? 'violet' : 'value']] : [[k === 'mode' ? String(p[k]) : val(k), 'label']] });
1051
+ const tlen = (tk) => tk.segs.reduce((n, sg) => n + len(sg[0]), 0);
1052
+ // split a token too wide for a row into row-sized pieces (whole characters by cell width, never an ellipsis)
1053
+ const fit = (tk) => {
1054
+ if (tlen(tk) <= room) return [tk];
1055
+ const out = [];
1056
+ let segs = [], w = 0;
1057
+ const flush = () => { out.push({ pre: out.length ? '' : tk.pre, segs, forceBreak: out.length > 0 }); segs = []; w = 0; };
1058
+ for (const [t, k, x] of tk.segs) for (const ch of t) {
1059
+ const cw = len(ch);
1060
+ if (w + cw > room && segs.length) flush();
1061
+ const last = segs[segs.length - 1];
1062
+ if (last && last[1] === k) last[0] += ch; else segs.push([ch, k, x]);
1063
+ w += cw;
1064
+ }
1065
+ if (segs.length) flush();
1066
+ return out;
1067
+ };
1068
+ // fill each row left to right; break only between items (or at a forced piece of an over-long one)
1069
+ const pack = (pieces, limit = room) => {
1070
+ const rows = [];
1071
+ let cur = null, w = 0;
1072
+ for (const tk of pieces) {
1073
+ const need = tlen(tk) + (cur && cur.length ? len(tk.pre) : 0);
1074
+ if (!cur || tk.forceBreak || w + need > limit) { cur = [{ ...tk, pre: '' }]; rows.push(cur); w = tlen(tk); continue; }
1075
+ cur.push(tk); w += need;
1076
+ }
1077
+ return rows;
1078
+ };
1079
+ // as few rows as filling gives, evened out (the narrowest limit that keeps that count): no item left alone on a row
1080
+ const balanced = (pieces) => {
1081
+ const n = pack(pieces).length;
1082
+ if (n < 2) return pack(pieces);
1083
+ let lo = Math.max(1, ...pieces.map(tlen)), hi = room;
1084
+ while (lo < hi) { const m = (lo + hi) >> 1; if (pack(pieces, m).length <= n) hi = m; else lo = m + 1; }
1085
+ return pack(pieces, lo);
1086
+ };
1087
+ const toSegs = (row) => row.flatMap(tk => [...(tk.pre ? [[tk.pre, 'sep']] : []), ...tk.segs]);
1088
+ const rowsOut = balanced(tokens.flatMap(fit)).map(row => ({ segs: toSegs(row), hint: '' }));
1089
+ const hintS = String(hint || '');
1090
+ if (hintS) for (const r of pack(hintS.split(' · ').map(t => ({ pre: ' · ', segs: [[t, 'hint']] })))) rowsOut.push({ segs: [], hint: toSegs(r).map(x => x[0]).join('') });
1091
+ const pad = ' '.repeat(lead);
1092
+ return rowsOut.map((r, i) => {
1093
+ const body = r.segs.map(x => x[0]).join('');
1094
+ const head = i === 0 ? ' ' + 'Ⓠ'.padEnd(qw, ' ') + ' ✦ ' : pad;
1095
+ return { segs: r.segs, hint: r.hint, gap: 0, lead, text: head + body + r.hint };
1096
+ });
1097
+ }
1098
+
1099
+ /** QUOXON-FW17: custom rows, one truncated line each, just above the status line. */
1100
+ export function RowsBar({ rows, cols }) {
1101
+ if (!rows?.length) return null;
1102
+ const shown = rows.slice(0, 3);
1103
+ const labelW = labelWidth(shown); // labels line up across rows
1104
+ return h(Box, { flexDirection: 'column', width: cols },
1105
+ ...shown.map((r) => {
1106
+ const parts = clipParts(rowParts(r, { labelW }), Math.max(1, cols - 2));
1107
+ // ROWS: label, value, tone and trend as their own runs; every role is a palette colour (no grey, owner rule).
1108
+ return h(Text, { key: r.row.id, wrap: 'truncate-end' }, ' ', ...parts.map(([t, role, bold], i) => h(Text, { key: i, color: c(themeRole(role)), bold: Boolean(bold) }, t)));
1109
+ }));
1110
+ }
1111
+
1112
+ export function StatusLine({ cols, slot, agent, groups = [], parts, right }) {
1113
+ const p = Array.isArray(parts) ? { profile: parts[0], org: parts[1], model: parts[2], mode: parts[3], rules: parts[4], auto: parts[5] } : parts;
1114
+ const qw = statusGlyphWidth();
1115
+ const rows = statusRows({ cols, slot, agent, groups, parts: p, hint: right, qw });
1116
+ // The still Q (qglyph.js): two Braille cells, or Ⓠ under NO_COLOR / no Braille. statusRows reserved its width.
1117
+ const q = glyphStill('resting', { mono: mono(), mode: glyphMode(process.env), depth: colorDepth(process.env) });
1118
+ const colour = (k, x) => k === 'agentDot' || k === 'agent' ? agentColor(x) : c(k === 'sep' || k === 'hint' ? 'label' : k);
1119
+ return h(Box, { width: cols, flexDirection: 'column' }, ...rows.map((r, i) => h(Text, { key: 'r' + i, wrap: 'truncate-end' },
1120
+ i === 0 ? h(Text, null, ' ', h(Text, { bold: true }, ...cellNodes(q.cells, 'sq')), h(Text, { color: c('icon') }, ' ✦ ')) : ' '.repeat(r.lead),
1121
+ ...r.segs.map(([t, k, x], j) => h(Text, { key: j, color: colour(k, x), bold: k === 'agent' || k === 'number' }, t)),
1122
+ r.hint ? h(Text, { color: c('label') }, ' '.repeat(r.gap), ...hintNodes(r.hint)) : null))); // no grey (owner rule)
1123
+ }
1124
+
1125
+ /** `Enter send · /help`: each key in the key role, its words and the separators in label (never a grey). */
1126
+ function hintNodes(hint) {
1127
+ return hint.split(' · ').flatMap((part, i) => {
1128
+ const [k, ...rest] = part.split(' ');
1129
+ return [i ? h(Text, { key: 's' + i, color: c('label') }, ' · ') : null, h(Text, { key: 'k' + i, color: c('key') }, k), rest.length ? h(Text, { key: 'w' + i, color: c('label') }, ' ' + rest.join(' ')) : null];
1130
+ }).filter(Boolean);
1131
+ }
1132
+
1133
+ /** Live, actionable counts under the status line. Never a transcript message. */
1134
+ export function NotifyBar({ approvals, error, cols, signedOut = false, alerts = [], hideCount = false }) {
1135
+ if (signedOut) return h(Text, { color: c('warn') }, ' ' + clip('not signed in: approvals unavailable until you sign in', cols - 2));
1136
+ const count = approvals && !hideCount ? h(Text, { key: 'n', color: c('ask'), bold: true }, ' ' + clip(`▶ ${approvals} approval${approvals === 1 ? '' : 's'} waiting for you${error ? ' (last known)' : ''} · /approvals`, cols - 3)) : null;
1137
+ // Background workflows that keep failing: one deduped line each (max 2), never roster rows.
1138
+ const alertLines = alerts.slice(0, 2).map((t, i) => h(Text, { key: 'a' + i, color: c('fail') }, ' ' + clip(t, cols - 2)));
1139
+ if (alertLines.length) {
1140
+ return h(Box, { flexDirection: 'column' }, count, ...alertLines,
1141
+ error ? h(Text, { key: 'e', color: c('warn') }, ' ' + clip('approvals unavailable: inbox not reachable, will retry', cols - 2)) : null);
1142
+ }
1143
+ if (!error) return count;
1144
+ // A failed refresh is never "no approvals": say the inbox is unavailable, keep any last known count.
1145
+ return h(Box, { flexDirection: 'column' }, count,
1146
+ h(Text, { key: 'e', color: c('warn') }, ' ' + clip('approvals unavailable: inbox not reachable, will retry', cols - 2)));
1147
+ }
1148
+
1149
+ const ROSTER_TONE = { running: 'sky', waiting: 'warn', done: 'ok', failed: 'fail' };
1150
+ /** Live roster under the status line. `view` comes from roster.rosterView; `peek` is plain lines; `confirm` a question. */
1151
+ export function Roster({ view, peek = [], confirm = null, hint = null, cols, fx = null }) {
1152
+ if (view.mode === 'hidden') return null;
1153
+ if (view.mode === 'summary') return h(Text, { color: c('dim'), wrap: 'truncate-end' }, view.summary);
1154
+ return h(Box, { flexDirection: 'column', width: cols },
1155
+ // glyph keeps the state colour; the rest of the line steps down in tone with position and (for finished rows) age
1156
+ ...view.lines.map((l, i) => {
1157
+ const e = fx && l.key != null ? fx(l.key) : null; // a row that just appeared decodes in (new rows land on top)
1158
+ return h(Text, { key: 'r' + i, bold: l.selected, wrap: 'truncate-end' },
1159
+ h(Text, { color: c(l.selected ? 'text' : (l.glyphRole || ROSTER_TONE[l.state])) }, ' ' + l.marker + l.glyph),
1160
+ e == null ? h(Text, { color: c(l.selected ? 'text' : l.tone) }, l.rest) : h(Text, null, ...fxNodes([{ t: l.rest, color: l.selected ? 'text' : l.tone }], { e, timing: TIMING.roster }, 29 + i)),
1161
+ l.pulse ? h(Text, { color: c(l.pulseRole === 'dim' && l.selected ? 'text' : l.pulseRole) }, l.pulse) : null);
1162
+ }),
1163
+ view.more ? h(Text, { key: 'more', color: c('dim') }, ` +${view.more} more · /roster`) : null,
1164
+ ...peek.map((p, i) => h(Text, { key: 'p' + i, color: c('dim'), wrap: 'truncate-end' }, ' ' + clip(p, cols - 5))),
1165
+ confirm ? h(Text, { key: 'cf', color: c('warn') }, ' ' + clip(confirm, cols - 2)) : null,
1166
+ hint ? h(Text, { key: 'h', color: c('dim') }, ' ' + clip(hint, cols - 2)) : null);
1167
+ }