quoxcli 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (327) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +2 -0
  3. package/bin/quox.js +99 -126
  4. package/lib/api/endpoints/admin.js +26 -0
  5. package/lib/api/endpoints/agent-schedules.js +26 -0
  6. package/lib/api/endpoints/agents.js +7 -0
  7. package/lib/api/endpoints/assistants.js +12 -0
  8. package/lib/api/endpoints/away.js +15 -0
  9. package/lib/api/endpoints/chat.js +7 -0
  10. package/lib/api/endpoints/contactsq.js +2 -0
  11. package/lib/api/endpoints/conversations.js +14 -0
  12. package/lib/api/endpoints/executions.js +13 -0
  13. package/lib/api/endpoints/fetchAllowlist.js +27 -0
  14. package/lib/api/endpoints/files.js +14 -0
  15. package/lib/api/endpoints/hat.js +30 -0
  16. package/lib/api/endpoints/inbox.js +20 -0
  17. package/lib/api/endpoints/index.js +14 -0
  18. package/lib/api/endpoints/memory.js +19 -0
  19. package/lib/api/endpoints/mind.js +13 -0
  20. package/lib/api/endpoints/mon.js +6 -0
  21. package/lib/api/endpoints/objectives.js +6 -0
  22. package/lib/api/endpoints/plugins.js +18 -0
  23. package/lib/api/endpoints/presence.js +33 -0
  24. package/lib/api/endpoints/quoxplan.js +7 -0
  25. package/lib/api/endpoints/quoxseo.js +6 -0
  26. package/lib/api/endpoints/router.js +17 -0
  27. package/lib/api/endpoints/safety.js +7 -0
  28. package/lib/api/endpoints/skills.js +20 -0
  29. package/lib/api/endpoints/stepup.js +20 -0
  30. package/lib/api/endpoints/teams.js +2 -2
  31. package/lib/api/endpoints/triggers.js +26 -0
  32. package/lib/api/endpoints/v1.js +86 -0
  33. package/lib/api/endpoints/ward.js +7 -0
  34. package/lib/api/endpoints/workflows.js +7 -0
  35. package/lib/api/endpoints/x.js +8 -0
  36. package/lib/commands/actions.js +61 -0
  37. package/lib/commands/admin.js +16 -0
  38. package/lib/commands/agent.js +163 -27
  39. package/lib/commands/agentAttach.js +94 -6
  40. package/lib/commands/approvals.js +73 -1
  41. package/lib/commands/aside.js +24 -0
  42. package/lib/commands/ask.js +144 -0
  43. package/lib/commands/assistant.js +59 -4
  44. package/lib/commands/auth.js +8 -2
  45. package/lib/commands/backup.js +8 -4
  46. package/lib/commands/brain.js +105 -14
  47. package/lib/commands/build.js +19 -5
  48. package/lib/commands/chat.js +57 -2
  49. package/lib/commands/claude.js +34 -8
  50. package/lib/commands/contacts.js +49 -0
  51. package/lib/commands/conversation.js +35 -4
  52. package/lib/commands/dashboard.js +43 -0
  53. package/lib/commands/department.js +28 -7
  54. package/lib/commands/devResident.js +15 -3
  55. package/lib/commands/devSupervisor.js +13 -1
  56. package/lib/commands/discord.js +75 -39
  57. package/lib/commands/done.js +98 -28
  58. package/lib/commands/engagement.js +17 -6
  59. package/lib/commands/fetchAllowlist.js +71 -0
  60. package/lib/commands/file.js +38 -1
  61. package/lib/commands/fleet.js +311 -4
  62. package/lib/commands/governance.js +12 -3
  63. package/lib/commands/gripe.js +44 -13
  64. package/lib/commands/hat.js +178 -9
  65. package/lib/commands/helpers.js +18 -1
  66. package/lib/commands/hyperion.js +58 -0
  67. package/lib/commands/inbox.js +74 -23
  68. package/lib/commands/licence.js +14 -155
  69. package/lib/commands/lucid.js +10 -4
  70. package/lib/commands/memory.js +85 -8
  71. package/lib/commands/mind.js +71 -0
  72. package/lib/commands/mon.js +69 -2
  73. package/lib/commands/monitoring.js +1 -1
  74. package/lib/commands/motion.js +2 -2
  75. package/lib/commands/nucleus.js +48 -17
  76. package/lib/commands/objective.js +11 -0
  77. package/lib/commands/offload.js +89 -0
  78. package/lib/commands/onboard.js +2 -1
  79. package/lib/commands/org.js +11 -1
  80. package/lib/commands/peek.js +28 -0
  81. package/lib/commands/plan.js +38 -0
  82. package/lib/commands/plugin.js +110 -0
  83. package/lib/commands/presence.js +89 -0
  84. package/lib/commands/provider.js +159 -0
  85. package/lib/commands/qlarity.js +19 -3
  86. package/lib/commands/quoxchat.js +13 -4
  87. package/lib/commands/quoxseo.js +38 -0
  88. package/lib/commands/reach.js +2 -1
  89. package/lib/commands/router.js +77 -0
  90. package/lib/commands/rows.js +358 -0
  91. package/lib/commands/run.js +64 -9
  92. package/lib/commands/server.js +292 -0
  93. package/lib/commands/setup.js +33 -7
  94. package/lib/commands/skills.js +48 -5
  95. package/lib/commands/slot.js +260 -7
  96. package/lib/commands/status.js +1 -1
  97. package/lib/commands/team.js +3 -1
  98. package/lib/commands/tips.js +23 -0
  99. package/lib/commands/tool.js +133 -45
  100. package/lib/commands/trace.js +12 -7
  101. package/lib/commands/trigger.js +95 -0
  102. package/lib/commands/update.js +46 -0
  103. package/lib/commands/vault.js +31 -8
  104. package/lib/commands/vm.js +237 -0
  105. package/lib/commands/voice.js +34 -3
  106. package/lib/commands/volt.js +45 -3
  107. package/lib/commands/walkthrough.js +32 -0
  108. package/lib/commands/watch.js +73 -21
  109. package/lib/commands/welcome.js +14 -0
  110. package/lib/commands/workflow.js +189 -32
  111. package/lib/commands/x.js +13 -4
  112. package/lib/core/actingAs.js +2 -2
  113. package/lib/core/activeSummary.js +186 -0
  114. package/lib/core/aside.js +54 -0
  115. package/lib/core/askDecision.js +60 -0
  116. package/lib/core/auth.js +164 -13
  117. package/lib/core/callerSource.js +1 -1
  118. package/lib/core/cliBuild.js +62 -0
  119. package/lib/core/client.js +144 -35
  120. package/lib/core/commandRegistrars.js +121 -0
  121. package/lib/core/config.js +142 -24
  122. package/lib/core/customHats.js +168 -0
  123. package/lib/core/doneEngineFetch.js +8 -0
  124. package/lib/core/errors.js +329 -20
  125. package/lib/core/global-flags.js +105 -5
  126. package/lib/core/hyperion.js +102 -0
  127. package/lib/core/inboxResolution.js +65 -0
  128. package/lib/core/lazyCommands.js +4 -1
  129. package/lib/core/offload.js +504 -0
  130. package/lib/core/org-override.js +1 -1
  131. package/lib/core/orgResolve.js +70 -0
  132. package/lib/core/output.js +4 -4
  133. package/lib/core/peek.js +126 -0
  134. package/lib/core/profileUsers.js +51 -0
  135. package/lib/core/promptCancel.js +18 -0
  136. package/lib/core/repoPath.js +11 -0
  137. package/lib/core/residentSeed.js +3 -1
  138. package/lib/core/rootOptions.js +39 -0
  139. package/lib/core/router.js +168 -0
  140. package/lib/core/selfUpdate.js +188 -0
  141. package/lib/core/sessionsOverview.js +64 -0
  142. package/lib/core/slotKeeper.js +248 -3
  143. package/lib/core/slotWake.js +120 -0
  144. package/lib/core/supervisorEngine.js +71 -2
  145. package/lib/core/version.js +1 -1
  146. package/lib/core/vm.js +361 -0
  147. package/lib/core/vmTui.js +86 -0
  148. package/lib/core/walkthrough.js +35 -0
  149. package/lib/hyperion/deck.js +29 -0
  150. package/lib/lucid/LucidCard.jsx +68 -0
  151. package/lib/lucid/LucidIntro.jsx +45 -58
  152. package/lib/lucid/cards.js +86 -0
  153. package/lib/lucid/markdown.js +81 -0
  154. package/lib/lucid/motion.js +56 -0
  155. package/lib/quoxon/activity.js +14 -2
  156. package/lib/quoxon/aocl.js +117 -0
  157. package/lib/quoxon/api.js +504 -25
  158. package/lib/quoxon/app.js +3000 -230
  159. package/lib/quoxon/approvalFacts.js +94 -0
  160. package/lib/quoxon/approvalsView.js +325 -0
  161. package/lib/quoxon/asideView.js +40 -0
  162. package/lib/quoxon/ask.js +121 -0
  163. package/lib/quoxon/away.js +52 -0
  164. package/lib/quoxon/bang.js +224 -0
  165. package/lib/quoxon/bulkRisk.js +17 -0
  166. package/lib/quoxon/capabilities.js +13 -6
  167. package/lib/quoxon/cells.js +53 -0
  168. package/lib/quoxon/commandShort.js +112 -0
  169. package/lib/quoxon/commands.js +363 -38
  170. package/lib/quoxon/connect.js +210 -23
  171. package/lib/quoxon/connectCard.js +19 -2
  172. package/lib/quoxon/consent.js +86 -0
  173. package/lib/quoxon/copy.js +4 -3
  174. package/lib/quoxon/countdownLine.js +11 -0
  175. package/lib/quoxon/demo.autoplay.js +29 -0
  176. package/lib/quoxon/demo.fixtures.js +1 -1
  177. package/lib/quoxon/demo.js +44 -3
  178. package/lib/quoxon/devObserver.js +20 -0
  179. package/lib/quoxon/dividerAocl.js +191 -0
  180. package/lib/quoxon/dividerRule.js +57 -0
  181. package/lib/quoxon/dragonModes.js +150 -0
  182. package/lib/quoxon/dragonView.js +41 -0
  183. package/lib/quoxon/earlyInput.js +40 -0
  184. package/lib/quoxon/echo.js +15 -0
  185. package/lib/quoxon/egg.js +2 -2
  186. package/lib/quoxon/events.js +244 -25
  187. package/lib/quoxon/feedback.js +50 -0
  188. package/lib/quoxon/gallery.js +21 -7
  189. package/lib/quoxon/handoffOffer.js +78 -0
  190. package/lib/quoxon/hatSwitch.js +37 -0
  191. package/lib/quoxon/headless.js +161 -0
  192. package/lib/quoxon/hostUrls.js +41 -0
  193. package/lib/quoxon/indicator.js +6 -2
  194. package/lib/quoxon/intent.js +12 -1
  195. package/lib/quoxon/keys.js +49 -6
  196. package/lib/quoxon/launcher.js +103 -0
  197. package/lib/quoxon/main.js +92 -160
  198. package/lib/quoxon/markdown.js +45 -21
  199. package/lib/quoxon/markdownSettle.js +19 -0
  200. package/lib/quoxon/matrixDomains.json +46 -0
  201. package/lib/quoxon/mentions.js +90 -0
  202. package/lib/quoxon/mind.js +103 -0
  203. package/lib/quoxon/mode.js +46 -0
  204. package/lib/quoxon/model.js +132 -26
  205. package/lib/quoxon/modelPrefs.js +345 -0
  206. package/lib/quoxon/motion/clock.js +3 -2
  207. package/lib/quoxon/motion/level.js +91 -0
  208. package/lib/quoxon/mouse.js +211 -0
  209. package/lib/quoxon/noai.js +57 -0
  210. package/lib/quoxon/objects/away.js +72 -0
  211. package/lib/quoxon/objects/fetch.js +17 -5
  212. package/lib/quoxon/objects/fleet.js +149 -0
  213. package/lib/quoxon/objects/health.js +186 -0
  214. package/lib/quoxon/objects/index.js +24 -3
  215. package/lib/quoxon/objects/mirror.js +137 -0
  216. package/lib/quoxon/objects/peek.js +17 -0
  217. package/lib/quoxon/objects/prove.js +130 -0
  218. package/lib/quoxon/objects/replay.js +125 -0
  219. package/lib/quoxon/objects/sitrep.js +58 -0
  220. package/lib/quoxon/objects/view.js +11 -0
  221. package/lib/quoxon/onboarding/copy.js +32 -4
  222. package/lib/quoxon/onboarding/credential.js +17 -4
  223. package/lib/quoxon/onboarding/dockerOffer.js +45 -0
  224. package/lib/quoxon/onboarding/failureCause.js +33 -0
  225. package/lib/quoxon/onboarding/finish.js +15 -0
  226. package/lib/quoxon/onboarding/index.js +34 -7
  227. package/lib/quoxon/onboarding/installRunner.js +64 -5
  228. package/lib/quoxon/onboarding/preflight.js +17 -3
  229. package/lib/quoxon/onboarding/probe.js +65 -0
  230. package/lib/quoxon/onboarding/view.js +76 -19
  231. package/lib/quoxon/orgLabel.js +10 -0
  232. package/lib/quoxon/orgSwitch.js +33 -0
  233. package/lib/quoxon/ownBroadcast.js +22 -0
  234. package/lib/quoxon/palette.js +43 -0
  235. package/lib/quoxon/print.js +9 -5
  236. package/lib/quoxon/profileSwitch.js +53 -0
  237. package/lib/quoxon/promptHistory.js +41 -0
  238. package/lib/quoxon/providers.js +48 -0
  239. package/lib/quoxon/qart.js +20 -51
  240. package/lib/quoxon/qdragon.js +410 -0
  241. package/lib/quoxon/qdragonFly.js +61 -0
  242. package/lib/quoxon/qdragonScenes.js +282 -0
  243. package/lib/quoxon/qeyes.js +213 -0
  244. package/lib/quoxon/qmotion.js +58 -37
  245. package/lib/quoxon/render.js +638 -119
  246. package/lib/quoxon/resend.js +72 -0
  247. package/lib/quoxon/roster.js +95 -9
  248. package/lib/quoxon/route.js +6 -5
  249. package/lib/quoxon/rows.js +711 -0
  250. package/lib/quoxon/rowsStore.js +80 -0
  251. package/lib/quoxon/screenContext.js +150 -0
  252. package/lib/quoxon/screenFrames.js +254 -0
  253. package/lib/quoxon/session.js +172 -2
  254. package/lib/quoxon/sessionDead.js +17 -0
  255. package/lib/quoxon/signin.js +125 -0
  256. package/lib/quoxon/slash/agents.js +112 -0
  257. package/lib/quoxon/slash/argSources.js +65 -0
  258. package/lib/quoxon/slash/args.js +65 -0
  259. package/lib/quoxon/slash/automate.js +89 -0
  260. package/lib/quoxon/slash/broadcast.js +140 -0
  261. package/lib/quoxon/slash/broadcastRun.js +108 -0
  262. package/lib/quoxon/slash/card.js +30 -0
  263. package/lib/quoxon/slash/challenge.js +114 -0
  264. package/lib/quoxon/slash/cliCommand.js +53 -0
  265. package/lib/quoxon/slash/cliLeaves.json +948 -0
  266. package/lib/quoxon/slash/compact.js +1 -1
  267. package/lib/quoxon/slash/confirm.js +30 -0
  268. package/lib/quoxon/slash/crystallize.js +69 -0
  269. package/lib/quoxon/slash/dashboard.js +136 -0
  270. package/lib/quoxon/slash/draft.js +54 -0
  271. package/lib/quoxon/slash/hatWalk.js +206 -0
  272. package/lib/quoxon/slash/inverses.js +77 -0
  273. package/lib/quoxon/slash/killer.js +24 -0
  274. package/lib/quoxon/slash/killerApi.js +16 -0
  275. package/lib/quoxon/slash/ledger.js +13 -8
  276. package/lib/quoxon/slash/listView.js +3 -1
  277. package/lib/quoxon/slash/modelCmd.js +261 -0
  278. package/lib/quoxon/slash/palette.js +21 -7
  279. package/lib/quoxon/slash/paletteController.js +117 -7
  280. package/lib/quoxon/slash/panels.js +21 -6
  281. package/lib/quoxon/slash/picker.js +20 -2
  282. package/lib/quoxon/slash/restart.js +71 -13
  283. package/lib/quoxon/slash/rowCmd.js +157 -0
  284. package/lib/quoxon/slash/rowWalk.js +175 -0
  285. package/lib/quoxon/slash/settings.js +16 -6
  286. package/lib/quoxon/slash/spawn.js +177 -0
  287. package/lib/quoxon/slash/stepUp.js +274 -0
  288. package/lib/quoxon/slash/stepUpCard.js +60 -0
  289. package/lib/quoxon/slash/stepUpCmds.js +78 -0
  290. package/lib/quoxon/slash/undoApi.js +38 -0
  291. package/lib/quoxon/slash/undoCmd.js +49 -0
  292. package/lib/quoxon/slash/undoWish.js +15 -0
  293. package/lib/quoxon/slash/watch.js +164 -0
  294. package/lib/quoxon/slash/wish.js +23 -0
  295. package/lib/quoxon/slash/wishCmd.js +32 -0
  296. package/lib/quoxon/slotChat.js +300 -0
  297. package/lib/quoxon/slotFormat.js +195 -0
  298. package/lib/quoxon/slotRun.js +131 -0
  299. package/lib/quoxon/sound/mapping.js +2 -1
  300. package/lib/quoxon/stray.js +22 -0
  301. package/lib/quoxon/suggestCommand.js +59 -0
  302. package/lib/quoxon/term.js +46 -11
  303. package/lib/quoxon/textBuffer.js +176 -2
  304. package/lib/quoxon/theme.js +58 -25
  305. package/lib/quoxon/tips.js +433 -0
  306. package/lib/quoxon/toolError.js +41 -0
  307. package/lib/quoxon/transcript.js +42 -4
  308. package/lib/quoxon/web/qface.js +18 -10
  309. package/lib/quoxon/welcome.js +1 -1
  310. package/lib/quoxon/whatsnew.js +136 -0
  311. package/lib/quoxon/whatsnew.jsonl +89 -0
  312. package/lib/quoxon/windowOrg.js +113 -0
  313. package/lib/server/diag.js +129 -0
  314. package/lib/server/doctor.js +71 -0
  315. package/lib/server/install.js +149 -0
  316. package/lib/server/pluginEnable.js +161 -0
  317. package/lib/server/skew.js +56 -0
  318. package/lib/server/snapshot.js +131 -0
  319. package/lib/server/status.js +128 -0
  320. package/lib/server/sys.js +65 -0
  321. package/lib/server/uninstall.js +63 -0
  322. package/lib/server/update.js +234 -0
  323. package/lib/toolOutputShape.js +315 -0
  324. package/lib/tui/views/HyperionDeck.jsx +79 -0
  325. package/lib/tui/views/LucidDeck.jsx +209 -217
  326. package/package.json +4 -1
  327. package/lib/core/websiteSession.js +0 -148
@@ -0,0 +1,195 @@
1
+ /**
2
+ * SLOT-RUN A (owner 2026-10-16, about a slot message that arrived as a wrapped wall of text around a long
3
+ * shell command: "messy needs a format, structure, styling"). Incoming slot message bodies render light
4
+ * markdown: paragraphs, - bullets, **bold**, `inline code` and fenced blocks.
5
+ *
6
+ * Code is never wrapped mid-token. A long inline command is lifted into its own block; a block line that does
7
+ * not fit breaks ONLY at unquoted whitespace and ends in " \" (shell line continuation), so a copied block is
8
+ * still the same command. A single token longer than the row hard-breaks with a visible " ↩".
9
+ *
10
+ * Pure layout: rows of segments { t, role, bold? }. render.js paints them; no grey roles (owner rule).
11
+ */
12
+ import { graphemes } from './textBuffer.js';
13
+
14
+ export const CONT = ' \\';
15
+ export const HARD = ' ↩';
16
+ export const GUTTER = '│ ';
17
+ export const LONG_INLINE = 32; // an inline code span longer than this gets its own block
18
+
19
+ const len = (s) => graphemes(String(s)).length;
20
+
21
+ /** Where a line may break: unquoted, unescaped whitespace before any unquoted # comment. */
22
+ function safeBreaks(chars) {
23
+ const safe = new Array(chars.length).fill(false);
24
+ let q = null; let comment = false;
25
+ for (let i = 0; i < chars.length; i++) {
26
+ const ch = chars[i];
27
+ if (comment) continue;
28
+ if (q === "'") { if (ch === "'") q = null; continue; }
29
+ if (q === '"') { if (ch === '\\') i++; else if (ch === '"') q = null; continue; }
30
+ if (ch === '\\') { i++; continue; }
31
+ if (ch === "'" || ch === '"') { q = ch; continue; }
32
+ if (ch === '#' && (i === 0 || /\s/.test(chars[i - 1]))) { comment = true; continue; }
33
+ if (/\s/.test(ch)) safe[i] = true;
34
+ }
35
+ return safe;
36
+ }
37
+
38
+ /** One command line as rows of at most `width` columns. Continuation rows are indented two spaces. */
39
+ export function wrapShell(line, width) {
40
+ const w = Math.max(12, width);
41
+ const chars = [...String(line)];
42
+ if (chars.length <= w) return [chars.join('')];
43
+ const safe = safeBreaks(chars);
44
+ const out = [];
45
+ let start = 0;
46
+ for (;;) {
47
+ const indent = out.length ? ' ' : '';
48
+ const avail = w - indent.length;
49
+ if (chars.length - start <= avail) { out.push(indent + chars.slice(start).join('')); break; }
50
+ const lim = avail - CONT.length;
51
+ let cut = -1;
52
+ for (let i = start + lim; i > start; i--) if (safe[i]) { cut = i; break; }
53
+ if (cut > start) {
54
+ out.push(indent + chars.slice(start, cut).join('').replace(/\s+$/, '') + CONT);
55
+ start = cut;
56
+ while (start < chars.length && safe[start]) start++;
57
+ } else {
58
+ const n = avail - HARD.length;
59
+ out.push(indent + chars.slice(start, start + n).join('') + HARD);
60
+ start += n;
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /** Inline runs of one line: plain text, **bold**, `code`. */
67
+ function inlineRuns(text) {
68
+ const out = [];
69
+ const re = /(`+)([^`]+?)\1|\*\*([^*]+?)\*\*/g;
70
+ let last = 0; let m;
71
+ const s = String(text);
72
+ while ((m = re.exec(s))) {
73
+ if (m.index > last) out.push({ kind: 'text', t: s.slice(last, m.index) });
74
+ if (m[1]) out.push({ kind: 'code', t: m[2].trim() });
75
+ else out.push({ kind: 'bold', t: m[3] });
76
+ last = m.index + m[0].length;
77
+ }
78
+ if (last < s.length) out.push({ kind: 'text', t: s.slice(last) });
79
+ return out;
80
+ }
81
+
82
+ /** Block structure: code (fenced, `$ ` lines, long inline spans), bullets, paragraphs, gaps. */
83
+ function blocks(text) {
84
+ const src = String(text ?? '').replace(/\r\n?/g, '\n').split('\n');
85
+ const out = [];
86
+ const push = (b) => {
87
+ if (b.type === 'gap' && (!out.length || out[out.length - 1].type === 'gap')) return;
88
+ out.push(b);
89
+ };
90
+ for (let i = 0; i < src.length; i++) {
91
+ const line = src[i];
92
+ const fence = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
93
+ if (fence) {
94
+ const lines = [];
95
+ for (i++; i < src.length && !src[i].trim().startsWith(fence[1]); i++) lines.push(src[i]);
96
+ push({ type: 'code', lines });
97
+ continue;
98
+ }
99
+ if (/^\s*\$ \S/.test(line)) {
100
+ const prev = out[out.length - 1];
101
+ if (prev?.type === 'code' && prev.dollar) prev.lines.push(line.trim()); else push({ type: 'code', lines: [line.trim()], dollar: true });
102
+ continue;
103
+ }
104
+ if (!line.trim()) { push({ type: 'gap' }); continue; }
105
+ const bullet = /^\s*(?:[-*•]|(\d+[.)]))\s+(.*)$/.exec(line);
106
+ const heading = /^\s*#{1,6}\s+(.*)$/.exec(line);
107
+ const kind = bullet ? 'bullet' : 'para';
108
+ const body = bullet ? bullet[2] : heading ? `**${heading[1]}**` : line;
109
+ const mark = bullet ? (bullet[1] ? `${bullet[1]} ` : '• ') : '';
110
+ // A long inline command is lifted out into its own block between the prose around it.
111
+ let pending = [];
112
+ let first = true;
113
+ const flush = () => {
114
+ if (!pending.length) return;
115
+ if (pending.some(r => r.kind !== 'text' || r.t.trim())) push({ type: kind, runs: pending, mark: first ? mark : (mark ? ' ' : '') });
116
+ first = false; pending = [];
117
+ };
118
+ for (const r of inlineRuns(body)) {
119
+ if (r.kind === 'code' && len(r.t) > LONG_INLINE) { flush(); push({ type: 'code', lines: [r.t] }); first = false; }
120
+ else pending.push(r);
121
+ }
122
+ flush();
123
+ }
124
+ while (out.length && out[out.length - 1].type === 'gap') out.pop();
125
+ return out;
126
+ }
127
+
128
+ /** Word-wrap inline runs to `width`; code spans are unbreakable atoms (hard-split only if wider than the row). */
129
+ function wrapRuns(runs, width, role, prefix) {
130
+ const atoms = [];
131
+ for (const r of runs) {
132
+ if (r.kind === 'code') { atoms.push({ t: r.t, role: 'code', sp: /^\s/.test(r.t) }); continue; }
133
+ const parts = r.t.split(/(\s+)/);
134
+ for (const p of parts) {
135
+ if (!p) continue;
136
+ if (/^\s+$/.test(p)) { atoms.push({ space: true }); continue; }
137
+ atoms.push({ t: p, role, bold: r.kind === 'bold' || undefined });
138
+ }
139
+ }
140
+ const rows = [];
141
+ let cur = []; let w = 0; let gap = false;
142
+ const ind = len(prefix);
143
+ const room = Math.max(8, width - ind);
144
+ const add = (seg) => {
145
+ const last = cur[cur.length - 1];
146
+ if (last && last.role === seg.role && last.bold === seg.bold) last.t += seg.t; else cur.push({ ...seg });
147
+ };
148
+ const newRow = () => { rows.push(cur); cur = []; w = 0; };
149
+ for (const a of atoms) {
150
+ if (a.space) { gap = true; continue; }
151
+ const sep = cur.length && gap ? 1 : 0; // runs that touch (`a`**b**) stay touching
152
+
153
+ let t = a.t;
154
+ if (w + sep + len(t) > room && cur.length) newRow();
155
+ else if (sep) { add({ t: ' ', role, bold: undefined }); w += 1; }
156
+ while (len(t) > room) { const g = graphemes(t); add({ t: g.slice(0, room - w).join(''), role: a.role, bold: a.bold }); t = g.slice(room - w).join(''); newRow(); }
157
+ add({ t, role: a.role, bold: a.bold }); w += len(t);
158
+ gap = false;
159
+ }
160
+ if (cur.length) rows.push(cur);
161
+ return rows.map((r, i) => (prefix ? [{ t: i === 0 ? prefix : ' '.repeat(ind), role: 'bullet1' }, ...r] : r));
162
+ }
163
+
164
+ /** A code block's rows: gutter, the code, and the continuation marker in the label colour. */
165
+ export function codeRows(lines, width) {
166
+ const rows = [];
167
+ for (const line of lines) {
168
+ for (const piece of wrapShell(line, Math.max(12, width - len(GUTTER)))) {
169
+ const mk = piece.endsWith(CONT) ? CONT : piece.endsWith(HARD) ? HARD : '';
170
+ const body = mk ? piece.slice(0, -mk.length) : piece;
171
+ rows.push([{ t: GUTTER, role: 'label' }, { t: body || ' ', role: 'code' }, ...(mk ? [{ t: mk, role: 'label' }] : [])]);
172
+ }
173
+ }
174
+ return rows;
175
+ }
176
+
177
+ /** The body of a slot message as rows of segments, at most `width` columns each. */
178
+ export function formatSlotBody(text, width, { role = 'text' } = {}) {
179
+ const rows = [];
180
+ for (const b of blocks(text)) {
181
+ if (b.type === 'gap') rows.push([]);
182
+ else if (b.type === 'code') rows.push(...codeRows(b.lines, width));
183
+ else rows.push(...wrapRuns(b.runs, width, role, b.mark));
184
+ }
185
+ return rows;
186
+ }
187
+
188
+ /** Plain text of each row (tests, NO_COLOR). */
189
+ export const rowsText = (rows) => rows.map(r => r.map(s => s.t).join(''));
190
+
191
+ /** A short plain one-liner keeps the chat shape: on the header line after the time. */
192
+ export function inlineable(text, room) {
193
+ const t = String(text ?? '');
194
+ return !/[\n`]|\*\*/.test(t) && !/^\s*(?:[-*•]\s|\d+[.)]\s|\$ |#{1,6}\s)/.test(t) && len(t) <= room;
195
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * SLOT-RUN B (owner 2026-10-16: "if its a request to run the command, let me just say yes"). A slot message may
3
+ * carry ONE command for the human to run here (`quox slot say <slot> --run '<command>' "<why>"`, stored by the
4
+ * collector as the message's `run` field, never executed server-side).
5
+ *
6
+ * The TUI shows it with the shared ask: "▶ SENDER asks you to run this here: why", the FULL command in a code
7
+ * block (wrapped, never truncated: this is a confirm-what-you-see boundary), then "Run it? y/N/c". y hands the
8
+ * terminal over (like /restart: the user's own shell, stdio inherited, so OTP and login prompts work), then
9
+ * the TUI resumes and prints the exit code.
10
+ *
11
+ * Rules (non-negotiable): never auto-run; only from a verified sender (author_identity) in this org, otherwise
12
+ * shown but refused; one command; the ask expires after RUN_TTL_MS; never through the model (no turn, never a
13
+ * terminal_suggest_command offer); secret-shaped or destructive commands need "run" typed instead of y.
14
+ */
15
+ import { spawnSync } from 'node:child_process';
16
+ import { askText, askAnswer } from './ask.js';
17
+ import { looksLikeKey } from './connect.js';
18
+ import { resolveCliSlash } from './slash/cliCommand.js';
19
+ import { tokenize } from './bang.js';
20
+ import { codeRows } from './slotFormat.js';
21
+ import { graphemes } from './textBuffer.js';
22
+ import { TERMINAL_RESTORE } from './slash/restart.js';
23
+ import { PASTE_ON } from './keys.js';
24
+
25
+ export const RUN_TTL_MS = 10 * 60 * 1000;
26
+ export const RUN_MAX_LEN = 2000;
27
+ // Same rule as the collector (devSlotMessagesStore validateRunCommand): one line, nothing that can make the
28
+ // screen differ from what runs (control/ESC/CR, bidi overrides, zero-width, line separators).
29
+ const RUN_BAD_RE = /[\u0000-\u001f\u007f\u0080-\u009f\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/;
30
+
31
+ /** -> null (no run request) | { id, cmd, why, who, runnable, refuse, danger, expiresAt }. */
32
+ export function runRequest(m, { orgId = null, now = Date.now() } = {}) {
33
+ if (!m || m.run === undefined || m.run === null || m.run === '') return null;
34
+ const cmd = String(m.run);
35
+ const who = String(m.from || m.author_identity || 'someone').replace(/\s*\(.*$/, '').trim() || 'someone';
36
+ let refuse = null;
37
+ if (!m.author_identity) refuse = `${who} is not a verified sender, so this window will not run it`;
38
+ else if (orgId && m.org_id && m.org_id !== orgId) refuse = `it came from another org, so this window will not run it`;
39
+ else if (cmd.length > RUN_MAX_LEN || !cmd.trim() || RUN_BAD_RE.test(cmd)) refuse = 'it is not one plain command on one line, so this window will not run it';
40
+ return { id: m.id || null, cmd, why: String(m.text ?? '').trim(), who, runnable: !refuse, refuse, danger: refuse ? null : runDanger(cmd), expiresAt: now + RUN_TTL_MS };
41
+ }
42
+
43
+ // Destructive shell shapes (the bang runner has no shell danger list of its own; these are the ones that cost
44
+ // real work to undo). Matching one means "type run", never a refusal: it is the human's machine.
45
+ const DESTRUCTIVE = [
46
+ /(^|[\s;&|(])rm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)*-[a-zA-Z]*[rRf]/, /(^|[\s;&|(])sudo\s/, /(^|[\s;&|(])doas\s/,
47
+ /(^|[\s;&|(])mkfs(\.\w+)?\s/, /(^|[\s;&|(])dd\s+[^|;&]*\bof=/, />\s*\/dev\/(sd|nvme|hd|vd|disk)/,
48
+ /\|\s*(sudo\s+)?(ba|z|da|k)?sh\b/, /\bgit\s+push\b[^|;&]*\s(--force\b|-f\b|--force-with-lease\b|--delete\b)/,
49
+ /\bgit\s+reset\s+--hard\b/, /\bgit\s+clean\s+-[a-zA-Z]*f/, /\bchmod\s+-R\b/, /\bchown\s+-R\b/,
50
+ /:\(\)\s*\{/, /(^|[\s;&|(])(shutdown|reboot|halt|poweroff)\b/, /\bdrop\s+(table|database)\b/i, /\bdocker\s+(system\s+prune|volume\s+rm)\b/,
51
+ ];
52
+
53
+ /** null when y is enough; otherwise the reason "run" must be typed. Reuses the bang secret check and the quox CLI leaf policy. */
54
+ export function runDanger(cmd) {
55
+ const c = String(cmd || '');
56
+ if (looksLikeKey(c)) return 'It contains something that looks like a key or token';
57
+ const words = tokenize(c);
58
+ if (words[0] === 'quox' || words[0] === 'q') {
59
+ const hit = resolveCliSlash(`/${words[1] || ''}`, words.slice(2).map(w => (/[\s"']/.test(w) ? JSON.stringify(w) : w)).join(' '));
60
+ if (hit?.cls === 'never') return `${hit.display} is a never-from-chat quox command`;
61
+ }
62
+ if (DESTRUCTIVE.some(re => re.test(c))) return /(^|[\s;&|(])(sudo|doas)\s/.test(c) ? 'It runs as root' : 'It looks destructive';
63
+ return null;
64
+ }
65
+
66
+ /** 'run' | 'needs-run' (y on a dangerous one) | 'no' | 'chat' | 'command'. */
67
+ export function runAnswer(text, req) {
68
+ const t = String(text ?? '').trim();
69
+ if (/^run$/i.test(t)) return 'run';
70
+ const a = askAnswer(t);
71
+ if (a === 'yes') return req?.danger ? 'needs-run' : 'run';
72
+ return a;
73
+ }
74
+
75
+ const len = (s) => graphemes(String(s)).length;
76
+ function wrapWords(text, width) {
77
+ const out = []; let line = '';
78
+ for (const w of String(text).split(/\s+/).filter(Boolean)) {
79
+ if (line && len(line) + 1 + len(w) > width) { out.push(line); line = w; } else line = line ? `${line} ${w}` : w;
80
+ }
81
+ if (line) out.push(line);
82
+ return out;
83
+ }
84
+
85
+ /** The ask as rows of segments (render.js paints them; slotFormat rows shape). */
86
+ export function runAskRows(req, width) {
87
+ const w = Math.max(20, width);
88
+ const head = askText(`${req.who} asks you to run this here:`, { keys: '' }) + (req.why ? ` ${req.why}` : '');
89
+ const rows = wrapWords(head, w).map((l, i) => [{ t: i ? ` ${l}` : l, role: 'ask', bold: true }]);
90
+ // wrapWords may push an indented continuation over the width: re-wrap continuations at w - 2
91
+ const fixed = [];
92
+ for (const r of rows) { if (len(r[0].t) <= w) fixed.push(r); else for (const p of wrapWords(r[0].t, w - 2)) fixed.push([{ t: ` ${p}`, role: 'ask', bold: true }]); }
93
+ fixed.push(...codeRows([req.cmd], w));
94
+ let last;
95
+ if (!req.runnable) last = { t: `✖ Not offered: ${req.refuse}. Copy it yourself only if you trust it.`, role: 'warn' };
96
+ else if (req.danger) last = { t: askText('Type run to run it, n to skip?', { keys: 'run/N/c', reason: req.danger }), role: 'ask', bold: true };
97
+ else last = { t: askText('Run it?'), role: 'ask', bold: true };
98
+ for (const l of wrapWords(last.t, w)) fixed.push([{ ...last, t: l }]);
99
+ return fixed;
100
+ }
101
+
102
+ /**
103
+ * Run the command in the user's own shell with the terminal handed over (stdio inherited), then take it back.
104
+ * The live frame is cleared first and padded after, so the TUI redraws below the command's output instead of
105
+ * over it. -> { code, signal, ms } | { error, ms }.
106
+ */
107
+ export function handOverRun({ cmd, file = null, args = null, env = process.env, cwd = process.cwd(), stdout = null, setRawMode = null, spawn = spawnSync, shell, now = Date.now, resume = '' }) {
108
+ const tty = Boolean(stdout?.isTTY);
109
+ const rows = tty ? Math.max(0, Number(stdout.frameRows) || 0) : 0;
110
+ const put = (s) => { try { stdout.write(Buffer.from(s)); } catch { /* closed */ } };
111
+ if (tty) put(`\r${rows ? `\x1b[${rows}A` : ''}\x1b[J${TERMINAL_RESTORE}`);
112
+ try { setRawMode?.(false); } catch { /* not a tty */ }
113
+ const t0 = now();
114
+ let r;
115
+ try { // file + args (BANG-TTY: !quox runs this binary's argv) or a shell command line
116
+ r = file ? spawn(file, args || [], { stdio: 'inherit', cwd, env }) : spawn(shell || env.SHELL || '/bin/sh', ['-lc', cmd], { stdio: 'inherit', cwd, env });
117
+ } catch (e) { r = { error: e }; }
118
+ const ms = now() - t0;
119
+ try { setRawMode?.(true); } catch { /* not a tty */ }
120
+ if (tty) put(`\n${'\n'.repeat(rows)}${PASTE_ON}${resume}`); // resume: mouse tracking back on when /mouse on (QUOXON-MOUSE)
121
+ if (r?.error) return { error: `${r.error.code || 'failed'}: ${r.error.message}`, ms };
122
+ return { code: r?.status ?? null, signal: r?.signal || null, ms };
123
+ }
124
+
125
+ /** The footer after a hand-over run. */
126
+ export function runFooter(r) {
127
+ const secs = `${(r.ms / 1000).toFixed(1)}s`;
128
+ if (r.error) return `Could not start it: ${r.error}`;
129
+ if (r.signal) return `killed by ${r.signal} · ${secs}`;
130
+ return `exit ${r.code} · ${secs}`;
131
+ }
@@ -78,8 +78,9 @@ export function soundForEvent(prev, action, ctx = {}) {
78
78
  const now = ctx.now ?? action?.now ?? 0;
79
79
  if (!prev || !action) return null;
80
80
  // An approval card appeared (stream event or inbox poll).
81
- if (next.approvals?.length > (prev.approvals?.length || 0)) return 'hitl';
81
+ if (next.approvals?.length > (prev.approvals?.length || 0) || next.waiting?.length > (prev.waiting?.length || 0)) return 'hitl'; // a card, or another session's notice
82
82
  if (action.type === 'approval.result') return action.outcome === 'failed' ? 'denied' : null;
83
+ if (action.type === 'stepup.result') return action.ok ? 'success' : 'denied'; // QUOXON-STEPUP: reuse the existing cues
83
84
  if (action.type !== 'event' || !action.ev) return null;
84
85
  const ev = action.ev;
85
86
  switch (ev.type) {
@@ -0,0 +1,22 @@
1
+ /**
2
+ * TUI-INPUT: a letter then "/" ("n/help") is a card answer that leaked into the composer ahead of a command.
3
+ * Pure: -> { letter, command } when the text looks like that and the rest names a known slash command, else null.
4
+ * (Ordinary "w/o", "b/c" or "and/or" never match: the word after the slash must be a command.)
5
+ */
6
+ export function strayCardAnswer(text, names = []) {
7
+ const t = String(text ?? '').trim();
8
+ const m = /^([a-z])\/([a-z][\w-]*)(?=\s|$)/i.exec(t);
9
+ if (!m) return null;
10
+ if (!names.includes(`/${m[2].toLowerCase()}`)) return null;
11
+ return { letter: m[1].toLowerCase(), command: t.slice(1) };
12
+ }
13
+
14
+ /** The one-line question (askText adds the y/N/c keys). */
15
+ export const strayQuestion = (s) => `Did you mean ${s.command.split(/\s+/)[0]}? (the '${s.letter}' looks like a stray card answer)`;
16
+
17
+ /** True when the same message is already queued, steered or waiting for the server. */
18
+ export function alreadyQueued(text, { pending = [], steering = [], waiting = null } = {}) {
19
+ const t = String(text ?? '').trim();
20
+ if (!t) return false;
21
+ return [...pending, ...steering].some(p => String(p.text ?? '').trim() === t) || String(waiting?.text ?? '').trim() === t;
22
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The assistant can hand the TUI a slash command for things only the terminal can do (attach a slot, join a room,
3
+ * open a view). The TUI is the authority: only an active registry command, never a `!` shell, never the commands the
4
+ * human must type (approvals, grants, session end, sign-in). The offer is "Run /slot hitl? y/N"; y runs it through the
5
+ * normal command path so every existing guard applies. A read-only view may run without asking when the user's
6
+ * ask was explicit.
7
+ */
8
+ import { findCommandEntry } from './commands.js';
9
+
10
+ // Commands that need the human's own typing: never offered, never run from a model suggestion.
11
+ export const HUMAN_TYPED = new Set(['approve', 'yestoall', 'deny', 'authorise', 'authorize', 'authenticate', 'override', 'restart', 'quit', 'exit', 'login']);
12
+ // Pure views, no effect: may run without asking when explicit, and only with no arguments.
13
+ export const READ_ONLY_VIEWS = new Set(['approvals', 'mind', 'which', 'context', 'rooms', 'active', 'slots']);
14
+
15
+ /** -> { ok: true, line, id, readOnly } | { ok: false, reason } */
16
+ export function vetSuggestion(raw) {
17
+ const line = typeof raw === 'string' ? raw.trim() : '';
18
+ if (!line) return { ok: false, reason: 'empty' };
19
+ if (/[\r\n]/.test(line) || line.length > 120) return { ok: false, reason: 'not a single short line' };
20
+ if (!line.startsWith('/') || line.startsWith('//')) return { ok: false, reason: 'only slash commands can be suggested' };
21
+ const word = line.slice(1).split(/\s+/)[0];
22
+ const entry = findCommandEntry(word);
23
+ if (!entry || entry.kind !== 'command' || entry.status !== 'active') return { ok: false, reason: `/${word} is not a command here` };
24
+ if (HUMAN_TYPED.has(entry.id) || HUMAN_TYPED.has(word.toLowerCase())) return { ok: false, reason: `/${word} must be typed by you` };
25
+ const bare = line.slice(1 + word.length).trim() === '';
26
+ return { ok: true, line, id: entry.id, readOnly: READ_ONLY_VIEWS.has(entry.id) && bare };
27
+ }
28
+
29
+ /**
30
+ * Safety net (owner 2026-10-15 "STOP TELLING ME TO RUN COMMANDS"): the answer text named a command instead of the
31
+ * model calling terminal_suggest_command. Exactly ONE distinct slash command in backticks that passes vetSuggestion
32
+ * -> that vetted suggestion; none, two or more distinct, or a human-typed one (/approve, /restart...) -> null.
33
+ */
34
+ export function namedCommand(text) {
35
+ const src = String(text || '');
36
+ const seen = new Set();
37
+ for (const m of src.matchAll(/`(\/[a-z][^`\n]{0,118})`/gi)) {
38
+ if (mentionsAPath(src, m.index, m[0].length, m[1])) continue; // `/health` the endpoint is not /health the command
39
+ seen.add(m[1].trim().replace(/\s+/g, ' '));
40
+ }
41
+ if (seen.size !== 1) return null;
42
+ const v = vetSuggestion([...seen][0]);
43
+ return v.ok ? v : null;
44
+ }
45
+
46
+ // A URL path, not a command (owner live 05:26: "the `/health` endpoint is erroring" offered "Run /health?").
47
+ const PATHY = /\b(endpoints?|paths?|routes?|urls?|uris?|ports?|curl|wget|https?|localhost)\b|:\d{2,5}\b/i;
48
+ const VERBS = /\b(GET|POST|PUT|PATCH|DELETE|HEAD)\b/; // upper-case only: "to get a view" is not an HTTP verb
49
+ function mentionsAPath(src, at, len, inner) {
50
+ const first = String(inner).trim().split(/\s+/)[0];
51
+ if (/^\/[^/]*\//.test(first)) return true; // /api/x or /health/ready: a longer path
52
+ const sentence = (from, dir) => { // the same sentence, up to 40 characters away
53
+ const t = dir < 0 ? src.slice(Math.max(0, from - 40), from) : src.slice(from, from + 40);
54
+ const parts = t.split(/[.!?\n]/);
55
+ return dir < 0 ? parts[parts.length - 1] : parts[0];
56
+ };
57
+ const near = sentence(at, -1) + ' ' + sentence(at + len, 1);
58
+ return PATHY.test(near) || VERBS.test(near);
59
+ }
@@ -1,3 +1,5 @@
1
+ import { graphemes } from './textBuffer.js';
2
+ import { createScreenMirror, MOUSE_ON, MOUSE_OFF, DSR } from './mouse.js';
1
3
  /**
2
4
  * The terminal as Ink sees it.
3
5
  *
@@ -12,9 +14,16 @@
12
14
  * repaint checks read stdout.rows). The app reads the real height from termRows() and keeps its live
13
15
  * region shorter than the screen itself (app.js), so log-update can always erase it.
14
16
  */
15
- export function inkStdout(out) {
17
+ export function inkStdout(out, { tap = null } = {}) {
16
18
  if (!out || out.__quoxInk) return out;
17
- const diff = out.isTTY ? createLineDiff() : null;
19
+ // QUOXON-MOUSE: what Ink has drawn, as plain lines, for click hit-testing (mouse.js). Fed the same writes.
20
+ const mirror = out.isTTY ? createScreenMirror({ columns: () => out.columns }) : null;
21
+ // SCREEN-FRAMES: the same feed goes to the screen recorder's tap (screenFrames.js), which only queues it.
22
+ const feed = mirror && tap ? {
23
+ write: (c) => { mirror.write(c); try { tap.write(c); } catch { /* never break a frame */ } },
24
+ reset: () => { mirror.reset(); try { tap.reset(); } catch { /* ignore */ } }
25
+ } : mirror;
26
+ const diff = out.isTTY ? createLineDiff({ columns: () => out.columns, mirror: feed }) : null;
18
27
  // One frame = one write: Ink brackets each frame in separate synchronized-update writes (?2026h ... ?2026l);
19
28
  // they are held and sent together with the (diffed) frame, and a frame that changed nothing sends nothing.
20
29
  // A write that sends nothing (or is held for the frame) must still call its callback: Ink's unmount waits
@@ -44,7 +53,9 @@ export function inkStdout(out) {
44
53
  if (prop === 'write' && write) return write;
45
54
  if (prop === 'rows') return Infinity;
46
55
  if (prop === 'termRows') return target.rows;
56
+ if (prop === 'frameRows') { const n = diff?.rows(); return n == null ? null : Math.min(n, Math.max(0, (target.rows || n + 1) - 1)); }
47
57
  if (prop === '__quoxInk') return true;
58
+ if (prop === 'screen') return mirror;
48
59
  const v = Reflect.get(target, prop, target);
49
60
  return typeof v === 'function' ? v.bind(target) : v;
50
61
  },
@@ -69,16 +80,34 @@ export function termRows(stdout) {
69
80
  * through untouched and resets the diff, so the next frame is drawn in full. Cursor moves never scroll.
70
81
  * (Ink's own incrementalRendering was tried first and duplicates lines on the dropped-text replay.)
71
82
  */
83
+ const ANSI_RE = /\x1b\[[0-9;?]*[ -\/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
84
+ const visibleWidth = (line) => graphemes(String(line).replace(ANSI_RE, '')).length;
72
85
  const FRAME_RE = /^((?:\x1b\[2K\x1b\[1A)*\x1b\[2K\x1b\[G)([\s\S]*)$/;
73
86
  const SYNC_ON = '\x1b[?2026h', SYNC_OFF = '\x1b[?2026l';
74
- const TRANSPARENT = new Set(['\x1b[?2026h', '\x1b[?2026l', '\x1b[?25l', '\x1b[?25h', '']);
75
- export function createLineDiff() {
87
+ const TRANSPARENT = new Set(['\x1b[?2026h', '\x1b[?2026l', '\x1b[?25l', '\x1b[?25h', '', MOUSE_ON, MOUSE_OFF, DSR]); // + QUOXON-MOUSE mode switches and the click's cursor query
88
+ export function createLineDiff({ columns = () => null, mirror = null } = {}) {
76
89
  let prev = null; // lines of the last frame, trailing '' included
90
+ const bodyLines = (body) => (body && body.endsWith('\n') && !body.includes('\x1b[2J') ? body.split('\n') : null);
77
91
  const diff = (chunk) => {
78
- if (TRANSPARENT.has(chunk)) return chunk; // synchronized-update brackets and cursor hide/show around a frame
92
+ if (TRANSPARENT.has(chunk)) return chunk;
93
+ mirror?.write(chunk); // synchronized-update brackets and cursor hide/show around a frame
79
94
  const m = FRAME_RE.exec(chunk);
80
- if (!m || !m[2].endsWith('\n') || m[2].includes('\x1b[2J')) { prev = null; return chunk; }
95
+ // Not an erase+frame: a frame Ink drew after clearing (no erase prefix), static history, or something else.
96
+ // The last such write before the next erase is the frame on screen (Ink writes static output, then the frame).
97
+ if (!m) { prev = bodyLines(chunk); return chunk; }
81
98
  const erased = (m[1].match(/\x1b\[2K/g) || []).length;
99
+ const cols = columns();
100
+ if (prev && cols > 0) {
101
+ // R3 D2: after a resize the terminal has reflowed the old frame (a narrower screen wraps each line onto more
102
+ // rows), and Ink's erase counts lines, not rows: ghost composers, or history reprinted to cover them. Count
103
+ // the rows the frame really occupies at this width; when that differs, go to its top and clear down.
104
+ const rows = prev.slice(0, -1).reduce((n, l) => n + Math.max(1, Math.ceil(visibleWidth(l) / cols)), 0);
105
+ if (rows !== erased - 1) {
106
+ prev = bodyLines(m[2]);
107
+ return `\r${rows ? `\x1b[${rows}A` : ''}\x1b[J${m[2]}`;
108
+ }
109
+ }
110
+ if (!m[2].endsWith('\n') || m[2].includes('\x1b[2J')) { prev = null; return chunk; }
82
111
  const next = m[2].split('\n');
83
112
  if (!prev || erased !== prev.length || next.length !== prev.length || next.length < 2) { prev = next; return chunk; }
84
113
  const body = next.length - 1;
@@ -92,15 +121,21 @@ export function createLineDiff() {
92
121
  prev = next;
93
122
  return changed ? out : '';
94
123
  };
95
- diff.reset = () => { prev = null; };
124
+ diff.reset = () => { prev = null; mirror?.reset(); };
125
+ /** Rows the live frame occupies on screen (wrapped at the current width), or null when unknown. SLOT-RUN hand-over. */
126
+ diff.rows = () => {
127
+ if (!prev) return null;
128
+ const cols = columns();
129
+ return prev.slice(0, -1).reduce((n, l) => n + (cols > 0 ? Math.max(1, Math.ceil(visibleWidth(l) / cols)) : 1), 0);
130
+ };
96
131
  return diff;
97
132
  }
98
133
 
99
134
  /**
100
135
  * Ink render options for the interactive client. Standard rendering (Ink's incrementalRendering duplicated lines on
101
- * the dropped-text replay); the stdout proxy line-diffs same-height frames instead. Never mouse tracking, never the
102
- * alternate screen: native selection keeps working.
136
+ * the dropped-text replay); the stdout proxy line-diffs same-height frames instead. Never the alternate screen, and
137
+ * no mouse tracking unless the user turns it on (/mouse on, QUOXON-MOUSE): native selection keeps working by default.
103
138
  */
104
- export function inkRenderOptions(stdout) {
105
- return { exitOnCtrlC: false, stdout: inkStdout(stdout) };
139
+ export function inkRenderOptions(stdout, { tap = null } = {}) {
140
+ return { exitOnCtrlC: false, stdout: inkStdout(stdout, { tap }) };
106
141
  }