Skip to content

Commit ececbe5

Browse files
ralyodioclaude
andcommitted
qc: terminal notifications for new messages (qc 0.6.2)
The TUI and `qc listen` already get messages pushed over SSE, but only redrew: nothing told you a message arrived while the terminal was behind another window. Now each message from someone else raises a notification drawn by the terminal (OSC 9; OSC 99 with o=unfocused in Kitty) plus a bell, so it reaches the machine you sit at even over SSH. In tmux the escape is wrapped for passthrough and the bare bell still flags the window. Only the chat and sender are shown, never the text: a notification centre is outside the E2EE. QC_NOTIFY=bell rings only, QC_NOTIFY=0 is quiet. `qc listen --json --notify` alerts alongside the NDJSON. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 8840da6 commit ececbe5

5 files changed

Lines changed: 93 additions & 3 deletions

File tree

‎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.1",
3+
"version": "0.6.2",
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/cli/commands.js‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { clearSession, loadSession, sessionPath, unlockSession } from './config.
77
import { login } from './login.js';
88
import { joinAsAgent } from './agent.js';
99
import { em, fail, info, mark, ok, warn, who } from './style.js';
10+
import { notify } from './notify.js';
1011

1112
/** An error as qc prints it: the error icon and the message. */
1213
export const errorLine = (err) => `qc: ${fail(err?.message ?? String(err))}`;
@@ -27,7 +28,7 @@ Usage:
2728
qc read <chat> [-n 20] print the last messages of a chat
2829
qc send <chat> <text> send a message (text "-" reads stdin; --reply <message-id> to reply)
2930
qc react <chat> <message-id> <emoji> [--remove] react to a message
30-
qc listen print new messages as they arrive (NDJSON with --json)
31+
qc listen print new messages as they arrive (NDJSON with --json; --notify alerts with it)
3132
qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message)
3233
3334
<chat> is a chat id or part of its name. Options: --json, --url <server> (or QC_URL).
@@ -36,7 +37,9 @@ OS keychain, or comes from a passphrase (QC_PASSPHRASE for scripts and qc mcp).
3637
HD: emoji and icons are drawn as our OpenEmoji/OpenIcon images in Kitty, Ghostty, WezTerm and iTerm2.
3738
Over SSH or in tmux (set -g allow-passthrough on) say which: QC_HD=1 (Kitty/Ghostty) or
3839
QC_HD=wezterm (WezTerm/iTerm2). QC_HD=0 turns it off. Mosh carries only text, so behind mosh
39-
emoji stay characters: for images use ssh or WezTerm's multiplexer (wezterm connect).`;
40+
emoji stay characters: for images use ssh or WezTerm's multiplexer (wezterm connect).
41+
New messages raise a terminal notification (OSC 9, Kitty OSC 99) and a bell, naming the chat
42+
but never the text; in tmux set -g allow-passthrough on. QC_NOTIFY=bell rings only, QC_NOTIFY=0 is quiet.`;
4043

4144
export function parseArgs(argv) {
4245
const args = { _: [], flags: {} };
@@ -240,6 +243,7 @@ export async function main(argv, { version = '0.0.0' } = {}) {
240243
for (const m of messages.slice(-5)) {
241244
if (seen.has(m.id) || m.id !== data.message.id) continue;
242245
seen.add(m.id);
246+
if (!m.mine && (flags.notify || !flags.json)) notify('qrypt.chat', `${m.sender} in ${titles.get(id) ?? 'a chat'}`);
243247
if (flags.json) out(JSON.stringify({ chat: id, title: titles.get(id), ...m }));
244248
else out(`[${stamp(m.at)}] ${mark('chat')}${titles.get(id) ?? id} · ${m.emoji ? `${m.emoji} ` : ''}${m.mine ? 'you' : m.sender}: ${em(m.text)}`);
245249
}

‎src/cli/notify.js‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* New-message alerts drawn by the terminal itself: a desktop notification
3+
* escape plus a bell. The terminal raises them on the machine you are sitting
4+
* at, so they work over SSH, where notify-send would pop up on the server.
5+
*
6+
* Kitty speaks OSC 99; iTerm2, WezTerm, Ghostty, foot and Windows Terminal
7+
* speak OSC 9. Inside tmux the escape is wrapped for passthrough
8+
* (set -g allow-passthrough on) and the bare bell still marks the window.
9+
*
10+
* Only the chat and sender are shown, never the message: a notification
11+
* centre is outside the end-to-end encryption.
12+
*
13+
* QC_NOTIFY=0 turns alerts off, QC_NOTIFY=bell rings without a notification.
14+
*/
15+
16+
const ESC = '\x1b';
17+
const BEL = '\x07';
18+
const ST = `${ESC}\\`;
19+
20+
/** Strip control characters so a name cannot end the escape early. */
21+
const clean = (s) => String(s ?? '').replace(/[\x00-\x1f\x7f;]/g, ' ').trim();
22+
23+
/** The bytes for one alert in this environment ('' when alerts are off). */
24+
export function notifySequence(title, body, env = process.env) {
25+
const mode = String(env.QC_NOTIFY ?? '1').toLowerCase();
26+
if (['0', 'off', 'false', 'no'].includes(mode)) return '';
27+
if (mode === 'bell') return BEL;
28+
const t = clean(title);
29+
const b = clean(body);
30+
const kitty = env.KITTY_WINDOW_ID || env.TERM === 'xterm-kitty';
31+
let osc = kitty
32+
? `${ESC}]99;i=qc:d=0:o=unfocused;${t}${ST}${ESC}]99;i=qc:d=1:p=body;${b}${ST}`
33+
: `${ESC}]9;${t}: ${b}${BEL}`;
34+
if (env.TMUX) osc = `${ESC}Ptmux;${osc.replaceAll(ESC, ESC + ESC)}${ST}`;
35+
return osc + BEL;
36+
}
37+
38+
/** Write an alert to the first of stdout/stderr that is a terminal. */
39+
export function notify(title, body, { env = process.env, streams = [process.stdout, process.stderr] } = {}) {
40+
const seq = notifySequence(title, body, env);
41+
const tty = streams.find((s) => s?.isTTY);
42+
if (seq && tty) tty.write(seq);
43+
return Boolean(seq && tty);
44+
}

‎src/cli/tui.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { createApp, createImageStore, drawIcon, drawRichText, editText, emojify,
2020
import { readFileSync, writeFileSync } from 'node:fs';
2121
import { join } from 'node:path';
2222
import { configDir } from './config.js';
23+
import { notify } from './notify.js';
2324

2425
const SIDEBAR = 30;
2526

@@ -437,6 +438,11 @@ export async function runTui(client, { initialChat } = {}) {
437438
if (type === 'NEW_MESSAGE') {
438439
const id = data?.message?.conversation_id;
439440
if (!id) return;
441+
// The terminal may be behind another window, so even the open chat alerts.
442+
if (data.message.sender_id !== client.me?.id) {
443+
const chat = state.conversations.find((c) => c.id === id);
444+
notify('qrypt.chat', `new message${chat ? ` in ${chat.title}` : ''}`);
445+
}
440446
if (id === state.activeId) loadMessages(id);
441447
else {
442448
if (data.message.sender_id !== client.me?.id) state.unread[id] = (state.unread[id] || 0) + 1;

‎tests/cli/notify.test.js‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { notify, notifySequence } from '../../src/cli/notify.js';
3+
4+
describe('qc new-message alerts', () => {
5+
it('sends OSC 9 and a bell by default', () => {
6+
expect(notifySequence('qrypt.chat', 'new message from Anthony', {})).toBe('\x1b]9;qrypt.chat: new message from Anthony\x07\x07');
7+
});
8+
9+
it('uses OSC 99 in Kitty', () => {
10+
const seq = notifySequence('qrypt.chat', 'hi', { TERM: 'xterm-kitty' });
11+
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');
12+
});
13+
14+
it('wraps the escape for tmux passthrough and keeps the bare bell', () => {
15+
const seq = notifySequence('a', 'b', { TMUX: '/tmp/tmux-1/default,1,0' });
16+
expect(seq).toBe('\x1bPtmux;\x1b\x1b]9;a: b\x07\x1b\\\x07');
17+
});
18+
19+
it('can be turned off or reduced to a bell', () => {
20+
expect(notifySequence('a', 'b', { QC_NOTIFY: '0' })).toBe('');
21+
expect(notifySequence('a', 'b', { QC_NOTIFY: 'bell' })).toBe('\x07');
22+
});
23+
24+
it('strips control characters so a name cannot break out of the escape', () => {
25+
expect(notifySequence('x', 'evil\x07\x1b]52;c;bad', {})).toBe('\x1b]9;x: evil ]52 c bad\x07\x07');
26+
});
27+
28+
it('writes only to a terminal', () => {
29+
const written = [];
30+
const pipe = { isTTY: false, write: (s) => written.push(['pipe', s]) };
31+
const tty = { isTTY: true, write: (s) => written.push(['tty', s]) };
32+
expect(notify('a', 'b', { env: {}, streams: [pipe, tty] })).toBe(true);
33+
expect(written).toEqual([['tty', '\x1b]9;a: b\x07\x07']]);
34+
expect(notify('a', 'b', { env: {}, streams: [pipe] })).toBe(false);
35+
});
36+
});

0 commit comments

Comments
 (0)