aboutsummaryrefslogtreecommitdiff
path: root/docs/specs/messenger-unification-spec.org
blob: 92985f59673ec8a757827f9556e0f42e5326275f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
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).