diff --git a/bin/qc.js b/bin/qc.js index a34eb717..3f9e1dd5 100755 --- a/bin/qc.js +++ b/bin/qc.js @@ -1,7 +1,7 @@ #!/usr/bin/env node // qc: qrypt.chat in the terminal. `qc` opens the chat client; `qc --help` lists the rest. import { readFileSync } from 'node:fs'; -import { main } from '../src/cli/commands.js'; +import { errorLine, main } from '../src/cli/commands.js'; const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); @@ -10,7 +10,7 @@ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), main(process.argv.slice(2), { version: pkg.version }).then( () => process.exit(0), (err) => { - process.stderr.write(`qc: ${err?.message ?? err}\n`); + process.stderr.write(`${errorLine(err)}\n`); process.exit(1); }, ); diff --git a/package.json b/package.json index 15b47be7..9fe421e1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "qryptchat-web", "private": true, - "version": "0.7.1", + "version": "0.7.2", "type": "module", "bin": { "qc": "./bin/qc.js", diff --git a/packages/qryptchat/bin/qc.js b/packages/qryptchat/bin/qc.js index 74e50a19..f095126a 100644 --- a/packages/qryptchat/bin/qc.js +++ b/packages/qryptchat/bin/qc.js @@ -1,14 +1,14 @@ #!/usr/bin/env node // qc: qrypt.chat in the terminal. `qc` opens the chat client; `qc --help` lists the rest. import { readFileSync } from 'node:fs'; -import { main } from '../dist/commands.js'; +import { errorLine, main } from '../dist/commands.js'; const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); main(process.argv.slice(2), { version: pkg.version }).then( () => process.exit(0), (err) => { - process.stderr.write(`qc: ${err?.message ?? err}\n`); + process.stderr.write(`${errorLine(err)}\n`); process.exit(1); }, ); diff --git a/packages/qryptchat/package.json b/packages/qryptchat/package.json index 1580716e..e41b70b6 100644 --- a/packages/qryptchat/package.json +++ b/packages/qryptchat/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/qryptchat", - "version": "0.6.0", + "version": "0.6.1", "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/app/agents/join/page.jsx b/src/app/agents/join/page.jsx index 7ad783b3..b31c85f1 100644 --- a/src/app/agents/join/page.jsx +++ b/src/app/agents/join/page.jsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; +import OpenEmoji from '@/lib/components/OpenEmoji.jsx'; /** * /agents/join#: where an agent invite link lands. The token is in the @@ -28,7 +29,7 @@ export default function AgentJoinPage() { return (
-

๐Ÿค– An invite for an AI agent

+

An invite for an AI agent

Someone on qrypt.chat wants to chat with an AI agent, end-to-end encrypted with ML-KEM-1024. This link is the agent's invite. It works once and expires in 7 days. diff --git a/src/app/u/[username]/page.jsx b/src/app/u/[username]/page.jsx index fe7250e3..7b5a6c2d 100644 --- a/src/app/u/[username]/page.jsx +++ b/src/app/u/[username]/page.jsx @@ -2,6 +2,7 @@ import { useState, useEffect, Suspense } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { useAuthStore } from '@/lib/stores/auth.js'; +import OpenEmoji from '@/lib/components/OpenEmoji.jsx'; function UserProfileInner() { const { username } = useParams(); @@ -41,13 +42,13 @@ function UserProfileInner() { {/* The same profile as OpenProfile.md (logicsrc.com/openprofile). */}

- {profile.emoji && } + {profile.emoji && } {profile.display_name || profile.username}

@{profile.username} {profile.pronouns && ยท {profile.pronouns}} - {profile.account_type === 'agent' && ยท AI agent} + {profile.account_type === 'agent' && ยท AI agent}

{profile.bio &&

{profile.bio}

} {profile.website && ( diff --git a/src/cli/commands.js b/src/cli/commands.js index 82f6a50c..25a6e4f3 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -6,11 +6,16 @@ import { QcClient } from './api.js'; import { clearSession, loadSession, sessionPath, unlockSession } from './config.js'; import { login } from './login.js'; import { joinAsAgent } from './agent.js'; +import { em, fail, info, mark, ok, warn, who } from './style.js'; + +/** An error as qc prints it: the error icon and the message. */ +export const errorLine = (err) => `qc: ${fail(err?.message ?? String(err))}`; export const HELP = `qc: qrypt.chat in your terminal (end-to-end encrypted, ML-KEM-1024) Usage: qc open the chat client (signs you in first if needed) + qc fonts install|status|remove our OpenEmoji colour font as the terminal's emoji font qc agent join join as an AI agent from an invite (makes its own keys) [--name --username] qc login [--oob] sign in through your browser; --oob to paste a code (SSH) qc logout forget this terminal's session and keys @@ -115,15 +120,41 @@ export async function main(argv, { version = '0.0.0' } = {}) { username: typeof flags.username === 'string' ? flags.username : undefined, }); if (flags.json) return json({ user: session.user, conversation_id: conversationId, operator }); - out(`Joined qrypt.chat as @${session.user?.username} (agent), operated by @${operator?.username ?? '?'}.`); - out(`Keys made here and sealed in ${sessionPath()}; only the public key left this machine.`); + out(ok(`Joined qrypt.chat as ${who(session.user, { agent: true })}, operated by ${who(operator)}.`)); + out(`${mark('key')}Keys made here and sealed in ${sessionPath()}; only the public key left this machine.`); const chatName = operator?.display_name || operator?.username || ''; - out(`Your chat with ${chatName} is open. Try: qc listen or qc send "${chatName}" "hello" or qc mcp`); + out(`${mark('chat')}Your chat with ${chatName} is open. Try: qc listen or qc send "${chatName}" "hello" or qc mcp`); return; } + case 'fonts': { + // The OpenEmoji colour font as the terminal's emoji font, via hqtui. + const { installEmojiFont, emojiFontStatus, removeEmojiFont } = await import('@profullstack/hqtui'); + const action = rest[0] || 'status'; + if (action === 'install') { + const result = await installEmojiFont(); + for (const note of result.notes) out(info(note)); + if (result.snippets?.length) { + out(`\n${mark('info')}Terminals that pick their own fonts need one line each:`); + for (const sn of result.snippets) out(` ${sn.terminal} (${sn.file}):\n ${sn.snippet}`); + } + return out(`\n${ok('Installed OpenEmoji. Restart the terminal, then run: qc fonts status')}`); + } + if (action === 'status') { + const st = await emojiFontStatus(); + out(`${st.installed ? ok('OpenEmoji installed') : warn('OpenEmoji not installed (qc fonts install)')}`); + if (st.emojiFont !== undefined) out(`${mark('info')}Emoji font in use: ${st.emojiFont || 'none'}`); + return out(st.active ? ok('Active: emoji draw as OpenEmoji') : warn('Not active yet (restart the terminal)')); + } + if (action === 'remove') { + const done = await removeEmojiFont(); + return out(done.length ? ok('Removed the OpenEmoji font setup.') : info('Nothing to remove.')); + } + throw new Error('Usage: qc fonts install|status|remove'); + } case 'login': { const { session } = await login({ oob: !!flags.oob }); - out(`Signed in as @${session.user?.username ?? 'unknown'} on ${session.base}. Keys sealed (ChaCha20-Poly1305) in ${sessionPath()}.`); + out(ok(`Signed in as ${who(session.user)} on ${session.base}.`)); + out(`${mark('lock')}Keys sealed (ChaCha20-Poly1305) in ${sessionPath()}.`); if ((await new QcClient(session).keyCheck()) === 'mismatch') { out("Warning: the browser handed over keys that are not your account's current keys, so messages will not decrypt here. Restore your keys in that browser (Settings > Keys) or approve from the device you chat on, then run qc login again."); } @@ -131,11 +162,11 @@ export async function main(argv, { version = '0.0.0' } = {}) { } case 'logout': clearSession(); - return out('Signed out. This terminal no longer holds your keys.'); + return out(ok('Signed out. This terminal no longer holds your keys.')); case 'whoami': { const session = loadSession(); if (!session) throw new Error('Not signed in. Run qc login.'); - return flags.json ? json({ user: session.user, base: session.base }) : out(`@${session.user?.username} (${session.user?.display_name ?? ''}) on ${session.base}`); + return flags.json ? json({ user: session.user, base: session.base }) : out(`${who(session.user)} on ${session.base}`); } case 'profile': { const c = await client(flags); @@ -144,17 +175,17 @@ export async function main(argv, { version = '0.0.0' } = {}) { for (const k of ['emoji', 'pronouns', 'website', 'bio']) if (flags[k] !== undefined) fields[k] = flags[k] === true ? '' : String(flags[k]); const p = Object.keys(fields).length ? await c.updateProfile(fields) : await c.profile(); if (flags.json) return json({ username: p.username, emoji: p.emoji ?? null, pronouns: p.pronouns ?? null, website: p.website ?? null, bio: p.bio ?? null }); - out(`${p.emoji ? `${p.emoji} ` : ''}${p.displayName ?? p.display_name ?? p.username} @${p.username}${p.pronouns ? ` (${p.pronouns})` : ''}`); - if (p.website) out(p.website); + out(who(p)); + if (p.website) out(`${mark('link')}${p.website}`); if (p.bio) out(p.bio); - if (!Object.keys(fields).length) out(`\nSet: qc profile --emoji ๐Ÿ”ญ --pronouns she/her --website https://you.example (an empty value clears)`); + if (!Object.keys(fields).length) out(`\n${info('Set: qc profile --emoji :telescope: --pronouns she/her --website https://you.example (an empty value clears)')}`); return; } case 'chats': case 'ls': { const chats = await (await client(flags)).conversations(); if (flags.json) return json(chats.map((c) => ({ id: c.id, title: c.title, type: c.type, updated_at: c.updated_at, participants: c.participants?.length ?? 0 }))); - for (const c of chats) out(`${c.id} ${c.title}`); + for (const c of chats) out(`${c.id} ${mark(c.type === 'group' ? 'users' : 'chat')}${c.title}`); return; } case 'read': { @@ -167,7 +198,7 @@ export async function main(argv, { version = '0.0.0' } = {}) { for (const m of last) { if (m.replyTo) out(` โ”ƒ ${m.replyTo.name ? `${m.replyTo.name}: ` : ''}${m.replyTo.snippet}`); const reactions = m.reactions?.length ? ` [${m.reactions.map((r) => `${r.emoji}${r.count > 1 ? r.count : ''}`).join(' ')}]` : ''; - out(`[${stamp(m.at)}] ${m.mine ? 'you' : m.sender}: ${m.text}${reactions} (${m.id.slice(0, 8)})`); + out(`[${stamp(m.at)}] ${m.emoji ? `${m.emoji} ` : ''}${m.mine ? 'you' : m.sender}: ${em(m.text)}${reactions} (${m.id.slice(0, 8)})`); } return; } @@ -181,7 +212,7 @@ export async function main(argv, { version = '0.0.0' } = {}) { const target = messages.filter((m) => m.id === messageId || m.id.startsWith(messageId)); if (target.length !== 1) throw new Error(target.length ? `"${messageId}" matches ${target.length} messages` : `No message "${messageId}" in ${chat.title}`); await c.react(chat.id, target[0].id, emoji, !!flags.remove); - return out(`${flags.remove ? 'Removed' : 'Reacted'} ${emoji} on ${chat.title}.`); + return out(ok(`${flags.remove ? 'Removed' : 'Reacted'} ${emoji} on ${chat.title}.`)); } case 'send': { if (!rest[0] || rest.length < 2) throw new Error('Usage: qc send (text "-" reads stdin)'); @@ -191,7 +222,8 @@ export async function main(argv, { version = '0.0.0' } = {}) { 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 }); - return out(`Sent to ${chat.title}${skipped ? ` (${skipped} participant(s) have no key yet)` : ''}.`); + if (skipped) out(warn(`${skipped} participant(s) have no key yet and will not see it.`)); + return out(`${mark('send')}Sent to ${chat.title}.`); } case 'listen': { const c = await client(flags); @@ -209,7 +241,7 @@ export async function main(argv, { version = '0.0.0' } = {}) { if (seen.has(m.id) || m.id !== data.message.id) continue; seen.add(m.id); if (flags.json) out(JSON.stringify({ chat: id, title: titles.get(id), ...m })); - else out(`[${stamp(m.at)}] ${titles.get(id) ?? id} ยท ${m.mine ? 'you' : m.sender}: ${m.text}`); + else out(`[${stamp(m.at)}] ${mark('chat')}${titles.get(id) ?? id} ยท ${m.emoji ? `${m.emoji} ` : ''}${m.mine ? 'you' : m.sender}: ${em(m.text)}`); } }, { onStatus: (s) => process.stderr.write(`qc: ${s}\n`) }, diff --git a/src/cli/style.js b/src/cli/style.js new file mode 100644 index 00000000..a57a83f7 --- /dev/null +++ b/src/cli/style.js @@ -0,0 +1,31 @@ +/** + * qc's line-oriented output, dressed the way hqtui apps are: OpenIcon glyphs + * from hqtui's icon() (nerd-font art, Unicode or ASCII fallbacks chosen for the + * terminal) and emoji through OpenEmoji (`qc fonts install` makes the terminal + * draw them as our artwork). + */ +import { emojify, icon } from '@profullstack/hqtui'; + +/** An icon followed by a space, or nothing when the set has no such icon. */ +export const mark = (name) => { + const glyph = icon(name); + return glyph ? `${glyph} ` : ''; +}; + +/** `:shortcode:` -> the OpenEmoji character; plain text passes through. */ +export const em = (text) => emojify(String(text ?? ''), { mode: 'emoji' }); + +export const ok = (text) => `${mark('check-circle')}${em(text)}`; +export const info = (text) => `${mark('info')}${em(text)}`; +export const warn = (text) => `${mark('warning')}${em(text)}`; +export const fail = (text) => `${mark('error')}${em(text)}`; + +/** A person or agent: their emoji (or a user/robot icon), name and handle. */ +export function who(user, { agent = false } = {}) { + if (!user) return ''; + const face = user.emoji ? `${user.emoji} ` : agent || user.account_type === 'agent' ? '๐Ÿค– ' : mark('user'); + const name = user.display_name || user.displayName || user.username || ''; + const handle = user.username && name !== user.username ? ` @${user.username}` : ''; + const pronouns = user.pronouns ? ` (${user.pronouns})` : ''; + return `${face}${name}${handle}${pronouns}`; +} diff --git a/src/lib/components/OpenEmoji.jsx b/src/lib/components/OpenEmoji.jsx new file mode 100644 index 00000000..2cb295cd --- /dev/null +++ b/src/lib/components/OpenEmoji.jsx @@ -0,0 +1,22 @@ +'use client'; + +import { artworkKey, emojiSrc } from '@/lib/emoji/openemoji.js'; + +/** + * One emoji drawn as our OpenEmoji artwork (falls back to the character when + * the set has no picture for it). `size` is a CSS length. + */ +export default function OpenEmoji({ char, size = '1.15em', label, className = '' }) { + const key = artworkKey(char); + if (!key) return {char}; + return ( + {label + ); +} diff --git a/src/lib/components/chat/MessageItem.jsx b/src/lib/components/chat/MessageItem.jsx index f53039a8..84cafd4b 100644 --- a/src/lib/components/chat/MessageItem.jsx +++ b/src/lib/components/chat/MessageItem.jsx @@ -7,6 +7,7 @@ import { detectTextFormat } from '@profullstack/text-type-detection'; import MessageAttachments from './MessageAttachments.jsx'; import EmojiPicker, { Icon } from './EmojiPicker.jsx'; import { renderOpenEmoji, isEmojiOnly, artworkKey, emojiSrc } from '@/lib/emoji/openemoji.js'; +import OpenEmoji from '@/lib/components/OpenEmoji.jsx'; import { QUICK_REACTIONS, myReaction } from '@/lib/chat/reactions.js'; import { parseCall, isJoinable, CALL_TYPE } from '@/lib/chat/calls.js'; import { useChatStore } from '@/lib/stores/chat.js'; @@ -84,7 +85,7 @@ export default function MessageItem({ message, showAvatar = true, showTimestamp {sender?.emoji &&