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,1262 @@
1
+ /**
2
+ * QUOXON app: composer + live turn. State lives in refs updated synchronously
3
+ * (so a stdin chunk carrying several keys applies in order) and mirrored into
4
+ * React state for rendering.
5
+ */
6
+ import React from 'react';
7
+ import { Box, Static, Text, measureElement, useApp, useStdin, useStdout } from 'ink';
8
+ import { spawnSync } from 'node:child_process';
9
+ import { mkdtempSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs';
10
+ import { tmpdir, hostname as osHostname } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { bufferReducer, initialBuffer, sanitizeForSend } from './textBuffer.js';
13
+ import { parseChunk, initialKeyState, flushDangling, hasDangling, PASTE_ON, PASTE_OFF, PASTE_DANGLING_MS } from './keys.js';
14
+ import { reduce, initialState, approvalKeyAllowed } from './events.js';
15
+ import { turnTokens } from './indicator.js';
16
+ import {
17
+ Composer, RingComposer, Pending, ApprovalCard, StatusLine, NotifyBar, Working, HistoryItem, liveRows, Intro, Roster, INTRO_FRAMES, introAnimates, describeActivity, clampCols, clip, COLORS, revealTimingFor
18
+ } from './render.js';
19
+ import { c, mono } from './theme.js';
20
+ import { findEntry, helpModel } from './commands.js';
21
+ import { osc52, pickCopy } from './copy.js';
22
+ import { detectSlotAgent } from './context.js';
23
+ import { rulesContext, rulesLabel, rulesLines } from './rules.js';
24
+ import { findSkill, skillRows, skillDetail, skillPrompt } from './skills.js';
25
+ import { rosterView, peekLines, selectRows, backgroundAlerts, isExpired } from './roster.js';
26
+ import { runObjectCommand } from './objects/index.js';
27
+ const OBJECT_CMDS = new Set(['/skills', '/agents', '/workflow', '/crystal', '/memory', '/rules', '/proof', '/reconcile']);
28
+ import { intentHint } from './intent.js';
29
+ import { observerLines } from './observers.js';
30
+ import { packageVersion } from './welcome.js';
31
+ import { INVITE, cleanName, loadNames, saveName, matchAgent } from './session.js';
32
+ import { termRows } from './term.js';
33
+ import { createConnectController, connectionLines, connectOfferFor, looksLikeKey, keyWarning, providerFor, parseOllamaArg, connectOllamaFlow, ollamaModelRows, connectCompatFlow, compatModelRows } from './connect.js';
34
+ import { ConnectCard } from './connectCard.js';
35
+ import { CHOICES, modelLines, selectModel, selectOllamaModel, selectCompatModel, modelLabel, fallbackNote, streamFields, normChoice } from './model.js';
36
+ import { overviewLines } from '../core/sessionsOverview.js';
37
+ import { capabilityLines, offerFor, findCapability } from './capabilities.js';
38
+ import { EyeLive, playEye, eyeActive } from './qmotion.js';
39
+ import { createSound } from './sound/index.js';
40
+ import { createPalette } from './slash/paletteController.js';
41
+ import { capabilityMap } from './slash/palette.js';
42
+ import { ListView } from './slash/listView.js';
43
+ import { createLedger, costModel } from './slash/ledger.js';
44
+ import { statusModel, contextModel, doctorModel, chatChecks } from './slash/panels.js';
45
+ import { createPicker } from './slash/picker.js';
46
+ import { resumeRows, pickerRow, matchConversation, turnsToHistory } from './slash/resume.js';
47
+ import { settingsList, applySettings, configRows, cycle } from './slash/settings.js';
48
+ import { startBuildWatch } from './slash/restart.js';
49
+ import { compactResult, compactError, contextFacts, compactShowLines } from './slash/compact.js';
50
+ import { intentPanel } from './intent.js';
51
+ import { launchEgg } from './egg.js';
52
+ import { INTRO_MS, EYE_MS } from './qart.js';
53
+ import { Clock, FRAME_MS, SECOND_MS, motionAllowed, createFrames } from './motion/clock.js';
54
+ import { FrameContext, useFrame } from './motion/frame.js';
55
+ import { ringMode, ringAnimating } from './motion/ring.js';
56
+ import { TIMING, revealSpan } from './motion/reveal.js';
57
+
58
+ const h = React.createElement;
59
+ const PASTE_GUARD_MS = 40;
60
+ const MAX_ROSTER = 4;
61
+ const ESC_ESC_MS = 500;
62
+ const INTERRUPT_FALLBACK_MS = 8000;
63
+ const INTRO_FRAME_MS = 50; // intro frame period on the shared clock (the plan's length is INTRO_MS, + the eye when the egg fires)
64
+ const HELD_WINDOW_MS = 1000; // a key seen this close before a card appears is treated as still held
65
+ const APPROVE_WORD = 'approve';
66
+ const CARD_GONE_MS = 400;
67
+ const CARD_EDIT_KEYS = ['backspace', 'delete', 'delete-word', 'kill-line', 'left', 'right', 'home', 'end', 'word-left', 'word-right', 'undo', 'redo'];
68
+
69
+
70
+ const STEER_OFF_NOTE = "This model can't take steering mid-turn; queued for next turn.";
71
+ const newTurnId = () => `QXN_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
72
+
73
+ function openEditor(text) {
74
+ const dir = mkdtempSync(join(tmpdir(), 'quoxon-'));
75
+ const file = join(dir, 'prompt.md');
76
+ writeFileSync(file, text, 'utf8');
77
+ const editor = process.env.VISUAL || process.env.EDITOR || 'vi';
78
+ const r = spawnSync(editor, [file], { stdio: 'inherit' });
79
+ let out = null;
80
+ if (!r.error) out = readFileSync(file, 'utf8').replace(/\n+$/, '');
81
+ try { unlinkSync(file); } catch { /* best-effort */ }
82
+ return out;
83
+ }
84
+
85
+ export const friendlyError = (e, profile) =>
86
+ (e?.status === 401 || /valid jwt|service key|unauthor/i.test(e?.message || ''))
87
+ ? `Not signed in for chat on profile ${profile}. Run: quox login -p ${profile}` : (e?.message || String(e));
88
+
89
+ const byId = (id) => CHOICES.find(x => x.id === id) || null;
90
+
91
+ export function App({ slotAgent, notifyMs = 4000, resultPollMs = 3000, resultTimeoutMs = 120000, rosterMs = 15000, api, profile = 'default', org = null, version = '0.1.0', cwd = '', columns, demoLabel = null, verbose = 'on', mode = 'ask', editor = openEditor, introNote = null, animateIntro, earlyInput = null, startup = null, blocked = null, autoplay = null, rules = null, welcome = false, sessionName = null, invite = false, namesPath, transcript = null, modelChoice = null, resumeConversation = null, resume = null, onRestart = null, restartCheck = null, startSha = null, buildWatch = startBuildWatch, env = process.env, motion, motionStats = null }) {
92
+ const { exit } = useApp();
93
+ const { stdin, setRawMode, isRawModeSupported } = useStdin();
94
+ const { stdout } = useStdout();
95
+ const [cols, setCols] = React.useState(clampCols(columns ?? ((stdout?.columns || 80) - 1)));
96
+ const [, bump] = React.useState(0);
97
+ const [epoch, setEpoch] = React.useState(0);
98
+
99
+ const introItem = { id: 'intro', kind: 'intro', text: `${version} · ${profile} · ${cwd}`, cols: clampCols(columns ?? ((process.stdout.columns || 80) - 1)) };
100
+ const introHistory = () => [introItem,
101
+ ...(welcome ? [{ id: 'welcome', kind: 'welcome', version: packageVersion() }] : []),
102
+ ...(demoLabel ? [{ id: 'demo', kind: 'note', text: demoLabel, tone: 'warn' }] : []),
103
+ ...(introNote ? [{ id: 'introNote', kind: 'note', text: introNote, tone: 'warn' }] : []),
104
+ ...(invite ? [{ id: 'invite', kind: 'note', text: INVITE }] : [])];
105
+ // The animated intro lives in the live region (no flicker, nothing printed twice) and moves into
106
+ // <Static> history once settled. Any keypress skips it; the composer is usable throughout.
107
+ const introAnimating = animateIntro ?? introAnimates(stdout?.isTTY);
108
+ // Motion (decorative animation) is decided once: a real terminal and no QUOX_REDUCED_MOTION / LITE. Colour
109
+ // is a separate setting (NO_COLOR); one never implies the other.
110
+ const motionOn = motion ?? motionAllowed(stdout?.isTTY);
111
+ const introStart = React.useRef(Date.now());
112
+ // Launch egg: rarely the Dragon's Eye after Orbit & Lock (egg.js decides once per launch; never reduced motion).
113
+ const eye = React.useMemo(() => (introAnimating && animateIntro === undefined ? launchEgg({ tty: Boolean(stdout?.isTTY) }) : false), []);
114
+ const introMs = INTRO_MS + (eye ? EYE_MS + 400 : 0);
115
+ const introDone = React.useRef(!introAnimating);
116
+ const finishIntro = () => {
117
+ if (introDone.current) return;
118
+ introDone.current = true;
119
+ const intro = introHistory();
120
+ S.current = { ...S.current, history: [...intro, ...S.current.history] };
121
+ committedRef.current = intro.length; // the intro itself is already on screen; later items may still reveal
122
+ bump(n => n + 1);
123
+ };
124
+ const finishIntroRef = React.useRef(finishIntro);
125
+ finishIntroRef.current = finishIntro;
126
+
127
+ const S = React.useRef(initialState({
128
+ verbose,
129
+ history: introAnimating ? [] : introHistory()
130
+ }));
131
+ const B = React.useRef(initialBuffer());
132
+ // Drop-in reveals: history items enter <Static> only once their reveal has ended (at most ~1 s); until then
133
+ // they render in the live region. committedRef = how many history items <Static> has been given.
134
+ const committedRef = React.useRef(introAnimating ? 0 : S.current.history.length);
135
+ const fxRef = React.useRef(new Map()); // item id -> { at, timing, running, settleAt }
136
+ const rosterSeen = React.useRef(new Map()); // roster row key -> first seen (ms)
137
+ const K = React.useRef({ keys: initialKeyState(), lastKeyAt: null, lastKeyCh: null, lastDataAt: 0, lastPasteAt: 0, lastEscAt: 0, ctrlC: 0, guard: null, cardFocus: false, cardGoneAt: 0, typed: '', danglingTimer: null });
138
+ const chromeRef = React.useRef(null);
139
+ const chromeH = React.useRef(null);
140
+ const R = React.useRef({ abort: null, histIdx: -1, history: [], stash: [], reviewing: false, fallbackTimer: null, ctrlCTimer: null, banner: null, closed: false, pollTimers: new Set(), caps: { steer: true, interrupt: true }, confirmMulti: null, roster: { rows: [], all: false, allRows: [], alerts: [], sources: null, on: true, focus: false, sel: 0, peek: false, confirm: null, stopping: false } });
141
+
142
+ const SND = React.useRef(null); SND.current ??= createSound({ api, stdout }); // QuoxSound: one subscription point (dispatch)
143
+ const dispatch = (a) => {
144
+ transcript?.action(a); // dev transcript capture (off by default; see transcript.js)
145
+ const prevS = S.current; S.current = reduce(prevS, { now: Date.now(), ...a }); SND.current.observe(prevS, S.current, a);
146
+ // Per shown card: remember the key that was already down when it appeared, and restart the typed word.
147
+ const c0 = S.current.approvals[0];
148
+ if (!c0 && K.current.guard) K.current.cardGoneAt = Date.now();
149
+ const gid = c0 ? `${c0.callId}:${c0.displayedAt}` : null;
150
+ if (gid !== K.current.guard?.id) {
151
+ const k = K.current;
152
+ const held = c0 && k.lastKeyAt != null && Date.now() - k.lastKeyAt < HELD_WINDOW_MS ? k.lastKeyCh : null;
153
+ k.guard = gid ? { id: gid, heldKey: held } : null;
154
+ k.typed = '';
155
+ k.cardFocus = false;
156
+ }
157
+ if (!R.current.closed) bump(n => n + 1);
158
+ };
159
+ const edit = (a) => { B.current = bufferReducer(B.current, a); if (!B.current.text) R.current.multiWarned = false; bump(n => n + 1); };
160
+ const say = (text, tone) => dispatch({ type: 'note', text, tone });
161
+ // /model: the --provider flag wins for this run ('auto' = ignore the saved choice), else the profile's saved choice.
162
+ const MC = React.useRef(modelChoice === 'auto' ? null : normChoice(modelChoice) || (api.modelChoice?.() ?? null));
163
+ // /connect card: owns its own secret box; the key never enters dispatch, the buffer or history.
164
+ const CN = React.useRef(null);
165
+ CN.current ??= createConnectController({ api, note: (t, tone) => say(t, tone), emit: (a) => dispatch(a), refresh: () => bump(n => n + 1) });
166
+ // L0 palette: opens under the composer while the draft is a bare /word (slash/paletteController.js).
167
+ const PL = React.useRef(null);
168
+ PL.current ??= createPalette({ getDraft: () => B.current.text, setDraft: (t) => edit({ type: 'set', text: t }), run: (t) => { R.current.history.push(t); command(t); }, say: (t, tone) => say(t, tone), ctx: () => ({ api, caps: R.current.contract, blocked }) });
169
+ const LG = React.useRef(null); LG.current ??= createLedger(); // /cost, /context: real usage only
170
+ const PK = React.useRef(null); PK.current ??= createPicker({ refresh: () => bump(n => n + 1) }); // /resume, /config
171
+ React.useEffect(() => (startSha ? buildWatch({ startSha, onNotice: (t) => say(t, 'dim') }) : undefined), []);
172
+ React.useEffect(() => { if (resume && !blocked) resumeCmd(resume === true ? '' : String(resume)); }, []);
173
+ React.useEffect(() => { if (!blocked && api.capabilities) api.capabilities().then((res) => { R.current.contract = capabilityMap(res); }, () => {}); }, []);
174
+
175
+ React.useEffect(() => {
176
+ if (columns != null) { setCols(clampCols(columns)); return undefined; }
177
+ // Rows the old width wrapped cannot be erased by Ink's line-count log-update (ghost composers). Wipe the
178
+ // visible screen, then remount <Static> so history reprints once at the new width.
179
+ const onResize = () => {
180
+ if (stdout?.isTTY !== false) stdout.write('\x1b[2J\x1b[H');
181
+ setEpoch(e => e + 1);
182
+ setCols(clampCols((stdout.columns || 80) - 1));
183
+ };
184
+ stdout?.on?.('resize', onResize);
185
+ return () => stdout?.off?.('resize', onResize);
186
+ }, [stdout, columns]);
187
+
188
+ R.current.startedAt ??= Date.now();
189
+ const where = React.useRef(slotAgent || detectSlotAgent());
190
+ // Session name (top rule) and the attached agent. The name is saved locally once a conversation id exists.
191
+ const SN = React.useRef({ name: cleanName(sessionName), saved: null, agent: null, baseAgent: where.current.agent ?? null });
192
+ React.useEffect(() => {
193
+ const id = S.current.ctx.conversationId; const key = `${id}|${SN.current.name}`;
194
+ if (SN.current.name && id && SN.current.saved !== key) { SN.current.saved = key; saveName(id, SN.current.name, namesPath); }
195
+ });
196
+
197
+ // Live approvals count: refreshed from the inbox every 30 s. A failed refresh keeps the last
198
+ // known count and records the error; it never reads as "0 waiting".
199
+ const refreshNotify = async () => {
200
+ if (blocked) return;
201
+ try {
202
+ const items = await api.approvals();
203
+ dispatch({ type: 'notify', patch: { approvals: items.length, error: null } });
204
+ dispatch({ type: 'inbox.sync', ids: items.map(i => i.id) });
205
+ // Provider paths that emit no approval.requested still surface here, as the same card.
206
+ for (const item of items) dispatch({ type: 'approval.found', item, since: R.current.startedAt, userId: api.userId?.() || null });
207
+ } catch (e) { dispatch({ type: 'notify', patch: { error: e?.message || 'failed' } }); }
208
+ };
209
+ React.useEffect(() => {
210
+ refreshNotify();
211
+ const id = setInterval(refreshNotify, notifyMs);
212
+ return () => clearInterval(id);
213
+ }, []);
214
+
215
+ // Roster: refreshed from real list endpoints. A failing source adds no rows and is named in /roster.
216
+ const refreshRoster = async () => {
217
+ if (blocked || !api.roster) return;
218
+ try {
219
+ let r = await api.roster(Date.now());
220
+ const R0 = R.current.roster;
221
+ const prevKey = R0.rows[R0.sel]?.key;
222
+ R0.allRows = r.rows; R0.alerts = backgroundAlerts(r.rows); R0.sources = r.sources;
223
+ r = { ...r, rows: selectRows(r.rows, { all: R0.all }) };
224
+ R0.rows = r.rows;
225
+ const at = r.rows.findIndex(x => x.key === prevKey);
226
+ R0.sel = at >= 0 ? at : Math.min(R0.sel, Math.max(0, r.rows.length - 1));
227
+ if (!r.rows.length) { R0.focus = false; R0.peek = false; R0.confirm = null; }
228
+ if (!R.current.closed) bump(n => n + 1);
229
+ } catch { /* api.roster handles per-source failures; nothing to show */ }
230
+ };
231
+ React.useEffect(() => {
232
+ refreshRoster();
233
+ const id = setInterval(refreshRoster, rosterMs);
234
+ return () => clearInterval(id);
235
+ }, []);
236
+
237
+ // Roster clock: finished rows fade out and drop locally at their expiry (runs on the motion clock's ticks).
238
+ const expireRoster = () => {
239
+ const ro = R.current.roster;
240
+ if (!ro.rows.length) return false;
241
+ const now = Date.now();
242
+ if (ro.rows.some(r => isExpired(r, now))) {
243
+ const selKey = ro.rows[ro.sel]?.key;
244
+ ro.rows = ro.rows.filter(r => !isExpired(r, now)); ro.allRows = ro.allRows.filter(r => !isExpired(r, now));
245
+ const at = ro.rows.findIndex(r => r.key === selKey);
246
+ ro.sel = at >= 0 ? at : Math.min(ro.sel, Math.max(0, ro.rows.length - 1));
247
+ if (!ro.rows.length) { ro.focus = false; ro.peek = false; ro.confirm = null; }
248
+ return true;
249
+ }
250
+ return false;
251
+ };
252
+
253
+ // ---- motion: ONE clock for every animation and visible tick (motion/clock.js). Each render declares what it
254
+ // still needs (frames, a second tick, a one-shot wake); with nothing declared there is no timer at all.
255
+ const clockRef = React.useRef(null);
256
+ const framesRef = React.useRef(null);
257
+ if (!framesRef.current) framesRef.current = createFrames();
258
+ const lastAppRender = React.useRef(0);
259
+ const ringRef = React.useRef(null);
260
+ const onClockTick = React.useRef(null);
261
+ onClockTick.current = () => {
262
+ if (R.current.closed) return;
263
+ R.current.tickPerf = performance.now();
264
+ const now = Date.now();
265
+ if (!introDone.current && now - introStart.current >= introMs) { finishIntro(); return; }
266
+ framesRef.current.emit(now); // animated leaves re-render themselves
267
+ if (expireRoster() || appDue(now)) bump(n => n + 1); // structural change: the whole view
268
+ else clockRef.current.schedule(demands(), now);
269
+ };
270
+ // The newest protocol stage stays live (its icon pulses) while it is the last thing that happened in the turn.
271
+ const isActiveStage = (st, i) => i === st.history.length - 1 && Boolean(st.turn) && !st.blocks.length && st.history[i].kind === 'trace';
272
+ // The app itself re-renders on a tick only when something it lays out is due (not for every frame).
273
+ function appDue(now) {
274
+ const s0 = S.current;
275
+ if (!introDone.current) return true;
276
+ const sec = (t) => Math.floor(t / 1000);
277
+ if (R.current.roster.rows.length && sec(now) !== sec(lastAppRender.current)) return true; // roster ages
278
+ if (motionOn) {
279
+ if (s0.history.length > committedRef.current && !fxActive(s0.history[committedRef.current], now) && !isActiveStage(s0, committedRef.current)) return true; // commit to Static
280
+ for (const r of R.current.roster.rows) if (rosterFx(r.key, now) != null || !rosterSeen.current.has(r.key)) return true;
281
+ }
282
+ const card0 = s0.approvals[0];
283
+ if (card0) {
284
+ const age = now - (card0.displayedAt ?? card0.shownAt);
285
+ if (motionOn && age < 1000) return true; // arrival animation
286
+ if (!R.current.armedShown && age >= 300) return true; // the card arms
287
+ }
288
+ return false;
289
+ }
290
+ if (!clockRef.current) clockRef.current = new Clock(() => onClockTick.current());
291
+ if (motionStats) motionStats.clock = clockRef.current;
292
+ React.useEffect(() => () => clockRef.current.stop(), []);
293
+ const demands = () => {
294
+ const s0 = S.current, now = Date.now(), d = [];
295
+ if (!introDone.current) d.push({ every: INTRO_FRAME_MS });
296
+ if (s0.turn) d.push(motionOn ? { every: FRAME_MS } : { every: SECOND_MS, since: s0.turn.startedAt });
297
+ const card0 = s0.approvals[0];
298
+ if (card0) { const at = (card0.displayedAt ?? card0.shownAt) + 320; if (now < at) d.push(motionOn ? { every: FRAME_MS } : { at }); }
299
+ if (R.current.roster.rows.length) d.push({ every: SECOND_MS });
300
+ if (ringAnimating(ringRef.current)) d.push({ every: FRAME_MS }); // the ring settling after the turn
301
+ if (eyeActive(now, !motionOn)) d.push({ every: motionOn ? 50 : 500 }); // /eye (qmotion EyeLive) plays on the shared clock
302
+ if (motionOn && fxBusy(now)) d.push({ every: FRAME_MS }); // reveals in flight
303
+ return d;
304
+ };
305
+ React.useEffect(() => { if (!R.current.closed) clockRef.current.schedule(demands()); });
306
+
307
+ const fxEnd = (e) => Math.max(e.at + revealSpan(e.timing) * 1000, e.settleAt != null ? e.settleAt + revealSpan(TIMING.settle) * 1000 : 0);
308
+ const fxActive = (item, now) => { const e = fxRef.current.get(item.id); return Boolean(e) && now < fxEnd(e); };
309
+ const fxFor = (item, now) => {
310
+ const e = fxRef.current.get(item.id);
311
+ if (!e || now >= fxEnd(e)) return null;
312
+ return { e: (now - e.at) / 1000, timing: e.timing, settle: e.settleAt != null ? (now - e.settleAt) / 1000 : null };
313
+ };
314
+ const registerFx = (item, now) => {
315
+ const t = revealTimingFor(item);
316
+ if (!t) return;
317
+ const e = fxRef.current.get(item.id);
318
+ if (!e) fxRef.current.set(item.id, { at: now, timing: t, running: item.kind === 'tool' && item.status === 'running', settleAt: null });
319
+ else if (e.running && item.status !== 'running' && e.settleAt == null) e.settleAt = now;
320
+ };
321
+ const rosterFx = (key, now) => { const at = rosterSeen.current.get(key); const e = at == null ? null : (now - at) / 1000; return e != null && e < revealSpan(TIMING.roster) ? e : null; };
322
+ function fxBusy(now) {
323
+ const s0 = S.current;
324
+ if (s0.history.length > committedRef.current) return true;
325
+ if (s0.blocks.some(b => fxActive(b, now))) return true;
326
+ const card0 = s0.approvals[0];
327
+ if (card0 && now - (card0.displayedAt ?? card0.shownAt) < 1000) return true;
328
+ for (const r of R.current.roster.rows) if (rosterFx(r.key, now) != null) return true;
329
+ return false;
330
+ }
331
+
332
+ // After approving from the terminal the tool runs asynchronously: poll its stored result.
333
+ const pollResult = async (run) => {
334
+ if (!run.approvalId || !api.approvalResult) return dispatch({ type: 'approval.result', callId: run.callId, outcome: 'timeout' });
335
+ const t0 = Date.now();
336
+ while (Date.now() - t0 < resultTimeoutMs) {
337
+ await new Promise(r => { const t = setTimeout(() => { R.current.pollTimers.delete(t); r(); }, resultPollMs); R.current.pollTimers.add(t); R.current.pollWake = r; });
338
+ if (R.current.closed) return undefined;
339
+ if (!S.current.running.some(x => x.callId === run.callId)) return undefined; // resolved elsewhere
340
+ try {
341
+ const r = await api.approvalResult(run.approvalId);
342
+ if (r?.status === 'denied') return dispatch({ type: 'approval.result', callId: run.callId, outcome: 'denied' });
343
+ if (r && (r.status === 'approved' || r.status === 'error') && r.executed_at) {
344
+ const text = r.result == null ? null : clip(typeof r.result === 'string' ? r.result : JSON.stringify(r.result), cols - 20);
345
+ return dispatch({ type: 'approval.result', callId: run.callId, outcome: r.status === 'error' ? 'failed' : 'done', text });
346
+ }
347
+ } catch { /* keep polling until the deadline */ }
348
+ }
349
+ return dispatch({ type: 'approval.result', callId: run.callId, outcome: 'timeout' });
350
+ };
351
+ const answer = async (card, action) => {
352
+ if (R.current.answering?.has(card.callId)) return;
353
+ (R.current.answering ||= new Set()).add(card.callId);
354
+ try {
355
+ await api.respond(card.inboxItemId, action);
356
+ dispatch({ type: 'approval.sent', callId: card.callId, action, tool: card.tool, target: card.target, approvalId: card.approvalId });
357
+ if (action === 'approve') pollResult({ callId: card.callId, approvalId: card.approvalId });
358
+ } catch (e) { dispatch({ type: 'error', text: `approval: ${e.message}` }); }
359
+ finally { R.current.answering.delete(card.callId); }
360
+ };
361
+
362
+ // ---- turn runner ---------------------------------------------------------------------------
363
+ async function runTurn(text) {
364
+ if (blocked) { say(blocked, 'warn'); return; }
365
+ const turnId = newTurnId();
366
+ return consumeTurn(turnId, (signal) => api.stream({ turnId, message: text, agent: SN.current.agent || 'quox', conversationId: S.current.ctx.conversationId, context: R.current.rulesOn === false ? undefined : rulesContext(rules), ...streamFields(MC.current) }, signal));
367
+ }
368
+
369
+ // QUOXON C5: render a turn that is already running on the server (after /resume, --conversation or a
370
+ // restart). Replays it from the start, then follows it live; exactly once by seq.
371
+ async function followTurn(conversationId, turnId) {
372
+ if (S.current.turn || !api.reattach) return;
373
+ say('A turn is still running in this conversation; reattaching to it.', 'dim');
374
+ return consumeTurn(turnId, (signal) => api.reattach({ conversationId, turnId, after: 0 }, signal));
375
+ }
376
+ async function reattachIfRunning(conversationId) {
377
+ if (!conversationId || !api.runningTurn || S.current.turn) return;
378
+ const r = await api.runningTurn(conversationId);
379
+ if (r?.running && r.turnId && !S.current.turn && S.current.ctx.conversationId === conversationId) followTurn(conversationId, r.turnId);
380
+ }
381
+
382
+ async function consumeTurn(turnId, open) {
383
+ R.current.caps.steer = true; R.current.steerNoted = false; // steer support is learned per turn (provider paths differ)
384
+ R.current.durable = false; // learned from the server: v1.1 events carry a seq
385
+ dispatch({ type: 'turn.begin', turnId });
386
+ const ctrl = new AbortController();
387
+ R.current.abort = ctrl;
388
+ let offered = false;
389
+ try {
390
+ for await (const ev of open(ctrl.signal)) {
391
+ if (ctrl.signal.aborted) break;
392
+ if (typeof ev?.seq === 'number') R.current.durable = true;
393
+ LG.current.observe(ev);
394
+ dispatch({ type: 'event', ev });
395
+ const fb = fallbackNote(MC.current, ev);
396
+ if (fb) say(fb, 'warn');
397
+ const offer = connectOfferFor(ev);
398
+ if (offer && !offered) { offered = true; say(offer); }
399
+ }
400
+ if (S.current.turn?.id === turnId) {
401
+ // stream closed without a terminal event
402
+ // never claim completion the server did not confirm
403
+ dispatch({ type: 'event', ev: { type: 'turn.finished', status: ctrl.signal.aborted ? 'interrupted' : 'unconfirmed' } });
404
+ }
405
+ } catch (e) {
406
+ if (!ctrl.signal.aborted) {
407
+ dispatch({ type: 'event', ev: { type: 'error', error: friendlyError(e, profile) } });
408
+ // A lost reattach is not a failed turn: the server may still be finishing it (C5).
409
+ if (S.current.turn?.id === turnId) dispatch({ type: 'event', ev: { type: 'turn.finished', status: e?.code === 'reattach_failed' ? 'unconfirmed' : 'failed' } });
410
+ }
411
+ } finally {
412
+ clearTimeout(R.current.fallbackTimer);
413
+ LG.current.end();
414
+ R.current.abort = null;
415
+ if (S.current.turn?.id === turnId) dispatch({ type: 'stopped.local' });
416
+ }
417
+ }
418
+
419
+ React.useEffect(() => {
420
+ const s = S.current;
421
+ if (!s.turn && s.autoDrain && s.pending.length) {
422
+ const next = s.pending[0];
423
+ dispatch({ type: 'queue.shift' });
424
+ dispatch({ type: 'user', text: next.text, queued: true });
425
+ runTurn(next.text);
426
+ }
427
+ });
428
+
429
+ function submit(text) {
430
+ dispatch({ type: 'user', text });
431
+ const tip = intentHint(text); // a hint only: the turn still goes to the model
432
+ if (tip) say(tip);
433
+ const panel = intentPanel(text); // the same panel as the command, from local facts, above the answer
434
+ if (panel) showPanel(panel, { local: true });
435
+ R.current.history.push(text);
436
+ R.current.histIdx = -1;
437
+ runTurn(text);
438
+ }
439
+
440
+ async function steer(text) {
441
+ const id = `steer_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
442
+ const s = S.current;
443
+ dispatch({ type: 'steer.add', id, text });
444
+ const r = await api.steer(s.ctx.conversationId, s.turn.id, text, id);
445
+ if (r.ok) return;
446
+ // Put the text back in the draft (in front of anything typed since) instead of losing or replaying it.
447
+ const restore = () => { const cur = B.current.text; edit({ type: 'set', text: cur ? `${text}\n${cur}` : text }); };
448
+ if (r.reason === 'ambiguous') {
449
+ dispatch({ type: 'steer.failed', id, reason: 'Steer may not have been applied; resend? Your text is back in the draft.' });
450
+ restore();
451
+ } else if (r.reason === 'denied') {
452
+ dispatch({ type: 'steer.failed', id, reason: `Steering was denied by policy (${r.message || 'steer_denied_by_policy'}). Your text is back in the draft; it was not run.` });
453
+ restore();
454
+ } else {
455
+ if (r.reason === 'unsupported') { R.current.caps.steer = false; R.current.steerNoted = true; bump(n => n + 1); }
456
+ dispatch({ type: 'steer.converted', id, reason: r.reason === 'turn_finished'
457
+ ? 'That turn already finished; queued your message as the next turn.'
458
+ : STEER_OFF_NOTE });
459
+ }
460
+ }
461
+
462
+ async function interrupt() {
463
+ const s = S.current;
464
+ if (!s.turn || s.turn.status === 'interrupting') return;
465
+ dispatch({ type: 'interrupt.requested' });
466
+ const r = await api.interrupt(s.ctx.conversationId, s.turn.id);
467
+ const turnId = s.turn.id;
468
+ const local = () => { if (S.current.turn?.id === turnId) { R.current.abort?.abort(); dispatch({ type: 'stopped.local' }); } };
469
+ if (!r.ok) { R.current.caps.interrupt = false; return local(); }
470
+ R.current.fallbackTimer = setTimeout(local, INTERRUPT_FALLBACK_MS);
471
+ }
472
+
473
+ // ---- L1 slash commands (models in slash/*.js) ------------------------------------------------
474
+ const help = (model) => dispatch({ type: 'help', model });
475
+ const leaveArmed = (kind) => { // a running turn would be disconnected: ask once, a second ask within 5 s goes ahead
476
+ if (!S.current.turn) return true;
477
+ // C5: a durable turn (the server sends seq) keeps running when we leave; /resume reattaches.
478
+ if (R.current.durable) { say('Quox keeps working; it will be here when you come back. /resume reattaches to the turn.', 'ok'); return true; }
479
+ const t = R.current.leaveAsk;
480
+ if (t && t.kind === kind && Date.now() - t.at < 5000) return true;
481
+ R.current.leaveAsk = { kind, at: Date.now() };
482
+ say(`A turn is running. ${kind === 'restart' ? 'Restarting' : 'Leaving'} disconnects it, and the server stops it before its next step (a tool already running finishes and is recorded). /${kind === 'restart' ? 'restart' : 'quit'} again within 5 s to go ahead, or wait for the turn.`, 'warn');
483
+ return false;
484
+ };
485
+ async function showPanel(cmd, { local = false } = {}) {
486
+ const base = { ctx: S.current.ctx, choice: MC.current, agent: SN.current.agent || where.current.agent, conversationId: S.current.ctx.conversationId };
487
+ if (cmd === '/cost') return help(costModel(LG.current));
488
+ if (cmd === '/status') {
489
+ let hl = R.current.lastHealth || null;
490
+ if (!local && api.health) { hl = await api.health(8000); R.current.lastHealth = hl; }
491
+ return help(statusModel({ ...base, profile, org: org || null, serverUrl: api.serverUrl?.() || hl?.url || null, health: hl, slot: where.current.slot, sessionName: SN.current.name, version, local: local && !hl }));
492
+ }
493
+ if (cmd === '/context') {
494
+ let skills = R.current.skillsInfo || null; let memory = R.current.memoryInfo || null;
495
+ if (!local) {
496
+ [skills, memory] = await Promise.all([
497
+ api.skills ? api.skills().then(l => ({ ok: true, count: l.length }), e => ({ ok: false, error: e?.status ? `HTTP ${e.status}` : (e?.message || 'failed') })) : { ok: false, error: 'not available in this mode' },
498
+ api.memoryStatus ? api.memoryStatus() : { ok: false, error: 'not available in this mode' }
499
+ ]);
500
+ R.current.skillsInfo = skills; R.current.memoryInfo = memory;
501
+ }
502
+ let server = null; let serverError = null;
503
+ if (!local && base.conversationId && api.conversationContext) {
504
+ try { server = contextFacts(await api.conversationContext(base.conversationId)); }
505
+ catch (e) { serverError = e?.status === 404 ? 'no stored turns yet' : e?.status ? `HTTP ${e.status}` : (e?.message || 'failed'); }
506
+ }
507
+ return help(contextModel({ ...base, server, serverError, tokens: LG.current.conversationTokens(S.current.ctx.conversationId), rules, rulesOn: R.current.rulesOn !== false, skills, memory }));
508
+ }
509
+ return undefined;
510
+ }
511
+ async function resumeTo(row) {
512
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
513
+ let turns;
514
+ try { turns = await api.conversationTurns(row.id); } catch (e) { return dispatch({ type: 'error', text: `resume: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}` }); }
515
+ const t = turnsToHistory(turns);
516
+ dispatch({ type: 'ctx', ctx: { conversationId: row.id } });
517
+ SN.current.name = loadNames(namesPath)[row.id]?.name || null; SN.current.saved = null;
518
+ say(`Resumed "${clip(row.title, cols - 20)}"${row.agent ? ` with ${row.agent}` : ''}.`, 'ok');
519
+ dispatch({ type: 'history.add', items: t.items, answers: t.answers });
520
+ say(t.total ? `Showing the last ${t.shown} of ${t.total} stored messages. Your next message continues this conversation.` : 'The server has no stored messages for it (it keeps them 7 days). Your next message continues it anyway.', 'dim');
521
+ return reattachIfRunning(row.id);
522
+ }
523
+ async function resumeCmd(q) {
524
+ if (!api.conversations || !api.conversationTurns) return say('Resume is not available in this mode.', 'warn');
525
+ let rows;
526
+ try {
527
+ const [list, index] = await Promise.all([api.conversations(30), api.sessionsIndex ? api.sessionsIndex().catch(() => null) : null]);
528
+ rows = resumeRows(list, index, loadNames(namesPath));
529
+ } catch (e) { return dispatch({ type: 'error', text: `resume: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}` }); }
530
+ if (q) {
531
+ const m = matchConversation(rows, q);
532
+ if (m.row) return resumeTo(m.row);
533
+ return say(m.ambiguous ? `"${q}" matches ${m.ambiguous.length} conversations: ${m.ambiguous.map(x => x.slice(0, 12)).join(', ')}. Use more of the id.` : `No recent conversation matches "${q}". /resume lists them.`, 'warn');
534
+ }
535
+ if (!rows.length) return say('No recent conversations to resume.');
536
+ const now = Date.now();
537
+ PK.current.open({ title: 'Resume a conversation', rows: rows.map(r => pickerRow(r, now)), hint: '↑↓ select · Enter resume · Esc cancel', onPick: (_, i) => resumeTo(rows[i]) });
538
+ return undefined;
539
+ }
540
+ function openConfig() {
541
+ const list = settingsList();
542
+ const saved = api.settings ? api.settings() : (R.current.sessionSettings ||= {});
543
+ const vals = { verbose: S.current.verbose, ...saved, sound: SND.current.settings?.().profile };
544
+ const set = (it, v) => {
545
+ vals[it.id] = v;
546
+ if (it.id === 'sound') { SND.current.command(v, say); return; } // the sound controller saves its own profile
547
+ const ok = api.saveSetting ? api.saveSetting(it.id, v) : ((R.current.sessionSettings ||= {})[it.id] = v, false);
548
+ if (it.id === 'verbose') dispatch({ type: 'verbose', mode: v });
549
+ if (it.id === 'theme' || it.id === 'motion') {
550
+ applySettings(vals, env);
551
+ if (it.id === 'theme' && stdout?.isTTY !== false) { stdout.write('\x1b[2J\x1b[H'); setEpoch(e => e + 1); } // repaint history in the new look
552
+ }
553
+ R.current.configSaved = ok;
554
+ };
555
+ PK.current.open({
556
+ title: api.saveSetting ? `Settings · saved to profile ${profile}` : 'Settings · this session only (no profile in this mode)',
557
+ rowsFn: () => configRows(list, vals, { model: modelLabel(S.current.ctx, MC.current) || 'auto' }),
558
+ hint: '↑↓ select · ←→ or Space change · Enter on Model opens /model · Esc close',
559
+ onAdjust: (i, dir) => { const it = list[i]; if (!it.action) set(it, cycle(it, vals[it.id], dir)); },
560
+ keepOpen: (i) => !list[i].action,
561
+ onPick: (_, i) => { const it = list[i]; if (it.action === 'model') command('/model'); else set(it, cycle(it, vals[it.id], 1)); }
562
+ });
563
+ }
564
+
565
+ async function command(line) {
566
+ if (/^\/\/(\s|$)/.test(line.trim())) line = line.trim().replace(/^\/\//, '/note');
567
+ const [cmd, arg] = line.trim().split(/\s+/);
568
+ if (cmd !== '/note') transcript?.command(line);
569
+ const restArg = line.trim().replace(/^\S+\s*/, '');
570
+ if (OBJECT_CMDS.has(cmd) && !(cmd === '/rules' && arg)) { // inline object views (objects/): a card into history, or an honest note
571
+ const r = await runObjectCommand(cmd, restArg, { objects: api.objects, rules, rulesOn: R.current.rulesOn, items: [...S.current.history, ...S.current.blocks] });
572
+ if (r) return r.object ? dispatch({ type: 'object', obj: r.object }) : say(clip(r.note, cols - 4), r.tone);
573
+ }
574
+ switch (cmd) {
575
+ case '/copy': {
576
+ const r = pickCopy(S.current.answers, arg || '');
577
+ if (r.error) return say(r.error, 'warn');
578
+ if (stdout?.isTTY) stdout.write(osc52(r.text));
579
+ return say(`copied ${r.text.length.toLocaleString('en-US')} chars to the clipboard (needs a terminal that allows OSC 52)`, 'dim');
580
+ }
581
+ case '/note': {
582
+ if (!restArg) return say('Usage: /note <text> (or // text). Nothing to note.', 'warn');
583
+ if (!transcript?.note) return say('notes need dev transcripts on (touch ~/.config/quox/dev-transcripts)', 'warn');
584
+ transcript.note(restArg.slice(0, 2000));
585
+ return say('noted for the observer', 'dim');
586
+ }
587
+ case '/rename': {
588
+ if (!restArg) return say(SN.current.name ? `Session name: ${SN.current.name}` : 'This session has no name. Use /rename <name>.');
589
+ const n = cleanName(restArg);
590
+ SN.current.name = n; SN.current.saved = null; bump(x => x + 1);
591
+ return say(`Session named "${n}".`, 'ok');
592
+ }
593
+ case '/slot': {
594
+ if (!restArg) return say(where.current.slot ? `Slot: ${where.current.slot}` : 'No slot. Use /slot <name> to give this session an address.');
595
+ if (!api.checkoutSlot) return say('Slots are not available in this mode.', 'warn');
596
+ const nm = restArg.split(/\s+/)[0];
597
+ const r = await api.checkoutSlot(nm, SN.current.name || S.current.ctx.conversationId || 'quox');
598
+ if (!r.ok) return say(`slot ${nm}: ${r.message}`, 'warn');
599
+ where.current = { ...where.current, slot: nm }; bump(x => x + 1);
600
+ return say(`Slot ${nm} checked out. Discord replies are not relayed into this window yet.`, 'ok');
601
+ }
602
+ case '/attach': {
603
+ if (!restArg) return say(SN.current.agent ? `Attached to ${SN.current.agent}. /detach returns to CommanderQ.` : 'Talking to CommanderQ. Use /attach <agent> to pick one.');
604
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
605
+ if (!api.agents) return say('Agents are not available in this mode.', 'warn');
606
+ let list;
607
+ try { list = await api.agents(); } catch (e) { return say(`could not list agents: ${e.message}`, 'warn'); }
608
+ const m = matchAgent(restArg, list);
609
+ if (!m.agent) return say(`No agent called "${restArg}".${m.matches.length ? ` Closest: ${m.matches.join(', ')}.` : ' Try quox agent list.'}`, 'warn');
610
+ SN.current.agent = m.agent.id; where.current = { ...where.current, agent: m.agent.id }; bump(x => x + 1);
611
+ return say(`Attached to ${m.agent.id}. Your next turns go to it. /detach returns to CommanderQ.`, 'ok');
612
+ }
613
+ case '/connect': {
614
+ if (!restArg) {
615
+ if (!api.connections) return say('Connections are not available in this mode.', 'warn');
616
+ for (const [t, tone] of connectionLines(await api.connections())) say(clip(t, cols - 4), tone);
617
+ return undefined;
618
+ }
619
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
620
+ const pv = providerFor(arg);
621
+ if (pv?.url && pv.id !== 'ollama') { // B5: LM Studio / vLLM / custom: probed server side, then saved (custom may need a key)
622
+ const rest = restArg.replace(/^\S+\s*/, '');
623
+ say(`Checking the URL from the server…`);
624
+ const r = await connectCompatFlow(api, pv.id, rest);
625
+ for (const [t, tone] of r.lines) say(clip(t, cols - 4), tone);
626
+ if (r.needsKey) CN.current.openCustom(r.extra);
627
+ return undefined;
628
+ }
629
+ if (pv?.url) { // B3: Ollama takes a URL, probed and saved server side
630
+ say(`Checking ${parseOllamaArg(restArg.replace(/^\S+\s*/, '')).url || 'the URL'} from the server…`);
631
+ const r = await connectOllamaFlow(api, restArg.replace(/^\S+\s*/, ''));
632
+ for (const [t, tone] of r.lines) say(clip(t, cols - 4), tone);
633
+ return undefined;
634
+ }
635
+ CN.current.open(arg);
636
+ return undefined;
637
+ }
638
+ case '/model': {
639
+ if (!api.connections) return say('Model choice is not available in this mode.', 'warn');
640
+ const res = await api.connections();
641
+ if (!restArg) { for (const [t, tone] of modelLines(res, MC.current)) say(clip(t, cols - 4), tone); return undefined; }
642
+ let r = selectModel(res, restArg);
643
+ let installed = null;
644
+ if (r.ok && r.choice?.provider === 'ollama' && api.ollamaModels) { // B3: check against what that Ollama has installed
645
+ r = selectOllamaModel(await api.ollamaModels(), r.choice.model || null);
646
+ if (r.models) installed = ollamaModelRows(r.models, r.current || r.choice?.model);
647
+ }
648
+ if (r.ok && byId(r.choice?.provider)?.compat && api.compatModels) { // B5: check against the provider's own /models
649
+ r = selectCompatModel(await api.compatModels(r.choice.provider), r.choice.provider, r.choice.model || null);
650
+ if (r.models) installed = compatModelRows(r.models, r.current || r.choice?.model);
651
+ }
652
+ if (r.ok) {
653
+ MC.current = r.choice; api.setModelChoice?.(r.choice);
654
+ dispatch({ type: 'ctx', ctx: { provider: null, model: null, endpoint: null } }); // the status line shows the new ask until the server answers
655
+ }
656
+ say(r.text, r.tone);
657
+ if (installed) for (const [t, tone] of installed) say(clip(t, cols - 4), tone);
658
+ return undefined;
659
+ }
660
+ case '/capabilities': {
661
+ if (!api.capabilities) return say('Capabilities are not available in this mode.', 'warn');
662
+ const res = await api.capabilities();
663
+ if (restArg && res.ok) {
664
+ const c = findCapability(restArg, res);
665
+ if (!c) return say(`Nothing called "${restArg}". /capabilities lists them.`, 'warn');
666
+ say(clip(`${c.name}: ${c.intent}`, cols - 4));
667
+ say(clip(`Proof: ${c.evidence || 'not stated'}`, cols - 4));
668
+ return say(clip(offerFor(c.id, res) || `✔ ${c.name} is ready here.`, cols - 4), c.availability === 'installed' ? 'ok' : 'warn');
669
+ }
670
+ for (const [t, tone] of capabilityLines(res)) say(clip(t, cols - 4), tone);
671
+ return undefined;
672
+ }
673
+ case '/workflow': case '/crystal': case '/memory': case '/proof': case '/reconcile': return undefined; // answered above (runObjectCommand)
674
+ case '/agents': return say('Usage: /agents <name> shows an agent card.'); // the bare list belongs to the slash-commands work
675
+ case '/detach':
676
+ SN.current.agent = null; where.current = { ...where.current, agent: SN.current.baseAgent }; bump(x => x + 1);
677
+ return say('Back to CommanderQ.');
678
+ case '/sessions': {
679
+ if (arg === 'all') {
680
+ if (!api.sessionsIndex) return say('The session index is not available in this mode.', 'warn');
681
+ try {
682
+ for (const l of overviewLines(await api.sessionsIndex(), { localHost: osHostname(), names: loadNames(namesPath) })) say(clip(l.text, cols - 4), l.tone);
683
+ } catch (e) { dispatch({ type: 'error', text: `sessions all: ${e.status ? `HTTP ${e.status}: ` : ''}${e.message}` }); }
684
+ return undefined;
685
+ }
686
+ if (!api.conversations) return say('Sessions are not available in this mode.', 'warn');
687
+ try {
688
+ const rows = await api.conversations(); const names = loadNames(namesPath);
689
+ if (!rows.length) return say('No recent conversations.');
690
+ for (const r of rows) say(`${r.id.slice(0, 12)} ${clip(names[r.id]?.name || r.title || '(untitled)', Math.max(10, cols - 30))} ${r.updated_at ? String(r.updated_at).slice(0, 16).replace('T', ' ') : ''}`);
691
+ } catch (e) { dispatch({ type: 'error', text: `sessions: ${e.message}` }); }
692
+ return undefined;
693
+ }
694
+ case '/quit': case '/exit': {
695
+ const midTurn = Boolean(S.current.turn);
696
+ if (!leaveArmed('quit')) return undefined;
697
+ R.current.leaveAsk = null;
698
+ return midTurn ? setTimeout(() => exit(), 30) : exit(); // mid-turn: let the "keeps working" line paint first
699
+ }
700
+ case '/restart': {
701
+ if (!onRestart) return say('Restart is not available in this mode.', 'warn');
702
+ if (!leaveArmed('restart')) return undefined;
703
+ R.current.leaveAsk = null;
704
+ if (R.current.restarting) return say('Already restarting…', 'dim');
705
+ R.current.restarting = true;
706
+ let target;
707
+ if (restartCheck) { // never let go of the terminal for a quox that cannot start: say so and stay
708
+ say('Checking the installed quox…', 'dim');
709
+ const c = await restartCheck();
710
+ if (!c.ok) { R.current.restarting = false; return say(`Could not restart: ${c.bin} did not start (${c.error}). Still in this client.`, 'warn'); }
711
+ target = c.onPath || undefined;
712
+ }
713
+ say(`Restarting${S.current.ctx.conversationId ? ' into this conversation' : ''}…`, 'dim');
714
+ onRestart({ conversationId: S.current.ctx.conversationId || null, name: SN.current.name || null, bin: target });
715
+ return setTimeout(() => exit(), 30);
716
+ }
717
+ case '/gripe': case '/gripe!': {
718
+ if (!api.gripe) return say('/gripe is not available in this mode.', 'warn');
719
+ const pending = R.current.pendingGripe && Date.now() - R.current.pendingGripe.at < 120000 ? R.current.pendingGripe.text : null;
720
+ const text = restArg || (cmd === '/gripe!' ? pending : null);
721
+ if (!text) return say(cmd === '/gripe!' ? 'Nothing to file. /gripe <text> first, or /gripe! <text>.' : 'Usage: /gripe <what is wrong> (/gripe! <text> files without asking)', 'warn');
722
+ if (cmd === '/gripe') { // ask first: filing makes a public-ish issue
723
+ R.current.pendingGripe = { text, at: Date.now() };
724
+ say(`File this as a GitHub issue via GRIPE? "${clip(text, Math.max(20, cols - 50))}"`);
725
+ return say('/gripe! files it · /gripe <new text> replaces it · anything else leaves it unfiled', 'dim');
726
+ }
727
+ R.current.pendingGripe = null;
728
+ say('Filing via GRIPE…', 'dim');
729
+ const g = await api.gripe(text.slice(0, 4000), { yes: true });
730
+ if (!g.ok) return say(`Gripe not filed: ${g.message}`, 'warn');
731
+ if (!g.superseded && g.response && !g.url) for (const l of String(g.response).split('\n').filter(Boolean).slice(0, 6)) say(clip(l, cols - 4), 'dim');
732
+ for (const n of g.notes || []) {
733
+ if (n.kind === 'created') say(`✔ GRIPE │ issue filed │ ${n.url}`, 'ok');
734
+ else say(clip(n.text, cols * 3), n.kind === 'warn' ? 'warn' : 'dim');
735
+ }
736
+ return undefined;
737
+ }
738
+ case '/status': case '/context': case '/cost': case '/usage':
739
+ return showPanel(cmd === '/usage' ? '/cost' : cmd);
740
+ case '/doctor': {
741
+ if (!api.doctorChecks) return say('/doctor is not available in this mode.', 'warn');
742
+ say('Running the quox doctor checks…', 'dim');
743
+ const [plat, chat] = await Promise.all([api.doctorChecks().then(c => ({ c }), e => ({ e })), chatChecks(api)]);
744
+ return help(doctorModel(plat.c || [], chat, plat.e ? (plat.e.message || 'failed') : null));
745
+ }
746
+ case '/config': case '/settings':
747
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
748
+ return openConfig();
749
+ case '/compact': {
750
+ if (arg === 'show') {
751
+ const last = R.current.lastCompact;
752
+ if (!last) return say('Nothing compacted in this session yet. /compact runs it.', 'warn');
753
+ return help(compactShowLines(last));
754
+ }
755
+ if (!api.compactConversation) return say('/compact is not available in this mode.', 'warn');
756
+ const id = S.current.ctx.conversationId;
757
+ if (!id) return say('Nothing to compact yet: this conversation starts with your next message.', 'warn');
758
+ if (S.current.turn) return say('A turn is still running in this conversation. Compact after it finishes.', 'warn');
759
+ say('Compacting on the server (one model call)…', 'dim');
760
+ let r;
761
+ try { r = await api.compactConversation(id); } catch (e) { return say(compactError(e), 'warn'); }
762
+ const v = compactResult(r);
763
+ R.current.lastCompact = v;
764
+ return help([
765
+ { kind: 'plain', text: v.head },
766
+ ...v.summaryLines.map(t => ({ kind: 'dim', text: `│ ${t}` })),
767
+ ...(v.more ? [{ kind: 'dim', text: `│ … ${v.more} more line${v.more === 1 ? '' : 's'}. /compact show for all` }] : []),
768
+ ...v.notes.map(t => ({ kind: 'dim', text: t }))
769
+ ]);
770
+ }
771
+ case '/resume':
772
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
773
+ return resumeCmd(restArg);
774
+ case '/eye': playEye(); bump(n => n + 1); return true; // the Dragon's Eye, on demand (qmotion.js EyeLive); the bump lets the app's clock frame it
775
+ case '/sound': return SND.current.command(arg, say);
776
+ case '/about': return dispatch({ type: 'welcome', version: packageVersion() });
777
+ case '/help': return dispatch({ type: 'help', model: helpModel(arg, R.current.caps) });
778
+ case '/clear': case '/new': case '/reset': {
779
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
780
+ const prev = S.current.ctx.conversationId;
781
+ dispatch({ type: 'ctx', ctx: { conversationId: null } });
782
+ SN.current.name = null; SN.current.saved = null;
783
+ dispatch({ type: 'history.reset' });
784
+ if (stdout?.isTTY) stdout.write('\x1b[2J\x1b[3J\x1b[H');
785
+ setEpoch(e => e + 1);
786
+ return say(prev ? 'Previous conversation saved. /resume to go back.' : 'New conversation.');
787
+ }
788
+ case '/verbose':
789
+ if (!['off', 'on', 'full'].includes(arg)) return say(`verbose is ${S.current.verbose}. Use /verbose off|on|full`);
790
+ dispatch({ type: 'verbose', mode: arg });
791
+ return say(`verbose ${arg}`);
792
+ case '/rules': {
793
+ if (arg === 'off' || arg === 'on') {
794
+ R.current.rulesOn = arg === 'on';
795
+ bump(n => n + 1);
796
+ return say(arg === 'on' ? (rules?.files?.length ? 'Rules on.' : 'No QUOX.md loaded, nothing to turn on.') : 'Rules off for this session (nothing from QUOX.md is sent).');
797
+ }
798
+ const lines = rulesLines(rules, { enabled: R.current.rulesOn !== false, cols });
799
+ const per = 20, pages = Math.max(1, Math.ceil(lines.length / per));
800
+ const pg = Math.min(pages, Math.max(1, Number(arg) || 1));
801
+ lines.slice((pg - 1) * per, pg * per).forEach(l => say(clip(l, cols - 4)));
802
+ if (pages > 1) say(`page ${pg}/${pages}${pg < pages ? ` · /rules ${pg + 1} for more` : ''}`);
803
+ return undefined;
804
+ }
805
+ case '/skills': {
806
+ if (!api.skills) return say('This server has no skills list.', 'warn');
807
+ try {
808
+ const all = await api.skills();
809
+ if (arg) {
810
+ const sk = findSkill(all, arg);
811
+ if (!sk) return say(`No skill named "${arg}". /skills lists them.`, 'warn');
812
+ const full = (api.skill && sk.id ? await api.skill(sk.id) : null) || sk;
813
+ skillDetail(full).forEach(l => say(clip(l, cols - 4)));
814
+ return undefined;
815
+ }
816
+ if (!all.length) return say('No skills in this org yet.');
817
+ say(`${all.length} skill${all.length === 1 ? '' : 's'}. /skills <name> for detail, /skill <name> [args] to run:`);
818
+ skillRows(all.slice(0, 25), cols - 4).forEach(l => say(l));
819
+ if (all.length > 25) say(`... and ${all.length - 25} more. /skills <name> shows any one.`);
820
+ } catch (e) { dispatch({ type: 'error', text: `skills: ${e.message}` }); }
821
+ return undefined;
822
+ }
823
+ case '/skill': {
824
+ const argRest = line.trim().split(/\s+/).slice(2).join(' ');
825
+ if (!arg) return say('Usage: /skill <name> [args]. /skills lists them.', 'warn');
826
+ if (S.current.turn) return say('Wait for the current turn or press Esc first.', 'warn');
827
+ if (blocked) return say(blocked, 'warn');
828
+ if (api.skills) {
829
+ try {
830
+ const sk = findSkill(await api.skills(), arg);
831
+ if (!sk) return say(`No skill named "${arg}". /skills lists them.`, 'warn');
832
+ return submit(skillPrompt(sk.name || arg, argRest));
833
+ } catch (e) { return dispatch({ type: 'error', text: `skills: ${e.message}` }); }
834
+ }
835
+ return submit(skillPrompt(arg, argRest));
836
+ }
837
+ case '/roster': {
838
+ const ro = R.current.roster;
839
+ if (arg === 'all') {
840
+ ro.all = !ro.all; ro.on = true;
841
+ ro.rows = selectRows(ro.allRows, { all: ro.all }); ro.sel = 0;
842
+ return say(ro.all ? 'Roster shows everything, background work included. /roster all again to hide it.' : 'Roster shows only what needs you. Background work is hidden.');
843
+ }
844
+ ro.on = !ro.on; if (!ro.on) { ro.focus = false; ro.peek = false; ro.confirm = null; }
845
+ say(`Roster ${ro.on ? 'on' : 'off'}.${ro.sources ? '' : ' Not loaded yet.'}`);
846
+ for (const src of ro.sources || []) {
847
+ say(`${src.state === 'connected' ? '✔' : src.state === 'error' ? '✖' : '·'} ${src.label}: ${src.state === 'connected' ? `${src.count} shown` : `${src.state}${src.note ? ` (${src.note})` : ''}`}`, src.state === 'error' ? 'warn' : undefined);
848
+ }
849
+ if (ro.sources) say('Down on an empty composer focuses the roster. Enter peeks, s stops (y confirms), Esc returns.');
850
+ return undefined;
851
+ }
852
+ case '/observers': {
853
+ if (!api.observers) return say('Observers are not available in this mode.', 'warn');
854
+ try { for (const [text, tone] of observerLines(await api.observers(), cols)) say(text, tone); }
855
+ catch (e) { dispatch({ type: 'error', text: `observers: ${e.message}` }); }
856
+ return undefined;
857
+ }
858
+ case '/approvals': {
859
+ try {
860
+ const items = await api.approvals();
861
+ dispatch({ type: 'inbox', items });
862
+ dispatch({ type: 'notify', patch: { approvals: items.length, error: null } });
863
+ if (!items.length) return say('Nothing is waiting for your approval.');
864
+ say(`${items.length} waiting. /approve N or /deny N:`, 'warn');
865
+ items.slice(0, 9).forEach((it, i) => say(`[${i + 1}] ${clip(it.title || it.summary || it.id, cols - 12)}`));
866
+ } catch (e) { dispatch({ type: 'error', text: `approvals: ${e.message}` }); }
867
+ return undefined;
868
+ }
869
+ case '/approve': case '/deny': {
870
+ const it = S.current.inbox[Number(arg) - 1];
871
+ if (!it) return say('Run /approvals first, then /approve N.', 'warn');
872
+ try {
873
+ await answer({ callId: it.id, inboxItemId: it.id, approvalId: it.approval_id || it.approvalId || null, tool: clip(it.title || it.id, cols - 40), target: null }, cmd === '/approve' ? 'approve' : 'reject');
874
+ } catch (e) { dispatch({ type: 'error', text: `approval: ${e.message}` }); }
875
+ return undefined;
876
+ }
877
+ default: {
878
+ const e = findEntry(cmd);
879
+ if (e && e.status === 'planned') return say(`${e.usage} is planned, not built yet. /help shows what works today.`, 'warn');
880
+ return say(`Unknown command ${cmd}. Try /help`, 'warn');
881
+ }
882
+ }
883
+ }
884
+
885
+ function onEnter(now) {
886
+ if (now - K.current.lastPasteAt < PASTE_GUARD_MS) return; // post-paste guard
887
+ if (now - K.current.cardGoneAt < CARD_GONE_MS) return; // keys meant for the card must not send a turn
888
+ const buf = B.current;
889
+ if (buf.text.endsWith('\\')) { edit({ type: 'set', text: buf.text.slice(0, -1) + '\n', keepPastes: true }); return; }
890
+ if (!buf.text.trim()) {
891
+ const s = S.current;
892
+ if (!s.turn && s.pending.length) { dispatch({ type: 'queue.shift' }); const n = s.pending[0]; dispatch({ type: 'user', text: n.text, queued: true }); runTurn(n.text); }
893
+ return;
894
+ }
895
+ const chk = sanitizeForSend(buf);
896
+ if (!chk.ok) { B.current = { ...chk.buffer, undo: buf.undo, redo: buf.redo }; say(chk.note, 'warn'); bump(n => n + 1); return; }
897
+ const text = chk.text.trim();
898
+ const keyHit = looksLikeKey(text);
899
+ if (keyHit) { B.current = initialBuffer(); R.current.confirmMulti = null; say(keyWarning(keyHit), 'warn'); return; } // never sent, never in history
900
+ const s = S.current;
901
+ const isCommand = text.startsWith('/') && !text.includes('\n');
902
+ if (blocked && !isCommand) { say(blocked, 'warn'); return; }
903
+ if (text.startsWith('/') && text.includes('\n') && R.current.confirmMulti !== text) {
904
+ // commands are one line: never ship a multi-line "/..." draft to the model without an explicit second Enter
905
+ R.current.confirmMulti = text;
906
+ if (!R.current.multiWarned) {
907
+ R.current.multiWarned = true;
908
+ say('That draft starts with / but has several lines, so it is not a command. Press Enter again to send it to Quox as chat, or edit it (Esc Esc clears it).', 'warn');
909
+ }
910
+ return;
911
+ }
912
+ R.current.confirmMulti = null; R.current.multiWarned = false;
913
+ B.current = initialBuffer();
914
+ if (text.startsWith('/') && !text.includes('\n')) { command(text); return; }
915
+ if (s.turn && !R.current.caps.steer) { // steering already refused for this turn: queue, say so once
916
+ dispatch({ type: 'queue.add', text });
917
+ if (!R.current.steerNoted) { R.current.steerNoted = true; say(STEER_OFF_NOTE, 'warn'); }
918
+ return;
919
+ }
920
+ if (s.turn) { steer(text); return; }
921
+ submit(text);
922
+ }
923
+
924
+ /** Returns true when the text was consumed as an approval key, false when it is ordinary typing. */
925
+ function approvalKey(name, text, now) {
926
+ const card = S.current.approvals[0];
927
+ if (!card) return false;
928
+ const ch = text || '';
929
+ const g = K.current.guard;
930
+ const armedAt = card.displayedAt ?? card.shownAt;
931
+ // With a draft in progress, a/d/o are ordinary text unless the card has focus (Tab).
932
+ if (B.current.text && !K.current.cardFocus) return false;
933
+ if (card.undescribable) {
934
+ // no one-key approve for an action Quox cannot describe: the word must be typed, fresh
935
+ if (ch === 'd' && approvalKeyAllowed({ now, shownAt: armedAt, lastKeyAt: K.current.lastKeyAt, heldKey: g?.heldKey, ch })) { answer(card, 'reject'); return true; }
936
+ if (now - armedAt < 300) return true;
937
+ K.current.typed = (K.current.typed + ch.toLowerCase()).slice(-APPROVE_WORD.length);
938
+ if (K.current.typed === APPROVE_WORD) { K.current.typed = ''; answer(card, 'approve'); }
939
+ return true;
940
+ }
941
+ if (!['a', 'd', 'o'].includes(ch)) return false; // ordinary typing: the draft keeps it
942
+ if (!approvalKeyAllowed({ now, shownAt: armedAt, lastKeyAt: K.current.lastKeyAt, heldKey: g?.heldKey, ch })) return true;
943
+ if (ch === 'o') { say(`open: ${card.url || '(no link)'}`); return true; }
944
+ const action = ch === 'a' ? 'approve' : 'reject';
945
+ answer(card, action);
946
+ return true;
947
+ }
948
+
949
+ const rosterOn = () => { const ro = R.current.roster; return ro.on && ro.rows.length > 0 && rosterView({ rows: ro.rows, cols, termRows: termRows(stdout) ?? 40 }).mode === 'full'; };
950
+ function focusRoster() { const ro = R.current.roster; ro.focus = true; ro.sel = Math.min(ro.sel, Math.min(ro.rows.length, MAX_ROSTER) - 1); ro.peek = false; ro.confirm = null; bump(n => n + 1); }
951
+ function leaveRoster() { const ro = R.current.roster; ro.focus = false; ro.peek = false; ro.confirm = null; bump(n => n + 1); }
952
+ /** Keys while the roster has focus. Returns true when consumed. */
953
+ function rosterKey(name, text) {
954
+ const ro = R.current.roster;
955
+ const visible = Math.min(ro.rows.length, MAX_ROSTER);
956
+ if (!visible) { leaveRoster(); return false; }
957
+ if (ro.confirm) { // any key answers; only y stops
958
+ const row = ro.confirm; ro.confirm = null;
959
+ if (text === 'y') stopRow(row); else { say('Stop cancelled.'); bump(n => n + 1); }
960
+ return true;
961
+ }
962
+ if (name === 'up') { ro.sel = Math.max(0, ro.sel - 1); ro.peek = false; bump(n => n + 1); return true; }
963
+ if (name === 'down') { ro.sel = Math.min(visible - 1, ro.sel + 1); ro.peek = false; bump(n => n + 1); return true; }
964
+ if (name === 'enter') { ro.peek = !ro.peek; bump(n => n + 1); if (ro.peek && ro.rows[ro.sel]?.kind === 'workflow') command(`/workflow ${ro.rows[ro.sel].id}`); return true; }
965
+ if (name === 'escape') { if (ro.peek) { ro.peek = false; bump(n => n + 1); } else leaveRoster(); return true; }
966
+ if (text === 's') { ro.confirm = ro.rows[ro.sel]; ro.peek = false; bump(n => n + 1); return true; }
967
+ if (text) { leaveRoster(); return false; } // ordinary typing goes to the draft
968
+ return true; // other keys are swallowed while focused
969
+ }
970
+ async function stopRow(row) {
971
+ const ro = R.current.roster;
972
+ if (!api.stopRow) return say('Stop not available on this server.', 'warn');
973
+ const r = await api.stopRow(row);
974
+ if (r.unavailable) return say(`Stop not available for ${row.kind} "${clip(row.name, 40)}": no governed stop route exists.`, 'warn');
975
+ say(r.message, r.ok ? undefined : 'warn');
976
+ if (r.ok) { refreshRoster(); }
977
+ return ro;
978
+ }
979
+
980
+ function onKey(e, now) {
981
+ const s = S.current;
982
+ const busy = Boolean(s.turn);
983
+ const name = e.name;
984
+ if (name === 'ctrl-c') {
985
+ if (B.current.text) { edit({ type: 'clear' }); return; }
986
+ if (busy) { interrupt(); return; }
987
+ if (K.current.ctrlC) return exit();
988
+ K.current.ctrlC = 1; say('Press Ctrl+C again to quit.');
989
+ clearTimeout(R.current.ctrlCTimer);
990
+ R.current.ctrlCTimer = setTimeout(() => { K.current.ctrlC = 0; }, 1500);
991
+ return;
992
+ }
993
+ if (name === 'ctrl-d') { if (!B.current.text) exit(); return; }
994
+ if (R.current.roster.focus && !s.approvals.length && rosterKey(name)) return;
995
+ if (!s.approvals.length && PL.current.onKey(name)) { bump(n => n + 1); return; }
996
+ if (name === 'escape') {
997
+ if (busy) { interrupt(); return; }
998
+ if (now - K.current.lastEscAt < ESC_ESC_MS && B.current.text) {
999
+ if (looksLikeKey(B.current.text)) { edit({ type: 'clear' }); say('Draft cleared. It looked like a key, so it was not kept.'); }
1000
+ else { R.current.history.push(B.current.text); edit({ type: 'clear' }); say('Draft cleared (Up recalls it).'); }
1001
+ }
1002
+ K.current.lastEscAt = now; return;
1003
+ }
1004
+ // with a card up only draft-editing keys work (so the draft can be cleared); nothing submits or recalls
1005
+ if (s.approvals.length && !CARD_EDIT_KEYS.includes(name)) return;
1006
+ switch (name) {
1007
+ case 'enter': return onEnter(now);
1008
+ case 'shift-enter': case 'newline': return edit({ type: 'newline' });
1009
+ case 'tab':
1010
+ if (busy && B.current.text.trim()) { const t = sanitizeForSend(B.current); if (!t.ok || looksLikeKey(t.text)) return onEnter(now); dispatch({ type: 'queue.add', text: t.text.trim() }); B.current = initialBuffer(); bump(n => n + 1); }
1011
+ return;
1012
+ case 'backspace': return edit({ type: 'backspace' });
1013
+ case 'delete': return edit({ type: 'delete' });
1014
+ case 'delete-word': return edit({ type: 'deleteWordBack' });
1015
+ case 'kill-line': return edit({ type: 'killLineBack' });
1016
+ case 'left': case 'right': case 'home': case 'end': return edit({ type: name });
1017
+ case 'word-left': case 'word-right': return edit({ type: name === 'word-left' ? 'left' : 'right' });
1018
+ case 'undo': return edit({ type: 'undo' });
1019
+ case 'redo': return edit({ type: 'redo' });
1020
+ case 'up': {
1021
+ if (B.current.text === '' && s.pending.length) { const last = s.pending[s.pending.length - 1]; dispatch({ type: 'queue.pop' }); edit({ type: 'set', text: last.text }); return; }
1022
+ if (!B.current.text.slice(0, B.current.cursor).includes('\n')) {
1023
+ const hist = R.current.history;
1024
+ if (B.current.text === '' || R.current.histIdx >= 0) {
1025
+ const i = R.current.histIdx < 0 ? hist.length - 1 : Math.max(0, R.current.histIdx - 1);
1026
+ if (hist[i] !== undefined) { R.current.histIdx = i; edit({ type: 'set', text: hist[i] }); }
1027
+ }
1028
+ return;
1029
+ }
1030
+ return edit({ type: 'up' });
1031
+ }
1032
+ case 'down': {
1033
+ if (B.current.text === '' && R.current.histIdx < 0 && rosterOn()) { focusRoster(); return; }
1034
+ if (!B.current.text.slice(B.current.cursor).includes('\n') && R.current.histIdx >= 0) {
1035
+ const i = R.current.histIdx + 1;
1036
+ if (i >= R.current.history.length) { R.current.histIdx = -1; edit({ type: 'clear' }); } else { R.current.histIdx = i; edit({ type: 'set', text: R.current.history[i] }); }
1037
+ return;
1038
+ }
1039
+ return edit({ type: 'down' });
1040
+ }
1041
+ case 'ctrl-g': {
1042
+ const full = sanitizeForSend({ ...B.current }).text ?? B.current.text;
1043
+ if (isRawModeSupported) setRawMode(false);
1044
+ const out = editor(full);
1045
+ if (isRawModeSupported) setRawMode(true);
1046
+ if (out !== null && out !== undefined) edit({ type: 'set', text: out });
1047
+ return;
1048
+ }
1049
+ case 'ctrl-s': {
1050
+ if (B.current.text) { R.current.stash.push(B.current.text); edit({ type: 'clear' }); say('Draft stashed. Ctrl+S on an empty composer restores it.'); }
1051
+ else if (R.current.stash.length) edit({ type: 'set', text: R.current.stash.pop() });
1052
+ return;
1053
+ }
1054
+ default:
1055
+ }
1056
+ }
1057
+
1058
+ const onData = React.useRef(null);
1059
+ const handleEvents = (events, now, burst) => {
1060
+ for (const e of events) {
1061
+ if (e.type === 'focus') { SND.current.focus(e.focused); continue; } // CSI I / CSI O, never a key
1062
+ if (CN.current.isOpen()) { CN.current.onEvent(e); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // the secure card takes every key
1063
+ if (PK.current.isOpen() && !S.current.approvals.length) { PK.current.onEvent(e); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; } // /resume, /config
1064
+ const card = S.current.approvals[0];
1065
+ const g = K.current.guard;
1066
+ // any different key ends the "this key was already down" suppression
1067
+ if (g?.heldKey && !(e.type === 'text' && e.text === g.heldKey)) g.heldKey = null;
1068
+ if (card && e.type === 'key' && e.name === 'tab') { if (B.current.text) { K.current.cardFocus = true; bump(n => n + 1); } K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
1069
+ if (card && e.type === 'key' && e.name === 'escape' && K.current.cardFocus) { K.current.cardFocus = false; bump(n => n + 1); K.current.lastKeyAt = now; K.current.lastKeyCh = null; continue; }
1070
+ if (e.type === 'paste') {
1071
+ K.current.lastPasteAt = now;
1072
+ edit({ type: 'paste', text: e.text }); // a card never discards a paste
1073
+ } else if (e.type === 'text') {
1074
+ if (card && approvalKey('text', e.text, now)) { /* consumed by the approval dialog */ }
1075
+ else if (!card && R.current.roster.focus && rosterKey(null, e.text)) { /* consumed by the roster */ }
1076
+ else edit({ type: 'insert', text: e.text });
1077
+ } else if (e.type === 'key') {
1078
+ if (card && !['escape', 'ctrl-c', ...CARD_EDIT_KEYS].includes(e.name)) { /* swallowed while approving */ }
1079
+ else if (e.name === 'enter' && burst) edit({ type: 'newline' }); // startup keys only: never submitted
1080
+ else onKey(e, now);
1081
+ }
1082
+ K.current.lastKeyAt = now;
1083
+ K.current.lastKeyCh = e.type === 'text' ? e.text : null;
1084
+ }
1085
+ };
1086
+ onData.current = (chunk) => {
1087
+ const now = Date.now();
1088
+ finishIntroRef.current();
1089
+ // Enter is a newline only inside a real paste (bracketed, or several lines in one chunk: keys.js).
1090
+ // Its timing relative to the previous chunk says nothing: tmux, expect and SSH batch segments.
1091
+ const burst = false;
1092
+ K.current.lastDataAt = now;
1093
+ const parsed = parseChunk(typeof chunk === 'string' ? chunk : chunk.toString('utf8'), K.current.keys);
1094
+ K.current.keys = parsed.state;
1095
+ clearTimeout(K.current.danglingTimer);
1096
+ if (hasDangling(parsed.state)) {
1097
+ K.current.danglingTimer = setTimeout(() => {
1098
+ const f = flushDangling(K.current.keys);
1099
+ K.current.keys = f.state;
1100
+ handleEvents(f.events, Date.now(), false); // as paste text: inserted, never submitted
1101
+ bump(n => n + 1);
1102
+ }, PASTE_DANGLING_MS);
1103
+ }
1104
+ handleEvents(parsed.events, now, burst);
1105
+ };
1106
+
1107
+ // QUOXON J2: `quox --conversation <id>` opens that conversation interactively.
1108
+ React.useEffect(() => {
1109
+ if (!resumeConversation || !api.conversationTurns) return undefined;
1110
+ let live = true;
1111
+ api.conversationTurns(resumeConversation).then((turns) => {
1112
+ if (!live) return;
1113
+ if (!turns.length) {
1114
+ // C5: the conversation's first turn may still be running (nothing is stored until it ends).
1115
+ return api.runningTurn?.(resumeConversation).then((r) => {
1116
+ if (!live) return;
1117
+ if (r?.running) { dispatch({ type: 'ctx', ctx: { conversationId: resumeConversation } }); followTurn(resumeConversation, r.turnId); return; }
1118
+ say(`No saved turns for conversation ${resumeConversation} (not yours, expired after 7 days, or a typo). Starting a new conversation.`, 'warn');
1119
+ }) ?? say(`No saved turns for conversation ${resumeConversation} (not yours, expired after 7 days, or a typo). Starting a new conversation.`, 'warn');
1120
+ }
1121
+ dispatch({ type: 'resume', conversationId: resumeConversation, turns });
1122
+ return reattachIfRunning(resumeConversation);
1123
+ }, (e) => { if (live) dispatch({ type: 'error', text: `could not load conversation ${resumeConversation}: ${e?.status ? `HTTP ${e.status}: ` : ''}${e?.message || 'failed'}` }); });
1124
+ return () => { live = false; };
1125
+ }, []);
1126
+ React.useEffect(() => {
1127
+ if (!startup) return undefined;
1128
+ let live = true;
1129
+ Promise.resolve().then(startup).then((note) => { if (live && note) say(note, 'warn'); }, () => {});
1130
+ return () => { live = false; };
1131
+ }, []);
1132
+ React.useEffect(() => { // --demo plays by itself
1133
+ if (!autoplay) return undefined;
1134
+ const t = setTimeout(() => { if (!S.current.turn && !R.current.closed) { dispatch({ type: 'user', text: autoplay }); runTurn(autoplay); } }, 600);
1135
+ return () => clearTimeout(t);
1136
+ }, []);
1137
+ React.useEffect(() => {
1138
+ if (!stdin) return undefined;
1139
+ if (isRawModeSupported) setRawMode(true);
1140
+ if (stdout?.isTTY) stdout.write(PASTE_ON);
1141
+ const fn = (d) => onData.current(d);
1142
+ if (earlyInput) { // keys typed during startup: into the draft as typed, never submitted, never a paste-submit
1143
+ earlyInput.detach();
1144
+ for (const chunk of earlyInput.chunks.splice(0)) {
1145
+ const parsed = parseChunk(chunk, K.current.keys);
1146
+ K.current.keys = parsed.state;
1147
+ handleEvents(parsed.events, Date.now(), true);
1148
+ }
1149
+ }
1150
+ stdin.on('data', fn);
1151
+ return () => {
1152
+ R.current.closed = true;
1153
+ R.current.abort?.abort(); // closing quoxon cancels the active stream (and its HTTP body)
1154
+ for (const k of ['fallbackTimer', 'ctrlCTimer']) clearTimeout(R.current[k]);
1155
+ clearTimeout(K.current.danglingTimer);
1156
+ for (const t of R.current.pollTimers) clearTimeout(t);
1157
+ R.current.pollTimers.clear();
1158
+ R.current.pollWake?.();
1159
+ stdin.off('data', fn);
1160
+ if (stdout?.isTTY) stdout.write(PASTE_OFF);
1161
+ SND.current?.dispose?.(); // focus reporting off: it must not outlive the client (or leak into a /restart child)
1162
+ if (isRawModeSupported) setRawMode(false);
1163
+ };
1164
+ }, [stdin]);
1165
+
1166
+ // ---- view ----------------------------------------------------------------------------------
1167
+ // Height of everything below the live blocks, measured after each layout; sizes the live region next frame.
1168
+ React.useLayoutEffect(() => { if (chromeRef.current) chromeH.current = measureElement(chromeRef.current).height; });
1169
+ // Frame cost (tick -> React commit), for measurement only (motionStats prop; tests and the bench).
1170
+ React.useLayoutEffect(() => {
1171
+ if (!motionStats || R.current.tickPerf == null) return;
1172
+ (motionStats.costs ||= []).push(performance.now() - R.current.tickPerf);
1173
+ R.current.tickPerf = null;
1174
+ });
1175
+ const s = S.current;
1176
+ const busy = Boolean(s.turn);
1177
+ // Reveals: register what just landed, then give <Static> every leading item whose reveal has ended.
1178
+ const nowR = Date.now();
1179
+ let pendingHist = [];
1180
+ if (introDone.current) {
1181
+ if (!motionOn) committedRef.current = s.history.length;
1182
+ else {
1183
+ for (let i = committedRef.current; i < s.history.length; i++) registerFx(s.history[i], nowR);
1184
+ for (const b of s.blocks) registerFx(b, nowR);
1185
+ // The newest protocol stage stays live (its icon pulses) while it is the last thing that happened in the turn.
1186
+ const activeStage = (i) => isActiveStage(s, i);
1187
+ while (committedRef.current < s.history.length && !fxActive(s.history[committedRef.current], nowR) && !activeStage(committedRef.current)) fxRef.current.delete(s.history[committedRef.current++].id);
1188
+ pendingHist = s.history.slice(committedRef.current);
1189
+ for (const r of R.current.roster.rows) if (!rosterSeen.current.has(r.key)) rosterSeen.current.set(r.key, nowR);
1190
+ }
1191
+ }
1192
+ lastAppRender.current = nowR;
1193
+ // Composer ring: motion and colour both on. Thinking/tools cyan-purple, writing green, approval amber.
1194
+ const ring = motionOn && !mono() ? { ref: ringRef, active: busy, mode: ringMode({ approvals: s.approvals.length, act: s.turn?.act }) } : null;
1195
+ const card = s.approvals[0];
1196
+ const armed = card ? Date.now() - (card.displayedAt ?? card.shownAt) >= 300 : false;
1197
+ R.current.armedShown = armed;
1198
+ const ctxParts = [profile, org, modelLabel(s.ctx, MC.current), demoLabel ? 'demo' : mode, R.current.rulesOn === false ? (rulesLabel(rules) ? 'rules: off' : null) : rulesLabel(rules)];
1199
+ const caps = R.current.caps;
1200
+ const hint = busy ? `${caps.steer ? 'Enter steer' : 'Enter queue'} · Tab queue · ${caps.interrupt ? 'Esc stop' : 'Esc disconnects'}` : 'Enter send · /help';
1201
+ const placeholder = busy ? (caps.steer ? 'Type to steer (Enter) or queue (Tab)' : 'Type to queue (Enter or Tab)') : 'Ask Quox anything…';
1202
+
1203
+ // The live region (unsettled blocks + chrome) must stay SHORTER than the screen: log-update can only
1204
+ // erase rows that are still on screen, and Ink's own tall-frame path loses a line of history (term.js).
1205
+ // A tall live tail shows its latest lines; the whole block prints to <Static> in full once it settles.
1206
+ function liveRegion(kids) {
1207
+ const rows = termRows(stdout);
1208
+ if (!rows || !kids.length) return kids;
1209
+ const max = Math.max(2, rows - (chromeH.current ?? 8) - 2);
1210
+ if (liveRows([...pendingHist, ...s.blocks], cols) <= max) return kids;
1211
+ return [
1212
+ h(Box, { key: 'live-cap', paddingLeft: 2 }, h(Text, { color: c('dim'), wrap: 'truncate-end' }, clip('⋮ latest lines; the full reply prints when this part ends', cols - 2))),
1213
+ h(Box, { key: 'live-clip', height: max - 1, overflow: 'hidden', flexDirection: 'column', justifyContent: 'flex-end' },
1214
+ h(Box, { flexDirection: 'column', flexShrink: 0 }, ...kids))
1215
+ ];
1216
+ }
1217
+
1218
+ function rosterNode() {
1219
+ const ro = R.current.roster;
1220
+ const view = rosterView({ rows: ro.rows, cols, termRows: termRows(stdout) ?? 40, max: MAX_ROSTER, on: ro.on, sel: ro.sel, focus: ro.focus, now: Date.now() });
1221
+ const row = ro.rows[ro.sel];
1222
+ return h(Roster, {
1223
+ view, cols,
1224
+ peek: ro.focus && ro.peek ? peekLines(row, Date.now()) : [],
1225
+ fx: motionOn ? (key) => rosterFx(key, Date.now()) : null,
1226
+ confirm: ro.focus && ro.confirm ? `Stop ${ro.confirm.kind} "${clip(ro.confirm.name, 30)}"? y to stop, any other key cancels` : null,
1227
+ hint: ro.focus && !ro.confirm ? '↑↓ select · Enter peek · s stop · Esc back' : null
1228
+ });
1229
+ }
1230
+
1231
+ return h(FrameContext.Provider, { value: framesRef.current }, h(Box, { flexDirection: 'column' },
1232
+ // Nothing enters <Static> before the intro settles: Static is index-based, so prepending the intro to
1233
+ // already-printed items would drop it (and reprint the rest).
1234
+ h(Static, { key: `static-${epoch}`, items: introDone.current ? s.history.slice(0, committedRef.current) : [] }, (it) => h(Box, { key: it.id, flexDirection: 'column' }, h(HistoryItem, { item: it, cols }))),
1235
+ introDone.current ? null : h(Box, { key: 'intro-live', flexDirection: 'column' }, h(Intro, { item: introItem, cols, eye, progress: Math.min(1, (Date.now() - introStart.current) / introMs) })),
1236
+ liveRegion([
1237
+ ...pendingHist.map((it, i) => h(Box, { key: it.id, flexDirection: 'column' }, h(FxItem, { item: it, cols, fxFor, active: busy && it.kind === 'trace' && i === pendingHist.length - 1 && !s.blocks.length }))),
1238
+ ...s.blocks.map(b => h(Box, { key: b.id, flexDirection: 'column' }, motionOn && revealTimingFor(b) ? h(FxItem, { item: b, cols, live: true, fxFor }) : h(HistoryItem, { item: b, cols, live: true })))]),
1239
+ h(EyeLive, { key: 'eye', cols }),
1240
+ h(Box, { key: 'chrome', ref: chromeRef, flexDirection: 'column' },
1241
+ busy ? h(Working, { key: 'working', motion: motionOn, tokens: turnTokens(s.turn), act: s.turn.act, activity: describeActivity(s), since: s.turn.startedAt, interrupting: s.turn.status === 'interrupting', escLabel: caps.interrupt ? null : 'Esc disconnects' }) : null,
1242
+ card ? h(ApprovalCard, { card, org: org || api.orgId?.() || null, cols, armed, motionE: motionOn && nowR - (card.displayedAt ?? card.shownAt) < 1000 ? (nowR - (card.displayedAt ?? card.shownAt)) / 1000 : null, draftHint: B.current.text ? (K.current.cardFocus ? 'focus' : 'draft') : null }) : null,
1243
+ // TODO(contract v2): background agents. `background.status` is NOT in events v1, so nothing sets s.background today.
1244
+ s.background > 0 ? h(Box, { paddingLeft: 2 }, h(Text, { color: c('dim') }, `Waiting for ${s.background} background agent${s.background === 1 ? '' : 's'} to finish`)) : null,
1245
+ h(Pending, { pending: s.pending, steering: s.steering, cols }),
1246
+ h(Box, { marginTop: 1 }),
1247
+ CN.current.isOpen() ? h(ConnectCard, { ...CN.current.view(), cols })
1248
+ : ring ? h(RingComposer, { buf: B.current, cols, mode: demoLabel ? 'demo' : mode, busy, placeholder, name: SN.current.name, ring })
1249
+ : h(Composer, { buf: B.current, cols, mode: demoLabel ? 'demo' : mode, busy, placeholder, name: SN.current.name }),
1250
+ !CN.current.isOpen() && PK.current.isOpen() && !card ? h(ListView, { ...PK.current.view(), cols }) : !CN.current.isOpen() && PL.current.isOpen() && !card ? h(ListView, { ...PL.current.view(), cols }) : null,
1251
+ h(StatusLine, { cols, slot: where.current.slot, agent: where.current.agent, parts: ctxParts, right: hint }),
1252
+ h(NotifyBar, { approvals: s.notify.approvals, error: s.notify.error, cols, signedOut: Boolean(blocked), alerts: R.current.roster.all ? [] : R.current.roster.alerts }),
1253
+ rosterNode())));
1254
+ }
1255
+
1256
+ /** A live item that may be revealing: re-renders on every clock frame by itself (not the app). */
1257
+ function FxItem({ item, cols, live = false, fxFor, active = false }) {
1258
+ const now = useFrame();
1259
+ const pulse = (item.kind === 'tool' && item.status === 'running') || active ? now : null;
1260
+ return h(HistoryItem, { item, cols, live, fx: fxFor(item, now), pulse });
1261
+ }
1262
+ export { COLORS };