aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-27 22:11:24 -0500
committerCraig Jennings <c@cjennings.net>2026-05-27 22:11:24 -0500
commit4daf2328756fa29b087870a2b1f672bc01aa6b51 (patch)
tree69e51b9156cbdda8946b4b24c795da2df2685c33
parentbfec0eab1132b7713a35500323e06ebea2da17a4 (diff)
downloaddotemacs-4daf2328756fa29b087870a2b1f672bc01aa6b51.tar.gz
dotemacs-4daf2328756fa29b087870a2b1f672bc01aa6b51.zip
feat(signal): initiate-message workflow (picker, guard, cache, keymap)
I built pieces 2-7 of the initiate-message workflow from docs/design/signal-client.org and added tests covering the fork's clobber-fix (commit 5ec56c0 over there). The picker is the user-facing change: a single key opens a name-based completing-read for any contact, with "Note to Self" pinned first. The picker stack from the bottom up: cj/signel--ensure-started is the daemon guard. With a live process it's a no-op. With signel-account set but no process it calls signel-start and pre-warms the contact cache. With signel-account nil it user-errors naming the remedy. Pre-warming on start means the picker feels instant on first use. cj/signel--fetch-contacts issues a listContacts RPC through the new request-callback contract (signel--send-rpc with a success-callback). The callback runs the result through the verified cj/signal--parse-contacts and stores the (LABEL . RECIPIENT) alist in cj/signel--contact-cache, a cj-owned variable kept separate from signel's receive-time contact-map. An empty result populates the cache as nil, distinct from an RPC failure (which never invokes the callback so the prior cache survives). cj/signel-refresh-contacts is the user-facing command that clears and refetches. cj/signel-message is the picker. Warm cache opens completing-read immediately. Cold cache kicks off a fetch and accept-process-outputs up to cj/signel-fetch-timeout seconds (3s default), then user-errors if the daemon hasn't responded so a wedged process can't hang Emacs. The candidate list pins "Note to Self" first (resolves to signel-account) with a display-sort metadata function that preserves the given order rather than alphabetizing. cj/signel-message-self skips the picker and goes straight to signel-account. cj/signel-connect is the friendly verb on the prefix key. cj/signel-prefix-map binds m / s / d / q / SPC and attaches under C-; M via with-eval-after-load keybindings so the binding survives load-order. l stays unbound for the future link command. 15 new ERT tests cover the ensure-started branches, the fetch + cache contract (issued, populated, empty), refresh-contacts, the picker's four scenarios (warm-cache contact, warm-cache Note to Self, cold-cache resolves in time, cold-cache timeout), message-self, and the keymap bindings. Plus 4 new tests in tests/test-signel-input-preservation.el for the fork's clobber fix: pending-input captures typed text and returns nil when empty; both signel--insert-msg and signel--insert-system-msg redraw the prompt without clobbering "halfwritten". todo.org closes three tasks as dated event-log entries: the contact picker, the input clobber, and the use-package wiring.
-rw-r--r--modules/signal-config.el152
-rw-r--r--tests/test-signal-config.el208
-rw-r--r--tests/test-signel-input-preservation.el68
-rw-r--r--todo.org16
4 files changed, 436 insertions, 8 deletions
diff --git a/modules/signal-config.el b/modules/signal-config.el
index 0b6668b1..bed0f1e6 100644
--- a/modules/signal-config.el
+++ b/modules/signal-config.el
@@ -127,5 +127,157 @@ time."
(when (file-readable-p cj/signal-private-config-file)
(load cj/signal-private-config-file nil t)))
+;;; Connection guard, contact fetch, and cache
+
+;; Forward declarations: signel.el is loaded by the use-package above (with
+;; :load-path on the fork), but the byte-compiler doesn't see those symbols
+;; statically. Declaring them keeps the compile clean without changing
+;; runtime behavior.
+(defvar signel-account)
+(defvar signel--process-name)
+(declare-function signel-start "signel" ())
+(declare-function signel--send-rpc "signel" (method params &optional target-buffer success-callback))
+
+(defvar cj/signel--contact-cache nil
+ "Cached `(LABEL . RECIPIENT)' alist for the contact picker.
+Populated by `cj/signel--fetch-contacts' on first invocation (or after a
+`cj/signel-refresh-contacts'), and cleared on `signel-stop' / restart so
+a stale list can't survive a reconnect. In-memory only.")
+
+(defcustom cj/signel-fetch-timeout 3.0
+ "Seconds the picker blocks on `accept-process-output' for a cold-cache fetch.
+On warm cache the picker opens instantly; on cold cache it kicks off a
+fetch and waits up to this many seconds for the RPC result before
+reporting a `user-error' so a dead or wedged daemon can't hang Emacs."
+ :type 'number
+ :group 'signel)
+
+(defun cj/signel--ensure-started ()
+ "Ensure the signel daemon is live, starting it if needed.
+Three branches:
+- The process is already live -- no-op, return nil.
+- `signel-account' is set but no live process exists -- call `signel-start'
+ and pre-warm the contact cache with a background `listContacts' fetch so
+ the picker is instant on first use.
+- `signel-account' is nil -- `user-error' naming the remedy (set the
+ account in `cj/signal-private-config-file').
+
+If startup launches but the RPC handshake exits before the first response,
+the subsequent `signel--send-rpc' call (in the pre-warm or any later
+fetch) signals through its own error path; check =*signel-log*= and
+=*signel-stderr*= for detail and link the account manually."
+ (cond
+ ((process-live-p (get-process signel--process-name))
+ nil)
+ ((null signel-account)
+ (user-error
+ "signel-account is unset. Set it in %s (or your private config) and link the device manually with `signal-cli link', then retry"
+ cj/signal-private-config-file))
+ (t
+ (signel-start)
+ (cj/signel--fetch-contacts))))
+
+(defun cj/signel--fetch-contacts (&optional after-callback)
+ "Fetch the contact list from signal-cli and populate `cj/signel--contact-cache'.
+Issues a `listContacts' RPC and registers a success callback that runs
+the result through `cj/signal--parse-contacts' (the verified parser) and
+stores the resulting `(LABEL . RECIPIENT)' alist in the cache. An empty
+result populates the cache as nil; a failure goes through the dispatch
+error path and never invokes the callback, so the prior cache survives.
+
+AFTER-CALLBACK, when non-nil, is invoked with no arguments after the
+cache has been populated -- the picker uses this to unblock its
+bounded-wait on cold caches."
+ (signel--send-rpc
+ "listContacts" nil nil
+ (lambda (result)
+ (setq cj/signel--contact-cache (cj/signal--parse-contacts result))
+ (when after-callback (funcall after-callback)))))
+
+(defun cj/signel-refresh-contacts ()
+ "Clear the picker's contact cache and refetch it from signal-cli.
+Use when a contact added or renamed on the phone hasn't shown up in the
+picker yet; this forces a fresh `listContacts' rather than reading the
+cached snapshot."
+ (interactive)
+ (setq cj/signel--contact-cache nil)
+ (cj/signel--fetch-contacts))
+
+;;; Picker, self-message, and connect
+
+(declare-function signel-chat "signel" (recipient))
+(declare-function signel-dashboard "signel" ())
+(declare-function signel-stop "signel" ())
+
+(defun cj/signel-connect ()
+ "Connect to signal-cli, starting the daemon if needed.
+Thin interactive wrapper around `cj/signel--ensure-started' so the
+keymap has a friendly verb to bind."
+ (interactive)
+ (cj/signel--ensure-started)
+ (message "Signel connected."))
+
+(defun cj/signel-message ()
+ "Pick a Signal contact by name and open the chat buffer.
+Ensures the daemon is connected first (auto-starts and pre-warms on
+cold start, or errors with the remedy if the account isn't set). Uses
+the cached contact list when warm; on a cold cache, kicks off a fetch
+and waits up to `cj/signel-fetch-timeout' seconds for the result before
+raising a `user-error' so a dead daemon can't hang Emacs. The picker
+offers a pinned \"Note to Self\" entry plus every Signal contact, and
+opens the chosen recipient in `signel-chat'."
+ (interactive)
+ (cj/signel--ensure-started)
+ (unless cj/signel--contact-cache
+ (let ((done nil)
+ (deadline (+ (float-time) cj/signel-fetch-timeout)))
+ (cj/signel--fetch-contacts (lambda () (setq done t)))
+ (while (and (not done) (< (float-time) deadline))
+ (accept-process-output nil 0.1))
+ (unless done
+ (user-error
+ "Signal contact fetch timed out after %.1fs; try again or run M-x cj/signel-refresh-contacts (see *signel-log* for detail)"
+ cj/signel-fetch-timeout))))
+ (let* ((note-self (cons "Note to Self" signel-account))
+ (candidates (cons note-self cj/signel--contact-cache))
+ (table (lambda (string pred action)
+ (if (eq action 'metadata)
+ '(metadata
+ (display-sort-function . identity)
+ (cycle-sort-function . identity))
+ (complete-with-action action candidates string pred))))
+ (label (completing-read "Signal recipient: " table nil t))
+ (recipient (cdr (assoc label candidates))))
+ (when recipient
+ (signel-chat recipient))))
+
+(defun cj/signel-message-self ()
+ "Open a Signal chat buffer addressed to Note to Self.
+Resolves to `signel-account' (the linked phone number). Sending to it
+lands in the Signal Note-to-Self thread on the phone; manual-verify
+that on first use."
+ (interactive)
+ (cj/signel--ensure-started)
+ (unless signel-account
+ (user-error "signel-account is unset; cannot send to self"))
+ (signel-chat signel-account))
+
+(defvar cj/signel-prefix-map
+ (let ((map (make-sparse-keymap)))
+ (keymap-set map "m" #'cj/signel-message)
+ (keymap-set map "s" #'cj/signel-message-self)
+ (keymap-set map "d" #'signel-dashboard)
+ (keymap-set map "q" #'signel-stop)
+ (keymap-set map "SPC" #'cj/signel-connect)
+ map)
+ "Signel \"Messages\" prefix keymap, bound under `C-; M'.
+Leaves =l= unbound for now -- the future =cj/signel-link= command lands
+in a later pass. See =docs/design/signal-client.org= scope summary.")
+
+(declare-function cj/custom-keymap "keybindings" ())
+(with-eval-after-load 'keybindings
+ (when (boundp 'cj/custom-keymap)
+ (keymap-set cj/custom-keymap "M" cj/signel-prefix-map)))
+
(provide 'signal-config)
;;; signal-config.el ends here
diff --git a/tests/test-signal-config.el b/tests/test-signal-config.el
index 40879342..6ddc7917 100644
--- a/tests/test-signal-config.el
+++ b/tests/test-signal-config.el
@@ -8,7 +8,16 @@
;;; Code:
(require 'ert)
+(require 'cl-lib)
(require 'json)
+
+;; signel is the fork at ~/code/signel; signal-config wires it via
+;; use-package but the connection-guard/fetch tests need the symbols
+;; available directly.
+(eval-and-compile
+ (add-to-list 'load-path (expand-file-name "~/code/signel")))
+(require 'signel)
+
(require 'signal-config)
;;; cj/signal--jstr
@@ -137,5 +146,204 @@ against a live linked account on 2026-05-26; the values here are fake.")
"Error: a nil viewing-buffer name does not suppress."
(should-not (cj/signal--suppress-notify-p "+15551112222" nil t)))
+;;; cj/signel--ensure-started
+
+(ert-deftest test-signal-config-ensure-started-live-process-noop ()
+ "Normal: with a live signel process, ensure-started returns without
+calling `signel-start' or the pre-warm fetch."
+ (let ((start-called nil)
+ (fetch-called nil))
+ (cl-letf (((symbol-function 'process-live-p) (lambda (_) t))
+ ((symbol-function 'get-process) (lambda (_) 'fake-proc))
+ ((symbol-function 'signel-start)
+ (lambda () (setq start-called t)))
+ ((symbol-function 'cj/signel--fetch-contacts)
+ (lambda (&rest _) (setq fetch-called t))))
+ (cj/signel--ensure-started)
+ (should-not start-called)
+ (should-not fetch-called))))
+
+(ert-deftest test-signal-config-ensure-started-starts-when-account-set ()
+ "Normal: with `signel-account' set and no live process, ensure-started
+calls `signel-start' to bring the daemon up."
+ (let ((start-called nil)
+ (signel-account "+15555550100"))
+ (cl-letf (((symbol-function 'process-live-p) (lambda (_) nil))
+ ((symbol-function 'get-process) (lambda (_) nil))
+ ((symbol-function 'signel-start)
+ (lambda () (setq start-called t)))
+ ((symbol-function 'cj/signel--fetch-contacts)
+ (lambda (&rest _) nil)))
+ (cj/signel--ensure-started)
+ (should start-called))))
+
+(ert-deftest test-signal-config-ensure-started-prewarms-on-start ()
+ "Normal: when ensure-started actually starts the daemon, it triggers a
+pre-warm fetch so the picker cache is warm on first invocation."
+ (let ((fetch-called nil)
+ (signel-account "+15555550100"))
+ (cl-letf (((symbol-function 'process-live-p) (lambda (_) nil))
+ ((symbol-function 'get-process) (lambda (_) nil))
+ ((symbol-function 'signel-start) (lambda () nil))
+ ((symbol-function 'cj/signel--fetch-contacts)
+ (lambda (&rest _) (setq fetch-called t))))
+ (cj/signel--ensure-started)
+ (should fetch-called))))
+
+(ert-deftest test-signal-config-ensure-started-errors-when-no-account ()
+ "Error: with `signel-account' nil, ensure-started signals a user-error
+naming the remedy (set the account in the private config) instead of
+starting an account-less daemon."
+ (let ((signel-account nil))
+ (cl-letf (((symbol-function 'process-live-p) (lambda (_) nil))
+ ((symbol-function 'get-process) (lambda (_) nil)))
+ (should-error (cj/signel--ensure-started) :type 'user-error))))
+
+;;; cj/signel--fetch-contacts + cj/signel--contact-cache
+
+(ert-deftest test-signal-config-fetch-contacts-issues-list-contacts-rpc ()
+ "Normal: fetch-contacts sends a `listContacts' RPC and registers a
+success callback so the response routes back."
+ (let (sent-method sent-callback)
+ (cl-letf (((symbol-function 'signel--send-rpc)
+ (lambda (method _params _target callback)
+ (setq sent-method method
+ sent-callback callback)
+ 1)))
+ (cj/signel--fetch-contacts))
+ (should (equal sent-method "listContacts"))
+ (should (functionp sent-callback))))
+
+(ert-deftest test-signal-config-fetch-contacts-callback-populates-cache ()
+ "Normal: on a successful result, the callback parses the contact list
+and stores the (LABEL . RECIPIENT) alist in `cj/signel--contact-cache'."
+ (let (sent-callback)
+ (cl-letf (((symbol-function 'signel--send-rpc)
+ (lambda (_method _params _target callback)
+ (setq sent-callback callback) 1)))
+ (setq cj/signel--contact-cache nil)
+ (cj/signel--fetch-contacts)
+ (funcall sent-callback
+ [((number . "+15555550100") (givenName . "Alice"))]))
+ (should (equal cj/signel--contact-cache
+ '(("Alice (+15555550100)" . "+15555550100"))))))
+
+(ert-deftest test-signal-config-fetch-contacts-empty-result-clears-cache ()
+ "Boundary: an empty listContacts result populates the cache as nil,
+distinct from a failure path (which never invokes the success callback)."
+ (let (sent-callback)
+ (cl-letf (((symbol-function 'signel--send-rpc)
+ (lambda (_method _params _target callback)
+ (setq sent-callback callback) 1)))
+ (setq cj/signel--contact-cache '(("stale" . "+10000000000")))
+ (cj/signel--fetch-contacts)
+ (funcall sent-callback []))
+ (should-not cj/signel--contact-cache)))
+
+;;; cj/signel-refresh-contacts
+
+(ert-deftest test-signal-config-refresh-contacts-clears-and-refetches ()
+ "Normal: `cj/signel-refresh-contacts' clears the cache and triggers a
+fresh fetch so a stale entry can't survive a user-driven refresh."
+ (let ((fetch-called nil))
+ (setq cj/signel--contact-cache '(("stale" . "+10000000000")))
+ (cl-letf (((symbol-function 'cj/signel--fetch-contacts)
+ (lambda (&rest _) (setq fetch-called t))))
+ (cj/signel-refresh-contacts))
+ (should-not cj/signel--contact-cache)
+ (should fetch-called)))
+
+;;; cj/signel-message picker
+
+(ert-deftest test-signal-config-message-warm-cache-picks-contact ()
+ "Normal: with a warm cache, picking a contact label opens that
+recipient's chat buffer."
+ (let ((chosen-recipient nil)
+ (signel-account "+15555550100"))
+ (setq cj/signel--contact-cache
+ '(("Alice (+15555550200)" . "+15555550200")))
+ (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil))
+ ((symbol-function 'completing-read)
+ (lambda (&rest _) "Alice (+15555550200)"))
+ ((symbol-function 'signel-chat)
+ (lambda (r) (setq chosen-recipient r))))
+ (cj/signel-message))
+ (should (equal chosen-recipient "+15555550200"))))
+
+(ert-deftest test-signal-config-message-warm-cache-picks-note-to-self ()
+ "Normal: the pinned `Note to Self' entry resolves to `signel-account'
+so a self-message lands in the Signal Note-to-Self thread."
+ (let ((chosen-recipient nil)
+ (signel-account "+15555550100"))
+ (setq cj/signel--contact-cache
+ '(("Alice (+15555550200)" . "+15555550200")))
+ (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil))
+ ((symbol-function 'completing-read)
+ (lambda (&rest _) "Note to Self"))
+ ((symbol-function 'signel-chat)
+ (lambda (r) (setq chosen-recipient r))))
+ (cj/signel-message))
+ (should (equal chosen-recipient "+15555550100"))))
+
+(ert-deftest test-signal-config-message-cold-cache-fetch-resolves-in-time ()
+ "Normal: cold cache, fetch's after-callback fires inside the bounded
+wait, picker proceeds with the now-warm cache."
+ (let ((chosen-recipient nil)
+ (signel-account "+15555550100")
+ (cj/signel-fetch-timeout 1.0))
+ (setq cj/signel--contact-cache nil)
+ (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil))
+ ((symbol-function 'cj/signel--fetch-contacts)
+ (lambda (&optional after-cb)
+ (setq cj/signel--contact-cache
+ '(("Bob (+15555550300)" . "+15555550300")))
+ (when after-cb (funcall after-cb))))
+ ((symbol-function 'completing-read)
+ (lambda (&rest _) "Bob (+15555550300)"))
+ ((symbol-function 'signel-chat)
+ (lambda (r) (setq chosen-recipient r))))
+ (cj/signel-message))
+ (should (equal chosen-recipient "+15555550300"))))
+
+(ert-deftest test-signal-config-message-cold-cache-timeout-errors ()
+ "Error: cold cache, fetch never resolves, picker user-errors before
+the bounded wait would let Emacs hang on a dead daemon."
+ (let ((signel-account "+15555550100")
+ (cj/signel-fetch-timeout 0.1))
+ (setq cj/signel--contact-cache nil)
+ (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil))
+ ((symbol-function 'cj/signel--fetch-contacts)
+ (lambda (&rest _) nil)))
+ (should-error (cj/signel-message) :type 'user-error))))
+
+;;; cj/signel-message-self
+
+(ert-deftest test-signal-config-message-self-calls-signel-chat-with-account ()
+ "Normal: the direct self-message command opens a chat buffer addressed
+to `signel-account', skipping the picker entirely."
+ (let ((chosen-recipient nil)
+ (signel-account "+15555550100"))
+ (cl-letf (((symbol-function 'cj/signel--ensure-started) (lambda () nil))
+ ((symbol-function 'signel-chat)
+ (lambda (r) (setq chosen-recipient r))))
+ (cj/signel-message-self))
+ (should (equal chosen-recipient "+15555550100"))))
+
+;;; cj/signel-prefix-map (C-; M)
+
+(ert-deftest test-signal-config-prefix-map-has-expected-bindings ()
+ "Normal: the signel C-; M prefix map binds m / s / d / q / SPC to the
+commands the workflow spec names."
+ (should (eq (keymap-lookup cj/signel-prefix-map "m")
+ #'cj/signel-message))
+ (should (eq (keymap-lookup cj/signel-prefix-map "s")
+ #'cj/signel-message-self))
+ (should (eq (keymap-lookup cj/signel-prefix-map "d")
+ #'signel-dashboard))
+ (should (eq (keymap-lookup cj/signel-prefix-map "q")
+ #'signel-stop))
+ (should (eq (keymap-lookup cj/signel-prefix-map "SPC")
+ #'cj/signel-connect)))
+
(provide 'test-signal-config)
;;; test-signal-config.el ends here
diff --git a/tests/test-signel-input-preservation.el b/tests/test-signel-input-preservation.el
new file mode 100644
index 00000000..e8ce4ddb
--- /dev/null
+++ b/tests/test-signel-input-preservation.el
@@ -0,0 +1,68 @@
+;;; test-signel-input-preservation.el --- Regression for signel #2 input clobber -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; signel-chat-mode buffers have an editable prompt area starting at
+;; `signel--input-marker'. Before this fix, both `signel--insert-msg' (the
+;; receive path) and `signel--insert-system-msg' (the RPC-error path)
+;; called `(delete-region (point) (point-max))' to clear the old prompt
+;; before redrawing it, which destroyed any text the user was mid-typing.
+;;
+;; These tests lock the preservation contract: a small `signel--pending-input'
+;; helper captures the in-progress input from the marker to `point-max', and
+;; both inserters restore it after the freshly drawn prompt. The chat-mode
+;; buffer is constructed in a temp buffer; `signel--insert-msg' is steered
+;; to it via a stub on `signel--get-buffer'.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(eval-and-compile
+ (add-to-list 'load-path (expand-file-name "~/code/signel")))
+(require 'signel)
+
+(defmacro test-signel--with-chat-buffer (&rest body)
+ "Set up a temp signel-chat-mode buffer with prompt drawn and run BODY."
+ (declare (indent 0))
+ `(with-temp-buffer
+ (signel-chat-mode)
+ (setq signel--chat-id "+15555550100")
+ (signel--draw-prompt)
+ ,@body))
+
+(ert-deftest test-signel-input-pending-returns-typed-text ()
+ "Normal: with text after the prompt marker, `signel--pending-input'
+returns the captured text."
+ (test-signel--with-chat-buffer
+ (insert "halfwritten")
+ (should (equal (signel--pending-input) "halfwritten"))))
+
+(ert-deftest test-signel-input-pending-returns-nil-when-empty ()
+ "Boundary: an empty input area returns nil so callers don't restore an
+empty string after the prompt."
+ (test-signel--with-chat-buffer
+ (should-not (signel--pending-input))))
+
+(ert-deftest test-signel-input-system-msg-preserves-pending-input ()
+ "Regression for #2: `signel--insert-system-msg' redraws the prompt
+without clobbering text the user was mid-typing."
+ (test-signel--with-chat-buffer
+ (insert "halfwritten")
+ (signel--insert-system-msg "An error happened" 'signel-error-face)
+ (should (string-match-p "An error happened" (buffer-string)))
+ (should (equal (signel--pending-input) "halfwritten"))))
+
+(ert-deftest test-signel-input-msg-preserves-pending-input ()
+ "Regression for #2: `signel--insert-msg' (the receive path) redraws the
+prompt without clobbering the user's in-progress input."
+ (test-signel--with-chat-buffer
+ (insert "halfwritten")
+ (cl-letf (((symbol-function 'signel--get-buffer)
+ (lambda (_) (current-buffer))))
+ (signel--insert-msg "+15555550100" "Alice" "Hi there" nil nil nil))
+ (should (string-match-p "Hi there" (buffer-string)))
+ (should (equal (signel--pending-input) "halfwritten"))))
+
+(provide 'test-signel-input-preservation)
+;;; test-signel-input-preservation.el ends here
diff --git a/todo.org b/todo.org
index 59c7d27c..acc1f120 100644
--- a/todo.org
+++ b/todo.org
@@ -50,11 +50,11 @@ signel is on MELPA but stale (one-author v0.1, all commits in a Jan-2026 burst,
*** 2026-05-26 Tue @ 20:06:58 -0500 Linked as secondary device; contact parser verified against live shape
Installed signal-cli 0.14.4.1 (AUR; imported AsamK's signing key FA10826A... to clear the makepkg verification). Linked the account via QR. Built and unit-tested the pure helper layer in =modules/signal-config.el= (contact-list parsing, notify-when-not-viewing predicate) with =tests/test-signal-config.el=. Confirmed the live =listContacts= shape: givenName/familyName are top-level in 0.14, not under profile as first assumed; corrected the parser and verified it produces a picker entry for all 94 real contacts. Sent a request to archsetup to add signal-cli to the standard install.
-*** TODO [#B] Contact picker command :feature:
-=cj/signel-pick-contact=: call signal-cli =listContacts= over JSON-RPC, feed the result through =cj/signal--parse-contacts= (done), =completing-read= the labels, open the chosen recipient's chat. signel today opens by raw phone number only.
+*** 2026-05-27 Wed @ 22:08:40 -0500 Shipped initiate-message workflow: picker + Note-to-Self + keymap
+=cj/signel-message= (=C-; M m=) names contacts via =completing-read= over the cj-owned =cj/signel--contact-cache=, with "Note to Self" pinned first. =cj/signel-message-self= (=C-; M s=) sends straight to =signel-account=. Daemon guard =cj/signel--ensure-started= auto-starts the daemon when =signel-account= is set and =user-error='s with the remedy when it isn't; on start it pre-warms the cache. =cj/signel--fetch-contacts= rides the new RPC callback contract (=signel--send-rpc= with success-callback), the result feeds =cj/signal--parse-contacts=, and =cj/signel-refresh-contacts= (=C-; M no leaf=) clears + refetches. Cold-cache invocations =accept-process-output= up to =cj/signel-fetch-timeout= seconds (3s default) and =user-error= on timeout so a wedged daemon can't hang Emacs. Prefix keymap =cj/signel-prefix-map= bound under =C-; M= via =keybindings.el='s =cj/custom-keymap=: m / s / d / q / SPC. 15 new ERT tests in =tests/test-signal-config.el= cover ensure-started branches, fetch contract, cache empty-vs-failure, refresh, picker happy-path + cold-cache resolves + cold-cache timeout, message-self, and the prefix map bindings.
-*** DOING [#B] JSON-RPC success-result dispatch for signel :feature:
-The contact picker needs =listContacts= results, but signel currently dispatches only =receive= notifications and RPC errors; successful =((id . N) (result . VALUE))= responses have no callback/result path. Add a request handler table or equivalent success-result dispatch in the fork, clean handlers up on success/error/reconnect, then build =cj/signel--fetch-contacts= on that contract. Review: [[file:docs/design/signal-client-review.org][docs/design/signal-client-review.org]].
+*** 2026-05-27 Wed @ 21:55:57 -0500 Added JSON-RPC success-result dispatch in the signel fork
+Fork commit 4740d97 added =signel--request-handler-map= (id → success callback), extended =signel--send-rpc= with an optional =success-callback= that registers under the new request id, and gave =signel--dispatch= a result branch that invokes the callback and removes the handler. Error responses also remhash the handler entry, and =signel-start= / =signel-stop= both =clrhash= the map so reconnect is reliably empty. Backward-compatible: existing callers that don't pass a callback hit the same code path as before. Five ERT tests in this project (=tests/test-signel-rpc-dispatch.el=, dotemacs commit bfec0eab) lock the contract: Normal (result invokes callback + cleanup, send-rpc registers), Boundary (unknown id is a no-op), Error (error response cleans up handler), reconnect (=signel-stop= empties the map). Refactor audit surfaced a separate pre-existing leak in =signel--handle-error= (request-buffer-map entries aren't removed on error); filed as the [#C] follow-up below.
*** TODO [#C] signel--handle-error leaks request-buffer-map entries :bug:
Surfaced during the JSON-RPC dispatch refactor audit. =signel--handle-error= reads =signel--request-buffer-map= by id but never =remhash='es the entry, so every error response leaves the request-id → buffer-name mapping behind for the life of the process. Low impact (the map clears on stop/start, and id collisions are unlikely at the counter scale), but unbounded growth in a long-lived session and inconsistent with how the new request-handler-map is cleaned up on error.
@@ -62,14 +62,14 @@ Surfaced during the JSON-RPC dispatch refactor audit. =signel--handle-error= rea
*** TODO [#B] Notify only for the unviewed conversation :feature:
Wire =cj/signal--should-notify-p= (done) into signel's =signel--handle-receive= notify block (signel.el:277), route through Craig's notify script instead of bare =notifications-notify=, and gate sound behind a defcustom that defaults off.
-*** TODO [#B] Input clobber on incoming message — upstream signel #2 :bug:
-signel.el:502 (=signel--insert-msg=) does =(delete-region (point) (point-max))=, wiping any unsent text in the prompt when a message arrives mid-typing. Preserve and restore the pending input across the redraw. Fix in the fork; it sits next to the notify edit.
+*** 2026-05-27 Wed @ 22:08:40 -0500 Shipped clobber fix for both insert paths
+Fork commit 5ec56c0 added =signel--pending-input= (capture from input-marker to point-max) and =signel--restore-input= (re-insert after the redrawn prompt; nil-safe), and wired both into =signel--insert-msg= (the receive path) and =signel--insert-system-msg= (the error path). A mid-type send now survives both an incoming message and a system-error insertion. Four ERT tests in =tests/test-signel-input-preservation.el= cover the helpers (typed text, empty) and both insert paths via a temp =signel-chat-mode= buffer.
*** TODO [#B] Link command with QR :feature:
=cj/signel-link= wrapping =signal-cli link -n NAME=, capturing the =sgnl://linkdevice= URI and rendering it as a scannable QR (qrencode). Convenience for re-linking; the first link was done by hand this session.
-*** TODO [#B] use-package wiring :feature:
-=use-package signel :load-path "~/code/signel" :ensure nil= in =signal-config.el=, plus a keybinding prefix (candidate =C-; M= for Messages), and the account source (defcustom vs authinfo, mirroring slack-config).
+*** 2026-05-27 Wed @ 22:08:40 -0500 use-package wired with C-; M keymap and local account config
+=use-package signel :load-path "~/code/signel" :ensure nil= already wired earlier with =signel-auto-open-buffer nil=. Account source is =signel-account= set from =cj/signal-private-config-file= (=signal-config.local.el=, gitignored) loaded in =:config=, decided in the workflow spec. Keymap prefix =C-; M= attached via =with-eval-after-load 'keybindings= so the binding survives load-order.
*** TODO [#D] Include Signal groups in the picker :feature:
vNext after the 1:1 initiate-message flow is stable. Merge =listGroups= with =listContacts=, label groups distinctly, and preserve the current v1 behavior where the picker is contacts-only.