Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "qryptchat-web",
"private": true,
"version": "0.15.1",
"version": "0.16.0",
"type": "module",
"bin": {
"qc": "./bin/qc.js",
Expand Down
7 changes: 5 additions & 2 deletions packages/qryptchat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ The first run signs you in through your browser, then opens your chats.
| Enter | send (or open the highlighted chat) |
| Ctrl+E | the emoji picker: search, recents and every group, drawn with [OpenEmoji](https://github.com/profullstack/openemoji) |
| `:rocket:` | shortcodes turn into emoji when you send |
| Tab / Esc | move between the chat list and the composer |
| `@` `@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 |
| Tab / Esc | move between the chat list and the composer (Tab completes instead when the cursor is in an `@name`) |
| Ctrl+N / Ctrl+P | next / previous chat |
| PgUp / PgDn, wheel | scroll back through the conversation |
| Ctrl+R | reload |
Expand All @@ -49,6 +50,8 @@ The mouse works too: click a chat, scroll the transcript, click an emoji. Unread
qc chats # id and name of every chat
qc read alice -n 20 # the last 20 messages, decrypted
qc send alice "on my way" # encrypted to every participant
qc send ops "@ri deploy is done" # an @partial matching one person is sent as @riotcoder
qc mentions ops @al # who @al completes to: @alice, @alina
echo "deploy done" | qc send ops -
qc listen --json # new messages as NDJSON, until you stop it
qc whoami
Expand All @@ -62,7 +65,7 @@ qc whoami
qc mcp
```

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:
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:

```sh
claude mcp add qryptchat -- qc mcp
Expand Down
2 changes: 1 addition & 1 deletion packages/qryptchat/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/qryptchat",
"version": "0.10.0",
"version": "0.11.0",
"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.",
"type": "module",
"bin": {
Expand Down
28 changes: 24 additions & 4 deletions src/cli/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { notify } from './notify.js';
import { parseVoiceArgs, runPairux, voiceLink, VOICE_HELP } from './voice.js';
import { voiceMessage } from '../lib/chat/voice.js';
import { namesLines } from '../lib/chat/irc.js';
import { expandMentions, matchMentions, mentionCandidates } from '../lib/chat/mentions.js';
export { exitWhenFlushed } from './exit.js';

/** An error as qc prints it: the error icon and the message. */
Expand Down Expand Up @@ -43,10 +44,12 @@ Usage:
qc call <number> call a phone (US/Canada free 10 min a day; +country code for others):
opens qrypt.chat's dialer with the number filled in
qc read <chat> [-n 20] print the last messages of a chat
qc send <chat> <text> send a message (text "-" reads stdin; --reply <message-id> to reply)
qc send <chat> <text> send a message (text "-" reads stdin; --reply <message-id> to reply);
an @partial that matches one person becomes their @username
qc mentions <chat> [@partial] who @ completes to in a chat, best match first (one per line)
qc react <chat> <message-id> <emoji> [--remove] react to a message
qc listen print new messages as they arrive (NDJSON with --json; --notify alerts with it)
qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message)
qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message, list_mentions)
qc join <link> join a group chat from its invite link (https://qrypt.chat/join/...)
qc invite <chat> print a group's invite link (group admins) [--reset makes a new one, --off turns it off]

Expand Down Expand Up @@ -99,6 +102,12 @@ export function findChat(chats, query) {
throw new Error(`No chat matches "${query}". Try qc chats.`);
}

/** Who @ can complete to in a chat (everyone with a username but you), best match first. */
export const mentionMatches = (chat, me, partial = '') => matchMentions(mentionCandidates(chat.participants, me?.id), String(partial).replace(/^@/, ''));

/** Text about to be sent with each @partial that matches one person written out in full. */
export const withMentions = (chat, me, text) => expandMentions(text, mentionCandidates(chat.participants, me?.id));

async function client(flags, { interactive = false } = {}) {
if (flags.url) process.env.QC_URL = flags.url;
// The session on disk is sealed: unlocking may ask for the passphrase (or read QC_PASSPHRASE / the keychain).
Expand Down Expand Up @@ -266,6 +275,15 @@ export async function main(argv, { version = '0.0.0' } = {}) {
for (const line of namesLines(chat, chat.title)) out(line);
return;
}
case 'mentions': {
if (!rest[0]) throw new Error('Usage: qc mentions <chat> [@partial]');
const c = await client(flags);
const chat = findChat(await c.conversations(), rest[0]);
const matches = mentionMatches(chat, c.me, rest[1] ?? '');
if (flags.json) return json(matches);
for (const p of matches) out(`@${p.username}${p.agent ? ' 🤖' : ''}${p.name ? ` ${p.name}` : ''}`);
return;
}
case 'read': {
if (!rest[0]) throw new Error('Usage: qc read <chat> [-n 20]');
const c = await client(flags);
Expand Down Expand Up @@ -317,9 +335,11 @@ export async function main(argv, { version = '0.0.0' } = {}) {
if (!text) throw new Error('Nothing to send.');
const c = await client(flags);
const chat = findChat(await c.conversations(), rest[0]);
const { message, skipped } = await c.send(chat.id, text, { replyTo: flags.reply || undefined });
if (flags.json) return json({ id: message?.id, conversation: chat.id, skipped });
const m = withMentions(chat, c.me, text);
const { message, skipped } = await c.send(chat.id, m.text, { replyTo: flags.reply || undefined });
if (flags.json) return json({ id: message?.id, conversation: chat.id, skipped, mentions: m.mentions, ambiguous: m.ambiguous });
if (skipped) out(warn(`${skipped} participant(s) have no key yet and will not see it.`));
for (const a of m.ambiguous) out(warn(`@${a.query} could be ${a.matches.join(', ')}: sent as typed.`));
return out(`${mark('send')}Sent to ${chat.title}.`);
}
case 'listen': {
Expand Down
27 changes: 23 additions & 4 deletions src/cli/mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* the keys; the server only ever sees ciphertext.
*/
import { createInterface } from 'node:readline';
import { findChat } from './commands.js';
import { findChat, mentionMatches, withMentions } from './commands.js';

const TOOLS = [
{
Expand All @@ -27,7 +27,7 @@ const TOOLS = [
},
{
name: 'send_message',
description: 'Send an end-to-end encrypted message (ML-KEM-1024) to a conversation.',
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).',
inputSchema: {
type: 'object',
properties: {
Expand All @@ -39,6 +39,19 @@ const TOOLS = [
additionalProperties: false,
},
},
{
name: 'list_mentions',
description: 'Who an @mention can complete to in a conversation, best match first (like typing @ali then Tab). Without partial: everyone you can mention.',
inputSchema: {
type: 'object',
properties: {
chat: { type: 'string', description: 'Conversation id or part of its name' },
partial: { type: 'string', description: 'Optional: the start of a name, with or without the @' },
},
required: ['chat'],
additionalProperties: false,
},
},
{
name: 'react_to_message',
description: 'React to a message with an emoji (Signal-style: one reaction per person; a new one replaces yours). End-to-end encrypted.',
Expand Down Expand Up @@ -80,8 +93,14 @@ export async function callTool(client, name, args = {}) {
if (name === 'send_message') {
if (!args.text || typeof args.text !== 'string') throw new Error('text is required');
const chat = findChat(await client.conversations(), args.chat);
const { message, skipped } = await client.send(chat.id, args.text, { replyTo: typeof args.reply_to === 'string' ? args.reply_to : undefined });
return { sent: true, id: message?.id, chat: chat.title, skipped };
const m = withMentions(chat, client.me, args.text);
const { message, skipped } = await client.send(chat.id, m.text, { replyTo: typeof args.reply_to === 'string' ? args.reply_to : undefined });
return { sent: true, id: message?.id, chat: chat.title, skipped, ...(m.mentions.length ? { mentions: m.mentions } : {}), ...(m.ambiguous.length ? { ambiguous: m.ambiguous } : {}) };
}
if (name === 'list_mentions') {
const chat = findChat(await client.conversations(), args.chat);
const matches = mentionMatches(chat, client.me, typeof args.partial === 'string' ? args.partial : '');
return { chat: chat.title, mentions: matches.map(({ username, name: display_name, agent }) => ({ mention: `@${username}`, username, ...(display_name ? { display_name } : {}), agent })) };
}
if (name === 'react_to_message') {
if (!args.emoji || typeof args.emoji !== 'string') throw new Error('emoji is required');
Expand Down
42 changes: 39 additions & 3 deletions src/cli/tui.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
* between your chats and the archived ones (also Ctrl+O; `a` in the list).
* /names (who is here) and /list (your chats) answer IRC-style in the
* transcript, shown only to you and never sent.
* @mentions complete like x.com and IRC: type @ and optionally the start of a
* name, then Tab (again to cycle, Shift+Tab back); the line above the composer
* lists the matches as you type. Tab outside an @name still switches panes.
* The chat list reloads every 15 s as well as on live events, so it never
* goes stale if the events stream drops.
*
Expand All @@ -32,6 +35,7 @@ import { notify } from './notify.js';
import { newVoiceRoomLink, parseSlashCommand, PAIRUX_URL, voiceMessage } from '../lib/chat/voice.js';
import { listLines, namesLines } from '../lib/chat/irc.js';
import { CHAT_LIST_EVENTS, MEMBERSHIP_EVENTS } from '../lib/chat/membership-events.js';
import { completeMention, matchMentions, mentionAt, mentionCandidates, mentionHint } from '../lib/chat/mentions.js';

const SIDEBAR = 30;

Expand All @@ -53,9 +57,31 @@ export function initialState(me) {
notice: '',
loading: true,
showArchived: false,
mention: null,
};
}

/** The people @ can complete in the open chat. */
const mentionPeople = (state) => mentionCandidates(state.conversations.find((c) => c.id === state.activeId)?.participants, state.me?.id);

/** The @name being typed and who it could be, or null: drawn above the composer. */
export function mentionSuggestions(state) {
if (state.focus !== 'compose' || !state.activeId) return null;
const cycle = state.mention;
if (cycle && cycle.value === state.field.value && cycle.cursor === state.field.cursor) return { query: cycle.query, matches: cycle.matches, index: cycle.index };
const at = mentionAt(state.field.value, state.field.cursor);
return at ? { query: at.query, matches: matchMentions(mentionPeople(state), at.query), index: -1 } : null;
}

/** Tab (dir 1) or Shift+Tab (dir -1) in the composer: true when it completed an @name. */
export function tabComplete(state, dir = 1) {
const done = completeMention(state.field.value, state.field.cursor, mentionPeople(state), state.mention, dir);
if (!done) return false;
state.field = { value: done.value, cursor: done.cursor };
state.mention = done.cycle;
return true;
}

/** The chats the sidebar shows: archived ones only in the archived view. */
export const listed = (state) => state.conversations.filter((c) => !!c.is_archived === !!state.showArchived);

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

ui.row({ size: 1 }, (bar) => {
bar.draw((s) => {
Expand Down Expand Up @@ -218,11 +245,18 @@ export function render({ ui, width, height, theme }, state, on = {}) {
drawTranscript(s, state);
p.ctx.hit({ rect: s.hitRect(), onScroll: (delta) => on.scroll?.(-delta) });
}, { size: 'fill' });
p.label(typingNames.length ? `${typingNames.join(', ')} ${typingNames.length === 1 ? 'is' : 'are'} typing…` : ' ', { size: 1 });
p.label(
mention
? mentionHint(mention.matches, mention.query, mention.index)
: typingNames.length
? `${typingNames.join(', ')} ${typingNames.length === 1 ? 'is' : 'are'} typing…`
: ' ',
{ size: 1 },
);
p.textInput({
value: state.field.value,
cursor: state.field.cursor,
placeholder: active ? `Message ${active.title} (Ctrl+E emoji, :rocket: works too)` : 'Pick a chat first',
placeholder: active ? `Message ${active.title} (@name Tab, Ctrl+E emoji, :rocket: works too)` : 'Pick a chat first',
focused: state.focus === 'compose' && !state.picker,
size: 1,
});
Expand All @@ -234,7 +268,7 @@ export function render({ ui, width, height, theme }, state, on = {}) {
items: [
{ key: 'Enter', label: state.focus === 'list' ? 'open' : 'send' },
{ key: 'Ctrl+E', label: 'emoji' },
{ key: 'Tab', label: state.focus === 'list' ? 'compose' : 'chats' },
{ key: 'Tab', label: state.focus === 'list' ? 'compose' : mention?.matches.length ? 'complete @' : 'chats' },
{ key: 'PgUp', label: 'scroll' },
...(state.focus === 'list' ? [{ key: 'a', label: state.showArchived ? 'unarchive' : 'archive' }] : []),
{ key: 'Ctrl+O', label: state.showArchived ? 'chats' : 'archived' },
Expand Down Expand Up @@ -489,6 +523,8 @@ export async function runTui(client, { initialChat } = {}) {
if (state.picker) return; // the picker's own key handler has it
if (event.key === 'ctrl+e') {
state.picker = widgets.createEmojiPicker(state.recent);
} else if ((event.key === 'tab' || event.key === 'shift+tab') && state.focus === 'compose' && tabComplete(state, event.key === 'tab' ? 1 : -1)) {
typed();
} else if (event.key === 'tab' || event.key === 'shift+tab' || (event.key === 'escape' && state.focus === 'compose')) {
state.focus = state.focus === 'list' ? 'compose' : 'list';
} else if (event.key === 'pageup') {
Expand Down
Loading
Loading