Skip to content

Commit 2bd6a00

Browse files
ralyodioclaude
andauthored
qc + web: OpenIcon and OpenEmoji everywhere, qc fonts (0.7.2, qc 0.6.1) (#297)
- qc's line output uses hqtui's icon() (OpenIcon: nerd glyphs, Unicode or ASCII fallback) and OpenEmoji (:shortcodes:, people's emoji), like hqtui apps do; errors print with the error icon. - qc fonts install|status|remove: the OpenEmoji colour font as the terminal's emoji font, via hqtui. - Web: <OpenEmoji> draws our artwork for the agent badge, Settings > AI agents, the join page and profile emoji. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 2c3bf47 commit 2bd6a00

13 files changed

Lines changed: 140 additions & 27 deletions

File tree

‎bin/qc.js‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env node
22
// qc: qrypt.chat in the terminal. `qc` opens the chat client; `qc --help` lists the rest.
33
import { readFileSync } from 'node:fs';
4-
import { main } from '../src/cli/commands.js';
4+
import { errorLine, main } from '../src/cli/commands.js';
55

66
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
77

@@ -10,7 +10,7 @@ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url),
1010
main(process.argv.slice(2), { version: pkg.version }).then(
1111
() => process.exit(0),
1212
(err) => {
13-
process.stderr.write(`qc: ${err?.message ?? err}\n`);
13+
process.stderr.write(`${errorLine(err)}\n`);
1414
process.exit(1);
1515
},
1616
);

‎package.json‎

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

‎packages/qryptchat/bin/qc.js‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
#!/usr/bin/env node
22
// qc: qrypt.chat in the terminal. `qc` opens the chat client; `qc --help` lists the rest.
33
import { readFileSync } from 'node:fs';
4-
import { main } from '../dist/commands.js';
4+
import { errorLine, main } from '../dist/commands.js';
55

66
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
77

88
main(process.argv.slice(2), { version: pkg.version }).then(
99
() => process.exit(0),
1010
(err) => {
11-
process.stderr.write(`qc: ${err?.message ?? err}\n`);
11+
process.stderr.write(`${errorLine(err)}\n`);
1212
process.exit(1);
1313
},
1414
);

‎packages/qryptchat/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@profullstack/qryptchat",
3-
"version": "0.6.0",
3+
"version": "0.6.1",
44
"description": "qc: qrypt.chat in your terminal. A full-screen end-to-end encrypted chat client (ML-KEM-1024), plus a scriptable CLI and an MCP server.",
55
"type": "module",
66
"bin": {

‎src/app/agents/join/page.jsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22

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

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

2930
return (
3031
<main className="container" style={{ maxWidth: 680, padding: '3rem 1rem' }}>
31-
<h1 style={{ fontSize: '1.6rem', fontWeight: 700, marginBottom: '.5rem' }}>🤖 An invite for an AI agent</h1>
32+
<h1 style={{ fontSize: '1.6rem', fontWeight: 700, marginBottom: '.5rem' }}><OpenEmoji char="🤖" /> An invite for an AI agent</h1>
3233
<p style={{ color: 'var(--color-text-secondary)', marginBottom: '1.5rem' }}>
3334
Someone on qrypt.chat wants to chat with an AI agent, end-to-end encrypted with ML-KEM-1024. This link is the
3435
agent&apos;s invite. It works once and expires in 7 days.

‎src/app/u/[username]/page.jsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { useState, useEffect, Suspense } from 'react';
33
import { useParams, useRouter } from 'next/navigation';
44
import { useAuthStore } from '@/lib/stores/auth.js';
5+
import OpenEmoji from '@/lib/components/OpenEmoji.jsx';
56

67
function UserProfileInner() {
78
const { username } = useParams();
@@ -41,13 +42,13 @@ function UserProfileInner() {
4142
{/* The same profile as OpenProfile.md (logicsrc.com/openprofile). */}
4243
<link rel="openprofile" type="text/markdown" href={`/u/${encodeURIComponent(profile.username)}/openprofile.md`} />
4344
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, marginBottom: '.25rem' }}>
44-
{profile.emoji && <span aria-hidden="true" style={{ marginRight: '.4rem' }}>{profile.emoji}</span>}
45+
{profile.emoji && <span aria-hidden="true" style={{ marginRight: '.4rem' }}><OpenEmoji char={profile.emoji} /></span>}
4546
{profile.display_name || profile.username}
4647
</h1>
4748
<p style={{ color: 'var(--color-text-secondary)', marginBottom: profile.bio || profile.website ? '.75rem' : '1.5rem' }}>
4849
@{profile.username}
4950
{profile.pronouns && <span> · {profile.pronouns}</span>}
50-
{profile.account_type === 'agent' && <span> · AI agent</span>}
51+
{profile.account_type === 'agent' && <span> · <OpenEmoji char="🤖" /> AI agent</span>}
5152
</p>
5253
{profile.bio && <p style={{ marginBottom: '.5rem', whiteSpace: 'pre-wrap' }}>{profile.bio}</p>}
5354
{profile.website && (

‎src/cli/commands.js‎

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,16 @@ import { QcClient } from './api.js';
66
import { clearSession, loadSession, sessionPath, unlockSession } from './config.js';
77
import { login } from './login.js';
88
import { joinAsAgent } from './agent.js';
9+
import { em, fail, info, mark, ok, warn, who } from './style.js';
10+
11+
/** An error as qc prints it: the error icon and the message. */
12+
export const errorLine = (err) => `qc: ${fail(err?.message ?? String(err))}`;
913

1014
export const HELP = `qc: qrypt.chat in your terminal (end-to-end encrypted, ML-KEM-1024)
1115
1216
Usage:
1317
qc open the chat client (signs you in first if needed)
18+
qc fonts install|status|remove our OpenEmoji colour font as the terminal's emoji font
1419
qc agent join <link> join as an AI agent from an invite (makes its own keys) [--name --username]
1520
qc login [--oob] sign in through your browser; --oob to paste a code (SSH)
1621
qc logout forget this terminal's session and keys
@@ -115,27 +120,53 @@ export async function main(argv, { version = '0.0.0' } = {}) {
115120
username: typeof flags.username === 'string' ? flags.username : undefined,
116121
});
117122
if (flags.json) return json({ user: session.user, conversation_id: conversationId, operator });
118-
out(`Joined qrypt.chat as @${session.user?.username} (agent), operated by @${operator?.username ?? '?'}.`);
119-
out(`Keys made here and sealed in ${sessionPath()}; only the public key left this machine.`);
123+
out(ok(`Joined qrypt.chat as ${who(session.user, { agent: true })}, operated by ${who(operator)}.`));
124+
out(`${mark('key')}Keys made here and sealed in ${sessionPath()}; only the public key left this machine.`);
120125
const chatName = operator?.display_name || operator?.username || '<chat>';
121-
out(`Your chat with ${chatName} is open. Try: qc listen or qc send "${chatName}" "hello" or qc mcp`);
126+
out(`${mark('chat')}Your chat with ${chatName} is open. Try: qc listen or qc send "${chatName}" "hello" or qc mcp`);
122127
return;
123128
}
129+
case 'fonts': {
130+
// The OpenEmoji colour font as the terminal's emoji font, via hqtui.
131+
const { installEmojiFont, emojiFontStatus, removeEmojiFont } = await import('@profullstack/hqtui');
132+
const action = rest[0] || 'status';
133+
if (action === 'install') {
134+
const result = await installEmojiFont();
135+
for (const note of result.notes) out(info(note));
136+
if (result.snippets?.length) {
137+
out(`\n${mark('info')}Terminals that pick their own fonts need one line each:`);
138+
for (const sn of result.snippets) out(` ${sn.terminal} (${sn.file}):\n ${sn.snippet}`);
139+
}
140+
return out(`\n${ok('Installed OpenEmoji. Restart the terminal, then run: qc fonts status')}`);
141+
}
142+
if (action === 'status') {
143+
const st = await emojiFontStatus();
144+
out(`${st.installed ? ok('OpenEmoji installed') : warn('OpenEmoji not installed (qc fonts install)')}`);
145+
if (st.emojiFont !== undefined) out(`${mark('info')}Emoji font in use: ${st.emojiFont || 'none'}`);
146+
return out(st.active ? ok('Active: emoji draw as OpenEmoji') : warn('Not active yet (restart the terminal)'));
147+
}
148+
if (action === 'remove') {
149+
const done = await removeEmojiFont();
150+
return out(done.length ? ok('Removed the OpenEmoji font setup.') : info('Nothing to remove.'));
151+
}
152+
throw new Error('Usage: qc fonts install|status|remove');
153+
}
124154
case 'login': {
125155
const { session } = await login({ oob: !!flags.oob });
126-
out(`Signed in as @${session.user?.username ?? 'unknown'} on ${session.base}. Keys sealed (ChaCha20-Poly1305) in ${sessionPath()}.`);
156+
out(ok(`Signed in as ${who(session.user)} on ${session.base}.`));
157+
out(`${mark('lock')}Keys sealed (ChaCha20-Poly1305) in ${sessionPath()}.`);
127158
if ((await new QcClient(session).keyCheck()) === 'mismatch') {
128159
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.");
129160
}
130161
return;
131162
}
132163
case 'logout':
133164
clearSession();
134-
return out('Signed out. This terminal no longer holds your keys.');
165+
return out(ok('Signed out. This terminal no longer holds your keys.'));
135166
case 'whoami': {
136167
const session = loadSession();
137168
if (!session) throw new Error('Not signed in. Run qc login.');
138-
return flags.json ? json({ user: session.user, base: session.base }) : out(`@${session.user?.username} (${session.user?.display_name ?? ''}) on ${session.base}`);
169+
return flags.json ? json({ user: session.user, base: session.base }) : out(`${who(session.user)} on ${session.base}`);
139170
}
140171
case 'profile': {
141172
const c = await client(flags);
@@ -144,17 +175,17 @@ export async function main(argv, { version = '0.0.0' } = {}) {
144175
for (const k of ['emoji', 'pronouns', 'website', 'bio']) if (flags[k] !== undefined) fields[k] = flags[k] === true ? '' : String(flags[k]);
145176
const p = Object.keys(fields).length ? await c.updateProfile(fields) : await c.profile();
146177
if (flags.json) return json({ username: p.username, emoji: p.emoji ?? null, pronouns: p.pronouns ?? null, website: p.website ?? null, bio: p.bio ?? null });
147-
out(`${p.emoji ? `${p.emoji} ` : ''}${p.displayName ?? p.display_name ?? p.username} @${p.username}${p.pronouns ? ` (${p.pronouns})` : ''}`);
148-
if (p.website) out(p.website);
178+
out(who(p));
179+
if (p.website) out(`${mark('link')}${p.website}`);
149180
if (p.bio) out(p.bio);
150-
if (!Object.keys(fields).length) out(`\nSet: qc profile --emoji 🔭 --pronouns she/her --website https://you.example (an empty value clears)`);
181+
if (!Object.keys(fields).length) out(`\n${info('Set: qc profile --emoji :telescope: --pronouns she/her --website https://you.example (an empty value clears)')}`);
151182
return;
152183
}
153184
case 'chats':
154185
case 'ls': {
155186
const chats = await (await client(flags)).conversations();
156187
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 })));
157-
for (const c of chats) out(`${c.id} ${c.title}`);
188+
for (const c of chats) out(`${c.id} ${mark(c.type === 'group' ? 'users' : 'chat')}${c.title}`);
158189
return;
159190
}
160191
case 'read': {
@@ -167,7 +198,7 @@ export async function main(argv, { version = '0.0.0' } = {}) {
167198
for (const m of last) {
168199
if (m.replyTo) out(` ┃ ${m.replyTo.name ? `${m.replyTo.name}: ` : ''}${m.replyTo.snippet}`);
169200
const reactions = m.reactions?.length ? ` [${m.reactions.map((r) => `${r.emoji}${r.count > 1 ? r.count : ''}`).join(' ')}]` : '';
170-
out(`[${stamp(m.at)}] ${m.mine ? 'you' : m.sender}: ${m.text}${reactions} (${m.id.slice(0, 8)})`);
201+
out(`[${stamp(m.at)}] ${m.emoji ? `${m.emoji} ` : ''}${m.mine ? 'you' : m.sender}: ${em(m.text)}${reactions} (${m.id.slice(0, 8)})`);
171202
}
172203
return;
173204
}
@@ -181,7 +212,7 @@ export async function main(argv, { version = '0.0.0' } = {}) {
181212
const target = messages.filter((m) => m.id === messageId || m.id.startsWith(messageId));
182213
if (target.length !== 1) throw new Error(target.length ? `"${messageId}" matches ${target.length} messages` : `No message "${messageId}" in ${chat.title}`);
183214
await c.react(chat.id, target[0].id, emoji, !!flags.remove);
184-
return out(`${flags.remove ? 'Removed' : 'Reacted'} ${emoji} on ${chat.title}.`);
215+
return out(ok(`${flags.remove ? 'Removed' : 'Reacted'} ${emoji} on ${chat.title}.`));
185216
}
186217
case 'send': {
187218
if (!rest[0] || rest.length < 2) throw new Error('Usage: qc send <chat> <text> (text "-" reads stdin)');
@@ -191,7 +222,8 @@ export async function main(argv, { version = '0.0.0' } = {}) {
191222
const chat = findChat(await c.conversations(), rest[0]);
192223
const { message, skipped } = await c.send(chat.id, text, { replyTo: flags.reply || undefined });
193224
if (flags.json) return json({ id: message?.id, conversation: chat.id, skipped });
194-
return out(`Sent to ${chat.title}${skipped ? ` (${skipped} participant(s) have no key yet)` : ''}.`);
225+
if (skipped) out(warn(`${skipped} participant(s) have no key yet and will not see it.`));
226+
return out(`${mark('send')}Sent to ${chat.title}.`);
195227
}
196228
case 'listen': {
197229
const c = await client(flags);
@@ -209,7 +241,7 @@ export async function main(argv, { version = '0.0.0' } = {}) {
209241
if (seen.has(m.id) || m.id !== data.message.id) continue;
210242
seen.add(m.id);
211243
if (flags.json) out(JSON.stringify({ chat: id, title: titles.get(id), ...m }));
212-
else out(`[${stamp(m.at)}] ${titles.get(id) ?? id} · ${m.mine ? 'you' : m.sender}: ${m.text}`);
244+
else out(`[${stamp(m.at)}] ${mark('chat')}${titles.get(id) ?? id} · ${m.emoji ? `${m.emoji} ` : ''}${m.mine ? 'you' : m.sender}: ${em(m.text)}`);
213245
}
214246
},
215247
{ onStatus: (s) => process.stderr.write(`qc: ${s}\n`) },

‎src/cli/style.js‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* qc's line-oriented output, dressed the way hqtui apps are: OpenIcon glyphs
3+
* from hqtui's icon() (nerd-font art, Unicode or ASCII fallbacks chosen for the
4+
* terminal) and emoji through OpenEmoji (`qc fonts install` makes the terminal
5+
* draw them as our artwork).
6+
*/
7+
import { emojify, icon } from '@profullstack/hqtui';
8+
9+
/** An icon followed by a space, or nothing when the set has no such icon. */
10+
export const mark = (name) => {
11+
const glyph = icon(name);
12+
return glyph ? `${glyph} ` : '';
13+
};
14+
15+
/** `:shortcode:` -> the OpenEmoji character; plain text passes through. */
16+
export const em = (text) => emojify(String(text ?? ''), { mode: 'emoji' });
17+
18+
export const ok = (text) => `${mark('check-circle')}${em(text)}`;
19+
export const info = (text) => `${mark('info')}${em(text)}`;
20+
export const warn = (text) => `${mark('warning')}${em(text)}`;
21+
export const fail = (text) => `${mark('error')}${em(text)}`;
22+
23+
/** A person or agent: their emoji (or a user/robot icon), name and handle. */
24+
export function who(user, { agent = false } = {}) {
25+
if (!user) return '';
26+
const face = user.emoji ? `${user.emoji} ` : agent || user.account_type === 'agent' ? '🤖 ' : mark('user');
27+
const name = user.display_name || user.displayName || user.username || '';
28+
const handle = user.username && name !== user.username ? ` @${user.username}` : '';
29+
const pronouns = user.pronouns ? ` (${user.pronouns})` : '';
30+
return `${face}${name}${handle}${pronouns}`;
31+
}

‎src/lib/components/OpenEmoji.jsx‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
'use client';
2+
3+
import { artworkKey, emojiSrc } from '@/lib/emoji/openemoji.js';
4+
5+
/**
6+
* One emoji drawn as our OpenEmoji artwork (falls back to the character when
7+
* the set has no picture for it). `size` is a CSS length.
8+
*/
9+
export default function OpenEmoji({ char, size = '1.15em', label, className = '' }) {
10+
const key = artworkKey(char);
11+
if (!key) return <span className={className}>{char}</span>;
12+
return (
13+
<img
14+
src={emojiSrc(key)}
15+
alt={label ?? char}
16+
title={label}
17+
className={`openemoji ${className}`}
18+
draggable={false}
19+
style={{ width: size, height: size, verticalAlign: '-0.2em', display: 'inline-block' }}
20+
/>
21+
);
22+
}

‎src/lib/components/chat/MessageItem.jsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { detectTextFormat } from '@profullstack/text-type-detection';
77
import MessageAttachments from './MessageAttachments.jsx';
88
import EmojiPicker, { Icon } from './EmojiPicker.jsx';
99
import { renderOpenEmoji, isEmojiOnly, artworkKey, emojiSrc } from '@/lib/emoji/openemoji.js';
10+
import OpenEmoji from '@/lib/components/OpenEmoji.jsx';
1011
import { QUICK_REACTIONS, myReaction } from '@/lib/chat/reactions.js';
1112
import { parseCall, isJoinable, CALL_TYPE } from '@/lib/chat/calls.js';
1213
import { useChatStore } from '@/lib/stores/chat.js';
@@ -84,7 +85,7 @@ export default function MessageItem({ message, showAvatar = true, showTimestamp
8485
{sender?.emoji && <span aria-hidden="true" className="message-sender-emoji" dangerouslySetInnerHTML={{ __html: `${renderOpenEmoji(String(sender.emoji).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`))} ` }} />}
8586
{displayName}
8687
{sender?.pronouns && <span className="message-sender-pronouns"> · {sender.pronouns}</span>}
87-
{sender?.account_type === 'agent' && <span className="message-sender-agent" title="AI agent">AI agent</span>}
88+
{sender?.account_type === 'agent' && <span className="message-sender-agent" title="AI agent"><OpenEmoji char="🤖" size="1em" /> AI agent</span>}
8889
</div>
8990
)}
9091

0 commit comments

Comments
 (0)