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); + }); +});