quoxcli 0.0.0-stage → 0.2.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 (348) hide show
  1. package/LICENSE +35 -0
  2. package/README.md +142 -2
  3. package/bin/quox.js +292 -0
  4. package/bin/quoxon.js +8 -0
  5. package/lib/api/endpoints/admin.js +125 -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/agents.js +56 -0
  9. package/lib/api/endpoints/assistants.js +50 -0
  10. package/lib/api/endpoints/audit.js +14 -0
  11. package/lib/api/endpoints/auth.js +50 -0
  12. package/lib/api/endpoints/awareness.js +34 -0
  13. package/lib/api/endpoints/backup-schedules.js +20 -0
  14. package/lib/api/endpoints/backups.js +86 -0
  15. package/lib/api/endpoints/brain.js +140 -0
  16. package/lib/api/endpoints/budgets.js +62 -0
  17. package/lib/api/endpoints/calendarq.js +71 -0
  18. package/lib/api/endpoints/catalog.js +14 -0
  19. package/lib/api/endpoints/chat.js +54 -0
  20. package/lib/api/endpoints/consent.js +26 -0
  21. package/lib/api/endpoints/contactsq.js +66 -0
  22. package/lib/api/endpoints/conversations.js +32 -0
  23. package/lib/api/endpoints/custom-roles.js +92 -0
  24. package/lib/api/endpoints/divisions.js +56 -0
  25. package/lib/api/endpoints/domains.js +38 -0
  26. package/lib/api/endpoints/engagement.js +231 -0
  27. package/lib/api/endpoints/engine.js +11 -0
  28. package/lib/api/endpoints/entity.js +50 -0
  29. package/lib/api/endpoints/executions.js +32 -0
  30. package/lib/api/endpoints/file.js +20 -0
  31. package/lib/api/endpoints/files.js +26 -0
  32. package/lib/api/endpoints/github.js +21 -0
  33. package/lib/api/endpoints/governance.js +43 -0
  34. package/lib/api/endpoints/hat.js +36 -0
  35. package/lib/api/endpoints/inbox.js +32 -0
  36. package/lib/api/endpoints/inboxq.js +120 -0
  37. package/lib/api/endpoints/index.js +228 -0
  38. package/lib/api/endpoints/invites.js +19 -0
  39. package/lib/api/endpoints/llm-policy.js +43 -0
  40. package/lib/api/endpoints/lucid.js +89 -0
  41. package/lib/api/endpoints/members.js +32 -0
  42. package/lib/api/endpoints/memory.js +75 -0
  43. package/lib/api/endpoints/mfa.js +32 -0
  44. package/lib/api/endpoints/mon.js +178 -0
  45. package/lib/api/endpoints/notesq.js +43 -0
  46. package/lib/api/endpoints/objectives.js +62 -0
  47. package/lib/api/endpoints/orgs.js +38 -0
  48. package/lib/api/endpoints/plugins.js +134 -0
  49. package/lib/api/endpoints/powers.js +75 -0
  50. package/lib/api/endpoints/prompts.js +69 -0
  51. package/lib/api/endpoints/qlarity.js +122 -0
  52. package/lib/api/endpoints/quoxbox.js +55 -0
  53. package/lib/api/endpoints/quoxchat.js +54 -0
  54. package/lib/api/endpoints/quoxflow.js +32 -0
  55. package/lib/api/endpoints/quoxplan.js +114 -0
  56. package/lib/api/endpoints/quoxseo.js +108 -0
  57. package/lib/api/endpoints/reach.js +47 -0
  58. package/lib/api/endpoints/runs.js +38 -0
  59. package/lib/api/endpoints/safety.js +46 -0
  60. package/lib/api/endpoints/scim.js +46 -0
  61. package/lib/api/endpoints/scopes.js +14 -0
  62. package/lib/api/endpoints/security.js +69 -0
  63. package/lib/api/endpoints/setup.js +90 -0
  64. package/lib/api/endpoints/skills.js +180 -0
  65. package/lib/api/endpoints/sso.js +67 -0
  66. package/lib/api/endpoints/tags.js +32 -0
  67. package/lib/api/endpoints/teams.js +56 -0
  68. package/lib/api/endpoints/telephony.js +46 -0
  69. package/lib/api/endpoints/tg.js +56 -0
  70. package/lib/api/endpoints/tools.js +50 -0
  71. package/lib/api/endpoints/traces.js +157 -0
  72. package/lib/api/endpoints/triggers.js +70 -0
  73. package/lib/api/endpoints/user-library.js +41 -0
  74. package/lib/api/endpoints/users.js +20 -0
  75. package/lib/api/endpoints/v1.js +499 -0
  76. package/lib/api/endpoints/vault.js +192 -0
  77. package/lib/api/endpoints/voice.js +22 -0
  78. package/lib/api/endpoints/volt.js +84 -0
  79. package/lib/api/endpoints/ward.js +40 -0
  80. package/lib/api/endpoints/webhooks.js +46 -0
  81. package/lib/api/endpoints/workflows.js +162 -0
  82. package/lib/api/endpoints/x.js +24 -0
  83. package/lib/api/endpoints.js +15 -0
  84. package/lib/commands/admin.js +359 -0
  85. package/lib/commands/aee.js +99 -0
  86. package/lib/commands/agent.js +201 -0
  87. package/lib/commands/agentAttach.js +1440 -0
  88. package/lib/commands/approvals.js +47 -0
  89. package/lib/commands/assistant.js +114 -0
  90. package/lib/commands/auth.js +458 -0
  91. package/lib/commands/awareness.js +88 -0
  92. package/lib/commands/backup.js +373 -0
  93. package/lib/commands/brain.js +994 -0
  94. package/lib/commands/budget.js +241 -0
  95. package/lib/commands/build.js +156 -0
  96. package/lib/commands/calendar.js +216 -0
  97. package/lib/commands/capabilities.js +34 -0
  98. package/lib/commands/chat.js +240 -0
  99. package/lib/commands/claude.js +354 -0
  100. package/lib/commands/config-cmd.js +106 -0
  101. package/lib/commands/consent.js +84 -0
  102. package/lib/commands/contacts.js +967 -0
  103. package/lib/commands/contactsSelftest.js +132 -0
  104. package/lib/commands/conversation.js +74 -0
  105. package/lib/commands/department.js +75 -0
  106. package/lib/commands/dev.js +620 -0
  107. package/lib/commands/devResident.js +314 -0
  108. package/lib/commands/devSupervisor.js +1050 -0
  109. package/lib/commands/discord.js +96 -0
  110. package/lib/commands/doctor.js +181 -0
  111. package/lib/commands/domain.js +114 -0
  112. package/lib/commands/done.js +277 -0
  113. package/lib/commands/engagement.js +586 -0
  114. package/lib/commands/entity.js +119 -0
  115. package/lib/commands/facebook.js +212 -0
  116. package/lib/commands/file.js +128 -0
  117. package/lib/commands/fleet.js +882 -0
  118. package/lib/commands/governance.js +638 -0
  119. package/lib/commands/gripe.js +324 -0
  120. package/lib/commands/hat.js +205 -0
  121. package/lib/commands/helpers.js +79 -0
  122. package/lib/commands/inbox.js +122 -0
  123. package/lib/commands/instagram.js +264 -0
  124. package/lib/commands/integration.js +100 -0
  125. package/lib/commands/inventory.js +115 -0
  126. package/lib/commands/invites.js +45 -0
  127. package/lib/commands/licence.js +280 -0
  128. package/lib/commands/linkedin.js +178 -0
  129. package/lib/commands/llm-policy.js +117 -0
  130. package/lib/commands/llm.js +45 -0
  131. package/lib/commands/lucid.js +591 -0
  132. package/lib/commands/mail.js +531 -0
  133. package/lib/commands/memory.js +284 -0
  134. package/lib/commands/mon.js +514 -0
  135. package/lib/commands/monitoring.js +86 -0
  136. package/lib/commands/motion.js +22 -0
  137. package/lib/commands/notes.js +1246 -0
  138. package/lib/commands/notesBatch.js +160 -0
  139. package/lib/commands/notesBridges.js +56 -0
  140. package/lib/commands/notesLinks.js +60 -0
  141. package/lib/commands/notification.js +57 -0
  142. package/lib/commands/nucleus.js +92 -0
  143. package/lib/commands/objective.js +229 -0
  144. package/lib/commands/onboard.js +328 -0
  145. package/lib/commands/open.js +56 -0
  146. package/lib/commands/org.js +266 -0
  147. package/lib/commands/plan.js +906 -0
  148. package/lib/commands/plugin-doctor.js +1091 -0
  149. package/lib/commands/plugin.js +539 -0
  150. package/lib/commands/policy.js +78 -0
  151. package/lib/commands/powers.js +296 -0
  152. package/lib/commands/prompt.js +350 -0
  153. package/lib/commands/qlarity.js +690 -0
  154. package/lib/commands/quoxbox.js +270 -0
  155. package/lib/commands/quoxchat.js +266 -0
  156. package/lib/commands/quoxseo.js +340 -0
  157. package/lib/commands/reach.js +158 -0
  158. package/lib/commands/role.js +286 -0
  159. package/lib/commands/run.js +132 -0
  160. package/lib/commands/safety.js +107 -0
  161. package/lib/commands/scim.js +88 -0
  162. package/lib/commands/scope.js +58 -0
  163. package/lib/commands/security.js +157 -0
  164. package/lib/commands/service.js +89 -0
  165. package/lib/commands/settings.js +99 -0
  166. package/lib/commands/setup.js +543 -0
  167. package/lib/commands/skillManifest.js +113 -0
  168. package/lib/commands/skillRightsFormat.js +38 -0
  169. package/lib/commands/skills.js +644 -0
  170. package/lib/commands/slot.js +1198 -0
  171. package/lib/commands/sso.js +229 -0
  172. package/lib/commands/status.js +137 -0
  173. package/lib/commands/tag.js +91 -0
  174. package/lib/commands/team.js +140 -0
  175. package/lib/commands/tg.js +240 -0
  176. package/lib/commands/tiktok.js +218 -0
  177. package/lib/commands/tool.js +216 -0
  178. package/lib/commands/trace.js +525 -0
  179. package/lib/commands/trigger.js +128 -0
  180. package/lib/commands/vault.js +1424 -0
  181. package/lib/commands/voice.js +182 -0
  182. package/lib/commands/volt.js +528 -0
  183. package/lib/commands/watch.js +115 -0
  184. package/lib/commands/webhook.js +137 -0
  185. package/lib/commands/workflow.js +503 -0
  186. package/lib/commands/x.js +439 -0
  187. package/lib/core/actingAs.js +136 -0
  188. package/lib/core/aee.js +53 -0
  189. package/lib/core/agentAlias.js +163 -0
  190. package/lib/core/approvalContext.js +61 -0
  191. package/lib/core/auth.js +350 -0
  192. package/lib/core/banner.js +143 -0
  193. package/lib/core/brainPrime.js +90 -0
  194. package/lib/core/callerSource.js +129 -0
  195. package/lib/core/claudeLs.js +94 -0
  196. package/lib/core/client.js +584 -0
  197. package/lib/core/completions.js +163 -0
  198. package/lib/core/config.js +551 -0
  199. package/lib/core/confirm.js +37 -0
  200. package/lib/core/deeplinks.js +76 -0
  201. package/lib/core/doneEngineFetch.js +219 -0
  202. package/lib/core/errors.js +132 -0
  203. package/lib/core/global-flags.js +90 -0
  204. package/lib/core/governedSession.js +402 -0
  205. package/lib/core/lazyCommands.js +119 -0
  206. package/lib/core/liveSessionsReport.js +112 -0
  207. package/lib/core/org-override.js +52 -0
  208. package/lib/core/output.js +239 -0
  209. package/lib/core/prodEnv.js +7 -0
  210. package/lib/core/quoxglyph.js +158 -0
  211. package/lib/core/residentAlarm.js +282 -0
  212. package/lib/core/residentAuth.js +243 -0
  213. package/lib/core/residentPump.js +229 -0
  214. package/lib/core/residentSeed.js +104 -0
  215. package/lib/core/sessionHeartbeat.js +192 -0
  216. package/lib/core/sessionsOverview.js +160 -0
  217. package/lib/core/slotKeeper.js +282 -0
  218. package/lib/core/supervisorEngine.js +864 -0
  219. package/lib/core/systemdInstaller.js +256 -0
  220. package/lib/core/version.js +87 -0
  221. package/lib/core/websiteSession.js +148 -0
  222. package/lib/lucid/LucidIntro.jsx +105 -0
  223. package/lib/lucid/blind.js +71 -0
  224. package/lib/lucid/deck.js +32 -0
  225. package/lib/lucid/fmt.js +139 -0
  226. package/lib/lucid/sitrep.js +173 -0
  227. package/lib/plugins/loader.js +93 -0
  228. package/lib/quoxon/activity.js +104 -0
  229. package/lib/quoxon/api.js +473 -0
  230. package/lib/quoxon/app.js +1262 -0
  231. package/lib/quoxon/capabilities.js +75 -0
  232. package/lib/quoxon/commands.js +154 -0
  233. package/lib/quoxon/connect.js +467 -0
  234. package/lib/quoxon/connectCard.js +28 -0
  235. package/lib/quoxon/context.js +68 -0
  236. package/lib/quoxon/copy.js +33 -0
  237. package/lib/quoxon/demo.fixtures.js +24 -0
  238. package/lib/quoxon/demo.js +35 -0
  239. package/lib/quoxon/egg.js +36 -0
  240. package/lib/quoxon/events.js +458 -0
  241. package/lib/quoxon/gallery.js +223 -0
  242. package/lib/quoxon/indicator.js +193 -0
  243. package/lib/quoxon/intent.js +41 -0
  244. package/lib/quoxon/keys.js +121 -0
  245. package/lib/quoxon/main.js +244 -0
  246. package/lib/quoxon/markdown.js +317 -0
  247. package/lib/quoxon/model.js +205 -0
  248. package/lib/quoxon/motion/clock.js +69 -0
  249. package/lib/quoxon/motion/color.js +35 -0
  250. package/lib/quoxon/motion/frame.js +14 -0
  251. package/lib/quoxon/motion/reveal.js +54 -0
  252. package/lib/quoxon/motion/ring.js +79 -0
  253. package/lib/quoxon/objects/agent.js +61 -0
  254. package/lib/quoxon/objects/crystal.js +100 -0
  255. package/lib/quoxon/objects/fetch.js +170 -0
  256. package/lib/quoxon/objects/index.js +85 -0
  257. package/lib/quoxon/objects/ladder.js +71 -0
  258. package/lib/quoxon/objects/memory.js +44 -0
  259. package/lib/quoxon/objects/model.js +118 -0
  260. package/lib/quoxon/objects/proof.js +110 -0
  261. package/lib/quoxon/objects/skill.js +105 -0
  262. package/lib/quoxon/objects/view.js +76 -0
  263. package/lib/quoxon/objects/workflow.js +119 -0
  264. package/lib/quoxon/observers.js +42 -0
  265. package/lib/quoxon/onboarding/copy.js +235 -0
  266. package/lib/quoxon/onboarding/credential.js +95 -0
  267. package/lib/quoxon/onboarding/detect.js +22 -0
  268. package/lib/quoxon/onboarding/finish.js +119 -0
  269. package/lib/quoxon/onboarding/index.js +67 -0
  270. package/lib/quoxon/onboarding/installRunner.js +225 -0
  271. package/lib/quoxon/onboarding/intent.js +53 -0
  272. package/lib/quoxon/onboarding/journey.js +73 -0
  273. package/lib/quoxon/onboarding/preflight.js +142 -0
  274. package/lib/quoxon/onboarding/runner.js +58 -0
  275. package/lib/quoxon/onboarding/view.js +625 -0
  276. package/lib/quoxon/print.js +94 -0
  277. package/lib/quoxon/qart.js +379 -0
  278. package/lib/quoxon/qglyph.js +143 -0
  279. package/lib/quoxon/qhello.js +58 -0
  280. package/lib/quoxon/qmotion.js +111 -0
  281. package/lib/quoxon/render.js +648 -0
  282. package/lib/quoxon/roster.js +221 -0
  283. package/lib/quoxon/route.js +38 -0
  284. package/lib/quoxon/rules.js +130 -0
  285. package/lib/quoxon/session.js +68 -0
  286. package/lib/quoxon/skills.js +30 -0
  287. package/lib/quoxon/slash/compact.js +90 -0
  288. package/lib/quoxon/slash/ledger.js +83 -0
  289. package/lib/quoxon/slash/listView.js +28 -0
  290. package/lib/quoxon/slash/palette.js +104 -0
  291. package/lib/quoxon/slash/paletteController.js +59 -0
  292. package/lib/quoxon/slash/panels.js +101 -0
  293. package/lib/quoxon/slash/picker.js +38 -0
  294. package/lib/quoxon/slash/restart.js +144 -0
  295. package/lib/quoxon/slash/resume.js +59 -0
  296. package/lib/quoxon/slash/settings.js +50 -0
  297. package/lib/quoxon/sound/index.js +186 -0
  298. package/lib/quoxon/sound/mapping.js +119 -0
  299. package/lib/quoxon/sound/output.js +52 -0
  300. package/lib/quoxon/sound/synth.js +122 -0
  301. package/lib/quoxon/term.js +106 -0
  302. package/lib/quoxon/textBuffer.js +242 -0
  303. package/lib/quoxon/theme.js +253 -0
  304. package/lib/quoxon/transcript.js +150 -0
  305. package/lib/quoxon/web/qface.js +81 -0
  306. package/lib/quoxon/welcome.js +52 -0
  307. package/lib/tui/app.jsx +289 -0
  308. package/lib/tui/components/Breadcrumb.jsx +42 -0
  309. package/lib/tui/components/ChatInput.jsx +68 -0
  310. package/lib/tui/components/Confirm.jsx +32 -0
  311. package/lib/tui/components/DataTable.jsx +270 -0
  312. package/lib/tui/components/DetailPanel.jsx +241 -0
  313. package/lib/tui/components/HelpOverlay.jsx +67 -0
  314. package/lib/tui/components/Layout.jsx +129 -0
  315. package/lib/tui/components/Palette.jsx +141 -0
  316. package/lib/tui/components/SearchInput.jsx +125 -0
  317. package/lib/tui/components/Sparkline.jsx +55 -0
  318. package/lib/tui/components/StatusBar.jsx +133 -0
  319. package/lib/tui/components/Toast.jsx +52 -0
  320. package/lib/tui/hooks/useApi.js +44 -0
  321. package/lib/tui/hooks/useHealth.js +90 -0
  322. package/lib/tui/hooks/usePolledApi.js +86 -0
  323. package/lib/tui/hooks/useTerminalSize.js +46 -0
  324. package/lib/tui/index.js +56 -0
  325. package/lib/tui/intro/Intro.jsx +332 -0
  326. package/lib/tui/jsx-loader.js +32 -0
  327. package/lib/tui/keys.js +87 -0
  328. package/lib/tui/registry.js +49 -0
  329. package/lib/tui/theme.js +103 -0
  330. package/lib/tui/views/AdminView.jsx +398 -0
  331. package/lib/tui/views/AgentView.jsx +204 -0
  332. package/lib/tui/views/BackupView.jsx +225 -0
  333. package/lib/tui/views/BrainView.jsx +223 -0
  334. package/lib/tui/views/ChatView.jsx +468 -0
  335. package/lib/tui/views/FilesView.jsx +218 -0
  336. package/lib/tui/views/FleetView.jsx +234 -0
  337. package/lib/tui/views/GovernanceView.jsx +453 -0
  338. package/lib/tui/views/HealthView.jsx +216 -0
  339. package/lib/tui/views/InboxView.jsx +228 -0
  340. package/lib/tui/views/LucidDeck.jsx +727 -0
  341. package/lib/tui/views/MainMenu.jsx +123 -0
  342. package/lib/tui/views/MemoryView.jsx +377 -0
  343. package/lib/tui/views/ObjectivesView.jsx +230 -0
  344. package/lib/tui/views/OrgView.jsx +229 -0
  345. package/lib/tui/views/ToolView.jsx +221 -0
  346. package/lib/tui/views/VaultView.jsx +422 -0
  347. package/lib/tui/views/WorkflowView.jsx +329 -0
  348. package/package.json +72 -4
@@ -0,0 +1,648 @@
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 { graphemes, lineCol } from './textBuffer.js';
9
+
10
+ const h = React.createElement;
11
+ /** Coloured runs (from theme.paintLine/paintRuns) as Text children; the characters are unchanged. */
12
+ const runs = (rs) => rs.map((r, i) => (r.role ? h(Text, { key: i, color: c(r.role) }, r.text) : r.text));
13
+
14
+ import { COLORS, MODE_COLOR, MIN_COLS, mono, c, clampCols, clip, paintLine, paintRuns, agentColor, proto } from './theme.js';
15
+ import { reducedMotion, motionAllowed } from './motion/clock.js';
16
+ import { ruleColors, ringStep, ringMode } from './motion/ring.js';
17
+ import { useFrame } from './motion/frame.js';
18
+ import { rgbStr, hexRgb, mix, clamp01, mixOk, pulse01 } from './motion/color.js';
19
+ import { revealCells, TIMING } from './motion/reveal.js';
20
+ import * as themeNs from './theme.js';
21
+ import { qArt, qArtFrame } from '../core/banner.js';
22
+ import { Markdown, markdownRows, wrapRows, WText, Heading } from './markdown.js';
23
+ import { termRows } from './term.js';
24
+ import { welcomeModel } from './welcome.js';
25
+ import { Q_GLYPH, qColor, stateWord, indicatorState, fmtTokens, wordFrame, wordColor } from './indicator.js';
26
+
27
+ import { qSize, qDims, orbitFrame, qLines, labSize, introPlan, qCells, cellsToAnsi, INTRO_MS } from './qart.js';
28
+ import { glyphStill, glyphMode, colorDepth } from './qglyph.js';
29
+ export { INTRO_MS };
30
+ import { QGlyph, cellNodes } from './qmotion.js';
31
+ import { ObjectView } from './objects/view.js';
32
+ export { COLORS, MODE_COLOR, MIN_COLS, clampCols, clip, reducedMotion };
33
+
34
+ // ---- working indicator (the one animated line) -------------------------------------------------
35
+ export const KANA = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン';
36
+ export const RAIN = 4;
37
+ const G0 = [56, 189, 248], G1 = [168, 85, 247];
38
+
39
+ /** Pure frame: array of { ch, rgb, bold } for tick t. rand is injectable for tests. */
40
+ export function rainFrame(t, rand = Math.random) {
41
+ return Array.from({ length: RAIN }, (_, i) => {
42
+ const ch = KANA[Math.floor(rand() * KANA.length)];
43
+ const k = ((t + i * 3) % 16) / 15;
44
+ const m = k < 0.5 ? k * 2 : 2 - k * 2;
45
+ return { ch, rgb: G0.map((v, j) => Math.round(v + (G1[j] - v) * m)), bold: i === RAIN - 1 };
46
+ });
47
+ }
48
+ export const STATIC_RAIN = 'クオック'.slice(0, RAIN);
49
+
50
+ export function describeActivity(state) {
51
+ const running = state.blocks.filter(b => b.kind === 'tool' && b.status === 'running');
52
+ if (state.approvals.length) return 'Waiting for approval';
53
+ if (running.length) return `Running ${running[running.length - 1].tool}`;
54
+ if (state.turn?.status === 'interrupting') return 'Stopping';
55
+ return state.turn?.stage || 'Working';
56
+ }
57
+
58
+ /**
59
+ * The one animated status line. It owns no timer: the app's motion clock re-renders it (frames while motion
60
+ * is on, once a second otherwise) and passes `now`. `motion` false = static word and colour (reduced motion);
61
+ * colour is separately off under NO_COLOR.
62
+ */
63
+ export const Working = ({ activity, since, interrupting, escLabel, state, failing = false, tokens = 0, act = null, now: nowProp, motion = !reducedMotion() }) => {
64
+ const now = useFrame(nowProp);
65
+ const quiet = !motion;
66
+ const t = Math.floor(now / 120); // the indicator's colour periods are in 120 ms ticks
67
+ const st = state || (act?.tone === 'wait' ? 'waiting' : indicatorState(act ? act.word : activity, failing));
68
+ // A new word decodes in over DECODE_MS; remember when the current word first appeared.
69
+ const wordKey = act ? act.word : null;
70
+ const seenRef = React.useRef({ key: wordKey, at: Date.now() });
71
+ if (seenRef.current.key !== wordKey) seenRef.current = { key: wordKey, at: now };
72
+ const flashing = Boolean(act?.flash) && now - act.flash < 800;
73
+ const secs = Math.max(0, Math.floor((now - since) / 1000));
74
+ 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
75
+ // The word is ALL colour: one gradient sweeps across it (sky/violet/teal thinking, green writing, amber
76
+ // waiting, red failed). While working it rains through itself (indicator.wordFrame): each letter on its own
77
+ // clock, ~30% scrambled at a time, cascades of 2..4 half-width katakana resolving with a flash. Reduced
78
+ // motion: a still, coloured, plain word. NO_COLOR: the motion stays, the colour goes. Elapsed, tokens and
79
+ // hints are never scrambled.
80
+ const plain = clip(stateWord(st, act ? act.word : activity), 40);
81
+ const scramble = st === 'working' && !quiet;
82
+ const cst = st === 'working' && (act ? act.word : activity) === 'Writing' ? 'writing' : st;
83
+ const cells = scramble ? wordFrame(plain, now, { since: seenRef.current.at }) : [...plain].map(ch => ({ ch, glyph: false, flash: false }));
84
+ const n = cells.length;
85
+ const wordNodes = cells.map((g, i) => h(Text, {
86
+ key: i, bold: g.flash,
87
+ color: mono() ? undefined : `rgb(${wordColor(cst, i, n, now, g.flash ? 'flash' : g.glyph ? 'glyph' : 'letter', quiet).join(',')})`
88
+ }, g.ch));
89
+ const ell = st === 'failing' ? null : h(Text, { key: 'e', color: mono() ? undefined : `rgb(${wordColor(cst, n, n + 1, now, 'letter', quiet).join(',')})` }, '…');
90
+ const detail = act?.detail && st !== 'failing' ? clip(act.detail, 28) : '';
91
+ const word = h(Text, { wrap: 'truncate-end' }, h(Text, null, ...wordNodes, ell),
92
+ detail ? h(Text, { color: c('dim') }, ` · ${detail} · `) : (st === 'failing' ? '' : ' '));
93
+ const { stdout: so } = useStdout();
94
+ const narrow = (so?.columns || 100) < 60; // keep the line on one row: short hint, tokens still shown
95
+ const tok = tokens > 0 ? ` · ↓ ${fmtTokens(tokens)} tokens` : '';
96
+ const tail = st === 'failing' ? '' : `${secs}s${tok} · ${interrupting ? 'stopping; waiting for the server to confirm' : (narrow ? 'Esc' : (escLabel || 'Esc to stop'))}`;
97
+ return h(Box, { paddingLeft: 2 }, q, h(Text, null, ' '), word, h(Text, { color: c('dim') }, tail));
98
+ };
99
+
100
+ // ---- drop-in reveals (motion/reveal.js) ------------------------------------------------------------
101
+ const KANA_RGB = [hexRgb('#38bdf8'), [58, 66, 82]];
102
+ const GLOW_RGB = [110, 231, 183];
103
+ /**
104
+ * Render segments [{ t, color, bold }] as Text nodes. With `fx` = { e, timing } (seconds since the item
105
+ * landed) the whole row decodes in along one edge; without it the output is exactly the plain segments.
106
+ */
107
+ export function fxNodes(segs, fx, salt = 1) {
108
+ const live = segs.filter(x => x && x.t);
109
+ 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));
110
+ const total = live.reduce((n, x) => n + graphemes(x.t).length, 0);
111
+ const runs = [];
112
+ let offset = 0;
113
+ for (const x of live) {
114
+ const resolved = x.color ? c(x.color) : undefined;
115
+ const base = typeof resolved === 'string' && /^#[0-9a-f]{6}$/i.test(resolved) ? hexRgb(resolved) : null; // palette role, hex or ansi256
116
+ for (const cell of revealCells(x.t, fx.e, fx.timing, { offset, total, salt })) {
117
+ let color, bold = false;
118
+ if (cell.kind === 'kana') color = mono() ? undefined : rgbStr(mix(KANA_RGB[1], KANA_RGB[0], 0.35 + 0.65 * cell.g));
119
+ else if (cell.kind === 'glow') { color = base ? rgbStr(mix(base, GLOW_RGB, 0.5 * cell.g)) : undefined; bold = cell.bold || Boolean(x.bold); }
120
+ else if (cell.kind === 'plain') { color = x.color ? c(x.color) : undefined; bold = Boolean(x.bold); }
121
+ const last = runs[runs.length - 1];
122
+ if (last && last.color === color && last.bold === bold) last.t += cell.ch; else runs.push({ t: cell.ch, color, bold });
123
+ }
124
+ offset += graphemes(x.t).length;
125
+ }
126
+ return runs.map((r, i) => h(Text, { key: i, color: r.color, bold: r.bold }, r.t));
127
+ }
128
+
129
+ // ---- rows ------------------------------------------------------------------------------------
130
+ const STATUS_GLYPH = { running: '◐', ok: '✔', failed: '✖', denied: '⊘', effect_unknown: '⚠', unknown: '?' };
131
+ const STATUS_TONE = { running: 'sky', ok: 'ok', failed: 'fail', denied: 'warn', effect_unknown: 'warn', unknown: 'dim' };
132
+ const PROTO = { aee: ['⛓', 'AEE'], aocl: ['▤', 'AOCL'], volt: ['▣', 'VOLT'], ward: ['◉', 'WARD'] };
133
+
134
+ export const fmtDuration = (ms) => (ms == null ? '' : ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`);
135
+
136
+ export const Row = ({ glyph, tone, name, body, tail, cols }) => {
137
+ const nameW = cols >= 70 ? 14 : 10;
138
+ const used = 2 + 2 + nameW + 3;
139
+ const room = Math.max(8, cols - used);
140
+ const tailStr = tail ? clip(tail, Math.max(8, Math.floor(room / 2))) : '';
141
+ const bodyStr = clip(body, Math.max(4, room - (tailStr ? [...tailStr].length + 3 : 0)));
142
+ return h(Box, null,
143
+ h(Text, null, ' '), h(Text, { color: c(tone) }, glyph), h(Text, null, ' '),
144
+ h(Text, { bold: true, color: c('text') }, clip(name, nameW - 1).padEnd(nameW - 1)), h(Text, { color: c('dim') }, ' │ '),
145
+ h(Text, { color: c('text') }, bodyStr),
146
+ tailStr ? h(Text, { color: c('dim') }, ' │ ') : null,
147
+ tailStr ? h(Text, { color: c(tone) }, tailStr) : null);
148
+ };
149
+
150
+ // Tool ids are `<family>_<verb words>`. The family is the short label in the QuoxGlyph grammar
151
+ // (`✔ SEO │ summary │ 30 crawls · 2.4s`), never the whole id upper-cased.
152
+ const FAMILY = { quoxseo: 'SEO', quoxplan: 'PLAN', quoxsignal: 'SIGNAL', quoxflow: 'FLOW', lucid: 'LUCID', fleet: 'FLEET', memory: 'MEMORY', bastion: 'BASTION' };
153
+ export function toolLabel(id) {
154
+ const t = String(id || 'tool');
155
+ const i = t.indexOf('_');
156
+ const pre = (i > 0 ? t.slice(0, i) : t).toLowerCase();
157
+ const verb = i > 0 ? t.slice(i + 1).replace(/_+/g, ' ').trim() : '';
158
+ const family = FAMILY[pre] || (pre.startsWith('quox') && pre.length > 4 ? pre.slice(4) : pre).toUpperCase();
159
+ return { family, verb: verb || t };
160
+ }
161
+
162
+ const gw = (s) => graphemes(s).length;
163
+ /** Clip free text at a word or comma boundary; a single over-long token becomes just the ellipsis. */
164
+ function clipWords(text, max) {
165
+ const t = String(text ?? '').replace(/\s+/g, ' ').trim();
166
+ if (gw(t) <= max) return t;
167
+ if (max < 2) return '';
168
+ const gs = graphemes(t).slice(0, max - 1).map(g => g.text).join('');
169
+ const nextIsBreak = /[\s,]/.test(graphemes(t)[max - 1]?.text || '');
170
+ const cut = nextIsBreak ? gs : gs.replace(/[^\s,]*$/, '');
171
+ const kept = cut.replace(/[\s,]+$/, '');
172
+ return kept ? kept + '…' : '…';
173
+ }
174
+
175
+ /**
176
+ * A server result_summary fitted to `max` columns. `key: value, key: value` summaries read as
177
+ * `value key` (numbers) or `key: value`; `k_total` supersedes `k` (k is the page, the total is the truth);
178
+ * whole pairs are dropped from the end to fit (all of them when none fits), never cut mid-token.
179
+ * Other text clips at a word boundary.
180
+ */
181
+ export function fitResult(summary, max) {
182
+ const raw = String(summary ?? '').replace(/\s+/g, ' ').trim();
183
+ const parts = raw.split(/,\s*/);
184
+ const pairs = parts.map(p => p.match(/^([A-Za-z_][\w ]*?):\s*(.+)$/));
185
+ if (!raw || !pairs.every(Boolean)) return clipWords(raw, max);
186
+ const map = new Map(pairs.map(m => [m[1].trim(), m[2].trim()]));
187
+ const items = [];
188
+ for (const [k, v] of map) {
189
+ if (map.has(`${k}_total`)) continue;
190
+ const key = k.replace(/_total$/, '').replace(/_+/g, ' ');
191
+ items.push(/^-?\d+(\.\d+)?$/.test(v) ? `${v} ${key}` : `${key}: ${v}`);
192
+ }
193
+ for (let n = items.length; n > 0; n--) {
194
+ const joined = items.slice(0, n).join(', ');
195
+ if (gw(joined) <= max) return joined;
196
+ }
197
+ return ''; // not even one whole pair fits: show none rather than a value without its key
198
+ }
199
+
200
+ export function ToolRow({ b, cols, fx = null, pulse = null }) {
201
+ const glyph = STATUS_GLYPH[b.status] || '◐';
202
+ const tone = STATUS_TONE[b.status] || 'sky';
203
+ const dur = fmtDuration(b.durationMs);
204
+ const { family, verb } = toolLabel(b.tool);
205
+ const name = clip(family, 10);
206
+ const body0 = b.summary && b.summary !== b.tool ? b.summary : verb;
207
+ const room = Math.max(4, cols - 2 - 2 - gw(name) - 3); // indent, glyph + space, family, ' │ '
208
+ const running = b.status === 'running';
209
+ // the facts after the result: duration, then witnessing state
210
+ let after = [];
211
+ if (!running && b.receipt) {
212
+ const v = b.receipt.verified;
213
+ after = [dur, v === true ? 'witnessed' : v === false ? 'unverified' : 'verification pending', `receipt ward:${b.receipt.ward_seq}`].filter(Boolean);
214
+ } else if (!running) {
215
+ after = [dur, b.envelopeId ? `envelope ${String(b.envelopeId).slice(0, 8)}` : '', b.envelopeId ? 'witnessing pending' : ''].filter(Boolean);
216
+ }
217
+ const result = running ? '' : (b.resultSummary ? `${b.status === 'unknown' ? 'unknown: ' : ''}${b.resultSummary}` : b.status);
218
+ const bodyStr = clipWords(body0, running || !result ? room : Math.max(6, Math.min(gw(body0), Math.floor(room / 2))));
219
+ const tailRoom = room - gw(bodyStr) - 3;
220
+ let tail = '';
221
+ if (!running && tailRoom > 0) {
222
+ const facts = after.join(' · ');
223
+ const resRoom = tailRoom - (facts ? gw(facts) + 3 : 0);
224
+ const res = resRoom >= 4 ? fitResult(result, resRoom) : '';
225
+ tail = clipWords([res, facts].filter(Boolean).join(' · '), tailRoom);
226
+ }
227
+ const verified = b.receipt?.verified === true;
228
+ const glyphColor = b.receipt && !running ? (verified ? 'ok' : 'warn') : tone;
229
+ // Running: one glyph whose colour pulses sky <-> violet in OKLab (never a spinner of different glyphs).
230
+ const runColor = running && pulse != null && !mono() ? rgbStr(mixOk(hexRgb(COLORS.sky), hexRgb(COLORS.violet), pulse01(pulse, 1200))) : null;
231
+ const tailRuns = tail ? paintRuns(tail, tone === 'ok' ? 'value' : tone).map(r => (/^(witnessed)$/.test(r.text.trim()) ? { ...r, role: 'ok' } : r)) : [];
232
+ const head = [{ t: ' ' }, { t: glyph, color: runColor || glyphColor }, { t: ' ' }, { t: name, color: agentColor(name), bold: true }, { t: ' │ ', color: 'muted' }, { t: bodyStr, color: 'text' }];
233
+ const tailSegs = tail ? [{ t: ' │ ', color: 'muted' }, ...tailRuns.map(r => ({ t: r.text, color: r.role }))] : [];
234
+ // Settle (running -> result): the glyph flashes once and the result decodes in behind it.
235
+ if (fx?.settle != null && !running) {
236
+ const flash = fx.settle < 0.12 && !mono();
237
+ if (flash) head[1] = { t: glyph, color: '#ffffff', bold: true };
238
+ return h(Box, null, ...fxNodes(head, null), ...fxNodes(tailSegs, { e: fx.settle, timing: TIMING.settle }, 7));
239
+ }
240
+ return h(Box, null, ...fxNodes([...head, ...tailSegs], fx, 3));
241
+ }
242
+
243
+ /** 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. */
244
+ export function compactTrace(text) {
245
+ return String(text || '')
246
+ .replace(/\bquox\.(chat\.)?/g, '')
247
+ .replace(/\bcorr\s+([A-Za-z0-9_-]{10,})/g, (m, id) => `corr …${id.slice(-6)}`)
248
+ .replace(/\s+/g, ' ').trim();
249
+ }
250
+
251
+ // Protocol stage colours: the palette's proto(name) role when theme.js provides one, else these.
252
+ const PROTO_FALLBACK = { aee: '#06b6d4', aocl: '#a855f7', volt: '#f59e0b', ward: '#4ade80' };
253
+ export const protoColor = (name) => (typeof themeNs.proto === 'function' && themeNs.proto(name)) || PROTO_FALLBACK[name] || COLORS.dim;
254
+ const BG_DIM = [58, 66, 82];
255
+
256
+ /**
257
+ * The protocol icon's colour. Icons never change glyph, only colour (owner 2026-10-12): `pulse` (clock ms) while
258
+ * this is the active stage = a smooth OKLab pulse in the stage colour; `fx` (arrival) = brightening in, with a
259
+ * one-frame glint for the WARD seal; otherwise the steady stage colour.
260
+ */
261
+ function protoIconColor(protocol, { pulse = null, fx = null } = {}) {
262
+ if (mono()) return undefined;
263
+ const raw = protoColor(protocol);
264
+ if (!/^#[0-9a-f]{6}$/i.test(String(raw))) return raw; // 256-colour fallback: steady, no pulse
265
+ const col = hexRgb(raw);
266
+ if (pulse != null) return rgbStr(mixOk(mixOk(col, BG_DIM, 0.55), mixOk(col, [255, 255, 255], 0.3), pulse01(pulse, 1200)));
267
+ if (fx && fx.e != null && fx.e < 0.36) {
268
+ if (protocol === 'ward' && fx.e >= 0.3) return '#ffffff';
269
+ return rgbStr(mixOk(BG_DIM, col, clamp01(fx.e / 0.3)));
270
+ }
271
+ return rgbStr(col);
272
+ }
273
+
274
+ export function TraceRow({ b, cols, fx = null, pulse = null }) {
275
+ const [g, label] = PROTO[b.protocol] || ['·', String(b.protocol || 'TRACE').toUpperCase()];
276
+ const ref = b.ref != null ? ` · ${String(b.ref).slice(0, 8)}` : '';
277
+ const body = compactTrace(`${b.label || ''}${b.detail ? ' · ' + b.detail : ''}`) + ref;
278
+ const head = ` ${g} ${label.padEnd(5)}│ `;
279
+ const afterIcon = head.slice(2 + g.length);
280
+ const room = Math.max(10, cols - [...head].length - 1);
281
+ // icon (colour only, never another glyph) + label in the protocol's own shade, the rest muted
282
+ const icon = h(Text, { color: PROTO[b.protocol] ? protoIconColor(b.protocol, { pulse, fx }) : c('muted') }, g);
283
+ const labelSeg = ' ' + label;
284
+ const labelNode = h(Text, { color: proto(b.protocol) }, labelSeg);
285
+ if ([...body].length <= room) {
286
+ if (!fx) return h(Box, null, h(Text, null, ' ', icon, labelNode, h(Text, { color: c('muted') }, afterIcon.slice(labelSeg.length) + body)));
287
+ 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));
288
+ }
289
+ // Too long even when compacted: wrap onto indented lines (at most 4) rather than hiding the end.
290
+ const chars = [...body]; const pad = ' '.repeat([...head].length); const lines = [];
291
+ for (let i = 0; i < chars.length && lines.length < 4; i += room) lines.push((lines.length ? pad : afterIcon) + chars.slice(i, i + room).join(''));
292
+ if (chars.length > room * 4) lines[3] = clip(lines[3], [...head].length + room);
293
+ return h(Box, { flexDirection: 'column' }, ...lines.map((l, i) => (i === 0
294
+ ? h(Text, { key: i }, ' ', icon, labelNode, h(Text, { color: c('muted') }, l.slice(labelSeg.length)))
295
+ : h(Text, { key: i, color: c('muted') }, l))));
296
+ }
297
+
298
+ export function HelpView({ model, cols }) {
299
+ const keyW = Math.min(24, Math.max(...model.filter(m => m.key).map(m => [...m.key].length), 8) + 2);
300
+ return h(Box, { flexDirection: 'column', marginTop: 1, width: cols }, ...model.map((m, i) => {
301
+ if (m.kind === 'gap') return h(Text, { key: i }, ' ');
302
+ 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 });
303
+ if (m.kind === 'row' || (m.kind === 'dim' && m.key)) {
304
+ const dim = m.kind === 'dim';
305
+ return h(Box, { key: i },
306
+ h(Box, { width: keyW + 2, flexShrink: 0 }, h(Text, { color: c('muted') }, ' ', ...(dim ? [m.key] : runs(paintRuns(m.key, 'key').map(r => (r.role === 'muted' || r.role === 'key' ? r : { ...r, role: /^\s*(just type|paste|status line)\s*$/.test(m.key) ? 'label' : 'key' })))))),
307
+ h(Box, { flexGrow: 1, flexShrink: 1 }, h(Text, { color: c(dim ? 'muted' : 'text') }, ...(dim ? [m.text] : runs(paintRuns(m.text, 'text'))))));
308
+ }
309
+ return h(Text, { key: i, color: c(m.kind === 'dim' ? 'muted' : 'text') }, ...runs(paintRuns(m.text, m.kind === 'dim' ? 'muted' : 'text')));
310
+ }));
311
+ }
312
+
313
+ export function WelcomeView({ version, cols }) {
314
+ const LABEL = 10;
315
+ return h(Box, { flexDirection: 'column', marginTop: 1, width: cols },
316
+ ...welcomeModel(version).map((m, i) => {
317
+ switch (m.kind) {
318
+ case 'title': return h(Heading, { key: i, text: m.text });
319
+ case 'sub': return h(Text, { key: i, color: c('muted') }, ...runs(paintRuns(m.text, 'muted')));
320
+ case 'rule': return h(Text, { key: i, color: c('dim') }, '─'.repeat(cols));
321
+ case 'field': if (m.link && [...m.value].length > cols - LABEL) { // a URL is never split: stack it under its label
322
+ return h(Box, { key: i, flexDirection: 'column' }, h(Text, { color: c('label') }, m.label), h(Text, { color: c('link'), underline: true }, m.value));
323
+ }
324
+ return h(Box, { key: i },
325
+ h(Box, { width: LABEL, flexShrink: 0 }, h(Text, { color: c('label') }, m.label)),
326
+ h(Box, { flexGrow: 1, flexShrink: 1 }, h(WText, { color: c(m.link ? 'link' : 'value'), underline: Boolean(m.link) && !mono() }, m.value)));
327
+ case 'hint': return h(WText, { key: i, color: c('muted') }, ...runs(paintRuns(m.text, 'muted')));
328
+ default: return h(WText, { key: i, color: c('text') }, m.text);
329
+ }
330
+ }));
331
+ }
332
+
333
+ export const INTRO_FRAMES = 18; // INTRO_MS (900) at 50 ms frames on the shared clock: Orbit & Lock on the lab's timing
334
+ /** Animate the intro? Only on a real terminal, never under reduced motion or QUOX_TUI_LITE. NO_COLOR animates in mono. */
335
+ export const introAnimates = (isTTY) => motionAllowed(isTTY);
336
+
337
+ /**
338
+ * Text rows of the Q for a terminal size and progress (0..1 of the plan; 1 = settled). Lab sizing (labSize: the
339
+ * compact particle Q under 60 cols or 12 rows) and the lab timeline (introPlan: Orbit & Lock in INTRO_MS, then
340
+ * the Dragon's Eye when the launch egg fired). Exported so tests can assert on frames.
341
+ */
342
+ export function introArt(cols, rows, progress = 1, color = !mono(), { eye = false } = {}) {
343
+ const size = labSize(cols, rows);
344
+ const plan = introPlan(size, { eye, color });
345
+ const t = progress >= 1 ? Infinity : Math.max(0, progress) * plan.ms;
346
+ return { size, w: qDims(size).cols, ms: plan.ms, lines: cellsToAnsi(t === Infinity ? qCells(size, { color }) : plan.at(t)) };
347
+ }
348
+
349
+ export function Intro({ item, cols, progress = 1, rows, eye = false }) {
350
+ const { stdout } = useStdout();
351
+ const art = introArt(cols, rows ?? termRows(stdout) ?? 40, progress, !mono(), { eye });
352
+ const wide = cols >= 60;
353
+ const info = h(Box, { flexDirection: 'column', justifyContent: 'center', marginLeft: wide ? 2 : 0, marginTop: wide ? 0 : 1 },
354
+ h(Grad, { text: 'Quox' }),
355
+ h(Text, { color: c('dim') }, clip(item.text, cols - (wide ? art.w + 4 : 2))),
356
+ h(Text, { color: c('dim') }, 'Talk to Quox. /help for everything.'));
357
+ 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);
358
+ }
359
+
360
+ /** Reveal timing per item kind (null = never revealed: user input, answer text, intro, help, welcome). */
361
+ export function revealTimingFor(item) {
362
+ switch (item?.kind) {
363
+ case 'tool': return TIMING.row;
364
+ case 'trace': return TIMING.dim;
365
+ case 'note': case 'summary': case 'error': return TIMING.dim;
366
+ case 'object': return TIMING.title; // inline object cards decode in on the shared clock (objects/view.js)
367
+ default: return null;
368
+ }
369
+ }
370
+
371
+ export function HistoryItem({ item, cols, live = false, fx = null, pulse = null }) {
372
+ switch (item.kind) {
373
+ case 'intro': return h(Intro, { item, cols });
374
+ case 'user': return h(Box, { marginTop: 1, width: cols }, // gutter box + wrapping text box = hanging indent
375
+ h(Box, { width: 2, flexShrink: 0 }, h(Text, { color: c('sky'), bold: true }, item.queued ? '» ' : '› ')),
376
+ h(Box, { flexGrow: 1, flexShrink: 1 }, h(WText, { color: c('input') }, item.text)));
377
+ case 'welcome': return h(WelcomeView, { version: item.version, cols });
378
+ case 'help': return h(HelpView, { model: item.model, cols });
379
+ case 'object': return h(ObjectView, { item, cols, fx });
380
+ case 'summary': { // the closing line wraps at its ' · ' separators: no fact (model, receipt id) is ever cut off
381
+ const ls = noteLines(item.text, cols - 4).map(l => clip(l, cols - 4));
382
+ const segs = (l) => paintRuns(l, 'muted').map(r => ({ t: r.text, color: r.role || 'muted' }));
383
+ return h(Box, { paddingLeft: 2, flexDirection: 'column' }, ...ls.map((l, i) => (fx
384
+ ? h(Text, { key: i }, ...fxNodes(segs(l), { e: fx.e - i * 0.05, timing: fx.timing }, 11 + i))
385
+ : h(Text, { key: i, color: c('dim') }, ...runs(paintRuns(l, 'muted'))))));
386
+ }
387
+ case 'text': return h(Box, { marginTop: 1, paddingLeft: 2, flexDirection: 'column' }, h(Markdown, { text: item.text, cols: cols - 2, live }));
388
+ case 'tool': return h(ToolRow, { b: item, cols, fx, pulse });
389
+ case 'trace': return h(TraceRow, { b: item, cols, fx, pulse });
390
+ case 'error': return h(Box, { paddingLeft: 2 }, fx ? h(Text, null, ...fxNodes([{ t: `✖ ${clip(item.text, cols - 6)}`, color: 'fail' }], fx, 13)) : h(Text, { color: c('fail') }, `✖ ${clip(item.text, cols - 6)}`));
391
+ case 'note': return h(NoteView, { item, cols, fx });
392
+ default: return null;
393
+ }
394
+ }
395
+
396
+ // ---- notes ---------------------------------------------------------------------------------------
397
+ const SEP = ' · ';
398
+ /** Lines of a note at `width`: one line when it fits; a ' · ' list breaks only at separators. */
399
+ export function noteLines(text, width) {
400
+ const t = String(text ?? '');
401
+ if (!t.includes(SEP) || t.includes('\n') || wrapRows(t, width) === 1) return [t];
402
+ const lines = [];
403
+ let cur = '';
404
+ for (const seg of t.split(SEP)) {
405
+ const next = cur ? cur + SEP + seg : seg;
406
+ if (!cur || graphemes(next).length <= width) cur = next;
407
+ else { lines.push(cur); cur = seg; }
408
+ }
409
+ lines.push(cur);
410
+ return lines;
411
+ }
412
+ export const noteRows = (text, cols) => noteLines(text, cols - 2).reduce((n, l) => n + wrapRows(l, cols - 2), 0);
413
+
414
+ function NoteView({ item, cols, fx = null }) {
415
+ const tone = item.tone === 'warn' ? 'warn' : item.tone === 'ok' ? 'ok' : item.tone === 'accent' ? 'sky' : 'dim';
416
+ const color = c(tone);
417
+ // While it reveals, a line that fits decodes in; a line that wraps keeps plain wrapping (never mis-measured).
418
+ return h(Box, { paddingLeft: 2, flexDirection: 'column', width: cols }, ...noteLines(item.text, cols - 2).map((l, i) => (fx && wrapRows(l, cols - 2) === 1
419
+ ? h(Text, { key: i }, ...fxNodes(paintLine(l, item.tone).map(r => (typeof r === 'string' ? { t: r, color: tone } : { t: r.text, color: r.role || tone })), { e: fx.e - i * 0.05, timing: fx.timing }, 17 + i))
420
+ : h(WText, { key: i, color }, ...runs(paintLine(l, item.tone))))));
421
+ }
422
+
423
+ /** Rows a live block takes as HistoryItem renders it (sizes the live region; keep in step with HistoryItem). */
424
+ export function liveRows(blocks, cols) {
425
+ return blocks.reduce((n, b) => {
426
+ switch (b.kind) {
427
+ case 'text': return n + 1 + markdownRows(b.text, cols - 2, true);
428
+ case 'note': return n + noteRows(b.text, cols);
429
+ case 'summary': return n + noteLines(b.text, cols - 4).length;
430
+ default: return n + 1;
431
+ }
432
+ }, 0);
433
+ }
434
+
435
+ // ---- composer --------------------------------------------------------------------------------
436
+ export const Grad = ({ text }) => {
437
+ if (mono()) return h(Text, { bold: true }, text);
438
+ const chars = [...text];
439
+ return h(Text, { bold: true }, ...chars.map((ch, i) => {
440
+ const k = chars.length > 1 ? i / (chars.length - 1) : 0;
441
+ return h(Text, { key: i, color: `rgb(${G0.map((v, j) => Math.round(v + (G1[j] - v) * k)).join(',')})` }, ch);
442
+ }));
443
+ };
444
+
445
+ /** Pure: the rule split around a right-aligned session name: `───── name ─`. Below 30 cols, or no name: plain. */
446
+ export function ruleParts(cols, name) {
447
+ if (!name || cols < 30) return { left: '─'.repeat(cols), label: '', right: '' };
448
+ const label = ` ${clip(name, cols - 8)} `;
449
+ return { left: '─'.repeat(Math.max(1, cols - [...label].length - 1)), label, right: '─' };
450
+ }
451
+
452
+ /**
453
+ * A ring rule segment as ONE pre-coloured string (not a Text per cell): the gradient is quantised to steps of
454
+ * RING_Q per channel so neighbouring cells share a colour run. Cuts the escapes Ink must tokenise per frame.
455
+ */
456
+ const RING_Q = 12;
457
+ function ringString(str, rgbs, from = 0) {
458
+ let out = '', run = '', key = null;
459
+ const flush = () => { if (run) out += chalk.rgb(...key.split(',').map(Number))(run); run = ''; };
460
+ [...str].forEach((ch, i) => {
461
+ const k = rgbs[from + i].map(v => Math.max(0, Math.min(255, Math.round(v / RING_Q) * RING_Q))).join(',');
462
+ if (k !== key) { flush(); key = k; }
463
+ run += ch;
464
+ });
465
+ flush();
466
+ return out;
467
+ }
468
+
469
+ /**
470
+ * A composer rule. `ring` (motion only, never under NO_COLOR) = { state, top }: the rule becomes half of the
471
+ * light ring (motion/ring.js); the session name stays bright on top of it.
472
+ */
473
+ export const Rule = ({ cols, color, name, ring = null }) => {
474
+ const p = ruleParts(cols, name);
475
+ if (ring && ring.state && ring.state.k > 0 && !mono()) {
476
+ const rgbs = ruleColors(cols, ring.top, ring.state, COLORS[color] || color);
477
+ if (!p.label) return h(Text, null, ringString(p.left, rgbs));
478
+ const ls = [...p.left].length, lb = [...p.label].length;
479
+ return h(Text, null, ringString(p.left, rgbs), h(Text, { color: c('violet'), bold: true }, p.label), ringString(p.right, rgbs, ls + lb));
480
+ }
481
+ if (!p.label) return h(Text, { color: c(color) }, p.left);
482
+ return h(Text, null, h(Text, { color: c(color) }, p.left), h(Text, { color: c('label'), bold: true }, p.label), h(Text, { color: c(color) }, p.right));
483
+ };
484
+
485
+ /** Split the buffer into visible lines with an inverse cursor cell. */
486
+ export function composerLines(buf, maxLines = 8) {
487
+ const lines = buf.text.split('\n');
488
+ const { line: cl, col } = lineCol(buf.text, buf.cursor);
489
+ let from = 0;
490
+ if (lines.length > maxLines) from = Math.min(Math.max(0, cl - Math.floor(maxLines / 2)), lines.length - maxLines);
491
+ return lines.slice(from, from + maxLines).map((text, i) => {
492
+ const idx = from + i;
493
+ if (idx !== cl) return { text, cursor: null, key: idx };
494
+ const gs = graphemes(text);
495
+ 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('') };
496
+ });
497
+ }
498
+
499
+ /**
500
+ * The composer with its light ring. `ring` = { ref, active, mode } (motion + colour only): this wrapper steps
501
+ * the ring on every clock frame itself, so a frame re-renders the composer, not the app.
502
+ */
503
+ export function RingComposer({ ring: ringIn, ...props }) {
504
+ const now = useFrame();
505
+ const ring = ringIn ? (ringIn.ref.current = ringStep(ringIn.ref.current, { active: ringIn.active, mode: ringIn.mode, now })) : null;
506
+ return h(Composer, { ...props, ring });
507
+ }
508
+
509
+ /** `ring` = a ring state from motion/ring.js, or null for the static rules. Hook-free (pure render). */
510
+ export function Composer({ buf, cols, mode, busy, placeholder, name, ring = null }) {
511
+ const empty = buf.text === '';
512
+ const lines = composerLines(buf);
513
+ const body = empty
514
+ ? [h(Box, { key: 'p' }, h(Text, { color: c('sky'), bold: true }, '› '), h(Text, { inverse: true }, ' '), h(Text, { color: c('placeholder') }, clip(placeholder, cols - 5)))]
515
+ : lines.map((l, i) => h(Box, { key: l.key },
516
+ h(Text, { color: c('sky'), bold: true }, i === 0 ? '› ' : ' '),
517
+ l.at === undefined
518
+ ? h(Text, { color: c('input') }, l.text || ' ')
519
+ : h(Text, { color: c('input') }, l.before, h(Text, { inverse: true }, l.at), l.after)));
520
+ return h(Box, { flexDirection: 'column', width: cols },
521
+ h(Rule, { cols, color: MODE_COLOR[mode] || 'sky', name, ring: ring ? { state: ring, top: true } : null }),
522
+ ...body,
523
+ h(Rule, { cols, color: 'dim', ring: ring ? { state: ring, top: false } : null }));
524
+ }
525
+
526
+ export function Pending({ pending, steering, cols }) {
527
+ if (!pending.length && !steering.length) return null;
528
+ return h(Box, { flexDirection: 'column', paddingLeft: 2 },
529
+ ...steering.map(s => h(Text, { key: 's' + s.id, color: c('sky') }, `↳ steering: ${clip(s.text, cols - 16)}`)),
530
+ ...pending.map((p, i) => h(Text, { key: 'q' + p.id, color: c('dim') }, `» queued ${i + 1}: ${clip(p.text, cols - 16)}`)),
531
+ pending.length ? h(Text, { key: 'hint', color: c('dim') }, 'Up edits the last queued message') : null);
532
+ }
533
+
534
+ /** A rule drawing itself in left to right over `dur` seconds (approval card arrival). Same width every frame. */
535
+ function DrawRule({ cols, color, e, dur = 0.25 }) {
536
+ const n = Math.round(clamp01(e / dur) * cols);
537
+ if (n >= cols) return h(Rule, { cols, color });
538
+ const edge = n > 0 && !mono() ? h(Text, { color: '#ffffff' }, '─') : (n > 0 ? h(Text, null, '─') : null);
539
+ return h(Text, null, h(Text, { color: c(color) }, '─'.repeat(Math.max(0, n - 1))), edge, ' '.repeat(cols - n));
540
+ }
541
+
542
+ export const ARM_BAR = 6;
543
+ /** `motionE` = seconds since the card appeared, when motion is on: the rule draws in, the title decodes, the arming bar fills. */
544
+ export function ApprovalCard({ card, org, cols, armed, draftHint = null, motionE = null }) {
545
+ const rows = card.undescribable ? [['tool', card.tool], ['org', org]] : [
546
+ ['tool', card.tool], ['target', card.target], ['scope', card.scope], ['risk', card.risk], ['org', org]
547
+ ].filter(r => r[1]);
548
+ const e = motionE;
549
+ const title = '⚠ Approval needed: execution is held';
550
+ const filled = e == null ? 0 : Math.min(ARM_BAR, Math.floor(clamp01(e / 0.3) * ARM_BAR));
551
+ const arming = e == null || mono()
552
+ ? h(Text, { color: c('dim') }, e == null ? ' arming…' : ` arming… ${'▰'.repeat(filled)}${'▱'.repeat(ARM_BAR - filled)}`)
553
+ : 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)));
554
+ return h(Box, { flexDirection: 'column', width: cols, marginTop: 1 },
555
+ e == null ? h(Rule, { cols, color: 'warn' }) : h(DrawRule, { cols, color: 'warn', e }),
556
+ e == null ? h(Text, { color: c('warn'), bold: true }, title) : h(Text, null, ...fxNodes([{ t: title, color: 'warn', bold: true }], { e: e - 0.1, timing: TIMING.title }, 23)),
557
+ ...rows.map(([k, v]) => h(Text, { key: k, color: c('text') }, ' ', h(Text, { color: c('label') }, k.padEnd(7)), h(Text, { color: c(k === 'risk' ? 'warn' : 'value') }, clip(v, cols - 10)))),
558
+ card.undescribable ? h(Text, { color: c('warn'), bold: true }, ' Quox could not describe this action') : null,
559
+ draftHint === 'draft' ? h(Text, { color: c('warn') }, ' clear your draft or press Tab to act on this approval') : null,
560
+ draftHint === 'focus' ? h(Text, { color: c('warn') }, ' card focused (Esc returns to your draft)') : null,
561
+ !armed ? arming : h(Text, { color: c('muted') }, ...runs(paintLine(card.undescribable ? ' type the word approve to allow · d deny (no default)'
562
+ : ' a approve · d deny · o open in dashboard (no default)'))),
563
+ e == null ? h(Rule, { cols, color: 'warn' }) : h(DrawRule, { cols, color: 'warn', e }));
564
+ }
565
+
566
+ /** Columns the status line's Q takes: 2 for the Braille glyph, 1 for the Ⓠ fallback. */
567
+ export const statusGlyphWidth = () => glyphStill('resting', { mono: mono(), mode: glyphMode(process.env) }).width;
568
+
569
+ /**
570
+ * Fit the status line to the width: fields are dropped by priority (org, then the key hint, then mode,
571
+ * then profile) and joined by separators, never clipped mid-word or glued to the hint. Pure.
572
+ */
573
+ export function statusLayout({ cols, slot, agent, parts, hint, qw = statusGlyphWidth() }) {
574
+ const room = Math.max(10, cols - 1);
575
+ const ident = slot ? `⬢ slot ${slot}` : '⬡ no slot';
576
+ const ag = agent ? `● ${agent}` : '○ no agent';
577
+ const order = ['org', 'rules', 'hint', 'mode', 'profile']; // first dropped first; model is kept longest
578
+ let keep = new Set(['profile', 'org', 'model', 'mode', 'hint', 'rules']);
579
+ const build = () => {
580
+ const tail = ['profile', 'org', 'model', 'mode', 'rules'].filter(k => keep.has(k) && parts[k]).map(k => parts[k]).join(' · ');
581
+ const left = ` Ⓠ ✦ ${ident} · ${ag}${tail ? ' · ' + tail : ''}`;
582
+ const hintS = keep.has('hint') && hint ? hint : '';
583
+ return { left, hint: hintS, width: [...left].length + (qw - 1) + (hintS ? 2 + [...hintS].length : 0) };
584
+ };
585
+ let out = build();
586
+ for (const k of order) { if (out.width <= room) break; keep.delete(k); out = build(); }
587
+ return out;
588
+ }
589
+
590
+ export function StatusLine({ cols, slot, agent, parts, right }) {
591
+ const p = Array.isArray(parts) ? { profile: parts[0], org: parts[1], model: parts[2], mode: parts[3], rules: parts[4] } : parts;
592
+ const L = statusLayout({ cols, slot, agent, parts: p, hint: right });
593
+ const gap = L.hint ? Math.max(2, cols - 1 - [...L.left].length - (statusGlyphWidth() - 1) - [...L.hint].length) : 0;
594
+ const lead = ' Ⓠ ✦ ';
595
+ const rest = L.left.slice(lead.length);
596
+ // The still Q (qglyph.js): two Braille cells, or Ⓠ under NO_COLOR / no Braille. statusLayout reserved its width.
597
+ const q = glyphStill('resting', { mono: mono(), mode: glyphMode(process.env), depth: colorDepth(process.env) });
598
+ return h(Box, { width: cols },
599
+ h(Text, { wrap: 'truncate-end' }, ' ', h(Text, { bold: true }, ...cellNodes(q.cells, 'sq')), h(Text, { color: c('icon') }, ' ✦ '),
600
+ h(Text, { color: c('text') }, ...statusIdent(rest.split(' · ').slice(0, 2).join(' · '))),
601
+ rest.split(' · ').length > 2 ? h(Text, { color: c('label') }, ...runs(paintRuns(' · ' + rest.split(' · ').slice(2).join(' · '), 'label'))) : null,
602
+ L.hint ? h(Text, { color: c('muted') }, ' '.repeat(gap), ...runs(paintRuns(L.hint, 'muted'))) : null));
603
+ }
604
+
605
+ /** `⬢ slot NAME · ● AGENT` as runs: glyphs in the icon role, `slot` a label, the agent in its own shade. */
606
+ function statusIdent(t) {
607
+ const m = t.match(/^(⬢|⬡) (slot |no slot)(.*?) · (●|○) (.*)$/);
608
+ if (!m) return [t];
609
+ const agent = m[4] === '●';
610
+ return [h(Text, { key: 'g', color: c(m[1] === '⬢' ? 'icon' : 'muted') }, m[1] + ' '), h(Text, { key: 's', color: c(m[3] ? 'label' : 'muted') }, m[2]),
611
+ m[3] ? h(Text, { key: 'n', color: c('value') }, m[3]) : null, h(Text, { key: 'd', color: c('muted') }, ' · '),
612
+ h(Text, { key: 'a', color: agent ? agentColor(m[5]) : c('muted') }, m[4] + ' '), h(Text, { key: 'an', color: agent ? agentColor(m[5]) : c('muted'), bold: agent }, m[5])];
613
+ }
614
+
615
+ /** Live, actionable counts under the status line. Never a transcript message. */
616
+ export function NotifyBar({ approvals, error, cols, signedOut = false, alerts = [] }) {
617
+ if (signedOut) return h(Text, { color: c('warn') }, ' ' + clip('not signed in: approvals unavailable until you sign in', cols - 2));
618
+ const count = approvals ? h(Text, { key: 'n', color: c('warn') }, ' ' + clip(`⛔ ${approvals} approval${approvals === 1 ? '' : 's'} waiting for you${error ? ' (last known)' : ''} · /approvals`, cols - 3)) : null;
619
+ // Background workflows that keep failing: one deduped line each (max 2), never roster rows.
620
+ const alertLines = alerts.slice(0, 2).map((t, i) => h(Text, { key: 'a' + i, color: c('fail') }, ' ' + clip(t, cols - 2)));
621
+ if (alertLines.length) {
622
+ return h(Box, { flexDirection: 'column' }, count, ...alertLines,
623
+ error ? h(Text, { key: 'e', color: c('warn') }, ' ' + clip('approvals unavailable: inbox not reachable, will retry', cols - 2)) : null);
624
+ }
625
+ if (!error) return count;
626
+ // A failed refresh is never "no approvals": say the inbox is unavailable, keep any last known count.
627
+ return h(Box, { flexDirection: 'column' }, count,
628
+ h(Text, { key: 'e', color: c('warn') }, ' ' + clip('approvals unavailable: inbox not reachable, will retry', cols - 2)));
629
+ }
630
+
631
+ const ROSTER_TONE = { running: 'sky', waiting: 'warn', done: 'ok', failed: 'fail' };
632
+ /** Live roster under the status line. `view` comes from roster.rosterView; `peek` is plain lines; `confirm` a question. */
633
+ export function Roster({ view, peek = [], confirm = null, hint = null, cols, fx = null }) {
634
+ if (view.mode === 'hidden') return null;
635
+ if (view.mode === 'summary') return h(Text, { color: c('dim'), wrap: 'truncate-end' }, view.summary);
636
+ return h(Box, { flexDirection: 'column', width: cols },
637
+ // glyph keeps the state colour; the rest of the line steps down in tone with position and (for finished rows) age
638
+ ...view.lines.map((l, i) => {
639
+ const e = fx && l.key != null ? fx(l.key) : null; // a row that just appeared decodes in (new rows land on top)
640
+ return h(Text, { key: 'r' + i, bold: l.selected, wrap: 'truncate-end' },
641
+ h(Text, { color: c(l.selected ? 'text' : ROSTER_TONE[l.state]) }, ' ' + l.marker + l.glyph),
642
+ 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)));
643
+ }),
644
+ view.more ? h(Text, { key: 'more', color: c('dim') }, ` +${view.more} more · /roster`) : null,
645
+ ...peek.map((p, i) => h(Text, { key: 'p' + i, color: c('dim'), wrap: 'truncate-end' }, ' ' + clip(p, cols - 5))),
646
+ confirm ? h(Text, { key: 'cf', color: c('warn') }, ' ' + clip(confirm, cols - 2)) : null,
647
+ hint ? h(Text, { key: 'h', color: c('dim') }, ' ' + clip(hint, cols - 2)) : null);
648
+ }