@profullstack/qryptchat 0.9.2 → 0.11.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 +5 -2
- package/dist/{chunk-8gpysrd6.js → chunk-4ea1xy2c.js} +105 -1
- package/dist/chunk-nsn86c8y.js +8 -0
- package/dist/{chunk-r98rnjvx.js → chunk-wkjs9anq.js} +1 -1
- package/dist/{chunk-ew1xhfzy.js → chunk-ywyhy3sp.js} +63 -13
- package/dist/commands.js +7 -3
- package/dist/mcp.js +27 -6
- package/dist/tui.js +35 -6
- 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,6 +50,8 @@ 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
|
|
@@ -62,7 +65,7 @@ qc whoami
|
|
|
62
65
|
qc mcp
|
|
63
66
|
```
|
|
64
67
|
|
|
65
|
-
runs an MCP server on stdio with
|
|
68
|
+
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
69
|
|
|
67
70
|
```sh
|
|
68
71
|
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";
|
|
@@ -969,7 +972,7 @@ function parsePasted(input, state) {
|
|
|
969
972
|
}
|
|
970
973
|
return /^[A-Za-z0-9_-]{20,}$/.test(text) ? text : null;
|
|
971
974
|
}
|
|
972
|
-
function
|
|
975
|
+
function openBrowser2(url, { platform = process.platform, env = process.env } = {}) {
|
|
973
976
|
if (isRemote({ platform, env }))
|
|
974
977
|
return false;
|
|
975
978
|
const [cmd, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
@@ -1010,7 +1013,7 @@ function waitForCallback(server, state) {
|
|
|
1010
1013
|
});
|
|
1011
1014
|
});
|
|
1012
1015
|
}
|
|
1013
|
-
async function
|
|
1016
|
+
async function login2(options = {}) {
|
|
1014
1017
|
const env = options.env ?? process.env;
|
|
1015
1018
|
const print = options.print ?? ((line) => process.stderr.write(`${line}
|
|
1016
1019
|
`));
|
|
@@ -1044,7 +1047,7 @@ async function login(options = {}) {
|
|
|
1044
1047
|
print(` Confirmation code: ${match}`);
|
|
1045
1048
|
print(" Check that the browser shows the same code before you approve.");
|
|
1046
1049
|
print("");
|
|
1047
|
-
const opened = !oob &&
|
|
1050
|
+
const opened = !oob && openBrowser2(url.toString(), { env });
|
|
1048
1051
|
print(opened ? " Opened your browser. If it did not, open:" : " Open this link in a browser where you are signed in to qrypt.chat:");
|
|
1049
1052
|
print(` ${url.toString()}`);
|
|
1050
1053
|
print("");
|
|
@@ -1267,11 +1270,16 @@ Usage:
|
|
|
1267
1270
|
qc archive <chat> archive a chat for you (qc unarchive <chat> brings it back)
|
|
1268
1271
|
qc voice [--send <chat>] a link to a fresh end-to-end encrypted PairUX voice room (no account
|
|
1269
1272
|
needed to join); qc voice login|join|... runs the pairux CLI (qc voice help)
|
|
1273
|
+
qc phone your free phone minutes, credit and recent calls to phones
|
|
1274
|
+
qc call <number> call a phone (US/Canada free 10 min a day; +country code for others):
|
|
1275
|
+
opens qrypt.chat's dialer with the number filled in
|
|
1270
1276
|
qc read <chat> [-n 20] print the last messages of a chat
|
|
1271
|
-
qc send <chat> <text> send a message (text "-" reads stdin; --reply <message-id> to reply)
|
|
1277
|
+
qc send <chat> <text> send a message (text "-" reads stdin; --reply <message-id> to reply);
|
|
1278
|
+
an @partial that matches one person becomes their @username
|
|
1279
|
+
qc mentions <chat> [@partial] who @ completes to in a chat, best match first (one per line)
|
|
1272
1280
|
qc react <chat> <message-id> <emoji> [--remove] react to a message
|
|
1273
1281
|
qc listen print new messages as they arrive (NDJSON with --json; --notify alerts with it)
|
|
1274
|
-
qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message)
|
|
1282
|
+
qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message, list_mentions)
|
|
1275
1283
|
qc join <link> join a group chat from its invite link (https://qrypt.chat/join/...)
|
|
1276
1284
|
qc invite <chat> print a group's invite link (group admins) [--reset makes a new one, --off turns it off]
|
|
1277
1285
|
|
|
@@ -1327,6 +1335,8 @@ function findChat2(chats, query) {
|
|
|
1327
1335
|
throw new Error(`"${query}" matches ${some.length} chats: ${some.map((c) => c.title).join(", ")}`);
|
|
1328
1336
|
throw new Error(`No chat matches "${query}". Try qc chats.`);
|
|
1329
1337
|
}
|
|
1338
|
+
var mentionMatches2 = (chat, me, partial = "") => matchMentions(mentionCandidates(chat.participants, me?.id), String(partial).replace(/^@/, ""));
|
|
1339
|
+
var withMentions2 = (chat, me, text) => expandMentions(text, mentionCandidates(chat.participants, me?.id));
|
|
1330
1340
|
async function client(flags, { interactive = false } = {}) {
|
|
1331
1341
|
if (flags.url)
|
|
1332
1342
|
process.env.QC_URL = flags.url;
|
|
@@ -1334,7 +1344,7 @@ async function client(flags, { interactive = false } = {}) {
|
|
|
1334
1344
|
if (!opened) {
|
|
1335
1345
|
if (!interactive)
|
|
1336
1346
|
throw new Error("Not signed in. Run qc login.");
|
|
1337
|
-
opened = await
|
|
1347
|
+
opened = await login2({ oob: !!flags.oob });
|
|
1338
1348
|
}
|
|
1339
1349
|
return new QcClient(opened.session, { save: opened.save });
|
|
1340
1350
|
}
|
|
@@ -1369,7 +1379,7 @@ async function main2(argv, { version = "0.0.0" } = {}) {
|
|
|
1369
1379
|
}
|
|
1370
1380
|
case "agent": {
|
|
1371
1381
|
if (rest[0] === "call") {
|
|
1372
|
-
const { runVoiceAgent } = await import("./chunk-
|
|
1382
|
+
const { runVoiceAgent } = await import("./chunk-wkjs9anq.js");
|
|
1373
1383
|
return runVoiceAgent(await client(flags), {
|
|
1374
1384
|
brain: typeof flags.brain === "string" ? flags.brain : undefined,
|
|
1375
1385
|
voice: typeof flags.voice === "string" ? flags.voice : undefined,
|
|
@@ -1424,7 +1434,7 @@ ${ok("Installed OpenEmoji. Restart the terminal, then run: qc fonts status")}`);
|
|
|
1424
1434
|
throw new Error("Usage: qc fonts install|status|remove");
|
|
1425
1435
|
}
|
|
1426
1436
|
case "login": {
|
|
1427
|
-
const { session } = await
|
|
1437
|
+
const { session } = await login2({ oob: !!flags.oob });
|
|
1428
1438
|
out(ok(`Signed in as ${who(session.user)} on ${session.base}.`));
|
|
1429
1439
|
out(`${mark("lock")}Keys sealed (ChaCha20-Poly1305) in ${sessionPath()}.`);
|
|
1430
1440
|
if (await new QcClient(session).keyCheck() === "mismatch") {
|
|
@@ -1484,6 +1494,31 @@ ${info("Set: qc profile --emoji :telescope: --pronouns she/her --website https:/
|
|
|
1484
1494
|
return json({ id: chat.id, archived: cmd === "archive" });
|
|
1485
1495
|
return out(ok(cmd === "archive" ? `Archived ${chat.title}. qc chats --archived lists it; qc unarchive brings it back.` : `${chat.title} is back in your chats.`));
|
|
1486
1496
|
}
|
|
1497
|
+
case "phone": {
|
|
1498
|
+
const c = await client(flags);
|
|
1499
|
+
const p = await c.request("/api/phone");
|
|
1500
|
+
if (flags.json)
|
|
1501
|
+
return json(p);
|
|
1502
|
+
const money = (m) => `$${(Math.floor(Math.max(0, m || 0) / 1e4) / 100).toFixed(2)}`;
|
|
1503
|
+
out(p.operator ? `${mark("info")}Phone: unlimited (operator).` : `${mark("info")}Phone: ${Math.floor(p.freeSecondsLeft / 60)} free US/Canada minutes left today, credit ${money(p.balanceMicros)}.`);
|
|
1504
|
+
out(` Calls show caller ID ${p.callerId}. Buy credit or call at ${c.base}/phone`);
|
|
1505
|
+
for (const call of (p.calls || []).slice(0, 10)) {
|
|
1506
|
+
const secs = call.billed_seconds || 0;
|
|
1507
|
+
const len = secs ? `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, "0")}` : "not connected";
|
|
1508
|
+
out(` ${stamp(call.created_at)} ${call.to_number} ${len}${call.charged_micros ? ` ${money(call.charged_micros)}` : ""}`);
|
|
1509
|
+
}
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
case "call": {
|
|
1513
|
+
if (!rest[0])
|
|
1514
|
+
throw new Error('Usage: qc call <number> (e.g. qc call "(415) 555-1234" or qc call +442079460958)');
|
|
1515
|
+
const c = await client(flags);
|
|
1516
|
+
const url = `${c.base}/phone?to=${encodeURIComponent(rest.join(" "))}`;
|
|
1517
|
+
const { openBrowser } = await import("./chunk-nsn86c8y.js");
|
|
1518
|
+
const opened = openBrowser(url);
|
|
1519
|
+
return out(`${mark("send")}${opened === false ? "Open" : "Opened"} ${url}
|
|
1520
|
+
Calls to phones need your browser for the audio; tap Call there.`);
|
|
1521
|
+
}
|
|
1487
1522
|
case "names": {
|
|
1488
1523
|
if (!rest[0])
|
|
1489
1524
|
throw new Error("Usage: qc names <chat>");
|
|
@@ -1495,6 +1530,18 @@ ${info("Set: qc profile --emoji :telescope: --pronouns she/her --website https:/
|
|
|
1495
1530
|
out(line);
|
|
1496
1531
|
return;
|
|
1497
1532
|
}
|
|
1533
|
+
case "mentions": {
|
|
1534
|
+
if (!rest[0])
|
|
1535
|
+
throw new Error("Usage: qc mentions <chat> [@partial]");
|
|
1536
|
+
const c = await client(flags);
|
|
1537
|
+
const chat = findChat2(await c.conversations(), rest[0]);
|
|
1538
|
+
const matches = mentionMatches2(chat, c.me, rest[1] ?? "");
|
|
1539
|
+
if (flags.json)
|
|
1540
|
+
return json(matches);
|
|
1541
|
+
for (const p of matches)
|
|
1542
|
+
out(`@${p.username}${p.agent ? " \uD83E\uDD16" : ""}${p.name ? ` ${p.name}` : ""}`);
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1498
1545
|
case "read": {
|
|
1499
1546
|
if (!rest[0])
|
|
1500
1547
|
throw new Error("Usage: qc read <chat> [-n 20]");
|
|
@@ -1558,11 +1605,14 @@ ${info("Set: qc profile --emoji :telescope: --pronouns she/her --website https:/
|
|
|
1558
1605
|
throw new Error("Nothing to send.");
|
|
1559
1606
|
const c = await client(flags);
|
|
1560
1607
|
const chat = findChat2(await c.conversations(), rest[0]);
|
|
1561
|
-
const
|
|
1608
|
+
const m = withMentions2(chat, c.me, text);
|
|
1609
|
+
const { message, skipped } = await c.send(chat.id, m.text, { replyTo: flags.reply || undefined });
|
|
1562
1610
|
if (flags.json)
|
|
1563
|
-
return json({ id: message?.id, conversation: chat.id, skipped });
|
|
1611
|
+
return json({ id: message?.id, conversation: chat.id, skipped, mentions: m.mentions, ambiguous: m.ambiguous });
|
|
1564
1612
|
if (skipped)
|
|
1565
1613
|
out(warn(`${skipped} participant(s) have no key yet and will not see it.`));
|
|
1614
|
+
for (const a of m.ambiguous)
|
|
1615
|
+
out(warn(`@${a.query} could be ${a.matches.join(", ")}: sent as typed.`));
|
|
1566
1616
|
return out(`${mark("send")}Sent to ${chat.title}.`);
|
|
1567
1617
|
}
|
|
1568
1618
|
case "listen": {
|
|
@@ -1638,4 +1688,4 @@ async function voiceCommand(argv, { out, json }) {
|
|
|
1638
1688
|
}
|
|
1639
1689
|
}
|
|
1640
1690
|
|
|
1641
|
-
export { CALL_TYPE, parseCall, exitWhenFlushed2, errorLine2, HELP2, parseArgs2, findChat2, main2 };
|
|
1691
|
+
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-ywyhy3sp.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-ywyhy3sp.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" },
|
|
@@ -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.11.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": {
|