quoxcli 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (306) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +2 -2
  3. package/bin/quox.js +19 -1
  4. package/bin/quoxon.js +6 -1
  5. package/lib/api/endpoints/actions.js +15 -0
  6. package/lib/api/endpoints/admin.js +27 -0
  7. package/lib/api/endpoints/agent-attach.js +7 -0
  8. package/lib/api/endpoints/blog.js +16 -0
  9. package/lib/api/endpoints/chat.js +7 -0
  10. package/lib/api/endpoints/compliance.js +25 -0
  11. package/lib/api/endpoints/contactsq.js +4 -0
  12. package/lib/api/endpoints/devServerRegistry.js +25 -0
  13. package/lib/api/endpoints/engagement.js +26 -0
  14. package/lib/api/endpoints/evidence.js +20 -0
  15. package/lib/api/endpoints/hat.js +14 -0
  16. package/lib/api/endpoints/inbox.js +6 -0
  17. package/lib/api/endpoints/inboxq.js +19 -0
  18. package/lib/api/endpoints/index.js +14 -0
  19. package/lib/api/endpoints/loops.js +26 -0
  20. package/lib/api/endpoints/mon.js +295 -0
  21. package/lib/api/endpoints/objectives.js +6 -0
  22. package/lib/api/endpoints/quoxseo.js +6 -0
  23. package/lib/api/endpoints/server.js +21 -0
  24. package/lib/api/endpoints/setup.js +6 -0
  25. package/lib/api/endpoints/tools.js +13 -0
  26. package/lib/api/endpoints/v1.js +47 -0
  27. package/lib/commands/actions.js +81 -11
  28. package/lib/commands/admin.js +35 -0
  29. package/lib/commands/agent.js +11 -4
  30. package/lib/commands/agentAttach.js +61 -7
  31. package/lib/commands/approvals.js +198 -3
  32. package/lib/commands/ask.js +4 -2
  33. package/lib/commands/auth.js +64 -11
  34. package/lib/commands/backup.js +11 -4
  35. package/lib/commands/blog.js +110 -0
  36. package/lib/commands/capabilities.js +3 -2
  37. package/lib/commands/chat.js +142 -28
  38. package/lib/commands/chatCompanion.js +27 -0
  39. package/lib/commands/chatState.js +45 -0
  40. package/lib/commands/claude.js +7 -33
  41. package/lib/commands/compliance.js +109 -0
  42. package/lib/commands/connect.js +69 -0
  43. package/lib/commands/contacts.js +85 -4
  44. package/lib/commands/contactsSendAt.js +21 -0
  45. package/lib/commands/dev.js +6 -8
  46. package/lib/commands/doctor.js +22 -3
  47. package/lib/commands/engagement.js +90 -0
  48. package/lib/commands/evidence.js +155 -0
  49. package/lib/commands/ext.js +23 -0
  50. package/lib/commands/fleet.js +25 -0
  51. package/lib/commands/gripe.js +24 -13
  52. package/lib/commands/hat.js +76 -0
  53. package/lib/commands/image.js +68 -0
  54. package/lib/commands/inbox.js +37 -3
  55. package/lib/commands/inboxq.js +147 -0
  56. package/lib/commands/kuma.js +1123 -0
  57. package/lib/commands/kumaSla.js +174 -0
  58. package/lib/commands/licence.js +39 -1
  59. package/lib/commands/licenceLinkView.js +311 -0
  60. package/lib/commands/linkedin.js +19 -0
  61. package/lib/commands/loop.js +318 -0
  62. package/lib/commands/lucid.js +1 -0
  63. package/lib/commands/mail.js +161 -20
  64. package/lib/commands/memory.js +68 -4
  65. package/lib/commands/memorySync.js +158 -0
  66. package/lib/commands/mon.js +55 -144
  67. package/lib/commands/notes.js +29 -7
  68. package/lib/commands/notesSeal.js +259 -0
  69. package/lib/commands/objective.js +53 -3
  70. package/lib/commands/offload.js +2 -3
  71. package/lib/commands/org.js +21 -4
  72. package/lib/commands/plan.js +17 -0
  73. package/lib/commands/provider.js +87 -8
  74. package/lib/commands/qlarity.js +32 -1
  75. package/lib/commands/quoxseo.js +31 -0
  76. package/lib/commands/research.js +79 -0
  77. package/lib/commands/rows.js +9 -0
  78. package/lib/commands/run.js +20 -2
  79. package/lib/commands/scope.js +2 -2
  80. package/lib/commands/server.js +150 -0
  81. package/lib/commands/setup.js +127 -75
  82. package/lib/commands/shortcut.js +41 -0
  83. package/lib/commands/skills.js +24 -5
  84. package/lib/commands/slot.js +49 -9
  85. package/lib/commands/status.js +92 -44
  86. package/lib/commands/tool.js +30 -0
  87. package/lib/commands/vault.js +6 -3
  88. package/lib/commands/vm.js +2 -2
  89. package/lib/commands/walkthrough.js +14 -2
  90. package/lib/commands/watch.js +11 -2
  91. package/lib/commands/wazuh.js +824 -0
  92. package/lib/commands/wazuhIntake.js +317 -0
  93. package/lib/commands/wazuhReport.js +192 -0
  94. package/lib/commands/workflow.js +47 -4
  95. package/lib/commands/x.js +50 -0
  96. package/lib/commands/youtube.js +172 -0
  97. package/lib/core/actionItems.js +115 -0
  98. package/lib/core/agentAlias.js +2 -0
  99. package/lib/core/agentFamilies.js +7 -0
  100. package/lib/core/announce.js +86 -0
  101. package/lib/core/auth.js +20 -2
  102. package/lib/core/callMe.js +118 -0
  103. package/lib/core/client.js +37 -3
  104. package/lib/core/commandRegistrars.js +17 -0
  105. package/lib/core/config.js +29 -3
  106. package/lib/core/confirm.js +68 -7
  107. package/lib/core/crossOrgApprovals.js +69 -0
  108. package/lib/core/errors.js +52 -1
  109. package/lib/core/findingReconcile.js +90 -0
  110. package/lib/core/global-flags.js +1 -1
  111. package/lib/core/governedSession.js +136 -8
  112. package/lib/core/inboxResolution.js +3 -2
  113. package/lib/core/liveSessionsReport.js +1 -1
  114. package/lib/core/loginGuard.js +73 -0
  115. package/lib/core/loopGlyph.js +97 -0
  116. package/lib/core/offload.js +73 -7
  117. package/lib/core/org-override.js +5 -1
  118. package/lib/core/output.js +8 -8
  119. package/lib/core/profileTrust.js +182 -0
  120. package/lib/core/quoxglyph.js +22 -1
  121. package/lib/core/router.js +14 -0
  122. package/lib/core/sessionHeartbeat.js +12 -8
  123. package/lib/core/shortcut.js +83 -0
  124. package/lib/core/stepUpPrompt.js +99 -0
  125. package/lib/core/version.js +1 -1
  126. package/lib/core/vm.js +5 -0
  127. package/lib/core/walkthrough.js +9 -5
  128. package/lib/dragon/companionView.js +34 -0
  129. package/lib/dragon/frames.js +118 -0
  130. package/lib/dragon/jobs.js +123 -0
  131. package/lib/dragon/renderWorker.js +15 -0
  132. package/lib/dragon/select.js +72 -0
  133. package/lib/evidence/bundleExport.js +47 -0
  134. package/lib/evidence/bundleVerify.js +77 -0
  135. package/lib/quoxon/EXTENSIONS.md +146 -0
  136. package/lib/quoxon/api.js +364 -27
  137. package/lib/quoxon/app.js +1629 -183
  138. package/lib/quoxon/approvalBatch.js +117 -0
  139. package/lib/quoxon/approvalFacts.js +64 -8
  140. package/lib/quoxon/approvalSentence.js +48 -0
  141. package/lib/quoxon/approvalsView.js +124 -7
  142. package/lib/quoxon/autoOpen.js +94 -0
  143. package/lib/quoxon/backgroundWork.js +59 -0
  144. package/lib/quoxon/bang.js +1 -1
  145. package/lib/quoxon/capabilities.js +7 -2
  146. package/lib/quoxon/commandShort.js +22 -3
  147. package/lib/quoxon/commands.js +110 -15
  148. package/lib/quoxon/connect.js +5 -3
  149. package/lib/quoxon/countdownLine.js +10 -1
  150. package/lib/quoxon/deck.js +30 -0
  151. package/lib/quoxon/deckLazy.js +58 -0
  152. package/lib/quoxon/demo.autoplay.js +1 -1
  153. package/lib/quoxon/demo.js +3 -1
  154. package/lib/quoxon/destructive.js +35 -0
  155. package/lib/quoxon/dividerAocl.js +4 -3
  156. package/lib/quoxon/dividerRule.js +3 -3
  157. package/lib/quoxon/dnd.js +71 -0
  158. package/lib/quoxon/dragonCompanion.js +285 -0
  159. package/lib/quoxon/dragonModes.js +1 -0
  160. package/lib/quoxon/dragonMoments.js +160 -0
  161. package/lib/quoxon/dragonReads.js +68 -0
  162. package/lib/quoxon/dragonView.js +2 -2
  163. package/lib/quoxon/echo.js +1 -0
  164. package/lib/quoxon/events.js +67 -10
  165. package/lib/quoxon/extensions.js +636 -0
  166. package/lib/quoxon/gripeQueue.js +135 -0
  167. package/lib/quoxon/inboxView.js +182 -0
  168. package/lib/quoxon/installCli.js +79 -0
  169. package/lib/quoxon/keys.js +4 -4
  170. package/lib/quoxon/loopCard.js +460 -0
  171. package/lib/quoxon/loopCards.js +189 -0
  172. package/lib/quoxon/loopStream.js +98 -0
  173. package/lib/quoxon/mailView.js +71 -0
  174. package/lib/quoxon/main.js +29 -5
  175. package/lib/quoxon/markdown.js +1 -1
  176. package/lib/quoxon/model.js +15 -0
  177. package/lib/quoxon/mouse.js +12 -1
  178. package/lib/quoxon/neverAnswered.js +20 -0
  179. package/lib/quoxon/objectiveOffer.js +82 -0
  180. package/lib/quoxon/objects/away.js +43 -5
  181. package/lib/quoxon/objects/index.js +22 -4
  182. package/lib/quoxon/objects/model.js +33 -0
  183. package/lib/quoxon/objects/proof.js +12 -4
  184. package/lib/quoxon/onboarding/connectServer.js +162 -0
  185. package/lib/quoxon/onboarding/copy.js +6 -0
  186. package/lib/quoxon/onboarding/index.js +5 -43
  187. package/lib/quoxon/onboarding/probe.js +20 -1
  188. package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
  189. package/lib/quoxon/onboarding/view.js +16 -3
  190. package/lib/quoxon/orgSwitch.js +49 -1
  191. package/lib/quoxon/planSteps.js +51 -0
  192. package/lib/quoxon/print.js +8 -3
  193. package/lib/quoxon/profileSwitch.js +7 -0
  194. package/lib/quoxon/promptHistory.js +3 -1
  195. package/lib/quoxon/qdragonFly.js +7 -6
  196. package/lib/quoxon/qmotion.js +17 -0
  197. package/lib/quoxon/render.js +187 -41
  198. package/lib/quoxon/roster.js +36 -17
  199. package/lib/quoxon/rosterSessions.js +44 -0
  200. package/lib/quoxon/rows.js +244 -1
  201. package/lib/quoxon/screenContext.js +1 -1
  202. package/lib/quoxon/secretShape.js +86 -0
  203. package/lib/quoxon/serverChip.js +42 -0
  204. package/lib/quoxon/session.js +10 -0
  205. package/lib/quoxon/slash/agents.js +2 -1
  206. package/lib/quoxon/slash/argSources.js +9 -2
  207. package/lib/quoxon/slash/args.js +4 -2
  208. package/lib/quoxon/slash/broadcast.js +18 -5
  209. package/lib/quoxon/slash/broadcastRun.js +13 -2
  210. package/lib/quoxon/slash/cliCommand.js +42 -1
  211. package/lib/quoxon/slash/cliLeaves.json +139 -2
  212. package/lib/quoxon/slash/evidenceCmd.js +34 -0
  213. package/lib/quoxon/slash/hatWalk.js +1 -1
  214. package/lib/quoxon/slash/palette.js +23 -2
  215. package/lib/quoxon/slash/paletteController.js +10 -4
  216. package/lib/quoxon/slash/qpeCmd.js +35 -0
  217. package/lib/quoxon/slash/restart.js +31 -4
  218. package/lib/quoxon/slash/resume.js +18 -1
  219. package/lib/quoxon/slash/stepUp.js +5 -5
  220. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  221. package/lib/quoxon/slash/todoCmd.js +122 -0
  222. package/lib/quoxon/slashSpinner.js +35 -0
  223. package/lib/quoxon/slotAttach.js +8 -0
  224. package/lib/quoxon/slotChat.js +17 -0
  225. package/lib/quoxon/slotFormat.js +1 -1
  226. package/lib/quoxon/slotOutbox.js +113 -0
  227. package/lib/quoxon/suggestCommand.js +34 -3
  228. package/lib/quoxon/term.js +25 -1
  229. package/lib/quoxon/toolCards.js +30 -0
  230. package/lib/quoxon/transcript.js +2 -1
  231. package/lib/quoxon/welcome.js +1 -1
  232. package/lib/quoxon/whatsnew.js +1 -1
  233. package/lib/quoxon/whatsnew.jsonl +81 -0
  234. package/lib/quoxon/windowOrg.js +11 -1
  235. package/lib/server/devServers.js +137 -0
  236. package/lib/server/installFlow.js +168 -0
  237. package/lib/server/status.js +15 -1
  238. package/lib/toolOutputShape.js +55 -0
  239. package/package.json +4 -2
  240. package/vendor/dragonlab/SOURCE +4 -0
  241. package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
  242. package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
  243. package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
  244. package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
  245. package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
  246. package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
  247. package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
  248. package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
  249. package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
  250. package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
  251. package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
  252. package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
  253. package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
  254. package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
  255. package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
  256. package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
  257. package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
  258. package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
  259. package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
  260. package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
  261. package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
  262. package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
  263. package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
  264. package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
  265. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  266. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  267. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  268. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  269. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  270. package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
  271. package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
  272. package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
  273. package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
  274. package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
  275. package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
  276. package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
  277. package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
  278. package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
  279. package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
  280. package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
  281. package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
  282. package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
  283. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  284. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  285. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  286. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  287. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  288. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  289. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  290. package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
  291. package/vendor/dragonlab/integration/companions-v1/characters.mjs +108 -0
  292. package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
  293. package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
  294. package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
  295. package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
  296. package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
  297. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  298. package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
  299. package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
  300. package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
  301. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
  302. package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
  303. package/vendor/dragonlab/lib/power-effects.js +294 -0
  304. package/vendor/dragonlab/lib/power-registry.js +74 -0
  305. package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
  306. package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
@@ -2,6 +2,7 @@
2
2
  * QUOXON view layer (Ink 6, React.createElement). Width-aware everywhere,
3
3
  * horizontal rules only (no side borders), semantic colour, mono-safe.
4
4
  */
5
+ import { toolCardLines } from './toolCards.js';
5
6
  import React from 'react';
6
7
  import chalk from 'chalk';
7
8
  import { Box, Text, useStdout } from 'ink';
@@ -23,11 +24,13 @@ import { dividerRuleEl } from './dividerRule.js';
23
24
  import * as themeNs from './theme.js';
24
25
  import { qArt, qArtFrame } from '../core/banner.js';
25
26
  import { cmd } from './palette.js';
26
- import { themeRole, rowParts, clipParts, labelWidth } from './rows.js';
27
+ import { themeRole, layoutRows, expandLines, limitGrid } from './rows.js';
28
+ import { wrapHang } from './objects/model.js';
27
29
  import { Markdown, wrapCell, markdownRows, wrapRows, WText, Heading } from './markdown.js';
28
30
  import { parseAsk, keysLabel, askText } from './ask.js';
29
31
  import { parseAsideNote } from './asideView.js';
30
32
  import { formatSlotBody, inlineable, codeRows } from './slotFormat.js';
33
+ import { BACKGROUND_NOTE } from './backgroundWork.js';
31
34
  import { termRows } from './term.js';
32
35
  import { cellWidth, ambiguousWide } from './cells.js';
33
36
  import { welcomeModel } from './welcome.js';
@@ -41,6 +44,8 @@ import { CLIPS as DRAGON_CLIPS, clipCells } from './qdragonScenes.js';
41
44
  import { dragonLayout } from './qdragon.js';
42
45
  import { ObjectView } from './objects/view.js';
43
46
  import { aoclLineSegs, aoclTableLines } from './aocl.js';
47
+ import { cardLines, wrapText, loopPinned } from './loopCard.js';
48
+ import { orgLabel } from './orgLabel.js';
44
49
  export { COLORS, MODE_COLOR, MIN_COLS, clampCols, clip, reducedMotion };
45
50
 
46
51
  // ---- working indicator (the one animated line) -------------------------------------------------
@@ -254,15 +259,24 @@ export function ToolRow({ b, cols, fx = null, pulse = null, ignite = 0 }) {
254
259
  const running = b.status === 'running';
255
260
  // the facts after the result: duration, then witnessing state
256
261
  let after = [];
262
+ let envExtra = null; // an id never shares a clipped line: when it does not fit it wraps onto its own line(s)
257
263
  if (!running && b.receipt) {
258
264
  const v = b.receipt.verified;
259
265
  after = [dur, v === true ? 'witnessed' : v === false ? 'unverified' : 'verification pending', `receipt ward:${b.receipt.ward_seq}`].filter(Boolean);
266
+ if (b.envelopeId) envExtra = `envelope ${b.envelopeId}`;
260
267
  } else if (!running) {
261
- after = [dur, b.envelopeId ? `envelope ${String(b.envelopeId).slice(0, 8)}` : '', b.envelopeId ? 'witnessing pending' : ''].filter(Boolean);
268
+ after = [dur, b.envelopeId ? `envelope ${b.envelopeId}` : '', b.envelopeId ? 'witnessing pending' : ''].filter(Boolean);
262
269
  }
263
270
  const result = running ? '' : deleg && deleg.result ? deleg.result : (b.resultSummary ? `${b.status === 'unknown' ? 'unknown: ' : ''}${b.resultSummary}` : b.status);
264
271
  const bodyStr = clipWords(body0, running || !result ? room : Math.max(6, Math.min(gw(body0), Math.floor(room / 2))));
265
272
  const tailRoom = room - gw(bodyStr) - 3;
273
+ if (!running && !b.receipt && b.envelopeId && gw(after.join(' · ')) + 3 > Math.max(0, tailRoom)) {
274
+ envExtra = `envelope ${b.envelopeId} · witnessing pending`;
275
+ after = after.filter(f => !/^envelope /.test(f) && f !== 'witnessing pending');
276
+ }
277
+ const extra = envExtra ? wrapHang(envExtra, Math.max(12, cols - 6), 2).map((l, i) => h(Text, { key: `e${i}`, color: c('muted') }, ` ${l}`)) : [];
278
+ if (!running) extra.push(...toolCardLines(b, cols).map((l, i) => h(Text, { key: `c${i}`, color: c(l.head ? 'muted' : 'prose'), wrap: 'truncate-end' }, ` ${l.text}`))); // HITL-INBOX-BAR: a list answer as a table
279
+ const withExtra = (node) => (extra.length ? h(Box, { flexDirection: 'column' }, node, ...extra) : node);
266
280
  let tail = '';
267
281
  if (!running && tailRoom > 0) {
268
282
  const facts = after.join(' · ');
@@ -285,9 +299,9 @@ export function ToolRow({ b, cols, fx = null, pulse = null, ignite = 0 }) {
285
299
  if (fx?.settle != null && !running) {
286
300
  const flash = fx.settle < 0.12 && !mono();
287
301
  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));
302
+ return withExtra(h(Box, null, ...fxNodes(head, null), ...fxNodes(tailSegs, { e: fx.settle, timing: TIMING.settle }, 7)));
289
303
  }
290
- return h(Box, null, ...fxNodes([...head, ...tailSegs], fx, 3));
304
+ return withExtra(h(Box, null, ...fxNodes([...head, ...tailSegs], fx, 3)));
291
305
  }
292
306
 
293
307
  /** 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. */
@@ -381,6 +395,7 @@ export function HelpView({ model, cols }) {
381
395
  const keyW = Math.min(24, Math.max(...model.filter(m => m.key).map(m => [...m.key].length), 8) + 2);
382
396
  return h(Box, { flexDirection: 'column', marginTop: 1, width: cols }, ...model.map((m, i) => {
383
397
  if (m.kind === 'gap') return h(Text, { key: i }, ' ');
398
+ if (m.kind === 'subhead') return h(Heading, { key: i, text: m.text });
384
399
  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
400
  if (m.kind === 'row' || (m.kind === 'dim' && m.key)) {
386
401
  const dim = m.kind === 'dim';
@@ -511,6 +526,7 @@ const GROUP = { tool: 'tools', trace: 'tools', note: 'notice', error: 'notice',
511
526
  const FOOTER = new Set(['aocl', 'summary']);
512
527
  export function historyGap(prev, item, rows = null) {
513
528
  if (!item || SELF_SPACED.has(item.kind)) return 0;
529
+ if (item.kind === 'loopcard' && loopPinned(item.card)) return 0; // drawn above the composer, not here
514
530
  if (item.kind === 'user') return 1;
515
531
  if (!prev) return item.kind === 'text' ? 1 : 0;
516
532
  if (rows != null && rows < 20) return 0;
@@ -546,6 +562,7 @@ export function HistoryItem({ item, cols, live = false, fx = null, pulse = null,
546
562
  case 'error': return h(ErrorView, { item, cols, fx });
547
563
  case 'note': return h(NoteView, { item, cols, fx });
548
564
  case 'slotmsg': return h(SlotMsgView, { item, cols });
565
+ case 'loopcard': return loopPinned(item.card) ? null : h(LoopCardView, { item, cols });
549
566
  case 'tip': return h(TipView, { item, cols });
550
567
  case 'whatsnew': return h(WhatsNewView, { item, cols });
551
568
  case 'bcard': return h(BroadcastCardView, { item, cols });
@@ -572,7 +589,10 @@ export function noteLines(text, width) {
572
589
  }
573
590
  /** 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
591
  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);
592
+ /** A note line that opens with a status glyph and wraps hangs under its text, not under the glyph (the glyph keeps its own 2 cells). */
593
+ const GLYPH_LEAD = /^(?:▶|✔|✖|⚠|∞) /u;
594
+ const hangs = (l, width) => GLYPH_LEAD.test(l) && !longUrl(l, width) && wrapRows(stripMarks(l), width) > 1;
595
+ export const noteRows = (text, cols, tone) => noteLines(toneText(text, tone), cols - 2).reduce((n, l) => n + (longUrl(l, cols - 2) ? 1 : hangs(l, cols - 2) ? wrapRows(stripMarks(l).slice(2), cols - 4) : wrapRows(stripMarks(l), cols - 2)), 0);
576
596
 
577
597
  /**
578
598
  * Z2H-7B3: a failure message can be several lines (what ran, what did not, the advice). Every line shows,
@@ -618,6 +638,8 @@ function NoteView({ item, cols, fx = null }) {
618
638
  // While it reveals, a line that fits decodes in; a line that wraps keeps plain wrapping (never mis-measured).
619
639
  return h(Box, { paddingLeft: 2, flexDirection: 'column', width: cols }, ...noteLines(text, cols - 2).map((l, i) => longUrl(l, cols - 2)
620
640
  ? h(Box, { key: i, width: graphemes(stripMarks(l)).length, flexShrink: 0 }, h(Text, { color: c('link'), wrap: 'truncate-end' }, stripMarks(l)))
641
+ : hangs(l, cols - 2)
642
+ ? h(Box, { key: i }, h(Box, { width: 2, flexShrink: 0 }, h(Text, null, ...runs(paintLine(l.slice(0, 2), item.tone)))), h(Box, { flexGrow: 1, flexShrink: 1 }, h(WText, { color, bold: item.tone === 'ask' }, ...runs(paintLine(l.slice(2), item.tone)))))
621
643
  : (fx && wrapRows(stripMarks(l), cols - 2) === 1
622
644
  ? 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
645
  : h(WText, { key: i, color, bold: item.tone === 'ask' }, ...runs(paintLine(l, item.tone))))));
@@ -628,6 +650,19 @@ function NoteView({ item, cols, fx = null }) {
628
650
  * outgoing "→ quox2 04:56 hello ✔" (sky: you). A file shows its name, three lines and a /slot log pointer;
629
651
  * /slot log prints the whole file (`full`).
630
652
  */
653
+ /** QLOOP-LIVE: the live loop card. The card object is updated in place by loopCards.js; this reads it on every draw. */
654
+ export const loopCardLines = (card, cols, now = Date.now(), hint = false) => cardLines(card.state, { cols, now, frame: Math.floor(now / 250), expanded: card.expanded, focused: card.focused, confirm: card.confirm, hint });
655
+ const cardMemo = new WeakMap(); // card -> { key, lines }: re-laid-out only when what it draws changed
656
+ export function LoopCardView({ item, cols, pinned = false }) {
657
+ const card = item.card, now = Date.now(), st = card.state;
658
+ const key = [cols, pinned, card.focused, card.expanded, card.confirm, Math.floor(now / 250), Math.floor(now / 1000), st, st.observed?.length, st.spool?.length].map(String).join('|');
659
+ let hit = cardMemo.get(card);
660
+ if (!hit || hit.st !== st || hit.key !== key) { hit = { st, key, lines: loopCardLines(card, cols, now, pinned) }; cardMemo.set(card, hit); }
661
+ const lines = hit.lines;
662
+ return h(Box, { flexDirection: 'column', paddingLeft: 2, width: cols },
663
+ ...lines.map((rs, i) => h(Text, { key: i }, ...rs.map((r, j) => h(Text, { key: j, color: c(r.role), bold: r.bold || undefined }, r.t)))));
664
+ }
665
+
631
666
  function SlotMsgView({ item, cols }) {
632
667
  const l = item.line || {};
633
668
  if (l.roomLine) { // QUOXON-ROOMS: `#group-chat QUOXON: text`, a stable colour per sender
@@ -699,10 +734,12 @@ function TipView({ item, cols }) {
699
734
  const body = wrapPlain(item.text, inner).map((l, i) => row(`t${i}`, [h(Text, { key: 0, color: c('prose') }, l)], [...l].length));
700
735
  const cmdText = clip(item.cmd, inner - 5);
701
736
  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);
737
+ const hintPlain = stripMarks(TIP_HINT); // too long for the frame: wrap it inside, never let the terminal wrap the border
738
+ const hintRows = [...hintPlain].length <= inner ? [row('hint', runs(paintRuns(TIP_HINT, 'quiet')), [...hintPlain].length)]
739
+ : wrapPlain(hintPlain, inner).map((l, i) => row(`hint${i}`, [h(Text, { key: 0, color: c('quiet') }, l)], [...l].length));
703
740
  return h(Box, { paddingLeft: 2, flexDirection: 'column' },
704
741
  h(Text, edge, '╭─ ', h(Text, { bold: true }, 'Tip'), ' ', '─'.repeat(W - 8), '╮'),
705
- ...body, tryRow, hintRow,
742
+ ...body, tryRow, ...hintRows,
706
743
  h(Text, edge, '╰', '─'.repeat(W - 2), '╯'));
707
744
  }
708
745
 
@@ -730,9 +767,13 @@ function WhatsNewView({ item, cols }) {
730
767
  return out;
731
768
  });
732
769
  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));
770
+ // a head that does not fit in the top edge (the compact "N new commands (...)" line at a narrow width) goes inside the
771
+ // frame, wrapped, under a bare top edge: the terminal must never be the one to wrap a border row
772
+ const headFits = [...HEAD].length <= W - 6;
773
+ if (!headFits) rows.unshift(...wrapPlain(HEAD, inner).map((l, i) => row(`h${i}`, [h(Text, { key: 0, color: c('prose'), bold: true }, l)], [...l].length)));
733
774
  if (item.pad) { rows.unshift(row('p0', [], 0)); rows.push(row('p1', [], 0)); }
734
775
  return h(Box, { paddingLeft: 2, flexDirection: 'column' },
735
- h(Text, edge, '╭─ ', h(Text, { bold: true }, HEAD), ' ', '─'.repeat(Math.max(1, W - 5 - HEAD.length)), '╮'),
776
+ headFits ? h(Text, edge, '╭─ ', h(Text, { bold: true }, HEAD), ' ', '─'.repeat(W - 5 - [...HEAD].length), '╮') : h(Text, edge, '╭', '─'.repeat(W - 2), '╮'),
736
777
  ...rows,
737
778
  h(Text, edge, '╰', '─'.repeat(W - 2), '╯'));
738
779
  }
@@ -770,6 +811,8 @@ export function liveRows(blocks, cols) {
770
811
  case 'summary': return n + noteLines(b.text, cols - 4).length;
771
812
  case 'error': return n + errorRows(b.text, cols);
772
813
  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
814
+ case 'tool': return n + 1 + (b.status === 'running' ? 0 : toolCardLines(b, cols).length);
815
+ case 'loopcard': return n + (loopPinned(b.card) ? 0 : 1 + loopCardLines(b.card, cols).length);
773
816
  default: return n + 1;
774
817
  }
775
818
  }, 0);
@@ -790,10 +833,11 @@ export const Grad = ({ text }) => {
790
833
  * never wraps: full, then a shortened provider (`anth claude-sonnet-5-5`), then the model alone, then the
791
834
  * model clipped. Leaves at least four rule cells on the left. Below 30 cols, or no model: ''.
792
835
  */
793
- export function modelRuleLabel(cols, model) {
836
+ export function modelRuleLabel(cols, model, reserve = 0) {
794
837
  const full = String(model || '').trim();
795
838
  if (!full || cols < 30) return '';
796
- const room = cols - 7; // ' label ' + one trailing dash + four leading dashes
839
+ const room = cols - 7 - reserve; // ' label ' + one trailing dash + four leading dashes (+ the approvals indicator)
840
+ if (reserve && room < 6) return '';
797
841
  const fits = (t) => cellWidth(t) <= room; // cells under the ambiguous-width policy (cells.js)
798
842
  if (fits(full)) return full;
799
843
  const segs = full.split(' · ');
@@ -803,7 +847,43 @@ export function modelRuleLabel(cols, model) {
803
847
  const provider = segs.length > 1 ? segs[0] : words.length > 1 ? words[0] : '';
804
848
  if (provider) { const short = `${provider.slice(0, 4)} ${name}`; if (fits(short)) return short; }
805
849
  if (fits(name)) return name;
806
- return clip(name, room);
850
+ return reserve ? '' : clip(name, room); // with the approvals indicator the model is dropped before the indicator is cut
851
+ }
852
+
853
+ /** Pure: a failed approvals poll goes stale (footer `?`) only after 3 consecutive misses or 60 s without a good read. */
854
+ export function approvalsPollStale({ fails, lastOkAt, now }) {
855
+ if (!(fails > 0)) return false;
856
+ return fails >= 3 || (typeof lastOkAt === 'number' && now - lastOkAt > 60000);
857
+ }
858
+
859
+ /**
860
+ * Pure: the permanent approvals indicator on the bottom rule. `a` = { count, error, signedOut }.
861
+ * Never a false 0: signed out, an unreachable inbox, or a count not read yet is `approvals ?` (warn).
862
+ */
863
+ export function approvalsIndicator(a) {
864
+ if (!a) return null;
865
+ const n = a.count;
866
+ if (!a.signedOut && a.error && a.slow) return { text: 'approvals ? (server slow)', role: 'warn' }; // sustained failure, never an all-clear
867
+ if (a.signedOut || a.error || typeof n !== 'number') return { text: 'approvals ?', role: 'warn' };
868
+ // Never an all-clear next to outstanding items or an unseen source: scope the claim to approvals and say what else is open.
869
+ const out = Number(a.outstanding) || 0, blind = Number(a.blind) || 0;
870
+ if (n === 0 && (out > 0 || blind > 0)) {
871
+ const more = [out > 0 ? `${out} other item${out === 1 ? '' : 's'} open` : null, blind > 0 ? `can't see ${blind} source${blind === 1 ? '' : 's'}` : null].filter(Boolean).join(' · ');
872
+ return { text: `no approvals in this view · ${more}`, role: 'warn' };
873
+ }
874
+ // Other orgs the user belongs to (cached counts, core/crossOrgApprovals): never an all-clear beside a waiting approval elsewhere.
875
+ const others = (Array.isArray(a.others) ? a.others : []).filter((o) => o && o.count > 0);
876
+ const elsewhere = others.reduce((t, o) => t + o.count, 0);
877
+ if (n === 0 && elsewhere > 0) return { text: others.length === 1 ? `⏸ ${elsewhere} in ${others[0].name} · /approvals` : `⏸ ${elsewhere} in ${others.length} other orgs · /approvals`, role: 'warn' };
878
+ if (n === 0) return { text: '✔ nothing waiting', role: 'ok' };
879
+ return { text: `⏸ ${n} approval${n === 1 ? '' : 's'} waiting${elsewhere ? ` · +${elsewhere} in ${others.length === 1 ? others[0].name : `${others.length} other orgs`}` : ''}`, role: 'warn' };
880
+ }
881
+
882
+ /** Pure: the labels for this width. The indicator sits top-left, the model bottom-right: independent of each other. */
883
+ export function barLabels(cols, model, a) {
884
+ const ind = cols >= 30 ? approvalsIndicator(a) : null;
885
+ if (ind && cols - cellWidth(` ${ind.text} `) - 5 < 4) return { model: modelRuleLabel(cols, model), ind: null }; // no room for the whole indicator
886
+ return { model: modelRuleLabel(cols, model), ind };
807
887
  }
808
888
 
809
889
  /** Pure: the rule split around a right-aligned session name: `───── name ─`. Below 30 cols, or no name: plain. */
@@ -836,10 +916,23 @@ function ringString(str, rgbs, from = 0) {
836
916
  * A composer rule. `ring` (motion only, never under NO_COLOR) = { state, top }: the rule becomes half of the
837
917
  * light ring (motion/ring.js); the session name stays bright on top of it.
838
918
  */
839
- export const Rule = ({ cols, color, name, ring = null, labelColor = null }) => {
919
+ export const Rule = ({ cols, color, name, ring = null, labelColor = null, extra = null }) => {
840
920
  const p = ruleParts(cols, name);
841
921
  // `labelColor` (the model on the bottom rule): its own colour, not bold, so it reads as context, not a title
842
922
  const lab = labelColor ? { color: labelColor } : null;
923
+ if (extra) { // TUI-BAR: the approvals indicator at the LEFT, the session name (if any) at the RIGHT: `─ ✔ nothing waiting ──── name ─`
924
+ const d = cellWidth('─');
925
+ const el = ` ${extra.text} `;
926
+ const nm = name ? ` ${name} ` : '';
927
+ const fill = '─'.repeat(Math.max(1, Math.floor((cols - cellWidth(nm) - cellWidth(el) - 2 * d) / d)));
928
+ const ex = { color: c(extra.role), bold: true };
929
+ if (ring && ring.state && ring.state.k > 0 && !mono()) {
930
+ const rgbs = ruleColors(cols, ring.top, ring.state, COLORS[color] || color);
931
+ const fl = [...fill].length, ln = [...el].length;
932
+ return h(Text, null, ringString('─', rgbs), h(Text, ex, el), ringString(fill, rgbs, 1 + ln), nm ? h(Text, lab || { color: c('violet'), bold: true }, nm) : null, ringString('─', rgbs, 1 + ln + fl + [...nm].length));
933
+ }
934
+ return h(Text, null, h(Text, { color: c(color) }, '─'), h(Text, ex, el), h(Text, { color: c(color) }, fill), nm ? h(Text, lab || { color: c('violet'), bold: true }, nm) : null, h(Text, { color: c(color) }, '─'));
935
+ }
843
936
  if (ring && ring.state && ring.state.k > 0 && !mono()) {
844
937
  const rgbs = ruleColors(cols, ring.top, ring.state, COLORS[color] || color);
845
938
  if (!p.label) return h(Text, null, ringString(p.left, rgbs));
@@ -889,7 +982,13 @@ export function endsInRule({ card = null, asks = [], background = 0, pending = [
889
982
  return Boolean(card);
890
983
  }
891
984
 
892
- export function Composer({ buf, cols, mode, busy, placeholder, name, model = null, ring = null, divider = null, now = 0, topRule = true }) {
985
+ export function Composer({ buf, cols, mode, busy, placeholder, name, model = null, ring = null, divider = null, now = 0, topRule = true, approvals = null }) {
986
+ const bar = barLabels(cols, model, approvals);
987
+ // TUI-BAR (owner 2026-10-23): the indicator once, top-left; the session name top-right (it drops before the indicator
988
+ // is cut); the model bottom-right. The top rule is drawn whenever the indicator is, even above a card's closing rule.
989
+ const topExtra = bar.ind;
990
+ const nameRoom = topExtra ? cols - 4 - cellWidth(` ${topExtra.text} `) - 4 : cols - 8;
991
+ const topName = name && topExtra ? (nameRoom >= 6 ? clip(name, nameRoom) : '') : name;
893
992
  const empty = buf.text === '';
894
993
  const lines = composerLines(buf);
895
994
  const body = empty
@@ -902,10 +1001,10 @@ export function Composer({ buf, cols, mode, busy, placeholder, name, model = nul
902
1001
  ? h(Text, { color: c('input') }, l.text || ' ')
903
1002
  : h(Text, { color: c('input') }, l.before, h(Text, { inverse: true }, l.at), l.after)));
904
1003
  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,
1004
+ (topRule || topExtra) ? h(Rule, { cols, color: MODE_COLOR[mode] || 'sky', name: topName, extra: topExtra, labelColor: topExtra ? c('violet') : null, ring: ring ? { state: ring, top: true } : null }) : null,
906
1005
  ...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 }));
1006
+ (ring && divider && dividerRuleEl({ cols, label: bar.model, extra: null, divider: divider.d, now, ruleColor: c('dim'), labelColor: modelColor(model), mono: mono(), reduced: cellWidth('─') !== 1, slow: divider.slow }))
1007
+ || h(Rule, { cols, color: 'dim', name: bar.model, labelColor: modelColor(model), ring: ring ? { state: ring, top: false } : null }));
909
1008
  }
910
1009
 
911
1010
  /** The model label's colour: its provider's brand colour, else the label role (never a grey). */
@@ -948,17 +1047,26 @@ export function WaitingNotice({ items = [], cols }) {
948
1047
  * a rule, a bold title saying who is asking, label/value rows (what a yes does, in full, and why), then the
949
1048
  * "▶ question? keys" footer in the ask colour, and a closing rule. `arming` replaces the footer until keys are live.
950
1049
  */
951
- export function NeedsYouCard({ cols, title, rows = [], block = [], notes = [], footer, arming = null, motionE = null }) {
1050
+ /** The "▶ question? keys" line: the glyph keeps its own 2 cells and a wrapped question hangs under its text, not under the glyph. */
1051
+ const HANG_ASK = /^▶ /;
1052
+ export function AskFoot({ footer }) {
1053
+ const f = String(footer ?? '');
1054
+ if (!HANG_ASK.test(f)) return h(Text, { color: c('prose'), bold: true, wrap: 'wrap' }, ' ', ...runs(paintLine(f, 'ask')));
1055
+ return h(Box, { paddingLeft: 2 }, h(Box, { width: 2, flexShrink: 0 }, h(Text, { bold: true }, ...runs(paintLine('▶ ', 'ask')))), h(Box, { flexGrow: 1, flexShrink: 1 }, h(Text, { color: c('prose'), bold: true, wrap: 'wrap' }, ...runs(paintLine(f.slice(2), 'ask')))));
1056
+ }
1057
+ const footRows = (footer, cols) => (HANG_ASK.test(footer) ? wrapRows(footer.slice(2), cols - 4) : wrapRows(footer, cols - 2));
1058
+ export function NeedsYouCard({ cols, title, rows = [], block = [], notes = [], footer, arming = null, motionE = null, lead = null }) {
952
1059
  const e = motionE;
953
1060
  return h(Box, { flexDirection: 'column', width: cols, marginTop: 1 },
954
1061
  e == null ? h(Rule, { cols, color: 'warn' }) : h(DrawRule, { cols, color: 'warn', e }),
955
1062
  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)),
1063
+ lead ? h(Text, { color: c('warn'), bold: true, wrap: 'wrap' }, ` ${lead}`) : null, // a destructive approval leads with what it deletes
956
1064
  // 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)))),
1065
+ ...rows.flatMap(([k, v], ri) => wrapPlain(String(v), Math.max(10, cols - 10)).slice(0, 8).map((line, i) => h(Text, { key: `${ri}${k}${i}`, color: c('text') }, ' ', h(Text, { color: c('label') }, (i ? '' : k).padEnd(7)), h(Text, { color: c(k === 'risk' ? 'warn' : 'value') }, line)))),
958
1066
  // block: pre-shaped segment rows shown verbatim (a command to run: never word-wrapped, never clipped)
959
1067
  ...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
1068
  ...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'))),
1069
+ arming || h(AskFoot, { footer }),
962
1070
  e == null ? h(Rule, { cols, color: 'warn' }) : h(DrawRule, { cols, color: 'warn', e }));
963
1071
  }
964
1072
 
@@ -974,29 +1082,43 @@ export function askCardModel(text) {
974
1082
  export function AskCard({ text, cols }) {
975
1083
  if (!String(text ?? '').replace(/^\s*▶?\s*/, '').trim()) return null; // an empty ask draws nothing: no rules, no blank line
976
1084
  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'))));
1085
+ if (m.plain) return h(Box, { width: cols, marginTop: 1, flexDirection: 'column' }, h(AskFoot, { footer: m.footer }));
978
1086
  return h(NeedsYouCard, { cols, title: m.title, rows: m.rows, footer: m.footer });
979
1087
  }
980
1088
  /** Rows an AskCard takes (sizes the live region). */
981
1089
  export const askCardRows = (text, cols) => {
982
1090
  if (!String(text ?? '').replace(/^\s*▶?\s*/, '').trim()) return 0;
983
1091
  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);
1092
+ if (m.plain) return 1 + footRows(m.footer, cols);
1093
+ 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) + footRows(m.footer, cols);
986
1094
  };
987
1095
 
988
- export function ApprovalCard({ card, org, cols, armed, draftHint = null, motionE = null, confirm = false, position = null }) {
1096
+ /** APPROVAL-BURST: one card for 2+ approvals from one agent for one tool (approvalBatch.batchModel). */
1097
+ export function BatchCard({ model, cols, armed, motionE = null }) {
1098
+ const e = motionE;
1099
+ const block = model.lines.map((l) => [{ t: `• ${clip(l, Math.max(10, cols - 8))}`, role: 'text' }]);
1100
+ if (model.more) block.push([{ t: model.more, role: 'dim' }]);
1101
+ const footer = askText(`Approve all ${model.n}?`, { keys: model.keys });
1102
+ return h(NeedsYouCard, { cols, title: model.title, rows: [], block, notes: model.notes, footer, arming: armed ? null : (e == null ? h(Text, { color: c('dim') }, ' arming…') : null), motionE: e });
1103
+ }
1104
+
1105
+ export function ApprovalCard({ card, org, cols, armed, draftHint = null, motionE = null, confirm = false, position = null, signedOut = false }) {
989
1106
  const when = card.askedAt ? String(card.askedAt).replace('T', ' ').slice(0, 16) + ' UTC' : null;
990
1107
  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]
1108
+ ['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', card.plan && (card.plan.orgName || card.plan.orgId) ? orgLabel(card.plan.orgName, card.plan.orgId) : org]
992
1109
  ].filter(r => r[1]);
1110
+ if (card.plan) { // the plan itself, so the approver sees what they are approving
1111
+ if (card.plan.lines.length) card.plan.lines.forEach((l, i) => rows.push([i ? '' : 'plan', l]));
1112
+ else rows.push(['plan', card.plan.loaded ? 'this objective has no steps recorded' : 'could not load the plan steps, deny or check `quox objective get` first']);
1113
+ }
993
1114
  if (card.privileged) rows.push(['grants', `PRIVILEGED: ${[card.tool, card.target].filter(Boolean).join(' ')}`]);
994
1115
  // APPROVAL-WWWH: what exactly (verbatim), where, who, why, when, read-only or write. The command is a block: never word-wrapped.
995
1116
  const f = card.facts || null; const block = [];
996
1117
  if (f && !card.undescribable) {
997
1118
  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); } }
1119
+ if (f.background) { const i = rows.findIndex(r => r[0] === 'what'); const note = BACKGROUND_NOTE[0].toUpperCase() + BACKGROUND_NOTE.slice(1); if (i >= 0) rows[i] = ['what', `${rows[i][1]}. ${note}`]; else rows.unshift(['what', note]); }
998
1120
  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);
1121
+ for (const r of [['where', f.where], ['who', f.who], ['why', f.why], ['asked', [f.asked && (f.askedRich ? f.asked : `"${f.asked}"`), f.when].filter(Boolean).join(' · ') || null], ['class', f.access]]) if (r[1]) rows.push(r);
1000
1122
  }
1001
1123
  const e = motionE;
1002
1124
  const title = `⚠ Approval needed${position && position.n > 1 ? ` (${position.i} of ${position.n})` : ''}: execution is held`;
@@ -1004,12 +1126,15 @@ export function ApprovalCard({ card, org, cols, armed, draftHint = null, motionE
1004
1126
  const arming = e == null || mono()
1005
1127
  ? h(Text, { color: c('dim') }, e == null ? ' arming…' : ` arming… ${'▰'.repeat(filled)}${'▱'.repeat(ARM_BAR - filled)}`)
1006
1128
  : 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`
1129
+ const footer = signedOut ? askText('Read-only while signed out. Sign in to answer?', { keys: 'y signs in' })
1130
+ : card.privileged && confirm ? `▶ Grant ${[card.tool, card.target].filter(Boolean).join(' ')}? press y again to confirm, any other key cancels`
1008
1131
  : card.undescribable && confirm ? '▶ Quox could not describe this action. Approve it anyway? press y again to confirm, any other key cancels'
1132
+ : draftHint === 'reason' ? askText('Why deny it?', { keys: 'type a reason · Enter denies · Esc goes back to y/n' })
1009
1133
  : 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
1134
  : 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 });
1135
+ : card.destructive ? '▶ Delete it? y then type the name · n deny · c chat · Tab later · /approvals all'
1136
+ : '▶ Approve it? y approve · n deny · c chat · Tab later · /approvals all';
1137
+ return h(NeedsYouCard, { cols, title, rows, block, lead: card.destructive ? `DELETES ${card.destructive.label}` : null, notes: [...(f && f.askedNone ? ['nothing in your request asks for this'] : []), ...(card.undescribable && !confirm ? ['Quox could not describe this action'] : []), ...(position && position.others > 0 ? [`other sessions: ${position.others} waiting · /approvals`] : [])], footer, arming: armed ? null : arming, motionE: e });
1013
1138
  }
1014
1139
 
1015
1140
  /** Columns the status line's Q takes: 2 for the Braille glyph, 1 for the Ⓠ fallback (2 where Ambiguous is wide). */
@@ -1046,8 +1171,10 @@ export function statusRows({ cols, slot, agent, groups = [], parts, hint, qw = s
1046
1171
  // every context item says what it is (owner: "profile moo .. ask?? what?"); /help status explains each in a line
1047
1172
  const word = { profile: 'profile ', org: 'org ', mode: 'mode ' };
1048
1173
  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']] });
1174
+ for (const k of ['profile', 'org', 'server', 'remote', 'mode', 'rules', 'auto'].filter(k => p[k])) tokens.push({ pre: ' · ',
1175
+ segs: k === 'server' ? [['⬡ ', 'icon'], ['server ', 'label'], [p.server.name, 'value'], ...(p.server.role ? [[` (${p.server.role})`, 'label']] : [])]
1176
+ : k === 'remote' ? [['→ ', 'icon'], [String(p.remote), 'value']]
1177
+ : 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
1178
  const tlen = (tk) => tk.segs.reduce((n, sg) => n + len(sg[0]), 0);
1052
1179
  // split a token too wide for a row into row-sized pieces (whole characters by cell width, never an ellipsis)
1053
1180
  const fit = (tk) => {
@@ -1097,20 +1224,21 @@ export function statusRows({ cols, slot, agent, groups = [], parts, hint, qw = s
1097
1224
  }
1098
1225
 
1099
1226
  /** QUOXON-FW17: custom rows, one truncated line each, just above the status line. */
1100
- export function RowsBar({ rows, cols }) {
1227
+ export function RowsBar({ rows, cols, limit }) {
1101
1228
  if (!rows?.length) return null;
1102
- const shown = rows.slice(0, 3);
1103
- const labelW = labelWidth(shown); // labels line up across rows
1229
+ // TUI-QPE: layoutRows fits cells rows by keep (whole items drop, never mid-word), lines up a grid's columns and
1230
+ // leaves out rows below their min_cols; plain rows take the classic rowParts + clipParts path unchanged.
1231
+ // /qpe N keeps the first N lines of a grid; it applies to drawn lines (a row can bring several), so expand first.
1232
+ const laid = layoutRows(limit ? limitGrid(expandLines(rows), limit.grid, limit.n) : rows, cols);
1233
+ if (!laid.length) return null;
1104
1234
  return h(Box, { flexDirection: 'column', width: cols },
1105
- ...shown.map((r) => {
1106
- const parts = clipParts(rowParts(r, { labelW }), Math.max(1, cols - 2));
1235
+ ...laid.map(({ st: r, parts }) =>
1107
1236
  // 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
- }));
1237
+ 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)))));
1110
1238
  }
1111
1239
 
1112
1240
  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;
1241
+ const p = Array.isArray(parts) ? { profile: parts[0], org: parts[1], model: parts[2], mode: parts[3], rules: parts[4], auto: parts[5], server: parts[6], remote: parts[7] } : parts;
1114
1242
  const qw = statusGlyphWidth();
1115
1243
  const rows = statusRows({ cols, slot, agent, groups, parts: p, hint: right, qw });
1116
1244
  // The still Q (qglyph.js): two Braille cells, or Ⓠ under NO_COLOR / no Braille. statusRows reserved its width.
@@ -1131,7 +1259,24 @@ function hintNodes(hint) {
1131
1259
  }
1132
1260
 
1133
1261
  /** Live, actionable counts under the status line. Never a transcript message. */
1134
- export function NotifyBar({ approvals, error, cols, signedOut = false, alerts = [], hideCount = false }) {
1262
+ export function NotifyBar({ approvals, error, cols, signedOut = false, alerts = [], hideCount = false, inbox = '', spinner = '', away = false, awayCount = 0, dnd = false, dndCount = 0, awaySince = 0, dndUntil = 0, awayUnknown = false }) {
1263
+ const hm = (ms) => { const d = new Date(ms); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
1264
+ if (away && dnd && !signedOut) { // DND: one persistent quiet line, shown even at 0 waiting so the mode is never invisible
1265
+ const head = dndUntil ? `🌙 do not disturb until ${hm(dndUntil)}` : '🌙 do not disturb';
1266
+ return h(Text, { color: c('ask'), bold: true }, ' ' + clip(`${head}${dndCount ? ` · ${dndCount} waiting` : ''} · /morningreport`, cols - 3));
1267
+ }
1268
+ if (away && !signedOut) { // AFK-QUIET-CARDS: away shows one quiet line, never a prompt
1269
+ const n = awayCount || approvals || 0;
1270
+ const head = awaySince ? `🌙 away since ${hm(awaySince)} · /back` : awayUnknown ? '🌙 away (since before this window opened) · /back' : '🌙 away · /back';
1271
+ return h(Text, { color: c('ask'), bold: true }, ' ' + clip(n ? `⏸ ${n} approval${n === 1 ? '' : 's'} waiting · /back to review` : head, cols - 3)); // >0 keeps the existing wording (asserted elsewhere)
1272
+ }
1273
+ const base = NotifyBarBase({ approvals, error, cols, signedOut, alerts, hideCount });
1274
+ const line = inbox && !signedOut ? h(Text, { key: 'inbox', color: c('ask'), bold: true }, ' ' + clip(inbox, cols - 3)) : null; // HITL-INBOX-BAR: 📨 N new · newest title
1275
+ const spin = spinner ? h(Text, { key: 'spin', color: c('ask') }, ' ' + clip(spinner, cols - 3)) : null; // INSTANT-FEEDBACK: ⠋ <verb>…
1276
+ const parts = [base, line, spin].filter(Boolean);
1277
+ return parts.length > 1 ? h(Box, { flexDirection: 'column' }, ...parts) : (parts[0] || base);
1278
+ }
1279
+ function NotifyBarBase({ approvals, error, cols, signedOut = false, alerts = [], hideCount = false }) {
1135
1280
  if (signedOut) return h(Text, { color: c('warn') }, ' ' + clip('not signed in: approvals unavailable until you sign in', cols - 2));
1136
1281
  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
1282
  // Background workflows that keep failing: one deduped line each (max 2), never roster rows.
@@ -1155,13 +1300,14 @@ export function Roster({ view, peek = [], confirm = null, hint = null, cols, fx
1155
1300
  // glyph keeps the state colour; the rest of the line steps down in tone with position and (for finished rows) age
1156
1301
  ...view.lines.map((l, i) => {
1157
1302
  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' },
1303
+ const row = h(Text, { key: 'r' + i, bold: l.selected, wrap: 'truncate-end' },
1159
1304
  h(Text, { color: c(l.selected ? 'text' : (l.glyphRole || ROSTER_TONE[l.state])) }, ' ' + l.marker + l.glyph),
1160
1305
  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
1306
  l.pulse ? h(Text, { color: c(l.pulseRole === 'dim' && l.selected ? 'text' : l.pulseRole) }, l.pulse) : null);
1307
+ return l.extra ? h(Box, { key: 'rx' + i, flexDirection: 'column' }, row, ...l.extra.map((t, j) => h(Text, { key: j, color: c('error') }, ' ' + t))) : row; // the stop reason, whole and wrapped
1162
1308
  }),
1163
1309
  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))),
1310
+ ...peek.flatMap((p, i) => wrapText(p, cols - 5).map((t, j) => h(Text, { key: `p${i}.${j}`, color: c('dim') }, (j ? ' ' : ' ') + t))), // QLOOP-LIVE: a peek line wraps, it is never cut
1165
1311
  confirm ? h(Text, { key: 'cf', color: c('warn') }, ' ' + clip(confirm, cols - 2)) : null,
1166
1312
  hint ? h(Text, { key: 'h', color: c('dim') }, ' ' + clip(hint, cols - 2)) : null);
1167
1313
  }