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,345 @@
1
+ /**
2
+ * QUOXON-FW6: model favourites and the default model. Pure helpers plus one quiet live-list reader.
3
+ *
4
+ * The preference is { favourites: [{ provider, model }], default: { provider, model } | null }.
5
+ * `provider` is a /model provider id (openai, anthropic, claude-cli, ollama, a compat preset).
6
+ * Persistence is the caller's (api.modelPrefs / api.setModelPrefs: the user's server-side
7
+ * preferences, with the profile as a cache). There is no model list in this file: ids come from
8
+ * the provider's own live list (liveModels) or from what the user typed. A typed id that is not on
9
+ * the live list is kept with a warning, never refused.
10
+ */
11
+ import { CHOICES, CLAUDE_FAMILIES, providerId } from './model.js';
12
+
13
+ export const MAX_FAVS = 20;
14
+ const nameOf = (id) => CHOICES.find((c) => c.id === id)?.name || id;
15
+
16
+ const cleanOne = (v) => {
17
+ const provider = providerId(v?.provider);
18
+ const model = typeof v?.model === 'string' ? v.model.trim().slice(0, 200) : '';
19
+ return provider && model && !/\s/.test(model) ? { provider, model } : null;
20
+ };
21
+ export const sameModel = (a, b) =>
22
+ Boolean(a && b) && a.provider === b.provider && a.model === b.model;
23
+
24
+ /** Anything stored or received -> a clean prefs object (bad rows dropped, duplicates removed). */
25
+ export function normPrefs(v) {
26
+ const favs = [];
27
+ for (const f of Array.isArray(v?.favourites) ? v.favourites : []) {
28
+ const c = cleanOne(f);
29
+ if (c && !favs.some((x) => sameModel(x, c)) && favs.length < MAX_FAVS) favs.push(c);
30
+ }
31
+ return { favourites: favs, default: cleanOne(v?.default) };
32
+ }
33
+
34
+ export const label = (m) => `${m.provider} ${m.model}`;
35
+
36
+ /** -> { prefs, added, text, tone }. */
37
+ export function addFav(prefs, provider, model) {
38
+ const c = cleanOne({ provider, model });
39
+ if (!c)
40
+ return { prefs, added: false, text: 'Usage: /model fav add <provider> <model>', tone: 'warn' };
41
+ const at = prefs.favourites.findIndex((f) => sameModel(f, c));
42
+ if (at >= 0)
43
+ return {
44
+ prefs,
45
+ added: false,
46
+ text: `${label(c)} is already favourite ${at + 1}.`,
47
+ tone: 'plain',
48
+ };
49
+ if (prefs.favourites.length >= MAX_FAVS)
50
+ return {
51
+ prefs,
52
+ added: false,
53
+ text: `You have ${MAX_FAVS} favourites already. /model fav rm <n> makes room.`,
54
+ tone: 'warn',
55
+ };
56
+ const next = { ...prefs, favourites: [...prefs.favourites, c] };
57
+ const n = next.favourites.length;
58
+ return {
59
+ prefs: next,
60
+ added: true,
61
+ text: `Added ${label(c)} as favourite ${n}. /model ${n} switches to it.`,
62
+ tone: 'ok',
63
+ };
64
+ }
65
+
66
+ /**
67
+ * ref = a 1-based number, or a model id (exact, "provider model", "provider/model", or a
68
+ * unique substring).
69
+ */
70
+ export function rmFav(prefs, ref) {
71
+ const q = String(ref ?? '')
72
+ .trim()
73
+ .toLowerCase();
74
+ const favs = prefs.favourites;
75
+ if (!q) return { prefs, removed: null, text: 'Usage: /model fav rm <n|model>', tone: 'warn' };
76
+ let idx = -1;
77
+ if (/^\d+$/.test(q)) idx = Number(q) - 1;
78
+ else {
79
+ const exact = favs
80
+ .map((f, i) => i)
81
+ .filter(
82
+ (i) =>
83
+ favs[i].model.toLowerCase() === q ||
84
+ label(favs[i]).toLowerCase() === q ||
85
+ `${favs[i].provider}/${favs[i].model}`.toLowerCase() === q,
86
+ );
87
+ const part = favs.map((f, i) => i).filter((i) => favs[i].model.toLowerCase().includes(q));
88
+ const hits = exact.length ? exact : part;
89
+ if (hits.length > 1)
90
+ return {
91
+ prefs,
92
+ removed: null,
93
+ text:
94
+ `"${ref}" matches ${hits.map((i) => `${i + 1} ${label(favs[i])}`).join(', ')}. ` +
95
+ 'Use the number.',
96
+ tone: 'warn',
97
+ };
98
+ idx = hits.length ? hits[0] : -1;
99
+ }
100
+ if (idx < 0 || idx >= favs.length)
101
+ return {
102
+ prefs,
103
+ removed: null,
104
+ text: favs.length
105
+ ? `No favourite "${ref}". /model fav lists them.`
106
+ : 'You have no favourites yet. /model fav add <provider> <model>',
107
+ tone: 'warn',
108
+ };
109
+ const removed = favs[idx];
110
+ return {
111
+ prefs: { ...prefs, favourites: favs.filter((_, i) => i !== idx) },
112
+ removed,
113
+ text: `Removed ${label(removed)}. The numbers after it moved up by one.`,
114
+ tone: 'ok',
115
+ };
116
+ }
117
+
118
+ export function setDefault(prefs, provider, model) {
119
+ const c = cleanOne({ provider, model });
120
+ if (!c)
121
+ return { prefs, ok: false, text: 'Usage: /model default <provider> <model>', tone: 'warn' };
122
+ return {
123
+ prefs: { ...prefs, default: c },
124
+ ok: true,
125
+ text:
126
+ `Default model: ${label(c)}. New sessions start on it when ${nameOf(c.provider)} is ` +
127
+ `connected. /model ${label(c)} uses it now.`,
128
+ tone: 'ok',
129
+ };
130
+ }
131
+ export const clearDefault = (prefs) => ({
132
+ prefs: { ...prefs, default: null },
133
+ text: 'Default model cleared. New sessions use the last choice, else the server policy.',
134
+ tone: 'ok',
135
+ });
136
+
137
+ /** Toggle one model in the favourites -> { prefs, added }. */
138
+ export function toggleFav(prefs, m) {
139
+ const i = prefs.favourites.findIndex((f) => sameModel(f, m));
140
+ if (i >= 0)
141
+ return {
142
+ prefs: { ...prefs, favourites: prefs.favourites.filter((_, k) => k !== i) },
143
+ added: false,
144
+ };
145
+ return {
146
+ prefs: {
147
+ ...prefs,
148
+ favourites: [...prefs.favourites, { provider: m.provider, model: m.model }].slice(
149
+ 0,
150
+ MAX_FAVS,
151
+ ),
152
+ },
153
+ added: true,
154
+ };
155
+ }
156
+
157
+ /** /model fav lines. `current` = the stored choice ({ provider, model?, via? }) or null. */
158
+ export function favLines(prefs, currentKey = null) {
159
+ if (!prefs.favourites.length)
160
+ return [
161
+ [
162
+ 'No favourites yet. Add one: /model fav add <provider> <model> ' +
163
+ '(or open /model and press f)',
164
+ ],
165
+ ];
166
+ const out = [['Favourite models (/model <n> switches):']];
167
+ prefs.favourites.forEach((f, i) => {
168
+ const marks = [
169
+ currentKey && sameModel(f, currentKey) ? 'current' : null,
170
+ prefs.default && sameModel(f, prefs.default) ? 'default' : null,
171
+ ].filter(Boolean);
172
+ out.push([` ${i + 1} ${label(f)}${marks.length ? ` (${marks.join(', ')})` : ''}`, 'plain']);
173
+ });
174
+ out.push([
175
+ prefs.default
176
+ ? `Default: ${label(prefs.default)}`
177
+ : 'No default model set. /model default <provider> <model>',
178
+ ]);
179
+ return out;
180
+ }
181
+
182
+ const FAV_WORDS = new Set(['fav', 'favs', 'favourite', 'favourites', 'favorite', 'favorites']);
183
+ const SUBS_ONE = { list: 'list', status: 'list' };
184
+
185
+ /**
186
+ * The words after /model -> what to do. kinds: chooser list use fav-list fav-add fav-rm
187
+ * default-show default-set default-clear error pass.
188
+ */
189
+ export function parseModelSub(arg) {
190
+ const w = String(arg || '')
191
+ .trim()
192
+ .split(/\s+/)
193
+ .filter(Boolean);
194
+ if (!w.length) return { kind: 'chooser' };
195
+ const first = w[0].toLowerCase();
196
+ if (SUBS_ONE[first] && w.length === 1) return { kind: SUBS_ONE[first] };
197
+ if (/^\d+$/.test(first) && w.length === 1) return { kind: 'use', n: Number(first) };
198
+ if (FAV_WORDS.has(first)) {
199
+ const sub = (w[1] || '').toLowerCase();
200
+ if (!sub) return { kind: 'fav-list' };
201
+ if (/^\d+$/.test(sub) && w.length === 2) return { kind: 'use', n: Number(sub) };
202
+ if (sub === 'add') {
203
+ if (w.length !== 4)
204
+ return {
205
+ kind: 'error',
206
+ text:
207
+ 'Usage: /model fav add <provider> <model> ' +
208
+ 'e.g. /model fav add openai gpt-5.6-terra',
209
+ };
210
+ return { kind: 'fav-add', provider: w[2], model: w[3] };
211
+ }
212
+ if (['rm', 'remove', 'del', 'delete'].includes(sub))
213
+ return w.length < 3
214
+ ? { kind: 'error', text: 'Usage: /model fav rm <n|model>' }
215
+ : { kind: 'fav-rm', ref: w.slice(2).join(' ') };
216
+ return {
217
+ kind: 'error',
218
+ text: 'Usage: /model fav [add <provider> <model> | rm <n|model> | <n>]',
219
+ };
220
+ }
221
+ if (first === 'default') {
222
+ if (w.length === 1) return { kind: 'default-show' };
223
+ if (w.length === 2 && ['clear', 'none', 'off', 'unset'].includes(w[1].toLowerCase()))
224
+ return { kind: 'default-clear' };
225
+ if (w.length === 3) return { kind: 'default-set', provider: w[1], model: w[2] };
226
+ return { kind: 'error', text: 'Usage: /model default [<provider> <model> | clear]' };
227
+ }
228
+ return { kind: 'pass' };
229
+ }
230
+
231
+ /**
232
+ * A provider's list response (the shape differs per provider) -> ids, newest first as the
233
+ * server gave them.
234
+ */
235
+ export function idsOf(choiceId, res) {
236
+ if (choiceId === 'claude-cli') return [...CLAUDE_FAMILIES];
237
+ if (!res?.ok || !Array.isArray(res.models)) return [];
238
+ const raw =
239
+ choiceId === 'ollama'
240
+ ? res.models.map((m) => (typeof m === 'string' ? m : m?.name))
241
+ : res.models.map((m) => (typeof m === 'string' ? m : m?.id));
242
+ return raw.filter((x) => typeof x === 'string' && x);
243
+ }
244
+
245
+ /**
246
+ * The live model ids for one provider, never throws. -> { ok, ids, def }. ok=false means
247
+ * "could not list" (the caller decides how quiet to be); ids is [] then.
248
+ */
249
+ export async function liveModels(api, id, { refresh = false } = {}) {
250
+ try {
251
+ if (id === 'claude-cli') return { ok: true, ids: [...CLAUDE_FAMILIES], def: null };
252
+ const row = CHOICES.find((c) => c.id === id);
253
+ if (!row) return { ok: false, ids: [], def: null };
254
+ let res = null;
255
+ if (id === 'ollama') res = await api.ollamaModels?.();
256
+ else if (row.compat) res = await api.compatModels?.(id);
257
+ else if (row.discover)
258
+ res = await api.providerModels?.(row.provider, refresh ? { refresh: true } : undefined);
259
+ if (!res?.ok) return { ok: false, ids: [], def: null };
260
+ return { ok: true, ids: idsOf(id, res), def: res.default_model || null };
261
+ } catch {
262
+ return { ok: false, ids: [], def: null };
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Typed id vs the live list -> { model, known: true|false|null, note? }. null = no list to
268
+ * check against.
269
+ */
270
+ export function checkModelId(ids, typed, providerName = '') {
271
+ if (!ids?.length) return { model: typed, known: null };
272
+ if (ids.includes(typed)) return { model: typed, known: true };
273
+ const ci =
274
+ ids.find((i) => i.toLowerCase() === typed.toLowerCase()) ||
275
+ ids.find((i) => i === `${typed}:latest`);
276
+ if (ci) return { model: ci, known: true };
277
+ return {
278
+ model: typed,
279
+ known: false,
280
+ note:
281
+ `${typed} is not on ${providerName || 'the provider'}'s live list right now; ` +
282
+ 'kept as typed in case it is new.',
283
+ };
284
+ }
285
+
286
+ /* ---- the /model chooser ---------------------------------------------------------- */
287
+
288
+ /**
289
+ * Selectable items, in display order: favourites first (numbered by their place in the full
290
+ * list; ones on a provider that is not connected are left out), then each connected provider's
291
+ * models.
292
+ * groups: [{ id, name, ids, note? }] for CONNECTED providers only.
293
+ */
294
+ export function chooserItems(prefs, groups) {
295
+ const connected = new Set(groups.map((g) => g.id));
296
+ const items = [];
297
+ prefs.favourites.forEach((f, i) => {
298
+ if (connected.has(f.provider))
299
+ items.push({ kind: 'fav', n: i + 1, provider: f.provider, model: f.model });
300
+ });
301
+ for (const g of groups) {
302
+ if (!g.ids.length)
303
+ items.push({
304
+ kind: 'provider',
305
+ provider: g.id,
306
+ model: null,
307
+ name: g.name,
308
+ note: g.note || 'no model list; the server picks',
309
+ });
310
+ else
311
+ for (const id of g.ids)
312
+ items.push({ kind: 'model', provider: g.id, model: id, name: g.name });
313
+ }
314
+ return items;
315
+ }
316
+
317
+ /**
318
+ * One picker row ({ key, text, note? }) for an item. `current` = { provider, model } of the
319
+ * stored choice, or null.
320
+ */
321
+ export function chooserRow(item, prefs, current) {
322
+ if (item.kind === 'provider')
323
+ return { key: item.provider, text: item.name, note: item.note, dim: false };
324
+ const here = current && current.model ? sameModel(current, item) : false;
325
+ const marks = [
326
+ here ? 'current' : null,
327
+ prefs.default && sameModel(prefs.default, item) ? 'default' : null,
328
+ item.kind === 'model' && prefs.favourites.some((f) => sameModel(f, item)) ? '★' : null,
329
+ ].filter(Boolean);
330
+ const body = item.kind === 'fav' ? label(item) : item.model;
331
+ return {
332
+ key: item.kind === 'fav' ? `★ ${item.n}` : item.provider,
333
+ text: body + (marks.length ? ` ${marks.join(' · ')}` : ''),
334
+ };
335
+ }
336
+
337
+ /**
338
+ * The stored choice as { provider: <CHOICES id>, model } for marking; null when it carries no
339
+ * model.
340
+ */
341
+ export function currentKey(choice, idOf) {
342
+ if (!choice?.model) return null;
343
+ const provider = idOf(choice);
344
+ return provider ? { provider, model: choice.model } : null;
345
+ }
@@ -6,6 +6,7 @@
6
6
  *
7
7
  * Demand shapes: { every: ms } (repeat while declared) or { at: epochMs } (one wake at that time).
8
8
  */
9
+ import { normaliseLevel } from './level.js';
9
10
  export const FRAME_MS = 83; // ~12 fps for anything that moves
10
11
  export const SECOND_MS = 1000;
11
12
 
@@ -49,8 +50,8 @@ export class Clock {
49
50
  get armed() { return this.id != null; }
50
51
  }
51
52
 
52
- /** Motion is decoration: off under QUOX_REDUCED_MOTION=1, QUOX_TUI_LITE, or when not on a real terminal. Colour is separate (NO_COLOR). */
53
- export const reducedMotion = (env = process.env) => env.QUOX_REDUCED_MOTION === '1' || Boolean(env.QUOX_TUI_LITE);
53
+ /** Motion is decoration: off at the quiet level (QUOX_MOTION=quiet, QUOX_REDUCED_MOTION=1, QUOX_TUI_LITE), or when not on a real terminal. Colour is separate (NO_COLOR). */
54
+ export const reducedMotion = (env = process.env) => env.QUOX_REDUCED_MOTION === '1' || Boolean(env.QUOX_TUI_LITE) || normaliseLevel(env.QUOX_MOTION) === 'quiet';
54
55
  export const motionAllowed = (isTTY, env = process.env) => Boolean(isTTY) && !reducedMotion(env);
55
56
 
56
57
  /**
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Motion levels (CLI-QUOX F3). One setting, four steps, chosen in /config or with QUOX_MOTION:
3
+ *
4
+ * quiet static except the elapsed timer (the old "reduced motion"; QUOX_REDUCED_MOTION=1 still means it)
5
+ * calm the default: exactly what shipped before levels (Orbit & Lock, the status Q, reveals, the ring)
6
+ * expressive calm, plus a short ignition glow when a tool row starts and a handoff dot while delegating
7
+ * cinematic expressive, plus the dragon: it greets you after Orbit & Lock on every launch, and plays a mood
8
+ * for approvals (wide eyes, amber glint), success (a wink) and failure (a puff of smoke)
9
+ *
10
+ * Pure: no process access except through the env argument, no timers. The frame helpers return cells
11
+ * { ch, rgb } like every other motion module, so the terminal and the web draw them the same way.
12
+ */
13
+ export const LEVELS = ['quiet', 'calm', 'expressive', 'cinematic'];
14
+ export const DEFAULT_LEVEL = 'calm';
15
+
16
+ /** A saved or typed value -> a level, or null. The pre-levels values map across: full = calm, reduced = quiet. */
17
+ export function normaliseLevel(v) {
18
+ const s = String(v ?? '').trim().toLowerCase();
19
+ if (s === 'full') return 'calm';
20
+ if (s === 'reduced') return 'quiet';
21
+ return LEVELS.includes(s) ? s : null;
22
+ }
23
+
24
+ /** The level in force. QUOX_REDUCED_MOTION=1 and QUOX_TUI_LITE always mean quiet (accessibility wins). */
25
+ export function motionLevel(env = process.env) {
26
+ if (env.QUOX_REDUCED_MOTION === '1' || env.QUOX_TUI_LITE) return 'quiet';
27
+ return normaliseLevel(env.QUOX_MOTION) || DEFAULT_LEVEL;
28
+ }
29
+
30
+ export const atLeast = (level, min) => LEVELS.indexOf(normaliseLevel(level) || DEFAULT_LEVEL) >= LEVELS.indexOf(min);
31
+
32
+ /** What each level turns on. launchEgg: the rare dragon greeting on launch (cinematic greets every time instead). */
33
+ export function levelFeatures(level) {
34
+ const l = normaliseLevel(level) || DEFAULT_LEVEL;
35
+ return {
36
+ animate: l !== 'quiet',
37
+ ignition: atLeast(l, 'expressive'),
38
+ handoff: atLeast(l, 'expressive'),
39
+ introDragon: l === 'cinematic',
40
+ moods: l === 'cinematic',
41
+ launchEgg: l === 'calm' || l === 'expressive',
42
+ };
43
+ }
44
+
45
+ const clamp = (x, a = 0, b = 1) => Math.max(a, Math.min(b, x));
46
+
47
+ // ---- tool-row ignition glow --------------------------------------------------------------------------------
48
+ export const IGNITION_MS = 700;
49
+ /** Glow 0..1 at ms since a tool row started running: up in 80 ms, then an eased fall to 0 at IGNITION_MS. */
50
+ export function ignitionGlow(age) {
51
+ if (!(age > 0) || age >= IGNITION_MS) return 0;
52
+ if (age <= 80) return age / 80;
53
+ const u = (age - 80) / (IGNITION_MS - 80);
54
+ return (1 - u) ** 2;
55
+ }
56
+
57
+ // ---- handoff dot -------------------------------------------------------------------------------------------
58
+ export const HANDOFF_MS = 900;
59
+ const SKY = [56, 189, 248], VIOLET = [168, 85, 247], DIM = [58, 66, 82];
60
+ const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * t));
61
+ /**
62
+ * The handoff track at t ms: n cells, a bright dot travelling from Quox (left) to the agent it is handing to
63
+ * (right) once every HANDOFF_MS, with one fading cell behind it. The dot shifts sky to violet as it goes.
64
+ * still: the dot rests at the target (reduced motion never calls this, but a frozen frame must read right).
65
+ */
66
+ export function handoffFrame(t, n = 6, { mono = false, still = false } = {}) {
67
+ const u = still ? 0.999 : (((t % HANDOFF_MS) + HANDOFF_MS) % HANDOFF_MS) / HANDOFF_MS;
68
+ const at = Math.min(n - 1, Math.floor(u * n));
69
+ return Array.from({ length: n }, (_, i) => {
70
+ const pos = n > 1 ? i / (n - 1) : 1;
71
+ if (i === at) return { ch: '●', rgb: mono ? null : mix(SKY, VIOLET, pos) };
72
+ if (i === at - 1 && !still) return { ch: '•', rgb: mono ? null : mix(DIM, mix(SKY, VIOLET, pos), 0.55) };
73
+ return { ch: '·', rgb: mono ? null : mix(DIM, SKY, 0.25 * clamp(pos)) };
74
+ });
75
+ }
76
+
77
+ // ---- dragon moods (cinematic) ------------------------------------------------------------------------------
78
+ const FAILED_TURN = new Set(['failed', 'unconfirmed']); // the same reading as the sound earcons (sound/mapping.js)
79
+ /**
80
+ * prevState + action + nextState -> the mood the dragon should play, or null. Only accepted state CHANGES
81
+ * count: an approval card appearing (waiting), a running turn finishing (success / failure). An interrupted
82
+ * turn is the human pressing Esc: no reaction.
83
+ */
84
+ export function moodForEvent(prev, action, next) {
85
+ if (!prev || !action || !next) return null;
86
+ if ((next.approvals?.length || 0) > (prev.approvals?.length || 0)) return 'waiting';
87
+ if (action.type !== 'event' || action.ev?.type !== 'turn.finished' || !prev.turn) return null;
88
+ const status = action.ev.status || 'completed';
89
+ if (FAILED_TURN.has(status)) return 'failure';
90
+ return status === 'completed' ? 'success' : null;
91
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * QUOXON-MOUSE (owner 2026-10-15: "when I click on '◆ QLOOP-ENGINE' enter /say [slot]", "select a chunk of text I
3
+ * typed and delete it"). Optional, OFF by default: mouse tracking makes the terminal send clicks to Quox, so
4
+ * selecting or copying text then needs Shift+drag in most terminals (said when it is turned on).
5
+ *
6
+ * Pure parts only (the app wires them):
7
+ * - MOUSE_ON / MOUSE_OFF: SGR button tracking with drag (1000 + 1002 + 1006). Never any-motion (1003).
8
+ * - createScreenMirror: what Ink has put on the screen, as plain lines. Fed every string Ink writes (term.js), it
9
+ * models exactly Ink's output: text, newlines, eraseLines (frame repaints) and clear-terminal. The live frame is
10
+ * the last write; everything above it is static history. Wrapping is applied at the current width when read, so
11
+ * a resize reflows it the way the terminal does.
12
+ * - screenHit: a click's screen row -> the mirror line under it. Rows are counted up from the cursor (which Ink
13
+ * always leaves on the line after the frame), read with a cursor position report (DSR) at click time, so the
14
+ * map holds whether the frame sits at the bottom of a full screen or halfway down a fresh one.
15
+ * - classifyClick: what that line is (a ◆ sender, a card's answer key label, the shared ask's y/N/c).
16
+ * - composerIndexAt: a click in the composer rows -> a buffer index, through Ink's word wrap.
17
+ */
18
+ import { graphemes, composerFrom } from './textBuffer.js';
19
+
20
+ export const MOUSE_ON = '\x1b[?1000h\x1b[?1002h\x1b[?1006h';
21
+ export const MOUSE_OFF = '\x1b[?1006l\x1b[?1002l\x1b[?1000l';
22
+ export const DSR = '\x1b[6n';
23
+ /** Two presses on the same cell within this window are a double-click. */
24
+ export const DOUBLE_MS = 400;
25
+ /** How long a click waits for the terminal's cursor report before it is dropped. */
26
+ export const DSR_TIMEOUT_MS = 400;
27
+
28
+ export function mouseOnNote() {
29
+ return 'Mouse on. Click a ◆ sender to reply, click a card\'s y / n / c to answer, click or drag in your draft to place the cursor or select (Backspace deletes, typing replaces, double-click selects a word). While it is on your terminal sends clicks and the wheel to Quox: hold Shift to select or copy text (Option on macOS Terminal and iTerm2), and use Shift+wheel or the scrollbar to scroll back. /mouse off returns all of it.';
30
+ }
31
+
32
+ // ---- cell widths --------------------------------------------------------------------------------
33
+ const WIDE = /\p{Extended_Pictographic}/u;
34
+ const EAW = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/u;
35
+ /** Terminal cells one grapheme takes: 2 for emoji presentation and East Asian wide, else 1. */
36
+ export function cellWidth(g) {
37
+ const cp = g.codePointAt(0);
38
+ if (EAW.test(g)) return 2;
39
+ if (WIDE.test(g) && (cp >= 0x1f000 || g.includes('️'))) return 2;
40
+ return 1;
41
+ }
42
+ const cellsOf = (s) => graphemes(String(s)).reduce((n, x) => n + cellWidth(x.text), 0);
43
+ /** Grapheme index in `gs` covering 0-based cell `col` (gs.length when past the end). */
44
+ function cellToIndex(gs, col) {
45
+ let at = 0;
46
+ for (let i = 0; i < gs.length; i++) { const w = cellWidth(gs[i].text); if (col < at + w) return i; at += w; }
47
+ return gs.length;
48
+ }
49
+ /** 1-based start cell of grapheme index `i`. */
50
+ function indexToCell(gs, i) { let at = 1; for (let k = 0; k < i && k < gs.length; k++) at += cellWidth(gs[k].text); return at; }
51
+
52
+ // ---- screen mirror ------------------------------------------------------------------------------
53
+ const ANSI_RE = /\x1b\[[0-9;?<>=]*[ -\/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-Za-z]|\x1b[=>78]/g;
54
+ const ERASE_RE = /^((?:\x1b\[2K\x1b\[1A)*\x1b\[2K\x1b\[G)/;
55
+ const CLEAR_RE = /\x1b\[2J|\x1bc/;
56
+
57
+ /** Wrap one plain line into rows of at most `cols` cells (what the terminal shows). */
58
+ function wrapRows(line, cols) {
59
+ if (!(cols > 0)) return [line];
60
+ const gs = graphemes(line);
61
+ if (!gs.length) return [''];
62
+ const out = []; let row = ''; let w = 0;
63
+ for (const g of gs) {
64
+ const cw = cellWidth(g.text);
65
+ if (w + cw > cols) { out.push(row); row = ''; w = 0; }
66
+ row += g.text; w += cw;
67
+ }
68
+ out.push(row);
69
+ return out;
70
+ }
71
+
72
+ export function createScreenMirror({ columns = () => null, cap = 3000 } = {}) {
73
+ let lines = ['']; // logical lines; the last one is the cursor line
74
+ let frameLen = 0; // logical lines written by the last write (the live frame, once Ink has written it)
75
+ const append = (text) => {
76
+ const parts = text.replace(ANSI_RE, '').replace(/\r/g, '').split('\n');
77
+ lines[lines.length - 1] += parts[0];
78
+ for (let i = 1; i < parts.length; i++) lines.push(parts[i]);
79
+ if (lines.length > cap) lines = lines.slice(lines.length - cap);
80
+ return parts.length - 1;
81
+ };
82
+ const m = {
83
+ /** One string Ink wrote, before any diffing. */
84
+ write(chunk) {
85
+ let s = String(chunk);
86
+ const clear = s.search(CLEAR_RE);
87
+ if (clear >= 0) {
88
+ const all = [...s.matchAll(/\x1b\[2J|\x1b\[3J|\x1b\[H|\x1bc/g)];
89
+ const last = all[all.length - 1];
90
+ s = s.slice(last.index + last[0].length);
91
+ lines = ['']; frameLen = 0;
92
+ }
93
+ const e = ERASE_RE.exec(s);
94
+ if (e) {
95
+ const n = (e[1].match(/\x1b\[2K/g) || []).length;
96
+ const drop = Math.min(n - 1, lines.length - 1);
97
+ lines.length -= drop;
98
+ lines[lines.length - 1] = '';
99
+ s = s.slice(e[1].length);
100
+ }
101
+ const plain = s.replace(ANSI_RE, '');
102
+ if (!plain) return;
103
+ const rows = append(s);
104
+ if (rows > 0) frameLen = rows;
105
+ },
106
+ /** Test helper: Ink repainting its frame as these lines. */
107
+ frame(fl) { m.write(`\x1b[2K\x1b[G${fl.join('\n')}\n`); },
108
+ /** Raw output we cannot place (a hand-over, a child on the terminal): forget what is above. */
109
+ reset() { lines = ['']; frameLen = 0; },
110
+ /** -> { rows, frameStart, staticRows, frameRows }: wrapped at the current width, cursor line excluded. */
111
+ snapshot() {
112
+ const cols = columns();
113
+ const body = lines.slice(0, -1);
114
+ const fl = Math.min(frameLen, body.length);
115
+ const stat = body.slice(0, body.length - fl).flatMap(l => wrapRows(l, cols));
116
+ const fr = body.slice(body.length - fl).flatMap(l => wrapRows(l, cols));
117
+ return { rows: [...stat, ...fr], frameStart: stat.length, staticRows: stat, frameRows: fr };
118
+ }
119
+ };
120
+ return m;
121
+ }
122
+
123
+ /** A click at 1-based (x, y) with the cursor on screen row `cursorRow` -> the line under it, or null. */
124
+ export function screenHit(snap, cursorRow, x, y) {
125
+ if (!snap || !(cursorRow > 0)) return null;
126
+ const idx = snap.rows.length - (cursorRow - y);
127
+ if (y >= cursorRow || idx < 0 || idx >= snap.rows.length) return null;
128
+ return {
129
+ text: snap.rows[idx], col: x, inFrame: idx >= snap.frameStart,
130
+ frameRow: idx - snap.frameStart, frameLines: snap.rows.slice(snap.frameStart), rowsBelow: snap.rows.slice(idx + 1)
131
+ };
132
+ }
133
+
134
+ // ---- what was clicked ---------------------------------------------------------------------------
135
+ const SENDER_RE = /^\s*◆ (\S+)( \(no reply address\))?/;
136
+ const CARD_KEY_RE = /(?<![\w/])([ync]) (approve|deny|chat|yes)(?![\w])/g;
137
+ const ASK_KEYS_RE = /(?<![\w/])(y|run)\/(N)\/(c)\s*$/;
138
+ const isAskRow = (r) => /^\s*▶ /.test(r) && (ASK_KEYS_RE.test(r) || /\b[ync] (approve|deny|chat)\b/.test(r));
139
+
140
+ /**
141
+ * -> { kind: 'sender', slot, who? } | { kind: 'key', key: 'y'|'n'|'c'|'run', source: 'approval'|'ask' } | null.
142
+ * A key needs the click ON its label. A card's labels count only in the live frame (a card is never answered from
143
+ * scrollback); an ask's y/N/c only when no newer ask sits below it on screen.
144
+ */
145
+ export function classifyClick(hit) {
146
+ if (!hit || typeof hit.text !== 'string') return null;
147
+ const t = hit.text;
148
+ const gs = graphemes(t);
149
+ const s = SENDER_RE.exec(t);
150
+ if (s) return s[2] ? { kind: 'sender', slot: null, who: s[1] } : { kind: 'sender', slot: s[1].toLowerCase() };
151
+ const within = (from, len) => {
152
+ const gi = graphemes(t.slice(0, from)).length;
153
+ const a = indexToCell(gs, gi); const b = a + cellsOf(t.slice(from, from + len));
154
+ return hit.col >= a && hit.col < b;
155
+ };
156
+ if (hit.inFrame) {
157
+ CARD_KEY_RE.lastIndex = 0;
158
+ let m;
159
+ while ((m = CARD_KEY_RE.exec(t))) if (within(m.index, m[0].length)) return { kind: 'key', key: m[1], source: 'approval' };
160
+ }
161
+ const k = ASK_KEYS_RE.exec(t);
162
+ if (k && /^\s*▶ /.test(t)) {
163
+ if ((hit.rowsBelow || []).some(isAskRow)) return null;
164
+ const base = k.index + k[0].indexOf(k[1]);
165
+ const yPos = base, nPos = yPos + k[1].length + 1, cPos = nPos + 2;
166
+ if (within(yPos, k[1].length)) return { kind: 'key', key: k[1] === 'run' ? 'run' : 'y', source: 'ask' };
167
+ if (within(nPos, 1)) return { kind: 'key', key: 'n', source: 'ask' };
168
+ if (within(cPos, 1)) return { kind: 'key', key: 'c', source: 'ask' };
169
+ }
170
+ return null;
171
+ }
172
+
173
+ // ---- the composer -------------------------------------------------------------------------------
174
+ const isRule = (r) => /^\s*─/.test(r || '');
175
+ const isPrompt = (r) => /^› /.test(r || '') || r === '›';
176
+
177
+ /**
178
+ * A click on frame row `row` (0-based), 1-based column `col` -> the buffer index under it, or null when the row is
179
+ * not one of the composer's. The composer is the "› " row right under a rule, down to the next rule.
180
+ */
181
+ export function composerIndexAt(frameLines, row, col, buf) {
182
+ let top = -1;
183
+ for (let i = frameLines.length - 1; i >= 1; i--) if (isPrompt(frameLines[i]) && isRule(frameLines[i - 1])) { top = i; break; }
184
+ if (top < 0 || row < top) return null;
185
+ let bottom = top;
186
+ while (bottom < frameLines.length && !isRule(frameLines[bottom])) bottom++;
187
+ if (row >= bottom) return null;
188
+ if (!buf.text) return 0;
189
+ const lines = buf.text.split('\n');
190
+ const starts = []; { let o = 0; for (const l of lines) { starts.push(o); o += l.length + 1; } }
191
+ const from = composerFrom(buf);
192
+ let r = top;
193
+ for (let li = from; li < lines.length && r < bottom; li++) {
194
+ const T = graphemes(lines[li]);
195
+ let p = 0; let first = true;
196
+ const rest = () => T.slice(p).some(g => !/\s/.test(g.text)); // trailing spaces never take a row of their own
197
+ while (r < bottom && (first || rest())) {
198
+ const R = graphemes(String(frameLines[r]).replace(/\s+$/, '')).slice(2);
199
+ while (!first && p < T.length && /\s/.test(T[p].text) && R.length && !/\s/.test(R[0].text)) p++;
200
+ const take = Math.min(R.length, T.length - p);
201
+ if (r === row) {
202
+ const gi = Math.min(T.length, p + Math.min(cellToIndex(R, Math.max(0, col - 3)), take));
203
+ return starts[li] + (gi < T.length ? T[gi].index : lines[li].length);
204
+ }
205
+ p += Math.max(take, 0); r++; first = false;
206
+ if (take <= 0) break;
207
+ }
208
+ }
209
+ return buf.text.length;
210
+ }
211
+