@profullstack/qryptchat 0.10.0 → 0.12.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.
- package/README.md +6 -2
- package/dist/{chunk-8gpysrd6.js → chunk-4ea1xy2c.js} +105 -1
- package/dist/{chunk-vj54b54e.js → chunk-hmyhm26d.js} +69 -9
- package/dist/{chunk-ckgythyp.js → chunk-ycvwj8wy.js} +1 -1
- package/dist/{chunk-kycg804s.js → chunk-zh85gmtq.js} +1 -1
- package/dist/commands.js +7 -3
- package/dist/mcp.js +27 -6
- package/dist/tui.js +36 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,7 +35,8 @@ The first run signs you in through your browser, then opens your chats.
|
|
|
35
35
|
| Enter | send (or open the highlighted chat) |
|
|
36
36
|
| Ctrl+E | the emoji picker: search, recents and every group, drawn with [OpenEmoji](https://github.com/profullstack/openemoji) |
|
|
37
37
|
| `:rocket:` | shortcodes turn into emoji when you send |
|
|
38
|
-
|
|
|
38
|
+
| `@` `@ali` + Tab | complete a mention, like x.com and IRC: Tab again cycles the matches, Shift+Tab goes back; the line above the composer lists who matches |
|
|
39
|
+
| Tab / Esc | move between the chat list and the composer (Tab completes instead when the cursor is in an `@name`) |
|
|
39
40
|
| Ctrl+N / Ctrl+P | next / previous chat |
|
|
40
41
|
| PgUp / PgDn, wheel | scroll back through the conversation |
|
|
41
42
|
| Ctrl+R | reload |
|
|
@@ -49,9 +50,12 @@ The mouse works too: click a chat, scroll the transcript, click an emoji. Unread
|
|
|
49
50
|
qc chats # id and name of every chat
|
|
50
51
|
qc read alice -n 20 # the last 20 messages, decrypted
|
|
51
52
|
qc send alice "on my way" # encrypted to every participant
|
|
53
|
+
qc send ops "@ri deploy is done" # an @partial matching one person is sent as @riotcoder
|
|
54
|
+
qc mentions ops @al # who @al completes to: @alice, @alina
|
|
52
55
|
echo "deploy done" | qc send ops -
|
|
53
56
|
qc listen --json # new messages as NDJSON, until you stop it
|
|
54
57
|
qc whoami
|
|
58
|
+
qc keys # are this terminal's keys your account's? (qc keys publish puts them back)
|
|
55
59
|
```
|
|
56
60
|
|
|
57
61
|
`<chat>` is a chat id or any unique part of its name. Add `--json` for machine output.
|
|
@@ -62,7 +66,7 @@ qc whoami
|
|
|
62
66
|
qc mcp
|
|
63
67
|
```
|
|
64
68
|
|
|
65
|
-
runs an MCP server on stdio with
|
|
69
|
+
runs an MCP server on stdio with `list_chats`, `read_chat`, `send_message`, `list_mentions` (who an `@partial` completes to) and `react_to_message`; `send_message` writes out an `@partial` that matches exactly one person. Encryption and decryption happen on this machine; the server only ever sees ciphertext. For Claude Code:
|
|
66
70
|
|
|
67
71
|
```sh
|
|
68
72
|
claude mcp add qryptchat -- qc mcp
|
|
@@ -264,4 +264,108 @@ function listLines(conversations, titleOf = (c) => c.name || "Chat") {
|
|
|
264
264
|
return [`${live.length} ${live.length === 1 ? "chat" : "chats"}${archived ? ` (+${archived} archived)` : ""}`, ...lines];
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
-
|
|
267
|
+
// src/lib/chat/mentions.js
|
|
268
|
+
var NAME_CHAR = /[\p{L}\p{N}_.-]/u;
|
|
269
|
+
var BEFORE_AT = /[\s([{"'“‘]/u;
|
|
270
|
+
function mentionCandidates(participants, selfId) {
|
|
271
|
+
const seen = new Set;
|
|
272
|
+
const out = [];
|
|
273
|
+
for (const p of participants || []) {
|
|
274
|
+
const u = p.user || p;
|
|
275
|
+
const id = p.user_id ?? u.id;
|
|
276
|
+
if (!u.username || selfId && id === selfId || seen.has(u.username.toLowerCase()))
|
|
277
|
+
continue;
|
|
278
|
+
seen.add(u.username.toLowerCase());
|
|
279
|
+
out.push({ id, username: u.username, name: u.display_name || "", agent: u.account_type === "agent" });
|
|
280
|
+
}
|
|
281
|
+
return out.sort((a, b) => a.username.localeCompare(b.username, undefined, { sensitivity: "base" }));
|
|
282
|
+
}
|
|
283
|
+
function mentionAt(value, cursor = value.length) {
|
|
284
|
+
let i = cursor;
|
|
285
|
+
while (i > 0 && NAME_CHAR.test(value[i - 1]))
|
|
286
|
+
i--;
|
|
287
|
+
if (value[i - 1] !== "@")
|
|
288
|
+
return null;
|
|
289
|
+
const start = i - 1;
|
|
290
|
+
if (start > 0 && !BEFORE_AT.test(value[start - 1]))
|
|
291
|
+
return null;
|
|
292
|
+
let end = cursor;
|
|
293
|
+
while (end < value.length && NAME_CHAR.test(value[end]))
|
|
294
|
+
end++;
|
|
295
|
+
return { start, end, query: value.slice(i, cursor) };
|
|
296
|
+
}
|
|
297
|
+
function matchMentions(candidates, query = "", { anywhere = true } = {}) {
|
|
298
|
+
const q = query.toLowerCase();
|
|
299
|
+
if (!q)
|
|
300
|
+
return [...candidates];
|
|
301
|
+
const rank = (c) => {
|
|
302
|
+
const u = c.username.toLowerCase();
|
|
303
|
+
if (u === q)
|
|
304
|
+
return 0;
|
|
305
|
+
if (u.startsWith(q))
|
|
306
|
+
return 1;
|
|
307
|
+
if (c.name && c.name.toLowerCase().split(/\s+/).some((w) => w.startsWith(q)))
|
|
308
|
+
return 2;
|
|
309
|
+
if (anywhere && u.includes(q))
|
|
310
|
+
return 3;
|
|
311
|
+
return -1;
|
|
312
|
+
};
|
|
313
|
+
return candidates.map((c) => ({ c, r: rank(c) })).filter((x) => x.r >= 0).sort((a, b) => a.r - b.r).map((x) => x.c);
|
|
314
|
+
}
|
|
315
|
+
function insertMention(value, { start, end }, username) {
|
|
316
|
+
const after = value.slice(end);
|
|
317
|
+
const space = after.startsWith(" ") ? "" : " ";
|
|
318
|
+
const next = `${value.slice(0, start)}@${username}${space}${after}`;
|
|
319
|
+
return { value: next, cursor: start + username.length + 2 };
|
|
320
|
+
}
|
|
321
|
+
function completeMention(value, cursor, candidates, cycle = null, dir = 1) {
|
|
322
|
+
if (cycle && cycle.value === value && cycle.cursor === cursor && cycle.matches.length) {
|
|
323
|
+
const n = cycle.matches.length;
|
|
324
|
+
const index = (cycle.index + dir + n) % n;
|
|
325
|
+
const at = { start: cycle.start, end: cycle.start + cycle.matches[cycle.index].username.length + 1 };
|
|
326
|
+
const done = insertMention(value, at, cycle.matches[index].username);
|
|
327
|
+
return { ...done, cycle: { ...cycle, index, value: done.value, cursor: done.cursor } };
|
|
328
|
+
}
|
|
329
|
+
const at = mentionAt(value, cursor);
|
|
330
|
+
if (!at)
|
|
331
|
+
return null;
|
|
332
|
+
const matches = matchMentions(candidates, at.query);
|
|
333
|
+
if (!matches.length)
|
|
334
|
+
return null;
|
|
335
|
+
const index = dir < 0 ? matches.length - 1 : 0;
|
|
336
|
+
const done = insertMention(value, at, matches[index].username);
|
|
337
|
+
return { ...done, cycle: { start: at.start, query: at.query, matches, index, value: done.value, cursor: done.cursor } };
|
|
338
|
+
}
|
|
339
|
+
function expandMentions(text, candidates) {
|
|
340
|
+
const mentions = [];
|
|
341
|
+
const ambiguous = [];
|
|
342
|
+
const unknown = [];
|
|
343
|
+
const out = String(text).replace(/(^|[\s([{"'“‘])@([\p{L}\p{N}_.-]+)/gu, (whole, before, raw) => {
|
|
344
|
+
const query = raw.replace(/[.-]+$/, "");
|
|
345
|
+
const tail = raw.slice(query.length);
|
|
346
|
+
if (!query)
|
|
347
|
+
return whole;
|
|
348
|
+
const exact = candidates.find((c) => c.username.toLowerCase() === query.toLowerCase());
|
|
349
|
+
const pick = exact ? [exact] : matchMentions(candidates, query, { anywhere: false });
|
|
350
|
+
if (pick.length === 1) {
|
|
351
|
+
if (!mentions.includes(pick[0].username))
|
|
352
|
+
mentions.push(pick[0].username);
|
|
353
|
+
return `${before}@${pick[0].username}${tail}`;
|
|
354
|
+
}
|
|
355
|
+
if (pick.length > 1)
|
|
356
|
+
ambiguous.push({ query, matches: pick.map((c) => c.username) });
|
|
357
|
+
else
|
|
358
|
+
unknown.push(query);
|
|
359
|
+
return whole;
|
|
360
|
+
});
|
|
361
|
+
return { text: out, mentions, ambiguous, unknown };
|
|
362
|
+
}
|
|
363
|
+
function mentionHint(matches, query, index = -1, max = 6) {
|
|
364
|
+
if (!matches.length)
|
|
365
|
+
return `@${query}: nobody here by that name`;
|
|
366
|
+
const shown = matches.slice(0, max).map((c, i) => i === index ? `[${c.username}]` : c.username);
|
|
367
|
+
const more = matches.length > max ? ` +${matches.length - max}` : "";
|
|
368
|
+
return `@${query} → ${shown.join(" ")}${more} (Tab)`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export { DEFAULT_URL, configDir, sessionPath, baseUrl, loadSession, unlockSession, storeSession, clearSession, notify, PAIRUX_URL, newVoiceRoomLink, voiceMessage, parseSlashCommand, namesLines, listLines, mentionCandidates, mentionAt, matchMentions, completeMention, expandMentions, mentionHint };
|
|
@@ -10,8 +10,11 @@ import {
|
|
|
10
10
|
PAIRUX_URL,
|
|
11
11
|
newVoiceRoomLink,
|
|
12
12
|
voiceMessage,
|
|
13
|
-
namesLines
|
|
14
|
-
|
|
13
|
+
namesLines,
|
|
14
|
+
mentionCandidates,
|
|
15
|
+
matchMentions,
|
|
16
|
+
expandMentions
|
|
17
|
+
} from "./chunk-4ea1xy2c.js";
|
|
15
18
|
|
|
16
19
|
// src/cli/crypto.js
|
|
17
20
|
import { MlKem1024 as MlKem10242 } from "mlkem";
|
|
@@ -733,6 +736,13 @@ class QcClient {
|
|
|
733
736
|
return "unknown";
|
|
734
737
|
}
|
|
735
738
|
}
|
|
739
|
+
async publishKey() {
|
|
740
|
+
const mine = this.session.keys?.keys1024?.publicKey;
|
|
741
|
+
if (!mine)
|
|
742
|
+
throw new QcError("This terminal holds no keys. Run qc login.");
|
|
743
|
+
await this.request("/api/crypto/public-keys", { method: "PUT", body: { public_key: mine, key_type: "ML-KEM-1024" } });
|
|
744
|
+
return this.keyCheck();
|
|
745
|
+
}
|
|
736
746
|
async profile() {
|
|
737
747
|
const { user } = await this.request(`/api/users/by-username/${encodeURIComponent(this.me?.username ?? "")}`);
|
|
738
748
|
return user;
|
|
@@ -1259,6 +1269,8 @@ Usage:
|
|
|
1259
1269
|
qc logout forget this terminal's session and keys
|
|
1260
1270
|
qc update [--check] update qc to the latest release (--check only says whether there is one)
|
|
1261
1271
|
qc whoami who this terminal is signed in as
|
|
1272
|
+
qc keys [publish] whether this terminal's keys are your account's; publish makes them the
|
|
1273
|
+
account's again (when another device published keys qc does not hold)
|
|
1262
1274
|
qc profile [--emoji \uD83D\uDD2D] [--pronouns she/her] [--website URL] [--bio TEXT]
|
|
1263
1275
|
show or set your public profile (an empty value clears it);
|
|
1264
1276
|
--openprofile prints it as OpenProfile.md
|
|
@@ -1271,10 +1283,12 @@ Usage:
|
|
|
1271
1283
|
qc call <number> call a phone (US/Canada free 10 min a day; +country code for others):
|
|
1272
1284
|
opens qrypt.chat's dialer with the number filled in
|
|
1273
1285
|
qc read <chat> [-n 20] print the last messages of a chat
|
|
1274
|
-
qc send <chat> <text> send a message (text "-" reads stdin; --reply <message-id> to reply)
|
|
1286
|
+
qc send <chat> <text> send a message (text "-" reads stdin; --reply <message-id> to reply);
|
|
1287
|
+
an @partial that matches one person becomes their @username
|
|
1288
|
+
qc mentions <chat> [@partial] who @ completes to in a chat, best match first (one per line)
|
|
1275
1289
|
qc react <chat> <message-id> <emoji> [--remove] react to a message
|
|
1276
1290
|
qc listen print new messages as they arrive (NDJSON with --json; --notify alerts with it)
|
|
1277
|
-
qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message)
|
|
1291
|
+
qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message, list_mentions)
|
|
1278
1292
|
qc join <link> join a group chat from its invite link (https://qrypt.chat/join/...)
|
|
1279
1293
|
qc invite <chat> print a group's invite link (group admins) [--reset makes a new one, --off turns it off]
|
|
1280
1294
|
|
|
@@ -1330,6 +1344,8 @@ function findChat2(chats, query) {
|
|
|
1330
1344
|
throw new Error(`"${query}" matches ${some.length} chats: ${some.map((c) => c.title).join(", ")}`);
|
|
1331
1345
|
throw new Error(`No chat matches "${query}". Try qc chats.`);
|
|
1332
1346
|
}
|
|
1347
|
+
var mentionMatches2 = (chat, me, partial = "") => matchMentions(mentionCandidates(chat.participants, me?.id), String(partial).replace(/^@/, ""));
|
|
1348
|
+
var withMentions2 = (chat, me, text) => expandMentions(text, mentionCandidates(chat.participants, me?.id));
|
|
1333
1349
|
async function client(flags, { interactive = false } = {}) {
|
|
1334
1350
|
if (flags.url)
|
|
1335
1351
|
process.env.QC_URL = flags.url;
|
|
@@ -1372,7 +1388,7 @@ async function main2(argv, { version = "0.0.0" } = {}) {
|
|
|
1372
1388
|
}
|
|
1373
1389
|
case "agent": {
|
|
1374
1390
|
if (rest[0] === "call") {
|
|
1375
|
-
const { runVoiceAgent } = await import("./chunk-
|
|
1391
|
+
const { runVoiceAgent } = await import("./chunk-ycvwj8wy.js");
|
|
1376
1392
|
return runVoiceAgent(await client(flags), {
|
|
1377
1393
|
brain: typeof flags.brain === "string" ? flags.brain : undefined,
|
|
1378
1394
|
voice: typeof flags.voice === "string" ? flags.voice : undefined,
|
|
@@ -1435,6 +1451,35 @@ ${ok("Installed OpenEmoji. Restart the terminal, then run: qc fonts status")}`);
|
|
|
1435
1451
|
}
|
|
1436
1452
|
return;
|
|
1437
1453
|
}
|
|
1454
|
+
case "keys": {
|
|
1455
|
+
const c = await client(flags);
|
|
1456
|
+
const status = await c.keyCheck();
|
|
1457
|
+
if (rest[0] !== "publish") {
|
|
1458
|
+
if (flags.json)
|
|
1459
|
+
return json({ keys: status });
|
|
1460
|
+
if (status === "ok")
|
|
1461
|
+
return out(ok("This terminal's keys are your account's keys."));
|
|
1462
|
+
if (status === "mismatch")
|
|
1463
|
+
return out(warn("Your account publishes a different key, so messages sent to you since then won't decrypt here. If older messages read fine in qc read, run qc keys publish."));
|
|
1464
|
+
return out(info("Could not compare keys (no key on file, or the check failed)."));
|
|
1465
|
+
}
|
|
1466
|
+
if (status === "ok")
|
|
1467
|
+
return out(ok("Already published: nothing to do."));
|
|
1468
|
+
if (!flags.yes) {
|
|
1469
|
+
if (!process.stdin.isTTY)
|
|
1470
|
+
throw new Error("qc keys publish replaces the key everyone encrypts to. Add --yes to confirm.");
|
|
1471
|
+
const { createInterface } = await import("node:readline/promises");
|
|
1472
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1473
|
+
const answer = await rl.question("Publish this terminal's key? Devices holding the other key will stop decrypting new messages until they restore your keys. [y/N] ");
|
|
1474
|
+
rl.close();
|
|
1475
|
+
if (!/^y(es)?$/i.test(answer.trim()))
|
|
1476
|
+
return out(info("Left the published key alone."));
|
|
1477
|
+
}
|
|
1478
|
+
const after = await c.publishKey();
|
|
1479
|
+
if (flags.json)
|
|
1480
|
+
return json({ published: true, keys: after });
|
|
1481
|
+
return out(ok("Published. New messages are encrypted to this terminal's key again; other devices on the other key should restore from Settings > Keys."));
|
|
1482
|
+
}
|
|
1438
1483
|
case "logout":
|
|
1439
1484
|
clearSession();
|
|
1440
1485
|
return out(ok("Signed out. This terminal no longer holds your keys."));
|
|
@@ -1507,7 +1552,7 @@ ${info("Set: qc profile --emoji :telescope: --pronouns she/her --website https:/
|
|
|
1507
1552
|
throw new Error('Usage: qc call <number> (e.g. qc call "(415) 555-1234" or qc call +442079460958)');
|
|
1508
1553
|
const c = await client(flags);
|
|
1509
1554
|
const url = `${c.base}/phone?to=${encodeURIComponent(rest.join(" "))}`;
|
|
1510
|
-
const { openBrowser } = await import("./chunk-
|
|
1555
|
+
const { openBrowser } = await import("./chunk-zh85gmtq.js");
|
|
1511
1556
|
const opened = openBrowser(url);
|
|
1512
1557
|
return out(`${mark("send")}${opened === false ? "Open" : "Opened"} ${url}
|
|
1513
1558
|
Calls to phones need your browser for the audio; tap Call there.`);
|
|
@@ -1523,6 +1568,18 @@ ${info("Set: qc profile --emoji :telescope: --pronouns she/her --website https:/
|
|
|
1523
1568
|
out(line);
|
|
1524
1569
|
return;
|
|
1525
1570
|
}
|
|
1571
|
+
case "mentions": {
|
|
1572
|
+
if (!rest[0])
|
|
1573
|
+
throw new Error("Usage: qc mentions <chat> [@partial]");
|
|
1574
|
+
const c = await client(flags);
|
|
1575
|
+
const chat = findChat2(await c.conversations(), rest[0]);
|
|
1576
|
+
const matches = mentionMatches2(chat, c.me, rest[1] ?? "");
|
|
1577
|
+
if (flags.json)
|
|
1578
|
+
return json(matches);
|
|
1579
|
+
for (const p of matches)
|
|
1580
|
+
out(`@${p.username}${p.agent ? " \uD83E\uDD16" : ""}${p.name ? ` ${p.name}` : ""}`);
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1526
1583
|
case "read": {
|
|
1527
1584
|
if (!rest[0])
|
|
1528
1585
|
throw new Error("Usage: qc read <chat> [-n 20]");
|
|
@@ -1586,11 +1643,14 @@ ${info("Set: qc profile --emoji :telescope: --pronouns she/her --website https:/
|
|
|
1586
1643
|
throw new Error("Nothing to send.");
|
|
1587
1644
|
const c = await client(flags);
|
|
1588
1645
|
const chat = findChat2(await c.conversations(), rest[0]);
|
|
1589
|
-
const
|
|
1646
|
+
const m = withMentions2(chat, c.me, text);
|
|
1647
|
+
const { message, skipped } = await c.send(chat.id, m.text, { replyTo: flags.reply || undefined });
|
|
1590
1648
|
if (flags.json)
|
|
1591
|
-
return json({ id: message?.id, conversation: chat.id, skipped });
|
|
1649
|
+
return json({ id: message?.id, conversation: chat.id, skipped, mentions: m.mentions, ambiguous: m.ambiguous });
|
|
1592
1650
|
if (skipped)
|
|
1593
1651
|
out(warn(`${skipped} participant(s) have no key yet and will not see it.`));
|
|
1652
|
+
for (const a of m.ambiguous)
|
|
1653
|
+
out(warn(`@${a.query} could be ${a.matches.join(", ")}: sent as typed.`));
|
|
1594
1654
|
return out(`${mark("send")}Sent to ${chat.title}.`);
|
|
1595
1655
|
}
|
|
1596
1656
|
case "listen": {
|
|
@@ -1666,4 +1726,4 @@ async function voiceCommand(argv, { out, json }) {
|
|
|
1666
1726
|
}
|
|
1667
1727
|
}
|
|
1668
1728
|
|
|
1669
|
-
export { CALL_TYPE, parseCall, openBrowser2, login2, exitWhenFlushed2, errorLine2, HELP2, parseArgs2, findChat2, main2 };
|
|
1729
|
+
export { CALL_TYPE, parseCall, openBrowser2, login2, exitWhenFlushed2, errorLine2, HELP2, parseArgs2, findChat2, mentionMatches2, withMentions2, main2 };
|
package/dist/commands.js
CHANGED
|
@@ -4,14 +4,18 @@ import {
|
|
|
4
4
|
HELP2,
|
|
5
5
|
parseArgs2,
|
|
6
6
|
findChat2,
|
|
7
|
+
mentionMatches2,
|
|
8
|
+
withMentions2,
|
|
7
9
|
main2
|
|
8
|
-
} from "./chunk-
|
|
9
|
-
import"./chunk-
|
|
10
|
+
} from "./chunk-hmyhm26d.js";
|
|
11
|
+
import"./chunk-4ea1xy2c.js";
|
|
10
12
|
export {
|
|
11
13
|
HELP2 as HELP,
|
|
12
14
|
errorLine2 as errorLine,
|
|
13
15
|
exitWhenFlushed2 as exitWhenFlushed,
|
|
14
16
|
findChat2 as findChat,
|
|
15
17
|
main2 as main,
|
|
16
|
-
|
|
18
|
+
mentionMatches2 as mentionMatches,
|
|
19
|
+
parseArgs2 as parseArgs,
|
|
20
|
+
withMentions2 as withMentions
|
|
17
21
|
};
|
package/dist/mcp.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import"./chunk-
|
|
1
|
+
import"./chunk-4ea1xy2c.js";
|
|
2
2
|
import {
|
|
3
|
-
findChat2
|
|
4
|
-
|
|
3
|
+
findChat2,
|
|
4
|
+
mentionMatches2,
|
|
5
|
+
withMentions2
|
|
6
|
+
} from "./chunk-hmyhm26d.js";
|
|
5
7
|
|
|
6
8
|
// src/cli/mcp.js
|
|
7
9
|
import { createInterface } from "node:readline";
|
|
@@ -26,7 +28,7 @@ var TOOLS = [
|
|
|
26
28
|
},
|
|
27
29
|
{
|
|
28
30
|
name: "send_message",
|
|
29
|
-
description: "Send an end-to-end encrypted message (ML-KEM-1024) to a conversation.",
|
|
31
|
+
description: "Send an end-to-end encrypted message (ML-KEM-1024) to a conversation. An @partial in the text that matches exactly one participant is written out as their full @username (list_mentions shows who matches).",
|
|
30
32
|
inputSchema: {
|
|
31
33
|
type: "object",
|
|
32
34
|
properties: {
|
|
@@ -38,6 +40,19 @@ var TOOLS = [
|
|
|
38
40
|
additionalProperties: false
|
|
39
41
|
}
|
|
40
42
|
},
|
|
43
|
+
{
|
|
44
|
+
name: "list_mentions",
|
|
45
|
+
description: "Who an @mention can complete to in a conversation, best match first (like typing @ali then Tab). Without partial: everyone you can mention.",
|
|
46
|
+
inputSchema: {
|
|
47
|
+
type: "object",
|
|
48
|
+
properties: {
|
|
49
|
+
chat: { type: "string", description: "Conversation id or part of its name" },
|
|
50
|
+
partial: { type: "string", description: "Optional: the start of a name, with or without the @" }
|
|
51
|
+
},
|
|
52
|
+
required: ["chat"],
|
|
53
|
+
additionalProperties: false
|
|
54
|
+
}
|
|
55
|
+
},
|
|
41
56
|
{
|
|
42
57
|
name: "react_to_message",
|
|
43
58
|
description: "React to a message with an emoji (Signal-style: one reaction per person; a new one replaces yours). End-to-end encrypted.",
|
|
@@ -79,8 +94,14 @@ async function callTool(client, name, args = {}) {
|
|
|
79
94
|
if (!args.text || typeof args.text !== "string")
|
|
80
95
|
throw new Error("text is required");
|
|
81
96
|
const chat = findChat2(await client.conversations(), args.chat);
|
|
82
|
-
const
|
|
83
|
-
|
|
97
|
+
const m = withMentions2(chat, client.me, args.text);
|
|
98
|
+
const { message, skipped } = await client.send(chat.id, m.text, { replyTo: typeof args.reply_to === "string" ? args.reply_to : undefined });
|
|
99
|
+
return { sent: true, id: message?.id, chat: chat.title, skipped, ...m.mentions.length ? { mentions: m.mentions } : {}, ...m.ambiguous.length ? { ambiguous: m.ambiguous } : {} };
|
|
100
|
+
}
|
|
101
|
+
if (name === "list_mentions") {
|
|
102
|
+
const chat = findChat2(await client.conversations(), args.chat);
|
|
103
|
+
const matches = mentionMatches2(chat, client.me, typeof args.partial === "string" ? args.partial : "");
|
|
104
|
+
return { chat: chat.title, mentions: matches.map(({ username, name: display_name, agent }) => ({ mention: `@${username}`, username, ...display_name ? { display_name } : {}, agent })) };
|
|
84
105
|
}
|
|
85
106
|
if (name === "react_to_message") {
|
|
86
107
|
if (!args.emoji || typeof args.emoji !== "string")
|
package/dist/tui.js
CHANGED
|
@@ -6,8 +6,13 @@ import {
|
|
|
6
6
|
voiceMessage,
|
|
7
7
|
parseSlashCommand,
|
|
8
8
|
namesLines,
|
|
9
|
-
listLines
|
|
10
|
-
|
|
9
|
+
listLines,
|
|
10
|
+
mentionCandidates,
|
|
11
|
+
mentionAt,
|
|
12
|
+
matchMentions,
|
|
13
|
+
completeMention,
|
|
14
|
+
mentionHint
|
|
15
|
+
} from "./chunk-4ea1xy2c.js";
|
|
11
16
|
|
|
12
17
|
// src/cli/tui.js
|
|
13
18
|
import { createApp, createImageStore, drawIcon, drawRichText, editText, emojify, insertText, stringWidth, truncate, widgets, wrap } from "@profullstack/hqtui";
|
|
@@ -41,9 +46,28 @@ function initialState(me) {
|
|
|
41
46
|
status: "connecting",
|
|
42
47
|
notice: "",
|
|
43
48
|
loading: true,
|
|
44
|
-
showArchived: false
|
|
49
|
+
showArchived: false,
|
|
50
|
+
mention: null
|
|
45
51
|
};
|
|
46
52
|
}
|
|
53
|
+
var mentionPeople = (state) => mentionCandidates(state.conversations.find((c) => c.id === state.activeId)?.participants, state.me?.id);
|
|
54
|
+
function mentionSuggestions(state) {
|
|
55
|
+
if (state.focus !== "compose" || !state.activeId)
|
|
56
|
+
return null;
|
|
57
|
+
const cycle = state.mention;
|
|
58
|
+
if (cycle && cycle.value === state.field.value && cycle.cursor === state.field.cursor)
|
|
59
|
+
return { query: cycle.query, matches: cycle.matches, index: cycle.index };
|
|
60
|
+
const at = mentionAt(state.field.value, state.field.cursor);
|
|
61
|
+
return at ? { query: at.query, matches: matchMentions(mentionPeople(state), at.query), index: -1 } : null;
|
|
62
|
+
}
|
|
63
|
+
function tabComplete(state, dir = 1) {
|
|
64
|
+
const done = completeMention(state.field.value, state.field.cursor, mentionPeople(state), state.mention, dir);
|
|
65
|
+
if (!done)
|
|
66
|
+
return false;
|
|
67
|
+
state.field = { value: done.value, cursor: done.cursor };
|
|
68
|
+
state.mention = done.cycle;
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
47
71
|
var listed = (state) => state.conversations.filter((c) => !!c.is_archived === !!state.showArchived);
|
|
48
72
|
var CHAT_LIST_REFRESH_MS = 15000;
|
|
49
73
|
function chatsWithNews(prev, next) {
|
|
@@ -138,6 +162,7 @@ function drawTranscript(surface, state) {
|
|
|
138
162
|
function render({ ui, width, height, theme }, state, on = {}) {
|
|
139
163
|
const active = state.conversations.find((c) => c.id === state.activeId);
|
|
140
164
|
const typingNames = Object.values(state.typing[state.activeId] || {}).map((t) => t.name);
|
|
165
|
+
const mention = mentionSuggestions(state);
|
|
141
166
|
ui.row({ size: 1 }, (bar) => {
|
|
142
167
|
bar.draw((s) => {
|
|
143
168
|
const t = s.theme;
|
|
@@ -188,11 +213,11 @@ function render({ ui, width, height, theme }, state, on = {}) {
|
|
|
188
213
|
drawTranscript(s, state);
|
|
189
214
|
p.ctx.hit({ rect: s.hitRect(), onScroll: (delta) => on.scroll?.(-delta) });
|
|
190
215
|
}, { size: "fill" });
|
|
191
|
-
p.label(typingNames.length ? `${typingNames.join(", ")} ${typingNames.length === 1 ? "is" : "are"} typing…` : " ", { size: 1 });
|
|
216
|
+
p.label(mention ? mentionHint(mention.matches, mention.query, mention.index) : typingNames.length ? `${typingNames.join(", ")} ${typingNames.length === 1 ? "is" : "are"} typing…` : " ", { size: 1 });
|
|
192
217
|
p.textInput({
|
|
193
218
|
value: state.field.value,
|
|
194
219
|
cursor: state.field.cursor,
|
|
195
|
-
placeholder: active ? `Message ${active.title} (Ctrl+E emoji, :rocket: works too)` : "Pick a chat first",
|
|
220
|
+
placeholder: active ? `Message ${active.title} (@name Tab, Ctrl+E emoji, :rocket: works too)` : "Pick a chat first",
|
|
196
221
|
focused: state.focus === "compose" && !state.picker,
|
|
197
222
|
size: 1
|
|
198
223
|
});
|
|
@@ -202,7 +227,7 @@ function render({ ui, width, height, theme }, state, on = {}) {
|
|
|
202
227
|
items: [
|
|
203
228
|
{ key: "Enter", label: state.focus === "list" ? "open" : "send" },
|
|
204
229
|
{ key: "Ctrl+E", label: "emoji" },
|
|
205
|
-
{ key: "Tab", label: state.focus === "list" ? "compose" : "chats" },
|
|
230
|
+
{ key: "Tab", label: state.focus === "list" ? "compose" : mention?.matches.length ? "complete @" : "chats" },
|
|
206
231
|
{ key: "PgUp", label: "scroll" },
|
|
207
232
|
...state.focus === "list" ? [{ key: "a", label: state.showArchived ? "unarchive" : "archive" }] : [],
|
|
208
233
|
{ key: "Ctrl+O", label: state.showArchived ? "chats" : "archived" },
|
|
@@ -237,7 +262,7 @@ function saveRecent(list) {
|
|
|
237
262
|
writeFileSync(recentFile(), JSON.stringify(list), { mode: 384 });
|
|
238
263
|
} catch {}
|
|
239
264
|
}
|
|
240
|
-
var KEY_MISMATCH = "Keys don't match your account, so messages can't decrypt.
|
|
265
|
+
var KEY_MISMATCH = "Keys don't match your account, so new messages can't decrypt. If your history reads here, run qc keys publish; else qc login from a browser that reads your chats.";
|
|
241
266
|
async function runTui(client, { initialChat } = {}) {
|
|
242
267
|
const state = initialState(client.me);
|
|
243
268
|
state.recent = loadRecent();
|
|
@@ -442,6 +467,8 @@ async function runTui(client, { initialChat } = {}) {
|
|
|
442
467
|
return;
|
|
443
468
|
if (event.key === "ctrl+e") {
|
|
444
469
|
state.picker = widgets.createEmojiPicker(state.recent);
|
|
470
|
+
} else if ((event.key === "tab" || event.key === "shift+tab") && state.focus === "compose" && tabComplete(state, event.key === "tab" ? 1 : -1)) {
|
|
471
|
+
typed();
|
|
445
472
|
} else if (event.key === "tab" || event.key === "shift+tab" || event.key === "escape" && state.focus === "compose") {
|
|
446
473
|
state.focus = state.focus === "list" ? "compose" : "list";
|
|
447
474
|
} else if (event.key === "pageup") {
|
|
@@ -562,7 +589,9 @@ export {
|
|
|
562
589
|
chatsWithNews,
|
|
563
590
|
initialState,
|
|
564
591
|
listed,
|
|
592
|
+
mentionSuggestions,
|
|
565
593
|
render,
|
|
566
594
|
runTui,
|
|
595
|
+
tabComplete,
|
|
567
596
|
transcriptLines
|
|
568
597
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/qryptchat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "qc: qrypt.chat in your terminal. A full-screen end-to-end encrypted chat client (ML-KEM-1024), plus a scriptable CLI and an MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|