From 74e9a1f9d47ca0cd8c6759d59235a76ad336ff86 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 26 Oct 2026 15:59:20 +0000 Subject: [PATCH] feat: @mention autocomplete everywhere: web/PWA, TUI, CLI, MCP (0.16.0, qc 0.11.0) 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 [@partial]. - MCP: send_message expands @partials (reports mentions/ambiguous); new list_mentions tool. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- packages/qryptchat/README.md | 7 +- packages/qryptchat/package.json | 2 +- src/cli/commands.js | 28 ++++- src/cli/mcp.js | 27 ++++- src/cli/tui.js | 42 ++++++- src/lib/chat/mentions.js | 140 ++++++++++++++++++++++ src/lib/chat/mentions.test.js | 111 +++++++++++++++++ src/lib/components/chat/EmojiEditor.jsx | 23 +++- src/lib/components/chat/MessageInput.jsx | 88 +++++++++++++- tests/cli/qc.test.js | 38 +++++- tests/cli/tui.test.js | 45 ++++++- tests/components/MentionComplete.test.jsx | 97 +++++++++++++++ 13 files changed, 630 insertions(+), 20 deletions(-) create mode 100644 src/lib/chat/mentions.js create mode 100644 src/lib/chat/mentions.test.js create mode 100644 tests/components/MentionComplete.test.jsx diff --git a/package.json b/package.json index 124d3f6c..b5c45b4a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "qryptchat-web", "private": true, - "version": "0.15.1", + "version": "0.16.0", "type": "module", "bin": { "qc": "./bin/qc.js", diff --git a/packages/qryptchat/README.md b/packages/qryptchat/README.md index 90eca8df..426da35a 100644 --- a/packages/qryptchat/README.md +++ b/packages/qryptchat/README.md @@ -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 | @@ -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 @@ -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 diff --git a/packages/qryptchat/package.json b/packages/qryptchat/package.json index d2c9c83e..329253db 100644 --- a/packages/qryptchat/package.json +++ b/packages/qryptchat/package.json @@ -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": { diff --git a/src/cli/commands.js b/src/cli/commands.js index 3dd54d8e..4d732a9c 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -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. */ @@ -43,10 +44,12 @@ Usage: qc call 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 [-n 20] print the last messages of a chat - qc send send a message (text "-" reads stdin; --reply to reply) + qc send send a message (text "-" reads stdin; --reply to reply); + an @partial that matches one person becomes their @username + qc mentions [@partial] who @ completes to in a chat, best match first (one per line) qc react [--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 join a group chat from its invite link (https://qrypt.chat/join/...) qc invite print a group's invite link (group admins) [--reset makes a new one, --off turns it off] @@ -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). @@ -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 [@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 [-n 20]'); const c = await client(flags); @@ -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': { diff --git a/src/cli/mcp.js b/src/cli/mcp.js index d37e4acb..17b4196d 100644 --- a/src/cli/mcp.js +++ b/src/cli/mcp.js @@ -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 = [ { @@ -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: { @@ -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.', @@ -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'); diff --git a/src/cli/tui.js b/src/cli/tui.js index 9569fb13..4d3ffbbd 100644 --- a/src/cli/tui.js +++ b/src/cli/tui.js @@ -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. * @@ -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; @@ -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); @@ -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) => { @@ -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, }); @@ -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' }, @@ -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') { diff --git a/src/lib/chat/mentions.js b/src/lib/chat/mentions.js new file mode 100644 index 00000000..7395de83 --- /dev/null +++ b/src/lib/chat/mentions.js @@ -0,0 +1,140 @@ +/** + * @mentions, completed like x.com and IRC: type `@` and optionally the start + * of a name, then Tab. Tab again cycles through the other matches (Shift+Tab + * goes back). Shared by the web composer (PWA and desktop), the qc TUI, and + * `qc send` / the MCP send_message tool, which expand an `@partial` that + * matches exactly one person. + * + * @ everyone in the chat but you + * @ali alice, alina, ... (username first, then display name) + * + * Candidates come from the chat's participants + * (/api/chat/conversations/[id]/participants), so completion costs no request + * and offers nobody the user could not already see in /names. + */ + +/** Characters a name can hold after the @ (usernames and Moshpit names like chovy.hacker). */ +const NAME_CHAR = /[\p{L}\p{N}_.-]/u; +/** What may sit just before the @: start, space, or opening punctuation (never a@b.com). */ +const BEFORE_AT = /[\s([{"'“‘]/u; + +/** The people you can mention in a chat: everyone with a username but you, A-Z. */ +export function mentionCandidates(participants, selfId) { + const seen = new Set(); + const out = []; + for (const p of participants || []) { + const u = p.user || p; + const id = p.user_id ?? u.id; + if (!u.username || (selfId && id === selfId) || seen.has(u.username.toLowerCase())) continue; + seen.add(u.username.toLowerCase()); + out.push({ id, username: u.username, name: u.display_name || '', agent: u.account_type === 'agent' }); + } + return out.sort((a, b) => a.username.localeCompare(b.username, undefined, { sensitivity: 'base' })); +} + +/** + * The @mention being typed at the cursor: `start` is the @, `end` the end of + * the name (past the cursor when it sits mid-word), `query` what is typed + * between the @ and the cursor. Null when the cursor is not in one. + */ +export function mentionAt(value, cursor = value.length) { + let i = cursor; + while (i > 0 && NAME_CHAR.test(value[i - 1])) i--; + if (value[i - 1] !== '@') return null; + const start = i - 1; + if (start > 0 && !BEFORE_AT.test(value[start - 1])) return null; + let end = cursor; + while (end < value.length && NAME_CHAR.test(value[end])) end++; + return { start, end, query: value.slice(i, cursor) }; +} + +/** + * Candidates for a partial name, best first: username prefix, display-name + * word prefix, then anywhere in the username (unless `anywhere` is false). + */ +export function matchMentions(candidates, query = '', { anywhere = true } = {}) { + const q = query.toLowerCase(); + if (!q) return [...candidates]; + const rank = (c) => { + const u = c.username.toLowerCase(); + if (u === q) return 0; + if (u.startsWith(q)) return 1; + if (c.name && c.name.toLowerCase().split(/\s+/).some((w) => w.startsWith(q))) return 2; + if (anywhere && u.includes(q)) return 3; + return -1; + }; + return candidates + .map((c) => ({ c, r: rank(c) })) + .filter((x) => x.r >= 0) + .sort((a, b) => a.r - b.r) + .map((x) => x.c); +} + +/** Put `@username ` in place of the mention at [start, end); one space after, never two. */ +export function insertMention(value, { start, end }, username) { + const after = value.slice(end); + const space = after.startsWith(' ') ? '' : ' '; + const next = `${value.slice(0, start)}@${username}${space}${after}`; + return { value: next, cursor: start + username.length + 2 }; +} + +/** + * Tab in a composer. Returns { value, cursor, cycle } or null (no mention at + * the cursor, or nobody matches: let Tab do its usual job). + * + * Pass the previous result's `cycle` back: when the field still holds what the + * last Tab put there, the next Tab moves to the next match (dir -1 for + * Shift+Tab), the way IRC clients cycle nicks. + */ +export function completeMention(value, cursor, candidates, cycle = null, dir = 1) { + if (cycle && cycle.value === value && cycle.cursor === cursor && cycle.matches.length) { + const n = cycle.matches.length; + const index = (cycle.index + dir + n) % n; + const at = { start: cycle.start, end: cycle.start + cycle.matches[cycle.index].username.length + 1 }; + const done = insertMention(value, at, cycle.matches[index].username); + return { ...done, cycle: { ...cycle, index, value: done.value, cursor: done.cursor } }; + } + const at = mentionAt(value, cursor); + if (!at) return null; + const matches = matchMentions(candidates, at.query); + if (!matches.length) return null; + const index = dir < 0 ? matches.length - 1 : 0; + const done = insertMention(value, at, matches[index].username); + return { ...done, cycle: { start: at.start, query: at.query, matches, index, value: done.value, cursor: done.cursor } }; +} + +/** + * For text sent without a composer (qc send, MCP send_message): each + * `@partial` that matches exactly one person becomes `@username`. Ambiguous + * and unknown ones are left as typed and reported, so nothing is guessed. + */ +export function expandMentions(text, candidates) { + const mentions = []; + const ambiguous = []; + const unknown = []; + const out = String(text).replace(/(^|[\s([{"'“‘])@([\p{L}\p{N}_.-]+)/gu, (whole, before, raw) => { + // "@ali." at the end of a sentence: the dot is punctuation, not the name. + const query = raw.replace(/[.-]+$/, ''); + const tail = raw.slice(query.length); + if (!query) return whole; + const exact = candidates.find((c) => c.username.toLowerCase() === query.toLowerCase()); + // Prefixes only: a stray substring match is too loose to rewrite someone's text. + const pick = exact ? [exact] : matchMentions(candidates, query, { anywhere: false }); + if (pick.length === 1) { + if (!mentions.includes(pick[0].username)) mentions.push(pick[0].username); + return `${before}@${pick[0].username}${tail}`; + } + if (pick.length > 1) ambiguous.push({ query, matches: pick.map((c) => c.username) }); + else unknown.push(query); + return whole; + }); + return { text: out, mentions, ambiguous, unknown }; +} + +/** One line of suggestions for a terminal: `@ali → alice alina (Tab)`. */ +export function mentionHint(matches, query, index = -1, max = 6) { + if (!matches.length) return `@${query}: nobody here by that name`; + const shown = matches.slice(0, max).map((c, i) => (i === index ? `[${c.username}]` : c.username)); + const more = matches.length > max ? ` +${matches.length - max}` : ''; + return `@${query} → ${shown.join(' ')}${more} (Tab)`; +} diff --git a/src/lib/chat/mentions.test.js b/src/lib/chat/mentions.test.js new file mode 100644 index 00000000..757d01b4 --- /dev/null +++ b/src/lib/chat/mentions.test.js @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { completeMention, expandMentions, insertMention, matchMentions, mentionAt, mentionCandidates, mentionHint } from './mentions.js'; + +const participants = [ + { user_id: 'me', user: { username: 'chovy', display_name: 'Anthony' } }, + { user_id: 'b', user: { username: 'riotcoder', account_type: 'agent' } }, + { user_id: 'c', user: { username: 'cha0spunk', account_type: 'agent' } }, + { user_id: 'd', user: { username: 'alice', display_name: 'Alice Smith' } }, + { user_id: 'e', user: { username: 'alina' } }, + { user_id: 'f', user: { username: 'zed.pit', display_name: 'Bob Ross' } }, + { user_id: 'g', user: { display_name: 'no username' } }, +]; +const people = mentionCandidates(participants, 'me'); + +describe('mentionCandidates', () => { + it('drops you and anyone without a username, sorted A-Z', () => { + expect(people.map((c) => c.username)).toEqual(['alice', 'alina', 'cha0spunk', 'riotcoder', 'zed.pit']); + expect(people.find((c) => c.username === 'riotcoder').agent).toBe(true); + }); +}); + +describe('mentionAt', () => { + it('finds a bare @ and a partial name at the cursor', () => { + expect(mentionAt('hey @')).toEqual({ start: 4, end: 5, query: '' }); + expect(mentionAt('@ali')).toEqual({ start: 0, end: 4, query: 'ali' }); + expect(mentionAt('(@ri')).toEqual({ start: 1, end: 4, query: 'ri' }); + }); + it('covers the rest of the word when the cursor is mid-name', () => { + expect(mentionAt('@alice hi', 3)).toEqual({ start: 0, end: 6, query: 'al' }); + }); + it('ignores email addresses and text without an @', () => { + expect(mentionAt('me@ali')).toBeNull(); + expect(mentionAt('hello')).toBeNull(); + expect(mentionAt('@alice ')).toBeNull(); + }); +}); + +describe('matchMentions', () => { + it('ranks username prefix, then display-name word, then substring', () => { + expect(matchMentions(people, 'al').map((c) => c.username)).toEqual(['alice', 'alina']); + expect(matchMentions(people, 'bob').map((c) => c.username)).toEqual(['zed.pit']); + expect(matchMentions(people, 'coder').map((c) => c.username)).toEqual(['riotcoder']); + expect(matchMentions(people, 'coder', { anywhere: false })).toEqual([]); + expect(matchMentions(people, '')).toHaveLength(5); + }); +}); + +describe('completeMention (Tab)', () => { + it('completes @partial and adds one space', () => { + const r = completeMention('hi @ri', 6, people); + expect(r.value).toBe('hi @riotcoder '); + expect(r.cursor).toBe(14); + }); + it('Tab again cycles the matches, Shift+Tab goes back', () => { + let r = completeMention('@al', 3, people); + expect(r.value).toBe('@alice '); + r = completeMention(r.value, r.cursor, people, r.cycle); + expect(r.value).toBe('@alina '); + r = completeMention(r.value, r.cursor, people, r.cycle); + expect(r.value).toBe('@alice '); + r = completeMention(r.value, r.cursor, people, r.cycle, -1); + expect(r.value).toBe('@alina '); + }); + it('a bare @ cycles everyone', () => { + let r = completeMention('@', 1, people); + const seen = [r.value]; + for (let i = 0; i < 4; i++) { + r = completeMention(r.value, r.cursor, people, r.cycle); + seen.push(r.value); + } + expect(seen).toEqual(['@alice ', '@alina ', '@cha0spunk ', '@riotcoder ', '@zed.pit ']); + }); + it('keeps the text after the cursor and does not double the space', () => { + const r = completeMention('@ch thanks', 3, people); + expect(r.value).toBe('@cha0spunk thanks'); + expect(r.cursor).toBe(11); + }); + it('starts fresh once the text changed after a Tab', () => { + const r = completeMention('@al', 3, people); + const again = completeMention('@alice @ri', 10, people, r.cycle); + expect(again.value).toBe('@alice @riotcoder '); + }); + it('returns null when nothing to complete, so Tab keeps its job', () => { + expect(completeMention('hello', 5, people)).toBeNull(); + expect(completeMention('@xyz', 4, people)).toBeNull(); + }); +}); + +describe('expandMentions (qc send, MCP)', () => { + it('expands a unique partial and reports ambiguous and unknown ones', () => { + const r = expandMentions('@ri and @al, ping @nobody. mail a@b.com', people); + expect(r.text).toBe('@riotcoder and @al, ping @nobody. mail a@b.com'); + expect(r.mentions).toEqual(['riotcoder']); + expect(r.ambiguous).toEqual([{ query: 'al', matches: ['alice', 'alina'] }]); + expect(r.unknown).toEqual(['nobody']); + }); + it('keeps sentence punctuation and fixes the case of exact names', () => { + expect(expandMentions('thanks @ALICE.', people).text).toBe('thanks @alice.'); + expect(expandMentions('@zed.pit!', people).text).toBe('@zed.pit!'); + }); +}); + +describe('insertMention and mentionHint', () => { + it('insertMention replaces the range', () => { + expect(insertMention('x @a y', { start: 2, end: 4 }, 'alice')).toEqual({ value: 'x @alice y', cursor: 9 }); + }); + it('mentionHint lists and marks the current pick', () => { + expect(mentionHint(matchMentions(people, 'al'), 'al', 1)).toBe('@al → alice [alina] (Tab)'); + expect(mentionHint([], 'zz')).toBe('@zz: nobody here by that name'); + }); +}); diff --git a/src/lib/components/chat/EmojiEditor.jsx b/src/lib/components/chat/EmojiEditor.jsx index d91d0053..47d675db 100644 --- a/src/lib/components/chat/EmojiEditor.jsx +++ b/src/lib/components/chat/EmojiEditor.jsx @@ -8,11 +8,14 @@ import { hasRawEmoji, render, selectionOffsets, serialize, setCaret, spliceValue * value stays plain text. Drop-in for the composer's textarea: * value / onChange(value) the message text * onSubmit() Enter (Shift+Enter is a new line) + * onKeyDown(e, { value, selection }) runs first; preventDefault() to take the key (@mentions) + * onCaret(offset) the caret moved (typing, arrows, clicks) * ref.insert(text) put text at the caret (the emoji picker) + * ref.replace(value, caret) set the whole value and the caret (completing an @mention) * ref.focus() */ const EmojiEditor = forwardRef(function EmojiEditor( - { value, onChange, onSubmit, onEscape, placeholder = '', disabled = false, className = '', ariaLabel = 'Message' }, + { value, onChange, onSubmit, onEscape, onKeyDown, onCaret, placeholder = '', disabled = false, className = '', ariaLabel = 'Message' }, ref ) { const el = useRef(null); @@ -34,6 +37,7 @@ const EmojiEditor = forwardRef(function EmojiEditor( last.current = next; if (caret != null && root.ownerDocument.activeElement === root) setCaret(root, caret); onChange?.(next); + if (caret != null) onCaret?.(caret); } useImperativeHandle(ref, () => ({ @@ -48,6 +52,12 @@ const EmojiEditor = forwardRef(function EmojiEditor( root.focus(); commit(next, caret); }, + replace(next, caret) { + const root = el.current; + if (!root) return; + root.focus(); + commit(next, caret); + }, })); function handleInput() { @@ -61,9 +71,15 @@ const EmojiEditor = forwardRef(function EmojiEditor( } last.current = next; onChange?.(next); + const sel = selectionOffsets(root); + if (sel) onCaret?.(sel.end); } function handleKeyDown(e) { + if (onKeyDown && !composing.current) { + onKeyDown(e, { value: last.current, selection: selectionOffsets(el.current) }); + if (e.defaultPrevented) return; + } if (e.key === 'Escape' && onEscape) { onEscape(); return; @@ -92,7 +108,10 @@ const EmojiEditor = forwardRef(function EmojiEditor( // Remember where the caret was, so the picker (which takes focus) inserts there. function rememberCaret() { const sel = el.current && selectionOffsets(el.current); - if (sel) caretRef.current = sel; + if (sel) { + caretRef.current = sel; + onCaret?.(sel.end); + } } return ( diff --git a/src/lib/components/chat/MessageInput.jsx b/src/lib/components/chat/MessageInput.jsx index cfb2cd27..656c7ad5 100644 --- a/src/lib/components/chat/MessageInput.jsx +++ b/src/lib/components/chat/MessageInput.jsx @@ -13,6 +13,7 @@ import { newVoiceRoomLink, parseSlashCommand, voiceMessage } from '@/lib/chat/vo import EmojiPicker, { Icon } from './EmojiPicker.jsx'; import { IRC_COMMANDS, listLines, namesLines } from '@/lib/chat/irc.js'; import { agentsIn } from '@/lib/chat/voice-mode.js'; +import { completeMention, insertMention, matchMentions, mentionAt, mentionCandidates } from '@/lib/chat/mentions.js'; import { useVoiceMode } from '@/lib/hooks/useVoiceMode.js'; import VoiceBar from './VoiceBar.jsx'; import { setBusy } from '@/lib/app-update.js'; @@ -104,6 +105,59 @@ export default function MessageInput({ conversationId, disabled = false }) { return useChatStore.getState().startCall(conversationId, { video: false }); } + // @mentions like x.com and IRC: type @ (and maybe the start of a name) and the + // matches show above the composer; ↑/↓ move, Tab or Enter picks, Tab again + // cycles the next match, Esc closes, a tap picks on a phone. + const people = mentionCandidates(activeConversation?.participants, user?.id); + const [caret, setCaretAt] = useState(0); + const [mentionIndex, setMentionIndex] = useState(0); + const [mentionClosedAt, setMentionClosedAt] = useState(null); + const mentionCycle = useRef(null); + const mention = mentionAt(messageText, Math.min(caret, messageText.length)); + const mentionList = mention && mentionClosedAt !== mention.start ? matchMentions(people, mention.query) : []; + const shownMentions = mentionList.slice(0, 8); + const mentionKey = mention ? `${mention.start}:${mention.query}` : ''; + useEffect(() => { + setMentionIndex(0); + // Esc closes the list for that one @; a new one opens it again. + if (!mention) setMentionClosedAt(null); + }, [mentionKey]); + + function pickMention(i) { + const done = insertMention(messageText, mention, mentionList[i].username); + // Remembered so a further Tab moves on to the next match, as in IRC. + mentionCycle.current = { start: mention.start, query: mention.query, matches: mentionList, index: i, value: done.value, cursor: done.cursor }; + editorRef.current?.replace(done.value, done.cursor); + } + + function handleMentionKey(e, { value, selection }) { + if (shownMentions.length) { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + const n = shownMentions.length; + setMentionIndex((i) => (i + (e.key === 'ArrowDown' ? 1 : -1) + n) % n); + return; + } + if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) { + e.preventDefault(); + pickMention(Math.min(mentionIndex, shownMentions.length - 1)); + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + setMentionClosedAt(mention.start); + return; + } + } + if (e.key === 'Tab' && selection) { + const done = completeMention(value, selection.end, people, mentionCycle.current, e.shiftKey ? -1 : 1); + if (!done) return; // Tab keeps its usual job + e.preventDefault(); + mentionCycle.current = done.cycle; + editorRef.current?.replace(done.value, done.cursor); + } + } + // An unsent draft or a voice conversation holds off the automatic reload after a deploy. const drafting = !!messageText.trim() || selectedFiles.length > 0; useEffect(() => { @@ -366,6 +420,30 @@ export default function MessageInput({ conversationId, disabled = false }) { + {shownMentions.length > 0 && ( +
    + {shownMentions.map((c, i) => ( +
  • { + e.preventDefault(); + pickMention(i); + }} + onMouseEnter={() => setMentionIndex(i)} + > + @{c.username} + {c.agent && 🤖} + {c.name && {c.name}} +
  • + ))} + +
+ )} + {showEmoji && setShowEmoji(false)} />} {replyingTo && ( @@ -404,9 +482,11 @@ export default function MessageInput({ conversationId, disabled = false }) { className="message-textarea" value={messageText} onChange={handleChange} + onCaret={setCaretAt} + onKeyDown={handleMentionKey} onSubmit={handleSend} onEscape={replyingTo ? clearReplyingTo : undefined} - placeholder="Type a message... (/voice starts a voice room)" + placeholder="Type a message... (@ mentions, /voice starts a voice room)" disabled={disabled || isSending} /> {/* Always drawn: where voice cannot work, the tap says why (a hidden button explains nothing). */} @@ -444,6 +524,12 @@ export default function MessageInput({ conversationId, disabled = false }) { .irc-notice-title { font-weight: 600; } .irc-notice-hint { opacity: .6; font-size: .75rem; margin-top: .25rem; } .irc-notice-close { position: absolute; top: .25rem; right: .5rem; background: none; border: 0; color: inherit; font-size: 1rem; cursor: pointer; opacity: .7; } + .mention-list { list-style: none; margin: 0 0 .5rem; padding: .25rem; max-height: 16rem; overflow: auto; background: var(--color-bg-secondary); border: 1px solid var(--color-border-primary); border-radius: .75rem; box-shadow: 0 4px 16px rgba(0,0,0,.15); } + .mention-item { display: flex; align-items: baseline; gap: .5rem; padding: .4rem .6rem; border-radius: .5rem; cursor: pointer; color: var(--color-text-primary); font-size: .875rem; } + .mention-item.active { background: var(--color-bg-tertiary); } + .mention-username { font-weight: 600; } + .mention-name { color: var(--color-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .mention-hint { padding: .25rem .6rem 0; font-size: .75rem; color: var(--color-text-muted); } .encryption-error { color: var(--color-error); font-size: .8125rem; margin-bottom: .5rem; padding: .5rem; background: rgba(239,68,68,.1); border-radius: .375rem; } .file-preview-list { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin-bottom: .5rem; } .file-chip { display: flex; align-items: center; gap: .4rem; max-width: 240px; padding: .35rem .5rem; background: var(--color-bg-tertiary); border: 1px solid var(--color-border-primary); border-radius: .5rem; font-size: .8125rem; color: var(--color-text-primary); } diff --git a/tests/cli/qc.test.js b/tests/cli/qc.test.js index 29590f2f..5bdda4df 100644 --- a/tests/cli/qc.test.js +++ b/tests/cli/qc.test.js @@ -180,7 +180,7 @@ describe('qc mcp', () => { const init = await handle(fake, { id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18' } }, { version: '1' }); expect(init.serverInfo.name).toBe('qc'); const { tools } = await handle(fake, { id: 2, method: 'tools/list' }, { version: '1' }); - expect(tools.map((t) => t.name)).toEqual(['list_chats', 'read_chat', 'send_message', 'react_to_message']); + expect(tools.map((t) => t.name)).toEqual(['list_chats', 'read_chat', 'send_message', 'list_mentions', 'react_to_message']); }); it('calls tools and reports errors as tool errors', async () => { @@ -191,6 +191,42 @@ describe('qc mcp', () => { await expect(handle(fake, { id: 5, method: 'nope' }, { version: '1' })).rejects.toThrow(/Method not found/); expect(await handle(fake, { method: 'notifications/initialized' }, { version: '1' })).toBeUndefined(); }); + + const group = { + me: { id: 'me' }, + conversations: async () => [ + { + id: 'g1', + title: 'Team', + participants: [ + { user_id: 'me', user: { username: 'chovy' } }, + { user_id: 'a', user: { username: 'alice', display_name: 'Alice Smith' } }, + { user_id: 'b', user: { username: 'alina' } }, + { user_id: 'r', user: { username: 'riotcoder', account_type: 'agent' } }, + ], + }, + ], + sent: [], + async send(id, text) { + this.sent.push(text); + return { message: { id: 'm' }, skipped: 0 }; + }, + }; + const call = (name, args) => handle(group, { id: 9, method: 'tools/call', params: { name, arguments: args } }, { version: '1' }); + + it('list_mentions completes an @partial like Tab does', async () => { + const all = await call('list_mentions', { chat: 'team' }); + expect(all.structuredContent.mentions.map((m) => m.mention)).toEqual(['@alice', '@alina', '@riotcoder']); + const al = await call('list_mentions', { chat: 'team', partial: '@al' }); + expect(al.structuredContent.mentions.map((m) => m.username)).toEqual(['alice', 'alina']); + }); + + it('send_message writes out a unique @partial and reports ambiguous ones', async () => { + const r = await call('send_message', { chat: 'team', text: '@ri can you and @al look?' }); + expect(group.sent.at(-1)).toBe('@riotcoder can you and @al look?'); + expect(r.structuredContent.mentions).toEqual(['riotcoder']); + expect(r.structuredContent.ambiguous).toEqual([{ query: 'al', matches: ['alice', 'alina'] }]); + }); }); describe('qc login on a remote box', () => { diff --git a/tests/cli/tui.test.js b/tests/cli/tui.test.js index 2a03330d..b1a6e477 100644 --- a/tests/cli/tui.test.js +++ b/tests/cli/tui.test.js @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { renderToScreen } from '@profullstack/hqtui/testing'; import { setIconMode } from '@profullstack/hqtui'; -import { CHAT_LIST_REFRESH_MS, chatsWithNews, initialState, listed, render, SLASH_COMMANDS, transcriptLines } from '../../src/cli/tui.js'; +import { CHAT_LIST_REFRESH_MS, chatsWithNews, initialState, listed, mentionSuggestions, render, SLASH_COMMANDS, tabComplete, transcriptLines } from '../../src/cli/tui.js'; setIconMode('unicode'); @@ -137,3 +137,46 @@ describe('the periodic chat list refresh', () => { expect(CHAT_LIST_REFRESH_MS).toBeLessThanOrEqual(30_000); }); }); + +describe('@mention completion in the composer', () => { + function group() { + const s = state(); + s.conversations[0].participants = [ + { user_id: 'me', user: { username: 'anthony' } }, + { user_id: 'a', user: { username: 'alice' } }, + { user_id: 'b', user: { username: 'alina' } }, + { user_id: 'r', user: { username: 'riotcoder', account_type: 'agent' } }, + ]; + return s; + } + const type = (s, value) => (s.field = { value, cursor: value.length }); + + it('Tab completes @partial, Tab again cycles, Shift+Tab goes back', () => { + const s = group(); + type(s, 'hey @al'); + expect(tabComplete(s)).toBe(true); + expect(s.field).toEqual({ value: 'hey @alice ', cursor: 11 }); + tabComplete(s); + expect(s.field.value).toBe('hey @alina '); + tabComplete(s, -1); + expect(s.field.value).toBe('hey @alice '); + }); + + it('a bare @ offers everyone but you; Tab outside an @name is left for pane switching', () => { + const s = group(); + type(s, '@'); + expect(mentionSuggestions(s).matches.map((c) => c.username)).toEqual(['alice', 'alina', 'riotcoder']); + type(s, 'hello'); + expect(tabComplete(s)).toBe(false); + expect(mentionSuggestions(s)).toBeNull(); + }); + + it('lists the matches above the composer while typing @…', () => { + const s = group(); + type(s, '@al'); + const text = draw(s).text(); + expect(text).toContain('@al → alice alina (Tab)'); + expect(text).not.toContain('alice is typing…'); + expect(text).toMatch(/TAB\s+complete @/i); + }); +}); diff --git a/tests/components/MentionComplete.test.jsx b/tests/components/MentionComplete.test.jsx new file mode 100644 index 00000000..3ded879e --- /dev/null +++ b/tests/components/MentionComplete.test.jsx @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +const store = vi.hoisted(() => ({ state: {} })); +vi.mock('@/lib/stores/chat.js', () => ({ + useChatStore: Object.assign((select) => select(store.state), { getState: () => store.state }), +})); +vi.mock('@/lib/stores/auth.js', () => ({ useAuthStore: (select) => select({ user: { id: 'me' } }) })); + +import MessageInput from '../../src/lib/components/chat/MessageInput.jsx'; + +const participants = [ + { user_id: 'me', user: { username: 'chovy' } }, + { user_id: 'a', user: { username: 'alice', display_name: 'Alice Smith' } }, + { user_id: 'b', user: { username: 'alina' } }, + { user_id: 'r', user: { username: 'riotcoder', account_type: 'agent' } }, +]; + +beforeEach(() => { + // The composer loads the chat's members itself (the store's list can be empty). + globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ participants }) })); + store.state = { + conversations: [], + sendMessage: vi.fn(async () => ({ success: true })), + setTyping: vi.fn(), + stopTyping: vi.fn(), + loadMessages: vi.fn(), + replyingTo: null, + clearReplyingTo: vi.fn(), + }; +}); + +/** Type into the editor with the caret at the end, as a person would. */ +function type(editor, text) { + editor.focus(); + editor.textContent = text; + const range = document.createRange(); + range.setStart(editor.firstChild, text.length); + range.collapse(true); + document.getSelection().removeAllRanges(); + document.getSelection().addRange(range); + fireEvent.input(editor); +} + +const text = (editor) => editor.textContent; + +describe('@mention completion in the web composer', () => { + it('lists matches for @partial and Tab picks the highlighted one', async () => { + render(); + const editor = screen.getByRole('textbox', { name: 'Message' }); + await waitFor(() => expect(globalThis.fetch).toHaveBeenCalled()); + type(editor, 'hey @al'); + const list = await screen.findByRole('listbox', { name: 'Mention someone' }); + expect([...list.querySelectorAll('[role=option] .mention-username')].map((n) => n.textContent)).toEqual(['@alice', '@alina']); + fireEvent.keyDown(editor, { key: 'ArrowDown' }); + fireEvent.keyDown(editor, { key: 'Tab' }); + await waitFor(() => expect(text(editor)).toBe('hey @alina ')); + expect(screen.queryByRole('listbox')).toBeNull(); + }); + + it('a bare @ offers everyone but you, Enter picks instead of sending', async () => { + render(); + const editor = screen.getByRole('textbox', { name: 'Message' }); + await waitFor(() => expect(globalThis.fetch).toHaveBeenCalled()); + type(editor, '@'); + const options = await screen.findAllByRole('option'); + expect(options.map((o) => o.querySelector('.mention-username').textContent)).toEqual(['@alice', '@alina', '@riotcoder']); + fireEvent.keyDown(editor, { key: 'Enter' }); + await waitFor(() => expect(text(editor)).toBe('@alice ')); + expect(store.state.sendMessage).not.toHaveBeenCalled(); + }); + + it('Tab again cycles to the next match, IRC-style', async () => { + render(); + const editor = screen.getByRole('textbox', { name: 'Message' }); + await waitFor(() => expect(globalThis.fetch).toHaveBeenCalled()); + type(editor, '@al'); + await screen.findByRole('listbox'); + fireEvent.keyDown(editor, { key: 'Tab' }); + await waitFor(() => expect(text(editor)).toBe('@alice ')); + fireEvent.keyDown(editor, { key: 'Tab' }); + await waitFor(() => expect(text(editor)).toBe('@alina ')); + }); + + it('a tap picks (phones), Esc closes the list', async () => { + render(); + const editor = screen.getByRole('textbox', { name: 'Message' }); + await waitFor(() => expect(globalThis.fetch).toHaveBeenCalled()); + type(editor, 'ping @ri'); + fireEvent.mouseDown(await screen.findByText('@riotcoder')); + await waitFor(() => expect(text(editor)).toBe('ping @riotcoder ')); + type(editor, 'ping @a'); + await screen.findByRole('listbox'); + fireEvent.keyDown(editor, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull()); + }); +});