From ececbe5f4bf1c2cf0829d1fee69bd71f19759ea1 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 7 Oct 2026 03:53:35 +0000 Subject: [PATCH] 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 --- packages/qryptchat/package.json | 2 +- src/cli/commands.js | 8 ++++-- src/cli/notify.js | 44 +++++++++++++++++++++++++++++++++ src/cli/tui.js | 6 +++++ tests/cli/notify.test.js | 36 +++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 src/cli/notify.js create mode 100644 tests/cli/notify.test.js diff --git a/packages/qryptchat/package.json b/packages/qryptchat/package.json index e41b70b6..ca6d73f1 100644 --- a/packages/qryptchat/package.json +++ b/packages/qryptchat/package.json @@ -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": { diff --git a/src/cli/commands.js b/src/cli/commands.js index 25a6e4f3..1e926bcb 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -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))}`; @@ -27,7 +28,7 @@ Usage: qc read [-n 20] print the last messages of a chat qc send send a message (text "-" reads stdin; --reply to reply) qc react [--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) is a chat id or part of its name. Options: --json, --url (or QC_URL). @@ -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: {} }; @@ -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)}`); } diff --git a/src/cli/notify.js b/src/cli/notify.js new file mode 100644 index 00000000..c031d774 --- /dev/null +++ b/src/cli/notify.js @@ -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); +} diff --git a/src/cli/tui.js b/src/cli/tui.js index a2157d4b..2d60d804 100644 --- a/src/cli/tui.js +++ b/src/cli/tui.js @@ -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; @@ -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; diff --git a/tests/cli/notify.test.js b/tests/cli/notify.test.js new file mode 100644 index 00000000..25644a3a --- /dev/null +++ b/tests/cli/notify.test.js @@ -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); + }); +});