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
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.1",
"version": "0.6.2",
"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
8 changes: 6 additions & 2 deletions src/cli/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { clearSession, loadSession, sessionPath, unlockSession } from './config.
import { login } from './login.js';
import { joinAsAgent } from './agent.js';
import { em, fail, info, mark, ok, warn, who } from './style.js';
import { notify } from './notify.js';

/** An error as qc prints it: the error icon and the message. */
export const errorLine = (err) => `qc: ${fail(err?.message ?? String(err))}`;
Expand All @@ -27,7 +28,7 @@ Usage:
qc read <chat> [-n 20] print the last messages of a chat
qc send <chat> <text> send a message (text "-" reads stdin; --reply <message-id> to reply)
qc react <chat> <message-id> <emoji> [--remove] react to a message
qc listen print new messages as they arrive (NDJSON with --json)
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)

<chat> is a chat id or part of its name. Options: --json, --url <server> (or QC_URL).
Expand All @@ -36,7 +37,9 @@ OS keychain, or comes from a passphrase (QC_PASSPHRASE for scripts and qc mcp).
HD: emoji and icons are drawn as our OpenEmoji/OpenIcon images in Kitty, Ghostty, WezTerm and iTerm2.
Over SSH or in tmux (set -g allow-passthrough on) say which: QC_HD=1 (Kitty/Ghostty) or
QC_HD=wezterm (WezTerm/iTerm2). QC_HD=0 turns it off. Mosh carries only text, so behind mosh
emoji stay characters: for images use ssh or WezTerm's multiplexer (wezterm connect).`;
emoji stay characters: for images use ssh or WezTerm's multiplexer (wezterm connect).
New messages raise a terminal notification (OSC 9, Kitty OSC 99) and a bell, naming the chat
but never the text; in tmux set -g allow-passthrough on. QC_NOTIFY=bell rings only, QC_NOTIFY=0 is quiet.`;

export function parseArgs(argv) {
const args = { _: [], flags: {} };
Expand Down Expand Up @@ -240,6 +243,7 @@ export async function main(argv, { version = '0.0.0' } = {}) {
for (const m of messages.slice(-5)) {
if (seen.has(m.id) || m.id !== data.message.id) continue;
seen.add(m.id);
if (!m.mine && (flags.notify || !flags.json)) notify('qrypt.chat', `${m.sender} in ${titles.get(id) ?? 'a chat'}`);
if (flags.json) out(JSON.stringify({ chat: id, title: titles.get(id), ...m }));
else out(`[${stamp(m.at)}] ${mark('chat')}${titles.get(id) ?? id} 路 ${m.emoji ? `${m.emoji} ` : ''}${m.mine ? 'you' : m.sender}: ${em(m.text)}`);
}
Expand Down
44 changes: 44 additions & 0 deletions src/cli/notify.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* New-message alerts drawn by the terminal itself: a desktop notification
* escape plus a bell. The terminal raises them on the machine you are sitting
* at, so they work over SSH, where notify-send would pop up on the server.
*
* Kitty speaks OSC 99; iTerm2, WezTerm, Ghostty, foot and Windows Terminal
* speak OSC 9. Inside tmux the escape is wrapped for passthrough
* (set -g allow-passthrough on) and the bare bell still marks the window.
*
* Only the chat and sender are shown, never the message: a notification
* centre is outside the end-to-end encryption.
*
* QC_NOTIFY=0 turns alerts off, QC_NOTIFY=bell rings without a notification.
*/

const ESC = '\x1b';
const BEL = '\x07';
const ST = `${ESC}\\`;

/** Strip control characters so a name cannot end the escape early. */
const clean = (s) => String(s ?? '').replace(/[\x00-\x1f\x7f;]/g, ' ').trim();

/** The bytes for one alert in this environment ('' when alerts are off). */
export function notifySequence(title, body, env = process.env) {
const mode = String(env.QC_NOTIFY ?? '1').toLowerCase();
if (['0', 'off', 'false', 'no'].includes(mode)) return '';
if (mode === 'bell') return BEL;
const t = clean(title);
const b = clean(body);
const kitty = env.KITTY_WINDOW_ID || env.TERM === 'xterm-kitty';
let osc = kitty
? `${ESC}]99;i=qc:d=0:o=unfocused;${t}${ST}${ESC}]99;i=qc:d=1:p=body;${b}${ST}`
: `${ESC}]9;${t}: ${b}${BEL}`;
if (env.TMUX) osc = `${ESC}Ptmux;${osc.replaceAll(ESC, ESC + ESC)}${ST}`;
return osc + BEL;
}

/** Write an alert to the first of stdout/stderr that is a terminal. */
export function notify(title, body, { env = process.env, streams = [process.stdout, process.stderr] } = {}) {
const seq = notifySequence(title, body, env);
const tty = streams.find((s) => s?.isTTY);
if (seq && tty) tty.write(seq);
return Boolean(seq && tty);
}
6 changes: 6 additions & 0 deletions src/cli/tui.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { createApp, createImageStore, drawIcon, drawRichText, editText, emojify,
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { configDir } from './config.js';
import { notify } from './notify.js';

const SIDEBAR = 30;

Expand Down Expand Up @@ -437,6 +438,11 @@ export async function runTui(client, { initialChat } = {}) {
if (type === 'NEW_MESSAGE') {
const id = data?.message?.conversation_id;
if (!id) return;
// The terminal may be behind another window, so even the open chat alerts.
if (data.message.sender_id !== client.me?.id) {
const chat = state.conversations.find((c) => c.id === id);
notify('qrypt.chat', `new message${chat ? ` in ${chat.title}` : ''}`);
}
if (id === state.activeId) loadMessages(id);
else {
if (data.message.sender_id !== client.me?.id) state.unread[id] = (state.unread[id] || 0) + 1;
Expand Down
36 changes: 36 additions & 0 deletions tests/cli/notify.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { notify, notifySequence } from '../../src/cli/notify.js';

describe('qc new-message alerts', () => {
it('sends OSC 9 and a bell by default', () => {
expect(notifySequence('qrypt.chat', 'new message from Anthony', {})).toBe('\x1b]9;qrypt.chat: new message from Anthony\x07\x07');
});

it('uses OSC 99 in Kitty', () => {
const seq = notifySequence('qrypt.chat', 'hi', { TERM: 'xterm-kitty' });
expect(seq).toBe('\x1b]99;i=qc:d=0:o=unfocused;qrypt.chat\x1b\\\x1b]99;i=qc:d=1:p=body;hi\x1b\\\x07');
});

it('wraps the escape for tmux passthrough and keeps the bare bell', () => {
const seq = notifySequence('a', 'b', { TMUX: '/tmp/tmux-1/default,1,0' });
expect(seq).toBe('\x1bPtmux;\x1b\x1b]9;a: b\x07\x1b\\\x07');
});

it('can be turned off or reduced to a bell', () => {
expect(notifySequence('a', 'b', { QC_NOTIFY: '0' })).toBe('');
expect(notifySequence('a', 'b', { QC_NOTIFY: 'bell' })).toBe('\x07');
});

it('strips control characters so a name cannot break out of the escape', () => {
expect(notifySequence('x', 'evil\x07\x1b]52;c;bad', {})).toBe('\x1b]9;x: evil ]52 c bad\x07\x07');
});

it('writes only to a terminal', () => {
const written = [];
const pipe = { isTTY: false, write: (s) => written.push(['pipe', s]) };
const tty = { isTTY: true, write: (s) => written.push(['tty', s]) };
expect(notify('a', 'b', { env: {}, streams: [pipe, tty] })).toBe(true);
expect(written).toEqual([['tty', '\x1b]9;a: b\x07\x07']]);
expect(notify('a', 'b', { env: {}, streams: [pipe] })).toBe(false);
});
});
Loading