Skip to content

Commit 1b72ebe

Browse files
ralyodioclaude
andauthored
feat: @mention autocomplete everywhere: web/PWA, TUI, CLI, MCP (0.16.0, qc 0.11.0) (#339)
Anthony: "qc tui needs an @ and optional partial-name[tab] to autocomplete like both x.com and irc do" ... "add that to all surfaces pwa/desktop/cli/mcp". - src/lib/chat/mentions.js: one shared module. mentionAt (the @word at the cursor, never a@b.com), matchMentions (username prefix, display-name word, substring), completeMention (Tab, Tab again cycles, Shift+Tab back), expandMentions (unique @partial -> @username for non-interactive sends). - Web composer (PWA and the installed desktop app): x.com-style list above the composer as you type @..., arrows move, Tab/Enter pick, Tab again cycles, Esc closes, tap picks on phones. EmojiEditor gains onKeyDown/onCaret/ref.replace. - qc TUI: Tab/Shift+Tab complete inside an @name (otherwise Tab still switches panes); the line above the composer lists the matches. - qc send expands a unique @partial and warns on ambiguous ones; new qc mentions <chat> [@partial]. - MCP: send_message expands @partials (reports mentions/ambiguous); new list_mentions tool. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0b864c2 commit 1b72ebe

13 files changed

Lines changed: 630 additions & 20 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "qryptchat-web",
33
"private": true,
4-
"version": "0.15.1",
4+
"version": "0.16.0",
55
"type": "module",
66
"bin": {
77
"qc": "./bin/qc.js",

‎packages/qryptchat/README.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ The first run signs you in through your browser, then opens your chats.
3535
| Enter | send (or open the highlighted chat) |
3636
| Ctrl+E | the emoji picker: search, recents and every group, drawn with [OpenEmoji](https://github.com/profullstack/openemoji) |
3737
| `:rocket:` | shortcodes turn into emoji when you send |
38-
| Tab / Esc | move between the chat list and the composer |
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`) |
3940
| Ctrl+N / Ctrl+P | next / previous chat |
4041
| PgUp / PgDn, wheel | scroll back through the conversation |
4142
| Ctrl+R | reload |
@@ -49,6 +50,8 @@ The mouse works too: click a chat, scroll the transcript, click an emoji. Unread
4950
qc chats # id and name of every chat
5051
qc read alice -n 20 # the last 20 messages, decrypted
5152
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
5255
echo "deploy done" | qc send ops -
5356
qc listen --json # new messages as NDJSON, until you stop it
5457
qc whoami
@@ -62,7 +65,7 @@ qc whoami
6265
qc mcp
6366
```
6467

65-
runs an MCP server on stdio with three tools: `list_chats`, `read_chat` and `send_message`. Encryption and decryption happen on this machine; the server only ever sees ciphertext. For Claude Code:
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:
6669

6770
```sh
6871
claude mcp add qryptchat -- qc mcp

‎packages/qryptchat/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@profullstack/qryptchat",
3-
"version": "0.10.0",
3+
"version": "0.11.0",
44
"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.",
55
"type": "module",
66
"bin": {

‎src/cli/commands.js‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { notify } from './notify.js';
1212
import { parseVoiceArgs, runPairux, voiceLink, VOICE_HELP } from './voice.js';
1313
import { voiceMessage } from '../lib/chat/voice.js';
1414
import { namesLines } from '../lib/chat/irc.js';
15+
import { expandMentions, matchMentions, mentionCandidates } from '../lib/chat/mentions.js';
1516
export { exitWhenFlushed } from './exit.js';
1617

1718
/** An error as qc prints it: the error icon and the message. */
@@ -43,10 +44,12 @@ Usage:
4344
qc call <number> call a phone (US/Canada free 10 min a day; +country code for others):
4445
opens qrypt.chat's dialer with the number filled in
4546
qc read <chat> [-n 20] print the last messages of a chat
46-
qc send <chat> <text> send a message (text "-" reads stdin; --reply <message-id> to reply)
47+
qc send <chat> <text> send a message (text "-" reads stdin; --reply <message-id> to reply);
48+
an @partial that matches one person becomes their @username
49+
qc mentions <chat> [@partial] who @ completes to in a chat, best match first (one per line)
4750
qc react <chat> <message-id> <emoji> [--remove] react to a message
4851
qc listen print new messages as they arrive (NDJSON with --json; --notify alerts with it)
49-
qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message)
52+
qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message, list_mentions)
5053
qc join <link> join a group chat from its invite link (https://qrypt.chat/join/...)
5154
qc invite <chat> print a group's invite link (group admins) [--reset makes a new one, --off turns it off]
5255
@@ -99,6 +102,12 @@ export function findChat(chats, query) {
99102
throw new Error(`No chat matches "${query}". Try qc chats.`);
100103
}
101104

105+
/** Who @ can complete to in a chat (everyone with a username but you), best match first. */
106+
export const mentionMatches = (chat, me, partial = '') => matchMentions(mentionCandidates(chat.participants, me?.id), String(partial).replace(/^@/, ''));
107+
108+
/** Text about to be sent with each @partial that matches one person written out in full. */
109+
export const withMentions = (chat, me, text) => expandMentions(text, mentionCandidates(chat.participants, me?.id));
110+
102111
async function client(flags, { interactive = false } = {}) {
103112
if (flags.url) process.env.QC_URL = flags.url;
104113
// The session on disk is sealed: unlocking may ask for the passphrase (or read QC_PASSPHRASE / the keychain).
@@ -266,6 +275,15 @@ export async function main(argv, { version = '0.0.0' } = {}) {
266275
for (const line of namesLines(chat, chat.title)) out(line);
267276
return;
268277
}
278+
case 'mentions': {
279+
if (!rest[0]) throw new Error('Usage: qc mentions <chat> [@partial]');
280+
const c = await client(flags);
281+
const chat = findChat(await c.conversations(), rest[0]);
282+
const matches = mentionMatches(chat, c.me, rest[1] ?? '');
283+
if (flags.json) return json(matches);
284+
for (const p of matches) out(`@${p.username}${p.agent ? ' 🤖' : ''}${p.name ? ` ${p.name}` : ''}`);
285+
return;
286+
}
269287
case 'read': {
270288
if (!rest[0]) throw new Error('Usage: qc read <chat> [-n 20]');
271289
const c = await client(flags);
@@ -317,9 +335,11 @@ export async function main(argv, { version = '0.0.0' } = {}) {
317335
if (!text) throw new Error('Nothing to send.');
318336
const c = await client(flags);
319337
const chat = findChat(await c.conversations(), rest[0]);
320-
const { message, skipped } = await c.send(chat.id, text, { replyTo: flags.reply || undefined });
321-
if (flags.json) return json({ id: message?.id, conversation: chat.id, skipped });
338+
const m = withMentions(chat, c.me, text);
339+
const { message, skipped } = await c.send(chat.id, m.text, { replyTo: flags.reply || undefined });
340+
if (flags.json) return json({ id: message?.id, conversation: chat.id, skipped, mentions: m.mentions, ambiguous: m.ambiguous });
322341
if (skipped) out(warn(`${skipped} participant(s) have no key yet and will not see it.`));
342+
for (const a of m.ambiguous) out(warn(`@${a.query} could be ${a.matches.join(', ')}: sent as typed.`));
323343
return out(`${mark('send')}Sent to ${chat.title}.`);
324344
}
325345
case 'listen': {

‎src/cli/mcp.js‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* the keys; the server only ever sees ciphertext.
55
*/
66
import { createInterface } from 'node:readline';
7-
import { findChat } from './commands.js';
7+
import { findChat, mentionMatches, withMentions } from './commands.js';
88

99
const TOOLS = [
1010
{
@@ -27,7 +27,7 @@ const TOOLS = [
2727
},
2828
{
2929
name: 'send_message',
30-
description: 'Send an end-to-end encrypted message (ML-KEM-1024) to a conversation.',
30+
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).',
3131
inputSchema: {
3232
type: 'object',
3333
properties: {
@@ -39,6 +39,19 @@ const TOOLS = [
3939
additionalProperties: false,
4040
},
4141
},
42+
{
43+
name: 'list_mentions',
44+
description: 'Who an @mention can complete to in a conversation, best match first (like typing @ali then Tab). Without partial: everyone you can mention.',
45+
inputSchema: {
46+
type: 'object',
47+
properties: {
48+
chat: { type: 'string', description: 'Conversation id or part of its name' },
49+
partial: { type: 'string', description: 'Optional: the start of a name, with or without the @' },
50+
},
51+
required: ['chat'],
52+
additionalProperties: false,
53+
},
54+
},
4255
{
4356
name: 'react_to_message',
4457
description: 'React to a message with an emoji (Signal-style: one reaction per person; a new one replaces yours). End-to-end encrypted.',
@@ -80,8 +93,14 @@ export async function callTool(client, name, args = {}) {
8093
if (name === 'send_message') {
8194
if (!args.text || typeof args.text !== 'string') throw new Error('text is required');
8295
const chat = findChat(await client.conversations(), args.chat);
83-
const { message, skipped } = await client.send(chat.id, args.text, { replyTo: typeof args.reply_to === 'string' ? args.reply_to : undefined });
84-
return { sent: true, id: message?.id, chat: chat.title, skipped };
96+
const m = withMentions(chat, client.me, args.text);
97+
const { message, skipped } = await client.send(chat.id, m.text, { replyTo: typeof args.reply_to === 'string' ? args.reply_to : undefined });
98+
return { sent: true, id: message?.id, chat: chat.title, skipped, ...(m.mentions.length ? { mentions: m.mentions } : {}), ...(m.ambiguous.length ? { ambiguous: m.ambiguous } : {}) };
99+
}
100+
if (name === 'list_mentions') {
101+
const chat = findChat(await client.conversations(), args.chat);
102+
const matches = mentionMatches(chat, client.me, typeof args.partial === 'string' ? args.partial : '');
103+
return { chat: chat.title, mentions: matches.map(({ username, name: display_name, agent }) => ({ mention: `@${username}`, username, ...(display_name ? { display_name } : {}), agent })) };
85104
}
86105
if (name === 'react_to_message') {
87106
if (!args.emoji || typeof args.emoji !== 'string') throw new Error('emoji is required');

‎src/cli/tui.js‎

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
* between your chats and the archived ones (also Ctrl+O; `a` in the list).
1818
* /names (who is here) and /list (your chats) answer IRC-style in the
1919
* transcript, shown only to you and never sent.
20+
* @mentions complete like x.com and IRC: type @ and optionally the start of a
21+
* name, then Tab (again to cycle, Shift+Tab back); the line above the composer
22+
* lists the matches as you type. Tab outside an @name still switches panes.
2023
* The chat list reloads every 15 s as well as on live events, so it never
2124
* goes stale if the events stream drops.
2225
*
@@ -32,6 +35,7 @@ import { notify } from './notify.js';
3235
import { newVoiceRoomLink, parseSlashCommand, PAIRUX_URL, voiceMessage } from '../lib/chat/voice.js';
3336
import { listLines, namesLines } from '../lib/chat/irc.js';
3437
import { CHAT_LIST_EVENTS, MEMBERSHIP_EVENTS } from '../lib/chat/membership-events.js';
38+
import { completeMention, matchMentions, mentionAt, mentionCandidates, mentionHint } from '../lib/chat/mentions.js';
3539

3640
const SIDEBAR = 30;
3741

@@ -53,9 +57,31 @@ export function initialState(me) {
5357
notice: '',
5458
loading: true,
5559
showArchived: false,
60+
mention: null,
5661
};
5762
}
5863

64+
/** The people @ can complete in the open chat. */
65+
const mentionPeople = (state) => mentionCandidates(state.conversations.find((c) => c.id === state.activeId)?.participants, state.me?.id);
66+
67+
/** The @name being typed and who it could be, or null: drawn above the composer. */
68+
export function mentionSuggestions(state) {
69+
if (state.focus !== 'compose' || !state.activeId) return null;
70+
const cycle = state.mention;
71+
if (cycle && cycle.value === state.field.value && cycle.cursor === state.field.cursor) return { query: cycle.query, matches: cycle.matches, index: cycle.index };
72+
const at = mentionAt(state.field.value, state.field.cursor);
73+
return at ? { query: at.query, matches: matchMentions(mentionPeople(state), at.query), index: -1 } : null;
74+
}
75+
76+
/** Tab (dir 1) or Shift+Tab (dir -1) in the composer: true when it completed an @name. */
77+
export function tabComplete(state, dir = 1) {
78+
const done = completeMention(state.field.value, state.field.cursor, mentionPeople(state), state.mention, dir);
79+
if (!done) return false;
80+
state.field = { value: done.value, cursor: done.cursor };
81+
state.mention = done.cycle;
82+
return true;
83+
}
84+
5985
/** The chats the sidebar shows: archived ones only in the archived view. */
6086
export const listed = (state) => state.conversations.filter((c) => !!c.is_archived === !!state.showArchived);
6187

@@ -162,6 +188,7 @@ function drawTranscript(surface, state) {
162188
export function render({ ui, width, height, theme }, state, on = {}) {
163189
const active = state.conversations.find((c) => c.id === state.activeId);
164190
const typingNames = Object.values(state.typing[state.activeId] || {}).map((t) => t.name);
191+
const mention = mentionSuggestions(state);
165192

166193
ui.row({ size: 1 }, (bar) => {
167194
bar.draw((s) => {
@@ -218,11 +245,18 @@ export function render({ ui, width, height, theme }, state, on = {}) {
218245
drawTranscript(s, state);
219246
p.ctx.hit({ rect: s.hitRect(), onScroll: (delta) => on.scroll?.(-delta) });
220247
}, { size: 'fill' });
221-
p.label(typingNames.length ? `${typingNames.join(', ')} ${typingNames.length === 1 ? 'is' : 'are'} typing…` : ' ', { size: 1 });
248+
p.label(
249+
mention
250+
? mentionHint(mention.matches, mention.query, mention.index)
251+
: typingNames.length
252+
? `${typingNames.join(', ')} ${typingNames.length === 1 ? 'is' : 'are'} typing…`
253+
: ' ',
254+
{ size: 1 },
255+
);
222256
p.textInput({
223257
value: state.field.value,
224258
cursor: state.field.cursor,
225-
placeholder: active ? `Message ${active.title} (Ctrl+E emoji, :rocket: works too)` : 'Pick a chat first',
259+
placeholder: active ? `Message ${active.title} (@name Tab, Ctrl+E emoji, :rocket: works too)` : 'Pick a chat first',
226260
focused: state.focus === 'compose' && !state.picker,
227261
size: 1,
228262
});
@@ -234,7 +268,7 @@ export function render({ ui, width, height, theme }, state, on = {}) {
234268
items: [
235269
{ key: 'Enter', label: state.focus === 'list' ? 'open' : 'send' },
236270
{ key: 'Ctrl+E', label: 'emoji' },
237-
{ key: 'Tab', label: state.focus === 'list' ? 'compose' : 'chats' },
271+
{ key: 'Tab', label: state.focus === 'list' ? 'compose' : mention?.matches.length ? 'complete @' : 'chats' },
238272
{ key: 'PgUp', label: 'scroll' },
239273
...(state.focus === 'list' ? [{ key: 'a', label: state.showArchived ? 'unarchive' : 'archive' }] : []),
240274
{ key: 'Ctrl+O', label: state.showArchived ? 'chats' : 'archived' },
@@ -489,6 +523,8 @@ export async function runTui(client, { initialChat } = {}) {
489523
if (state.picker) return; // the picker's own key handler has it
490524
if (event.key === 'ctrl+e') {
491525
state.picker = widgets.createEmojiPicker(state.recent);
526+
} else if ((event.key === 'tab' || event.key === 'shift+tab') && state.focus === 'compose' && tabComplete(state, event.key === 'tab' ? 1 : -1)) {
527+
typed();
492528
} else if (event.key === 'tab' || event.key === 'shift+tab' || (event.key === 'escape' && state.focus === 'compose')) {
493529
state.focus = state.focus === 'list' ? 'compose' : 'list';
494530
} else if (event.key === 'pageup') {

0 commit comments

Comments
 (0)