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
|
#+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.
- *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.
* 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).
|