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
4 changes: 2 additions & 2 deletions bin/qc.js
Original file line number Diff line number Diff line change
@@ -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'));

Expand All @@ -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);
},
);
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.7.1",
"version": "0.7.2",
"type": "module",
"bin": {
"qc": "./bin/qc.js",
Expand Down
4 changes: 2 additions & 2 deletions packages/qryptchat/bin/qc.js
Original file line number Diff line number Diff line change
@@ -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);
},
);
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.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": {
Expand Down
3 changes: 2 additions & 1 deletion src/app/agents/join/page.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useEffect, useState } from 'react';
import OpenEmoji from '@/lib/components/OpenEmoji.jsx';

/**
* /agents/join#<token>: where an agent invite link lands. The token is in the
Expand Down Expand Up @@ -28,7 +29,7 @@ export default function AgentJoinPage() {

return (
<main className="container" style={{ maxWidth: 680, padding: '3rem 1rem' }}>
<h1 style={{ fontSize: '1.6rem', fontWeight: 700, marginBottom: '.5rem' }}>🤖 An invite for an AI agent</h1>
<h1 style={{ fontSize: '1.6rem', fontWeight: 700, marginBottom: '.5rem' }}><OpenEmoji char="🤖" /> An invite for an AI agent</h1>
<p style={{ color: 'var(--color-text-secondary)', marginBottom: '1.5rem' }}>
Someone on qrypt.chat wants to chat with an AI agent, end-to-end encrypted with ML-KEM-1024. This link is the
agent&apos;s invite. It works once and expires in 7 days.
Expand Down
5 changes: 3 additions & 2 deletions src/app/u/[username]/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -41,13 +42,13 @@ function UserProfileInner() {
{/* The same profile as OpenProfile.md (logicsrc.com/openprofile). */}
<link rel="openprofile" type="text/markdown" href={`/u/${encodeURIComponent(profile.username)}/openprofile.md`} />
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, marginBottom: '.25rem' }}>
{profile.emoji && <span aria-hidden="true" style={{ marginRight: '.4rem' }}>{profile.emoji}</span>}
{profile.emoji && <span aria-hidden="true" style={{ marginRight: '.4rem' }}><OpenEmoji char={profile.emoji} /></span>}
{profile.display_name || profile.username}
</h1>
<p style={{ color: 'var(--color-text-secondary)', marginBottom: profile.bio || profile.website ? '.75rem' : '1.5rem' }}>
@{profile.username}
{profile.pronouns && <span> · {profile.pronouns}</span>}
{profile.account_type === 'agent' && <span> · AI agent</span>}
{profile.account_type === 'agent' && <span> · <OpenEmoji char="🤖" /> AI agent</span>}
</p>
{profile.bio && <p style={{ marginBottom: '.5rem', whiteSpace: 'pre-wrap' }}>{profile.bio}</p>}
{profile.website && (
Expand Down
60 changes: 46 additions & 14 deletions src/cli/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <link> 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
Expand Down Expand Up @@ -115,27 +120,53 @@ 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 || '<chat>';
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.");
}
return;
}
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);
Expand All @@ -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': {
Expand All @@ -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;
}
Expand All @@ -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 <chat> <text> (text "-" reads stdin)');
Expand All @@ -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);
Expand All @@ -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`) },
Expand Down
31 changes: 31 additions & 0 deletions src/cli/style.js
Original file line number Diff line number Diff line change
@@ -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}`;
}
22 changes: 22 additions & 0 deletions src/lib/components/OpenEmoji.jsx
Original file line number Diff line number Diff line change
@@ -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 <span className={className}>{char}</span>;
return (
<img
src={emojiSrc(key)}
alt={label ?? char}
title={label}
className={`openemoji ${className}`}
draggable={false}
style={{ width: size, height: size, verticalAlign: '-0.2em', display: 'inline-block' }}
/>
);
}
3 changes: 2 additions & 1 deletion src/lib/components/chat/MessageItem.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -84,7 +85,7 @@ export default function MessageItem({ message, showAvatar = true, showTimestamp
{sender?.emoji && <span aria-hidden="true" className="message-sender-emoji" dangerouslySetInnerHTML={{ __html: `${renderOpenEmoji(String(sender.emoji).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`))} ` }} />}
{displayName}
{sender?.pronouns && <span className="message-sender-pronouns"> · {sender.pronouns}</span>}
{sender?.account_type === 'agent' && <span className="message-sender-agent" title="AI agent">AI agent</span>}
{sender?.account_type === 'agent' && <span className="message-sender-agent" title="AI agent"><OpenEmoji char="🤖" size="1em" /> AI agent</span>}
</div>
)}

Expand Down
5 changes: 3 additions & 2 deletions src/lib/components/settings/AgentSettings.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useEffect, useState } from 'react';
import OpenEmoji from '@/lib/components/OpenEmoji.jsx';

/**
* Settings > AI agents: invite an agent by link, email or text, see who you
Expand Down Expand Up @@ -68,7 +69,7 @@ export default function AgentSettings() {
return (
<div className="agent-settings">
<p className="as-hint">
Invite an AI agent (Claude Code, a bot, any program) to chat with you here, end-to-end encrypted. It makes its own keys;
<OpenEmoji char="🤖" /> Invite an AI agent (Claude Code, a bot, any program) to chat with you here, end-to-end encrypted. It makes its own keys;
you are listed as its operator.
</p>
<form onSubmit={invite} className="as-form">
Expand Down Expand Up @@ -101,7 +102,7 @@ export default function AgentSettings() {
<h3 className="as-h">Your agents</h3>
<ul className="as-list">
{list.agents.map((a) => (
<li key={a.id}><a href={`/u/${encodeURIComponent(a.username)}`}>{a.emoji ? `${a.emoji} ` : '🤖 '}{a.display_name || a.username}</a> <span className="as-muted">@{a.username}</span></li>
<li key={a.id}><a href={`/u/${encodeURIComponent(a.username)}`}><OpenEmoji char={a.emoji || '🤖'} /> {a.display_name || a.username}</a> <span className="as-muted">@{a.username}</span></li>
))}
</ul>
</>
Expand Down
3 changes: 2 additions & 1 deletion src/lib/components/settings/ProfileSettings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import { useAuthStore } from '@/lib/stores/auth.js';
import EmojiPicker from '@/lib/components/chat/EmojiPicker.jsx';
import OpenEmoji from '@/lib/components/OpenEmoji.jsx';

/**
* Settings > Profile: the public profile at /u/<username>, also served as
Expand Down Expand Up @@ -59,7 +60,7 @@ export default function ProfileSettings() {
<label htmlFor="ps-emoji">Emoji</label>
<div className="ps-emoji">
<button type="button" id="ps-emoji" className="ps-emoji-btn emoji-btn" onClick={() => setPicking((p) => !p)} aria-label="Pick your emoji">
{form.emoji || '+'}
{form.emoji ? <OpenEmoji char={form.emoji} size="1.6rem" /> : '+'}
</button>
{form.emoji && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setForm((f) => ({ ...f, emoji: '' }))}>Clear</button>
Expand Down
23 changes: 23 additions & 0 deletions tests/cli/style.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { setIconMode, icon } from '@profullstack/hqtui';
import { em, mark, ok, who } from '../../src/cli/style.js';

describe('qc output style', () => {
it('prefixes lines with OpenIcon glyphs from hqtui', () => {
setIconMode('unicode');
expect(mark('check-circle')).toBe(`${icon('check-circle')} `);
expect(ok('done')).toBe(`${icon('check-circle')} done`);
expect(mark('no-such-icon-at-all')).toBe('');
});

it('turns :shortcodes: into OpenEmoji characters', () => {
expect(em('ship it :rocket:')).toBe('ship it 🚀');
});

it('shows people and agents with their emoji, handle and pronouns', () => {
setIconMode('ascii');
expect(who({ username: 'chovy', display_name: 'Anthony', emoji: '🔭', pronouns: 'he/him' })).toBe('🔭 Anthony @chovy (he/him)');
expect(who({ username: 'riotcoder', display_name: 'riotcoder', account_type: 'agent' })).toBe('🤖 riotcoder');
expect(who({ username: 'ada', display_name: 'Ada' })).toBe(`${mark('user')}Ada @ada`);
});
});
Loading