Skip to content

Commit 4e554ae

Browse files
ralyodioclaude
andauthored
qc TUI: HD OpenEmoji + OpenIcon images (hqtui 0.9.0), qc 0.4.4 (#288)
Messages, reactions and the brand lock draw our artwork as real images via hqtui's image store (Kitty Unicode placeholders), so they survive redraws, scrolling and tmux. On in Kitty/Ghostty; QC_HD=1 over SSH or in tmux. Plain characters everywhere else, as before. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 214204d commit 4e554ae

6 files changed

Lines changed: 41 additions & 12 deletions

File tree

‎bun.lock‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "qryptchat-web",
33
"private": true,
4-
"version": "0.5.4",
4+
"version": "0.5.5",
55
"type": "module",
66
"bin": {
77
"qc": "./bin/qc.js",
@@ -34,7 +34,7 @@
3434
"dependencies": "command -v pnpm && pnpm dlx @socketsecurity/socket-patch apply --silent --ecosystems npm || true"
3535
},
3636
"devDependencies": {
37-
"@profullstack/hqtui": "^0.8.0",
37+
"@profullstack/hqtui": "^0.9.0",
3838
"@testing-library/jest-dom": "^6.8.0",
3939
"@testing-library/react": "^16.0.0",
4040
"@testing-library/user-event": "^14.6.1",

‎packages/qryptchat/package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@profullstack/qryptchat",
3-
"version": "0.4.3",
3+
"version": "0.4.4",
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": {
@@ -17,7 +17,7 @@
1717
},
1818
"dependencies": {
1919
"@noble/ciphers": "^2.0.0",
20-
"@profullstack/hqtui": "^0.8.0",
20+
"@profullstack/hqtui": "^0.9.0",
2121
"mlkem": "^2.5.0"
2222
},
2323
"keywords": [

‎src/cli/commands.js‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ Usage:
2222
2323
<chat> is a chat id or part of its name. Options: --json, --url <server> (or QC_URL).
2424
Keys and tokens are sealed (ChaCha20-Poly1305) in ${'$'}QC_HOME or ~/.config/qc; the key is in your
25-
OS keychain, or comes from a passphrase (QC_PASSPHRASE for scripts and qc mcp).`;
25+
OS keychain, or comes from a passphrase (QC_PASSPHRASE for scripts and qc mcp).
26+
HD: emoji and icons are drawn as our OpenEmoji/OpenIcon images in Kitty and Ghostty. Over SSH or in
27+
tmux (set -g allow-passthrough on), run with QC_HD=1; QC_HD=0 turns it off.`;
2628

2729
export function parseArgs(argv) {
2830
const args = { _: [], flags: {} };

‎src/cli/tui.js‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
* draw the real layout with renderToText. The controller (runTui) owns the
1717
* network: conversations, decrypted messages, sending, and the SSE stream.
1818
*/
19-
import { createApp, editText, emojify, insertText, stringWidth, truncate, widgets, wrap } from '@profullstack/hqtui';
19+
import { createApp, createImageStore, drawIcon, drawRichText, editText, emojify, insertText, stringWidth, truncate, widgets, wrap } from '@profullstack/hqtui';
2020
import { readFileSync, writeFileSync } from 'node:fs';
2121
import { join } from 'node:path';
2222
import { configDir } from './config.js';
@@ -115,11 +115,12 @@ function drawTranscript(surface, state) {
115115
for (let y = top; y < surface.height; y++) {
116116
const line = lines[start + y - top];
117117
if (!line) break;
118+
// Emoji draw as our OpenEmoji artwork on terminals that show images (HD).
118119
if (line.spans) {
119120
let x = 0;
120-
for (const span of line.spans) x += surface.text(x, y, span.text, { fg: span.fg, attrs: span.bold ? 1 : 0 });
121+
for (const span of line.spans) x += drawRichText(surface, x, y, span.text, { fg: span.fg, attrs: span.bold ? 1 : 0 }, state.images);
121122
} else {
122-
surface.text(0, y, truncate(line.text, surface.width), { fg: line.fg });
123+
drawRichText(surface, 0, y, truncate(line.text, surface.width), { fg: line.fg }, state.images);
123124
}
124125
}
125126
if (state.scrollBack > 0) {
@@ -137,7 +138,10 @@ export function render({ ui, width, height, theme }, state, on = {}) {
137138
bar.draw((s) => {
138139
const t = s.theme;
139140
s.fillRect(0, 0, s.width, 1, { bg: t.surface ?? t.background });
140-
let x = s.text(0, 0, ' 🔒 qrypt.chat ', { fg: t.background, bg: t.primary, attrs: 1 });
141+
const brand = { fg: t.background, bg: t.primary, attrs: 1 };
142+
let x = s.text(0, 0, ' ', brand);
143+
x += drawIcon(s, x, 0, 'lock', state.images, '🔒', brand);
144+
x += s.text(x, 0, ' qrypt.chat ', brand);
141145
x += s.text(x, 0, ` @${state.me?.username ?? '?'}`, { fg: t.foreground, bg: t.surface ?? t.background });
142146
if (state.notice) s.text(x + 2, 0, truncate(state.notice, Math.max(0, s.width - x - 16)), { fg: t.warning, bg: t.surface ?? t.background });
143147
const live = { live: ['●', 'live', t.success], connecting: ['◌', 'connecting', t.warning], offline: ['○', 'offline', t.danger] }[state.status] ?? ['○', state.status, t.muted];
@@ -245,6 +249,11 @@ export async function runTui(client, { initialChat } = {}) {
245249
const abort = new AbortController();
246250
const app = await createApp({ quitKeys: ['ctrl+c'], focusNavigation: false });
247251
const redraw = () => app.invalidate();
252+
// HD: our OpenEmoji/OpenIcon PNGs as real images where the terminal can show
253+
// them. Kitty/Ghostty are detected; over SSH or in tmux say so with QC_HD=1
254+
// (or HQTUI_IMAGES=1). Everywhere else the characters are drawn as before.
255+
const env = process.env.QC_HD && !process.env.HQTUI_IMAGES ? { ...process.env, HQTUI_IMAGES: process.env.QC_HD } : process.env;
256+
state.images = createImageStore({ write: (seq) => app.terminal.write(seq), onReady: redraw, env });
248257
const note = (msg, { sticky = false } = {}) => {
249258
state.notice = msg;
250259
redraw();
@@ -410,7 +419,10 @@ export async function runTui(client, { initialChat } = {}) {
410419
state.field = insertText(state.field, event.text);
411420
redraw();
412421
});
413-
app.on('exit', () => abort.abort());
422+
app.on('exit', () => {
423+
state.images?.clear();
424+
abort.abort();
425+
});
414426

415427
app.render((args) => render(args, state, handlers));
416428

‎tests/cli/tui.test.js‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,21 @@ describe('the qc client screen', () => {
4343
expect(text).toMatch(/ENTER\s+send/i);
4444
});
4545

46+
it('draws emoji and the brand lock as HD images when the terminal can show them', () => {
47+
const s = state();
48+
// Every emoji and icon is "loaded" as image 7.
49+
s.images = { enabled: true, emoji: () => 7, icon: () => 7, clear: () => {} };
50+
const screen = draw(s);
51+
const rocket = screen.find('shipped it');
52+
const cells = screen.buffer.chars;
53+
const at = (x, y) => cells[y * screen.width + x];
54+
// "shipped it " is 11 columns; the rocket's two cells follow as image placeholders.
55+
expect(screen.text()).not.toContain('shipped it 🚀');
56+
expect(at(rocket.x + 11, rocket.y)).not.toBe(at(rocket.x + 10, rocket.y));
57+
expect(screen.text()).not.toContain('🔒');
58+
expect(screen.text()).toContain('qrypt.chat');
59+
});
60+
4661
it('groups a run of messages under one name line', () => {
4762
const lines = transcriptLines(state().messages.c1, 60, { border: 0, primary: 1, muted: 2, foreground: 3, accent: 4, info: 5, success: 6, warning: 7, secondary: 8 });
4863
const headers = lines.filter((l) => l.spans);

0 commit comments

Comments
 (0)