aboutsummaryrefslogtreecommitdiff
path: root/docs/specs/messenger-unification-spec.org
diff options
context:
space:
mode:
Diffstat (limited to 'docs/specs/messenger-unification-spec.org')
-rw-r--r--docs/specs/messenger-unification-spec.org350
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).