diff options
Diffstat (limited to 'docs/specs/messenger-unification-spec.org')
| -rw-r--r-- | docs/specs/messenger-unification-spec.org | 350 |
1 files changed, 350 insertions, 0 deletions
diff --git a/docs/specs/messenger-unification-spec.org b/docs/specs/messenger-unification-spec.org new file mode 100644 index 000000000..92985f596 --- /dev/null +++ b/docs/specs/messenger-unification-spec.org @@ -0,0 +1,350 @@ +:PROPERTIES: +:ID: 4bfc2011-8ffc-4765-8886-91df12141171 +:STATUS: not-started +:END: +#+TITLE: Messenger Unification — Shared Window Placement and Key Conventions +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-06-11 +#+STATUS: Draft — decisions 1-9 settled (Craig, 2026-06-11/12); held open for further ideas before Ready + +* Problem + +Three messengers live in this config — Signel (Signal), telega (Telegram), and +emacs-slack — and each invented its own window placement and its own send/cancel +chords. Switching between them means re-learning the same two gestures three +times. The goal: chat windows rise from the bottom of the frame under one rule, +C-c C-c acts as the okay button, C-c C-k cancels, and a messenger joins the +convention with one registration call instead of bespoke config. The same +registration should carry a shared verb set (attach now; next-unread, +jump-to-chat later) so future chords land everywhere at once. + +* Current State (surveyed 2026-06-11) + +** Signel (fork at ~/code/signel, =signel-chat-mode=) + +- Placement: bottom 30% via a private =display-buffer-alist= entry + (=modules/signal-config.el:184=, matches =\`\*Signel: =). +- Keys (bound in the fork, =signel.el:493=): RET and C-c C-c send + (=signel--send-input=), C-c C-k clears input (=signel--cancel-input=), + C-c C-a attaches a file. +- Verdict: already the proposed convention. Becomes the reference backend. + +** telega (=telega-chat-mode=) + +- Placement: none configured — falls to display-buffer's defaults. +- Keys (upstream =telega-chat.el=): RET sends + (=telega-chatbuf-newline-or-input-send=, line 1796); C-c C-k already cancels + (=telega-chatbuf-cancel-dwim=, line 1790 — also on C-M-c and ESC ESC); + C-c C-c is taken by =telega-chatbuf-filter-cancel= (line 1832). +- Verdict: half-conformant. Cancel matches; confirm needs the chord, which + shadows filter-cancel (decision 4). + +** emacs-slack + +- Placement: room buffers route through =cj/slack--display-buffer= + (=modules/slack-config.el:105=) — reuse / some-window / pop-up, deliberately + landing beside current work in a split. +- Keys: compose/edit buffers derive from =slack-edit-message-mode=, which + already binds C-c C-c send (=slack-message-send-from-buffer=) and C-c C-k + cancel (=slack-message-cancel-edit=) upstream (=slack-message-editor.el:46=). + Config adds C-<return> send (=slack-config.el:297=). Room buffers are + read-only; composing happens in the separate compose buffer. +- Verdict: keys already conform in compose. The open question is placement + (decision 5). + +** ERC + +Present (=modules/erc-config.el=) but out of scope for v1; joins later with one +registration call (decision 7). + +* Design + +Two cooperating mechanisms in one new library, =modules/cj-messenger-lib.el=. +Each messenger's =*-config.el= makes a single registration call; the library +owns the display rule and the keymap. + +** The registry + +#+begin_src elisp +(cj/messenger-register 'signel + :buffer-match "\\`\\*Signel: " ; regexp, or a list of major modes + :chat-modes '(signel-chat-mode) ; hooks that enable the minor mode + :confirm #'signel--send-input + :cancel #'signel--cancel-input + :attach #'signel-attach-file) +#+end_src + +- =:buffer-match= feeds the window-placement predicate. +- =:chat-modes= names the major-mode hooks where =cj/messenger-mode= turns on. +- The verb keys (=:confirm=, =:cancel=, =:attach=, future verbs) populate + buffer-local dispatch variables when the minor mode enables. A nil verb means + "not supported here" — the dispatcher reports it instead of erroring. + +** Window placement + +One =display-buffer-alist= entry, installed by the library: + +- Condition: =cj/messenger-buffer-p= — true when the buffer matches any + registered =:buffer-match=. +- Action: =(display-buffer-reuse-window display-buffer-at-bottom)= with + =window-height= from a shared defcustom =cj/messenger-window-height= + (default 0.3) and =reusable-frames nil= — the exact shape signel uses today. + Signel's private entry in =signal-config.el= is removed in favor of this one. +- A registration may override the height for one backend if a real need + appears; the default is the convention. + +Deliberately a normal bottom window (=display-buffer-at-bottom=), not a side +window: side windows are atomic, refuse splits, and fight other display +commands. The signel entry has proven the at-bottom shape in daily use. The +geometry capture/replay helpers in =cj-window-toggle-lib.el= can be layered on +later if remembered sizing is wanted (out of scope for v1). + +** The minor mode and dispatch + +=cj/messenger-mode=, a buffer-local minor mode whose keymap outranks the major +mode's: + +- C-c C-c → =cj/messenger-confirm= +- C-c C-k → =cj/messenger-cancel= +- C-c C-a → =cj/messenger-attach= + +Each command funcalls its buffer-local dispatch variable +(=cj/messenger--confirm-fn= etc.), set from the registry when the mode enables +via the registered =:chat-modes= hooks. Unset verb → =user-error= naming the +messenger and the missing verb. RET is untouched — every backend keeps its +native RET behavior; the convention adds chords, it never removes keys. + +This is the established Emacs-wide C-c C-c / C-c C-k convention (org-capture, +message-mode, with-editor/git-commit), so the muscle memory transfers in both +directions. + +** Backend wiring (per messenger, in its existing config module) + +- Smoke (the ground-up signel replacement at =~/code/smoke=, decided + 2026-06-12): implements the conventions natively from day one — bottom + drawer, dismiss-preserving C-c C-k per decision 3, unread tracking feeding + jump-to-unread — per its architecture spec. Signel remains the running + reference until smoke reaches parity; =signal-config.el='s private display + entry retires at the switchover. Registration stays one call; smoke is the + reference backend. (Tracked in the smoke project's todo.) +- telega: =:confirm #'telega-chatbuf-input-send=, =:cancel= wraps + =telega-chatbuf-cancel-dwim= (decision 3 ladder), =:buffer-match + '(telega-chat-mode)=. +- Slack: compose modes get the minor mode for uniformity (shadowing upstream's + identical bindings — a no-op in practice); room-buffer placement per + decision 5. + +* Decisions + +1. Placement engine is =display-buffer-at-bottom= in a normal window, shared + height defcustom 0.3. Proven by signel. (Proposed.) +2. One registry call per messenger is the entire integration surface; the + library owns the display rule and keymap. (Proposed.) +3. Cancel semantics (Craig, 2026-06-11; superseded 2026-06-12): C-c C-k + dismisses, never destroys — (a) backend pending state (telega + edit/reply/forward) → the backend's own dwim cancel; (b) otherwise → + =quit-window=. Typed drafts are not cancel's business: input survives the + burial and is waiting at the prompt on the next visit (signel's + pending-input machinery, generalized). Where a backend wants an explicit + clear-draft, it kills to the kill-ring so the text is recoverable. + /Superseded version (2026-06-11):/ a three-rung ladder whose first rung + cleared typed input before a second press closed the window — dropped + because the first press destroyed text while dismissing nothing, and it + broke the org-capture/git-commit muscle memory where C-c C-k means + "abandon and dismiss" in one press. +4. Telega shadow accepted (Craig, 2026-06-11): the minor mode's C-c C-c hides + =telega-chatbuf-filter-cancel= in telega chats. Craig doesn't use chat + filters; the command stays reachable via M-x and the C-c / filter flow. +5. Slack joins the bottom convention (Craig, 2026-06-11): room buffers move + from the beside-work split to the shared bottom rule; =cj/slack--display-buffer= + is retired in favor of the library's placement entry. Compose buffers + conform via the minor mode as planned. +6. v1 verb set: confirm, cancel, attach. Revised 2026-06-12 (Craig): + jump-to-unread is promoted from candidate to committed verb — a global + chord that raises the most recent unread conversation in the bottom + window, completing the pull flow (toast → chord → chat). Backends supply + an unread source at registration (=:unread=). Still candidates: + next/prev-unread, jump-to-chat picker, mark-read-and-bury. + Addendum from the 2026-06 config audit: the notification path is the same + unification shape on the inbound side — four messengers, four mechanisms + (signel hardened with truncation/sound-gating/fallback; slack unhardened; + ERC double-notifying; telega notifying not at all). A shared + =cj/messenger-notify= (title prefix, truncation, sound flag, + script-with-fallback) belongs in this library, registered per backend like + the verbs. Details in the audit's messengers findings in =todo.org=. +7. ERC deferred; one registration call when wanted. (Proposed.) Google Voice + (SMS + dialer) is a future backend candidate behind its own [#C] + investigation task in =todo.org= — if it goes, it joins through the same + registration surface. +8. RET is never rebound or removed. (Proposed.) +9. No auto-open, ever (Craig, 2026-06-12): no backend claims the bottom slot + unbidden — awareness is pull-based (hardened notifications + + jump-to-unread). =signel-auto-open-buffer= stays nil and equivalent knobs + in other backends are configured off. The drawer is summoned by the user, + not by traffic. + +* Phases + +- *Phase 1 — library + signel (reference backend).* =cj-messenger-lib.el= + (registry, predicate, display rule, minor mode, dispatchers), TDD: ERT over + the pure parts (registration shape, buffer matching, dispatch with stub + fns, nil-verb error). Wire signel; retire its private display entry. + - /Smoke-first parity (Craig 2026-06-16)./ Signal is the least-built backend + and the only one whose UX Craig fully controls (no upstream package fighting + back), so the smoke rebuild is where the shape gets dialed in first: build + smoke to implement every core leaf (=j a u m k C Q=) and the in-buffer + chords natively, tune the feel against real use, and only then adapt telega + and slack to the now-proven contract. The guardrail: design the contract to + the /capability floor/, not to smoke's ceiling. Smoke can do anything, which + makes it the least representative backend — validate each core leaf against + the others' known limits as it is built (telega keeps its modal root buffer; + ERC has no threads/reactions/files; slack has no file upload or search), so + the reference does not bake in something a thinner backend can never match. + Rich verbs (=r e f /=) stay optional per-backend extensions, never core. +- *Phase 2 — telega.* Registration + the decision-3 cancel ladder; audit what + else the minor-mode map hides in =telega-chat-mode-map=. +- *Phase 3 — slack.* Per decision 5; conform compose buffers either way. +- *Phase 4 — shared verbs + ERC.* jump-to-unread first (committed per the + decision-6 revision), then remaining decision-6 candidates, each verb + landing in every backend at once. ERC joins when wanted. + +Each phase ends with a manual-test checklist filed under the +"Manual testing and validation" parent in =todo.org= (placement, each chord, +the not-supported message), per the verification discipline. + +* Global Prefix Keybinding Alphabet (per-app) + +/DRAFT addition, Claude 2026-06-16, for Craig's review (his request: "put this/ +/in the spec and I'll review it"). Folds the per-action prefix-key analysis/ +/into the held-open spec./ + +This is the third of three keybinding surfaces, and the only one the rest of the +spec doesn't already cover. The first is the in-chat buffer chords (C-c C-c +confirm, C-c C-k cancel, C-c C-a attach) owned by =cj/messenger-mode=. The second +is the cross-app aggregate verb (decision 6's jump-to-unread, one global chord +that raises the newest unread conversation in /any/ backend). This third surface +is the per-app global prefix: each messenger hangs off =C-;= with its own second +key (=S= Slack, =M= Signal, =T= Telega, =E= ERC), and today the third key, the +action leaf, is ad hoc per app. The goal: one leaf alphabet so the same action +is the same final keystroke under every messenger. + +** The problem: the same key means different things today + +| Action | Slack (C-; S) | Signal (C-; M) | Telega (C-; T) | ERC (C-; E) | +|----------------------+---------------+----------------+----------------+-------------| +| Open a chat by name | C | m | unbound | c | +|----------------------+---------------+----------------+----------------+-------------| +| Directory: all | C | d | root buffer | b | +|----------------------+---------------+----------------+----------------+-------------| +| Directory: unread | c | none | unbound | unbound | +|----------------------+---------------+----------------+----------------+-------------| +| New DM / message | d | m | unbound | unbound | +|----------------------+---------------+----------------+----------------+-------------| +| Close this chat | unbound | none | C-x k | q | +|----------------------+---------------+----------------+----------------+-------------| +| Mark read + bury | q | none | unbound | n/a | +|----------------------+---------------+----------------+----------------+-------------| +| Connect / start | s | SPC | T = launch | C | +|----------------------+---------------+----------------+----------------+-------------| +| Disconnect / stop | S | q | Q (in-buffer) | Q | +|----------------------+---------------+----------------+----------------+-------------| + +Read down a column and the leaves are arbitrary; read across a row and they +disagree. Worse, the same letter collides on meaning: =C= opens Slack's roster +but connects an ERC server; =q= disconnects Signal, marks-read in Slack, and +parts a channel in ERC. There is no muscle-memory transfer between messengers. + +** Canonical per-app actions (spelled out) + +Daily verbs (per-conversation): open a specific chat by name; view the directory +of all chats/channels; view the directory of only unread / reply-needed chats; +message someone new; reply in a thread; close the current chat window; mark the +current chat read and bury it; jump to next / previous unread chat; add a +reaction; send an attachment; search messages. + +Session verbs (lifecycle): connect / bring online; disconnect / take offline; +open the dashboard / roster overview. + +** Proposed unified leaf alphabet + +Keep each app's second key (=S M T E=); make the third key identical across all +four so the action is the same tail-keystroke regardless of app. + +| Leaf | Action | Backends that can bind it today | +|-------+------------------------------+--------------------------------------| +| j | jump to / open a chat | Slack, Signal, Telega*, ERC | +|-------+------------------------------+--------------------------------------| +| a | directory: all chats | Slack, Signal, Telega, ERC | +|-------+------------------------------+--------------------------------------| +| u | directory: unread only | Slack, Telega*, ERC* (signel: gap) | +|-------+------------------------------+--------------------------------------| +| m | message someone new / DM | Slack, Signal, Telega, ERC* | +|-------+------------------------------+--------------------------------------| +| k | close (bury) this chat | all four (thin wrappers) | +|-------+------------------------------+--------------------------------------| +| SPC | mark read + bury | Slack, Telega* (others: gap) | +|-------+------------------------------+--------------------------------------| +| n / p | next / previous unread | Telega, ERC* (others: gap) | +|-------+------------------------------+--------------------------------------| +| r | reply in thread | Slack (others: protocol N/A) | +|-------+------------------------------+--------------------------------------| +| e | reaction / emoji | Slack, Telega (others: protocol N/A) | +|-------+------------------------------+--------------------------------------| +| f | attach file | Telega (others: protocol N/A) | +|-------+------------------------------+--------------------------------------| +| / | search | Telega in-chat (others: gap) | +|-------+------------------------------+--------------------------------------| +| C | connect / start | all four | +|-------+------------------------------+--------------------------------------| +| Q | disconnect / stop | all four | +|-------+------------------------------+--------------------------------------| + +(* = the package command exists but needs a global wrapper or a binding added.) + +The core seven (=j a u m k C Q=) are the unifiable floor: every backend can +support them (once signel's gaps are filled). The richer verbs (=r e f /=) bind +only where the protocol and package allow; which-key then shows fewer options +under a thinner backend, which is honest rather than confusing. An unsupported +leaf is simply absent under that app's prefix, the same "nil verb = not +supported" stance the registry already takes for the in-buffer chords. + +** How this rides the registry + +These leaves are the global-prefix face of the same verb set decision 6 is +already growing. jump-to-unread, jump-to-chat-picker, and mark-read-and-bury in +decision 6 map directly to =u= (or the cross-app aggregate), =j=, and =SPC= +here. The registry should carry an optional per-backend command for each leaf +(=:open=, =:roster=, =:unread=, =:message=, =:close= ...), and the library +builds each app's =C-; <key>= submap from whatever the backend registered, so a +new verb lands everywhere in one place, exactly as the in-buffer verbs do. A +backend that registers nil for a leaf gets no binding for it. + +** A caveat on visual UX (keys unify cleanly; rendering does not) + +The leaf can be identical, but "open the directory" still /looks/ different per +backend, because the packages have different display models: Slack and ERC pop a +minibuffer completing-read picker; Telega and (smoke) Signal show a persistent +roster buffer. The bottom-drawer placement rule unifies where a /chat/ lands; +it does not make Slack grow a persistent roster. Unify the keys and the action +vocabulary; accept that the roster rendering differs per backend rather than +fighting each package's design. + +** Open questions for Craig + +1. The leaf letters: are =j a u m k C Q= (+ =r e f / n p SPC=) right, or do you + want different mnemonics (=o= open, =l= list, ...)? This is the muscle-memory + commitment, so it is yours to set before any binding lands. +2. Signal parity (now in scope per Craig 2026-06-16): the smoke rebuild is the + place to hit every core leaf natively. See the smoke-first note added to the + Phases section. + +* Risks + +- Minor-mode shadowing in telega beyond C-c C-c — Phase 2 audits the C-c + prefix in =telega-chat-mode-map= before shipping. +- Slack's many buffer modes: room buffers derive from =slack-buffer-mode=, + compose from =slack-edit-message-mode= — =:buffer-match= must name the right + ancestors or the placement rule over- or under-matches. +- Live-daemon rollout: the display-buffer-alist swap and mode hooks need a + module reload plus re-opening existing chat buffers (already-open buffers + won't have the minor mode until their mode hook reruns). |
