aboutsummaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
Diffstat (limited to 'modules')
-rw-r--r--modules/ai-config.el585
-rw-r--r--modules/ai-conversations-browser.el241
-rw-r--r--modules/ai-conversations.el369
-rw-r--r--modules/ai-mcp.el416
-rw-r--r--modules/ai-quick-ask.el141
-rw-r--r--modules/ai-rewrite.el108
-rw-r--r--modules/ai-term.el305
-rw-r--r--modules/auth-config.el9
-rw-r--r--modules/auto-dim-config.el9
-rw-r--r--modules/browser-config.el3
-rw-r--r--modules/calendar-sync.el96
-rw-r--r--modules/calibredb-epub-config.el17
-rw-r--r--modules/chrono-tools.el5
-rw-r--r--modules/config-utilities.el19
-rw-r--r--modules/coverage-core.el42
-rw-r--r--modules/custom-ordering.el8
-rw-r--r--modules/dashboard-config.el65
-rw-r--r--modules/diff-config.el6
-rw-r--r--modules/dirvish-config.el145
-rw-r--r--modules/dwim-shell-config.el10
-rw-r--r--modules/eat-config.el443
-rw-r--r--modules/elfeed-config.el60
-rw-r--r--modules/erc-config.el52
-rw-r--r--modules/eshell-config.el136
-rw-r--r--modules/eww-config.el9
-rw-r--r--modules/external-open.el73
-rw-r--r--modules/face-diagnostic.el62
-rw-r--r--modules/flycheck-config.el8
-rw-r--r--modules/font-config.el8
-rw-r--r--modules/games-config.el32
-rw-r--r--modules/gcmh-config.el30
-rw-r--r--modules/google-keep-config.el210
-rw-r--r--modules/help-utils.el4
-rw-r--r--modules/httpd-config.el12
-rw-r--r--modules/jumper.el61
-rw-r--r--modules/latex-config.el9
-rw-r--r--modules/ledger-config.el47
-rw-r--r--modules/local-repository.el16
-rw-r--r--modules/mail-config.el79
-rw-r--r--modules/markdown-config.el12
-rw-r--r--modules/media-utils.el6
-rw-r--r--modules/mousetrap-mode.el10
-rw-r--r--modules/mu4e-org-contacts-integration.el1
-rw-r--r--modules/mu4e-org-contacts-setup.el4
-rw-r--r--modules/music-config.el328
-rw-r--r--modules/nerd-icons-config.el46
-rw-r--r--modules/org-agenda-config-debug.el3
-rw-r--r--modules/org-agenda-config.el37
-rw-r--r--modules/org-babel-config.el6
-rw-r--r--modules/org-capture-config.el3
-rw-r--r--modules/org-config.el82
-rw-r--r--modules/org-contacts-config.el48
-rw-r--r--modules/org-faces-config.el56
-rw-r--r--modules/org-noter-config.el25
-rw-r--r--modules/org-refile-config.el11
-rw-r--r--modules/org-roam-config.el36
-rw-r--r--modules/pdf-config.el21
-rw-r--r--modules/prog-general.el26
-rw-r--r--modules/selection-framework.el6
-rw-r--r--modules/system-defaults.el19
-rw-r--r--modules/system-utils.el15
-rw-r--r--modules/term-config.el478
-rw-r--r--modules/tramp-config.el9
-rw-r--r--modules/transcription-config.el14
-rw-r--r--modules/ui-navigation.el10
-rw-r--r--modules/user-constants.el15
-rw-r--r--modules/vc-config.el21
-rw-r--r--modules/video-audio-recording.el7
-rw-r--r--modules/weather-config.el11
69 files changed, 2377 insertions, 2939 deletions
diff --git a/modules/ai-config.el b/modules/ai-config.el
deleted file mode 100644
index 97af1296d..000000000
--- a/modules/ai-config.el
+++ /dev/null
@@ -1,585 +0,0 @@
-;;; ai-config.el --- Configuration for AI Integrations -*- lexical-binding: t; coding: utf-8; -*-
-;; author Craig Jennings <c@cjennings.net>
-;;
-;;; Commentary:
-;;
-;; Layer: 3 (Domain Workflow).
-;; Category: D/P.
-;; Load shape: eager.
-;; Eager reason: registers the cj/ai-keymap (C-; a); GPTel itself should load on
-;; command, a Phase 5 deferral candidate.
-;; Top-level side effects: defines cj/ai-keymap, registers it under cj/custom-keymap.
-;; Runtime requires: keybindings, system-lib.
-;; Direct test load: yes (requires keybindings explicitly).
-;;
-;; Configuration for AI integrations in Emacs, focused on GPTel.
-;;
-;; Main Features:
-;; - Quick toggle for AI assistant window (C-; a t)
-;; - Custom keymap (C-; a prefix) for AI-related commands.
-;; - Enhanced org-mode conversation formatting with timestamps
-;; allows switching models and easily compare and track responses.
-;; - Various specialized AI directives (coder, reviewer, etc.)
-;; - Context management for adding files/buffers to conversations
-;; - Conversation persistence with save/load functionality
-;; - Integration with Magit for code review
-;;
-;; Basic Workflow
-;;
-;; Using a side-chat window:
-;; - Launch GPTel via C-; a t, and chat in the AI-Assistant side window (C-<return> to send)
-;; - Change system prompt (expertise, personalities) with C-; a p
-;; - Add context from files (C-; a f) or current buffer (C-; a .)
-;; - Save conversations with C-; a s, load previous ones with C-; a l
-;; - Clear the conversation and start over with C-; a x
-;; Or in any buffer:
-;; - Add directive as above, and select a region to rewrite with C-; a r.
-;;
-
-;;; Code:
-
-(require 'keybindings) ;; provides cj/custom-keymap
-(require 'system-lib) ;; provides cj/auth-source-secret-value
-(require 'cj-window-toggle-lib) ;; side-window size memory for the panel
-
-(autoload 'cj/gptel-save-conversation "ai-conversations" "Save the AI conversation to a file." t)
-(autoload 'cj/gptel-load-conversation "ai-conversations" "Load a saved AI conversation." t)
-(autoload 'cj/gptel-delete-conversation "ai-conversations" "Delete a saved AI conversation." t)
-(autoload 'cj/gptel-autosave-toggle "ai-conversations" "Toggle autosave in the current GPTel buffer." t)
-(autoload 'cj/gptel-quick-ask "ai-quick-ask" "One-shot quick-ask in a transient buffer." t)
-(autoload 'cj/gptel-rewrite-with-directive "ai-rewrite" "Pick a directive and run gptel-rewrite on the region." t)
-(autoload 'cj/gptel-rewrite-redo-with-different-directive "ai-rewrite" "Re-run the previous rewrite with a different directive." t)
-(autoload 'cj/gptel-browse-conversations "ai-conversations-browser" "Browse saved GPTel conversations." t)
-
-;;; ------------------------- AI Config Helper Functions ------------------------
-
-;; Define variables upfront
-(defvar cj/anthropic-api-key-cached nil "Cached Anthropic API key.")
-(defvar cj/openai-api-key-cached nil "Cached OpenAI API key.")
-(defvar gptel-claude-backend nil "Claude backend, lazy-initialized.")
-(defvar gptel-chatgpt-backend nil "ChatGPT backend, lazy-initialized.")
-
-(defcustom cj/gptel-tools-directory
- (expand-file-name "gptel-tools/" user-emacs-directory)
- "Directory containing optional local GPTel tool modules."
- :type 'directory
- :group 'cj)
-
-(defcustom cj/gptel-local-tool-features
- '(read_buffer
- read_text_file
- write_text_file
- update_text_file
- list_directory_files
- move_to_trash
- git_status
- git_log
- git_diff
- web_fetch)
- "Feature symbols for optional local GPTel tool modules."
- :type '(repeat symbol)
- :group 'cj)
-
-(defun cj/gptel-load-local-tools
- (&optional tools-directory tool-features)
- "Load optional GPTel tools from TOOLS-DIRECTORY.
-TOOL-FEATURES defaults to `cj/gptel-local-tool-features'. Return a list
-of loaded feature symbols. Missing directories or individual optional
-tools are reported with `message' and do not signal."
- (let ((dir (file-name-as-directory
- (expand-file-name (or tools-directory cj/gptel-tools-directory))))
- (features (or tool-features cj/gptel-local-tool-features))
- (loaded nil))
- (cond
- ((not (file-directory-p dir))
- (message "GPTel tools directory not found: %s" dir)
- nil)
- (t
- (add-to-list 'load-path dir)
- (dolist (feature features)
- (condition-case err
- (if (require feature nil 'noerror)
- (push feature loaded)
- (message "Optional GPTel tool not found: %s" feature))
- (error
- (message "Failed to load GPTel tool %s: %s"
- feature
- (error-message-string err)))))
- (nreverse loaded)))))
-
-(with-eval-after-load 'gptel
- (require 'ai-conversations)
- (cj/gptel-load-local-tools))
-
-(defun cj/auth-source-secret (host user)
- "Fetch a required secret from auth-source for HOST and USER.
-
-HOST and USER must be strings that identify the credential to return.
-Errors when no secret is found."
- (or (cj/auth-source-secret-value host user)
- (error "No usable secret found for host %s and user %s" host user)))
-
-(defun cj/anthropic-api-key ()
- "Return the Anthropic API key, caching the result after first retrieval."
- (or cj/anthropic-api-key-cached
- (setq cj/anthropic-api-key-cached
- (cj/auth-source-secret "api.anthropic.com" "apikey"))))
-
-(defun cj/openai-api-key ()
- "Return the OpenAI API key, caching the result after first retrieval."
- (or cj/openai-api-key-cached
- (setq cj/openai-api-key-cached
- (cj/auth-source-secret "api.openai.com" "apikey"))))
-
-(defun cj/--gptel-load-backend-libs ()
- "Require the gptel backend libraries so their `gptel-make-*' constructors exist.
-The local fork (`:load-path \"~/code/gptel\"', `:ensure nil') ships no generated
-autoloads, so requiring `gptel' alone never loads `gptel-anthropic' /
-`gptel-openai', where the constructors are defined."
- (require 'gptel-anthropic)
- (require 'gptel-openai))
-
-(defun cj/ensure-gptel-backends ()
- "Initialize GPTel backends if they are not already available.
-Loads the backend libraries first so the `gptel-make-*' constructors are
-defined even when gptel is the local fork without generated autoloads."
- (cj/--gptel-load-backend-libs)
- (unless gptel-claude-backend
- (setq gptel-claude-backend
- (gptel-make-anthropic
- "Claude"
- :key (cj/anthropic-api-key)
- :models '(
- "claude-opus-4-7"
- "claude-sonnet-4-6"
- "claude-haiku-4-5-20251001"
- )
- :stream t)))
- (unless gptel-chatgpt-backend
- (setq gptel-chatgpt-backend
- (gptel-make-openai
- "ChatGPT"
- :key (cj/openai-api-key)
- :models '(
- "gpt-5.5"
- "gpt-5.4-mini"
- "o3"
- )
- :stream t)))
- ;; Set default backend and model
- (unless gptel-backend
- (setq gptel-backend (or gptel-chatgpt-backend gptel-claude-backend))
- (setq gptel-model 'gpt-5.5)))
-
-;; ------------------ GPTel Conversation And Utility Commands ------------------
-
-(defun cj/gptel--available-backends ()
- "Return an alist of (NAME . BACKEND).
-Ensures gptel and backends are initialized."
- (unless (featurep 'gptel)
- (require 'gptel))
- (cj/ensure-gptel-backends)
- (delq nil
- (list (and (bound-and-true-p gptel-claude-backend)
- (cons "Anthropic - Claude" gptel-claude-backend))
- (and (bound-and-true-p gptel-chatgpt-backend)
- (cons "OpenAI - ChatGPT" gptel-chatgpt-backend)))))
-
-(defun cj/gptel--model-to-string (m)
- "Return model M as a string regardless of its type."
- (cond
- ((stringp m) m)
- ((symbolp m) (symbol-name m))
- (t (format "%s" m))))
-
-(defun cj/gptel--model-to-symbol (m)
- "Return model M as a symbol regardless of its type.
-`gptel-model' must be a symbol: gptel's modeline code calls `symbolp'
-on it and signals `wrong-type-argument' on a string, which surfaces as a
-redisplay hang. Coerce any model value through this before assigning it."
- (cond
- ((symbolp m) m)
- ((stringp m) (intern m))
- (t (intern (format "%s" m)))))
-
-;; Backend/model switching helpers (pure logic, extracted for testability)
-
-(defun cj/gptel--build-model-list (backends model-fn)
- "Build a flat list of all models across BACKENDS.
-BACKENDS is an alist of (NAME . BACKEND-OBJECT). MODEL-FN is called
-with each backend object and should return a list of model identifiers.
-Returns a list of entries: (DISPLAY-STRING BACKEND MODEL-STRING BACKEND-NAME)
-where DISPLAY-STRING is \"Backend: model\" for use in completing-read."
- (mapcan
- (lambda (pair)
- (let* ((backend-name (car pair))
- (backend (cdr pair))
- (models (funcall model-fn backend)))
- (mapcar (lambda (m)
- (list (format "%s: %s" backend-name (cj/gptel--model-to-string m))
- backend
- (cj/gptel--model-to-string m)
- backend-name))
- models)))
- backends))
-
-(defun cj/gptel--current-model-selection (backends current-backend current-model)
- "Format the current backend/model as a display string.
-BACKENDS is the alist from `cj/gptel--available-backends'.
-CURRENT-BACKEND and CURRENT-MODEL are the active gptel settings.
-Returns a string like \"Anthropic - Claude: claude-opus-4-7\"."
- (let ((backend-name (car (rassoc current-backend backends))))
- (format "%s: %s"
- (or backend-name "AI")
- (cj/gptel--model-to-string current-model))))
-
-(defun cj/--gptel-apply-model-selection (scope backend model backend-name)
- "Set gptel BACKEND and MODEL, globally or buffer-locally per SCOPE.
-SCOPE is \"global\" or \"buffer\"; any non-\"global\" value is buffer-local.
-MODEL is a symbol. BACKEND-NAME is the display name for the confirmation.
-Returns the confirmation message string."
- (if (string= scope "global")
- (progn
- (setq gptel-backend backend)
- (setq gptel-model model)
- (format "Changed to %s model: %s (global)" backend-name model))
- (setq-local gptel-backend backend)
- (setq-local gptel-model model)
- (format "Changed to %s model: %s (buffer-local)" backend-name model)))
-
-;; Backend/model switching commands
-(defun cj/gptel-change-model ()
- "Change the GPTel backend and select a model from that backend.
-Present all available models from every backend, switching backends when
-necessary. Prompt for whether to apply the selection globally or buffer-locally."
- (interactive)
- (let* ((backends (cj/gptel--available-backends))
- (all-models (cj/gptel--build-model-list
- backends
- (lambda (b)
- (when (fboundp 'gptel-backend-models)
- (gptel-backend-models b)))))
- (current-selection (cj/gptel--current-model-selection
- backends
- (bound-and-true-p gptel-backend)
- (bound-and-true-p gptel-model)))
- (scope (completing-read "Set model for: " '("buffer" "global") nil t))
- (selected (completing-read
- (format "Select model (current: %s): " current-selection)
- (mapcar #'car all-models) nil t nil nil current-selection)))
- (let* ((model-info (assoc selected all-models))
- (backend (nth 1 model-info))
- (model (intern (nth 2 model-info)))
- (backend-name (nth 3 model-info)))
- (message "%s" (cj/--gptel-apply-model-selection
- scope backend model backend-name)))))
-
-(defun cj/gptel-switch-backend ()
- "Switch the GPTel backend and then choose one of its models."
- (interactive)
- (let* ((backends (cj/gptel--available-backends))
- (choice (completing-read "Select GPTel backend: " (mapcar #'car backends) nil t))
- (backend (cdr (assoc choice backends))))
- (unless backend
- (user-error "Invalid GPTel backend: %s" choice))
- (let* ((models (when (fboundp 'gptel-backend-models)
- (gptel-backend-models backend)))
- (model (completing-read (format "Select %s model: " choice)
- (mapcar #'cj/gptel--model-to-string models)
- nil t nil nil (cj/gptel--model-to-string (bound-and-true-p gptel-model)))))
- (setq gptel-backend backend
- gptel-model (cj/gptel--model-to-symbol model))
- (message "Switched to %s with model: %s" choice model))))
-
-;; Clear assistant buffer (moved out so it's always available)
-(defun cj/gptel-clear-buffer ()
- "Erase the current GPTel buffer while preserving the initial Org heading.
-Operate only when `gptel-mode' is active in an Org buffer so the heading
-can be reinserted."
- (interactive)
- (let ((is-gptel (bound-and-true-p gptel-mode))
- (is-org (derived-mode-p 'org-mode)))
- (if (and is-gptel is-org)
- (progn
- (erase-buffer)
- (when (fboundp 'cj/gptel--fresh-org-prefix)
- (insert (cj/gptel--fresh-org-prefix)))
- (message "GPTel buffer cleared and heading reset"))
- (message "Not a GPTel buffer in org-mode. Nothing cleared."))))
-
-;; ----------------------------- Context Management ----------------------------
-
-(defun cj/gptel--add-file-to-context (file-path)
- "Add FILE-PATH to the GPTel context.
-Returns t on success, nil on failure.
-Provides consistent user feedback about the context state."
- (when (and file-path (file-exists-p file-path))
- (gptel-add-file file-path)
- (let ((context-count (if (boundp 'gptel-context--alist)
- (length gptel-context--alist)
- 0)))
- (message "Added %s to GPTel context (%d sources total)"
- (file-name-nondirectory file-path)
- context-count))
- t))
-
-(defun cj/gptel-add-file ()
- "Add a file to the GPTel context.
-If inside a Projectile project, prompt from that project's file list.
-Otherwise, prompt with `read-file-name'."
- (interactive)
- (let* ((in-proj (and (featurep 'projectile)
- (fboundp 'projectile-project-p)
- (projectile-project-p)))
- (file-name (if in-proj
- (let ((cands (projectile-current-project-files)))
- (if (fboundp 'projectile-completing-read)
- (projectile-completing-read "GPTel add file: " cands)
- (completing-read "GPTel add file: " cands nil t)))
- (read-file-name "GPTel add file: ")))
- (file-path (if in-proj
- (expand-file-name file-name (projectile-project-root))
- file-name)))
- (unless (cj/gptel--add-file-to-context file-path)
- (error "Failed to add file: %s" file-path))))
-
-(defun cj/gptel-add-buffer-file ()
- "Select a buffer and add its associated file to the GPTel context.
-Lists all open buffers for selection. If the selected buffer is visiting
-a file, that file is added to the GPTel context. Otherwise, an error
-message is displayed."
- (interactive)
- (let* ((buffers (mapcar #'buffer-name (buffer-list)))
- (selected-buffer-name (completing-read "Add file from buffer: " buffers nil t))
- (selected-buffer (get-buffer selected-buffer-name))
- (file-path (and selected-buffer
- (buffer-file-name selected-buffer))))
- (if file-path
- (cj/gptel--add-file-to-context file-path)
- (message "Buffer '%s' is not visiting a file" selected-buffer-name))))
-
-(defun cj/gptel-add-this-buffer ()
- "Add the current buffer to the GPTel context.
-Works for any buffer, whether it's visiting a file or not."
- (interactive)
- ;; Load gptel-context if needed
- (unless (featurep 'gptel-context)
- (require 'gptel-context))
- ;; Use gptel-add with prefix arg '(4) to add current buffer
- (gptel-add '(4))
- (message "Added buffer '%s' to GPTel context" (buffer-name)))
-
-;;; -------------------------- Org Header Construction --------------------------
-
-(defun cj/gptel--fresh-org-prefix ()
- "Generate a fresh org-mode header with current timestamp for user messages."
- (concat "* " user-login-name " " (format-time-string "[%Y-%m-%d %H:%M:%S]") "\n"))
-
-(defun cj/gptel--refresh-org-prefix (&rest _)
- "Update the org-mode prefix with fresh timestamp before sending message."
- (setf (alist-get 'org-mode gptel-prompt-prefix-alist)
- (cj/gptel--fresh-org-prefix)))
-
-(defun cj/gptel-backend-and-model ()
- "Return backend, model, and timestamp as a single string."
- (let* ((backend (pcase (bound-and-true-p gptel-backend)
- ((and v (pred vectorp)) (aref v 1))
- (_ "AI")))
- (model (format "%s" (or (bound-and-true-p gptel-model) "")))
- (ts (format-time-string "[%Y-%m-%d %H:%M:%S]")))
- (format "%s: %s %s" backend model ts)))
-
-(defun cj/gptel-insert-model-heading (response-begin-pos _response-end-pos)
- "Insert an Org heading for the AI reply at RESPONSE-BEGIN-POS."
- (save-excursion
- (goto-char response-begin-pos)
- (insert (format "* %s\n" (cj/gptel-backend-and-model)))))
-
-;;; ---------------------------- GPTel Configuration ----------------------------
-
-(use-package gptel
- :load-path "~/code/gptel"
- :ensure nil
- :defer t
- :commands (gptel gptel-send gptel-menu)
- :bind
- (:map gptel-mode-map
- ("C-<return>" . gptel-send))
- :custom
- (gptel-default-mode 'org-mode)
- (gptel-expert-commands t)
- (gptel-track-media t)
- ;; Options: t (include + resend), 'ignore (show but don't resend),
- ;; nil (discard), or a buffer name to redirect reasoning to
- (gptel-include-reasoning "*AI-Reasoning*")
- (gptel-log-level 'info)
- (gptel--debug nil)
- :config
- (cj/ensure-gptel-backends)
- ;; Set ChatGPT (gpt-5.5) as default after initialization. Model
- ;; must be a symbol -- gptel's modeline-display code calls `symbolp'
- ;; on it and signals `wrong-type-argument' otherwise.
- (setq gptel-backend gptel-chatgpt-backend)
- (setq gptel-model 'gpt-5.5)
-
- (setq gptel-confirm-tool-calls nil) ;; allow tool access by default
-
- ;; Initialize org-mode user prefix and wire up hooks
- (setf (alist-get 'org-mode gptel-prompt-prefix-alist)
- (cj/gptel--fresh-org-prefix))
- (advice-add 'gptel-send :before #'cj/gptel--refresh-org-prefix)
- (add-hook 'gptel-post-response-functions #'cj/gptel-insert-model-heading))
-
-;;; ---------------------------- Toggle GPTel Window ----------------------------
-
-(defvar cj/ai-assistant-window-width 0.4
- "Default fraction of frame width for the *AI-Assistant* side window.
-Used until the panel is resized and toggled off this session; after that,
-the toggled-off width is remembered in `cj/--ai-assistant-width'.")
-
-(defvar cj/--ai-assistant-width nil
- "Last width fraction the *AI-Assistant* side window was toggled off at.
-nil falls back to `cj/ai-assistant-window-width'. Shared by the panel's
-entry points (toggle, load-conversation, quick-ask escalation) so the
-panel reopens at one consistent width. In-memory only -- resets each
-Emacs session.")
-
-(defun cj/toggle-gptel ()
- "Toggle the visibility of the AI-Assistant buffer, and place point at its end.
-The panel opens at `cj/ai-assistant-window-width'; once it has been resized
-and toggled off this session, it reopens at that remembered width."
- (interactive)
- (let* ((buf-name "*AI-Assistant*")
- (buffer (get-buffer buf-name))
- (win (and buffer (get-buffer-window buffer))))
- (if win
- (progn
- (cj/side-window-capture-size win 'right 'cj/--ai-assistant-width)
- (delete-window win))
- ;; Ensure GPTel and our backends are initialized before creating the buffer
- (unless (featurep 'gptel)
- (require 'gptel))
- (cj/ensure-gptel-backends)
- (unless buffer
- ;; Pass backend, not model
- (gptel buf-name gptel-backend))
- (setq buffer (get-buffer buf-name))
- (setq win
- (cj/side-window-display
- buffer 'right 'cj/--ai-assistant-width
- cj/ai-assistant-window-width))
- (select-window win)
- (with-current-buffer buffer
- (goto-char (point-max))))))
-
-;; ------------------------------- Clear Context -------------------------------
-
-(defun cj/gptel-context-clear ()
- "Clear all GPTel context sources, with compatibility across GPTel versions."
- (interactive)
- (cond
- ((fboundp 'gptel-context-remove-all)
- (call-interactively 'gptel-context-remove-all)
- (message "GPTel context cleared"))
- ((fboundp 'gptel-context-clear)
- (call-interactively 'gptel-context-clear)
- (message "GPTel context cleared"))
- ((boundp 'gptel-context--alist)
- (setq gptel-context--alist nil)
- (message "GPTel context cleared"))
- (t
- (message "No known GPTel context clearing function available"))))
-
-;;; -------------------------------- GPTel-Magit --------------------------------
-
-;; Each integration point waits on its actual dependency, not on `magit'
-;; broadly. `magit.el' calls `(provide 'magit)' BEFORE its
-;; `cl-eval-when (load eval) ...' block requires `magit-commit' and
-;; `magit-stash', so a single `with-eval-after-load 'magit' fires while
-;; the transient prefixes the wiring references are still undefined.
-;; `transient-append-suffix' silently no-ops on missing prefixes (it
-;; calls `message' unless `transient-error-on-insert-failure' is set),
-;; which is how the failure stayed invisible.
-;;
-;; Keys:
-;; M-g — generate commit message (in commit message buffer)
-;; g — generate commit (in magit-commit transient)
-;; x — explain diff (in magit-diff transient)
-
-(use-package gptel-magit
- :defer t
- :commands (gptel-magit-generate-message
- gptel-magit-commit-generate
- gptel-magit-diff-explain)
- :init
- (with-eval-after-load 'git-commit
- (define-key git-commit-mode-map (kbd "M-g") #'gptel-magit-generate-message))
- (with-eval-after-load 'magit-commit
- (transient-append-suffix 'magit-commit #'magit-commit-create
- '("g" "Generate commit" gptel-magit-commit-generate)))
- (with-eval-after-load 'magit-diff
- (transient-append-suffix 'magit-diff #'magit-stash-show
- '("x" "Explain" gptel-magit-diff-explain))))
-
-;; ------------------------------ GPTel Directives -----------------------------
-
-(use-package gptel-prompts
- :load-path (lambda () (expand-file-name "custom/" user-emacs-directory))
- :after gptel
- :if (file-exists-p (expand-file-name "custom/gptel-prompts.el" user-emacs-directory))
- :custom
- (gptel-prompts-directory (concat user-emacs-directory "ai-prompts"))
- :config
- (gptel-prompts-update)
- (gptel-prompts-add-update-watchers)
- ;; gptel--system-message is set at gptel load time, before gptel-prompts
- ;; replaces the default directive. Re-apply it now.
- (when-let* ((dir (alist-get 'default gptel-directives)))
- (setq gptel--system-message dir)))
-
-;;; --------------------------------- AI Keymap ---------------------------------
-
-(defvar-keymap cj/ai-keymap
- :doc "Keymap for gptel and other AI operations."
- "A" #'cj/gptel-autosave-toggle ;; toggle autosave on the current GPTel buffer
- "B" #'cj/gptel-switch-backend ;; change the backend (OpenAI, Anthropic, etc.
- "M" #'gptel-menu ;; gptel's transient menu
- "d" #'cj/gptel-delete-conversation ;; delete conversation
- "." #'cj/gptel-add-this-buffer ;; add buffer to context
- "f" #'cj/gptel-add-file ;; add a file to context
- "b" #'cj/gptel-browse-conversations ;; browse saved conversations
- "l" #'cj/gptel-load-conversation ;; load and continue conversation
- "m" #'cj/gptel-change-model ;; change the LLM model
- "p" #'gptel-system-prompt ;; change prompt
- "q" #'cj/gptel-quick-ask ;; one-shot quick ask
- "r" #'cj/gptel-rewrite-with-directive ;; rewrite region with a chosen directive
- "R" #'cj/gptel-rewrite-redo-with-different-directive ;; redo last rewrite, new directive
- "c" #'cj/gptel-context-clear ;; clear all context
- "s" #'cj/gptel-save-conversation ;; save conversation
- "t" #'cj/toggle-gptel ;; toggles the ai-assistant window
- "x" #'cj/gptel-clear-buffer) ;; clears the assistant buffer
-(cj/register-prefix-map "a" cj/ai-keymap)
-
-(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements
- "C-; a" "AI assistant menu"
- "C-; a A" "toggle autosave"
- "C-; a B" "switch backend"
- "C-; a M" "gptel menu"
- "C-; a b" "browse conversations"
- "C-; a d" "delete conversation"
- "C-; a ." "add buffer"
- "C-; a f" "add file"
- "C-; a l" "load conversation"
- "C-; a m" "change model"
- "C-; a p" "change prompt"
- "C-; a q" "quick ask"
- "C-; a r" "rewrite region (directive)"
- "C-; a R" "redo rewrite, new directive"
- "C-; a c" "clear context"
- "C-; a s" "save conversation"
- "C-; a t" "toggle window"
- "C-; a x" "clear buffer"))
-
-(provide 'ai-config)
-;;; ai-config.el ends here.
diff --git a/modules/ai-conversations-browser.el b/modules/ai-conversations-browser.el
deleted file mode 100644
index 9f2a7de43..000000000
--- a/modules/ai-conversations-browser.el
+++ /dev/null
@@ -1,241 +0,0 @@
-;;; ai-conversations-browser.el --- Browse saved GPTel conversations -*- lexical-binding: t; coding: utf-8; -*-
-
-;; Author: Craig Jennings <c@cjennings.net>
-
-;;; Commentary:
-;; Provides `cj/gptel-browse-conversations': a dired-style buffer
-;; listing saved conversations in `cj/gptel-conversations-directory'.
-;; Each row shows date, time, topic, and a short preview of the most
-;; recent message. Single-key bindings load / delete / rename a
-;; conversation in place.
-;;
-;; RET, l Load the conversation under point
-;; d Delete the conversation under point
-;; r Rename the conversation under point (renames the file)
-;; g Refresh the listing
-;; n / p Move to next / previous row
-;; q Quit the browser window
-
-;;; Code:
-
-(require 'cl-lib)
-(require 'subr-x)
-
-(declare-function cj/gptel-load-conversation "ai-conversations" ())
-(declare-function cj/gptel--slugify-topic "ai-conversations" (s))
-(declare-function cj/gptel--timestamp-from-filename "ai-conversations" (filename))
-
-(defcustom cj/gptel-browser-preview-length 60
- "Number of preview characters shown per row in the browser."
- :type 'integer
- :group 'cj/ai-conversations)
-
-(defconst cj/gptel-browser--buffer-name "*GPTel-Conversations*"
- "Buffer name for the saved-conversations browser.")
-
-(defvar-keymap cj/gptel-browser-mode-map
- :doc "Keymap for `cj/gptel-browser-mode'."
- "RET" #'cj/gptel-browser-load
- "l" #'cj/gptel-browser-load
- "d" #'cj/gptel-browser-delete
- "r" #'cj/gptel-browser-rename
- "g" #'cj/gptel-browser-refresh
- "n" #'next-line
- "p" #'previous-line
- "q" #'quit-window)
-
-(define-derived-mode cj/gptel-browser-mode special-mode "GPTel-Browser"
- "Major mode for browsing saved GPTel conversations."
- (setq-local truncate-lines t))
-
-;; -------------------------- helpers (pure where possible)
-
-(defun cj/gptel-browser--topic-from-filename (filename)
- "Return the topic slug from FILENAME, or nil if it isn't a gptel file."
- (when (string-match "\\`\\(.+\\)_[0-9]\\{8\\}-[0-9]\\{6\\}\\.gptel\\'" filename)
- (match-string 1 filename)))
-
-(defun cj/gptel-browser--strip-headers (text)
- "Drop the org #+STARTUP / #+VISIBILITY headers from TEXT and return the rest."
- (let ((s text))
- (while (string-match "\\`#\\+\\(STARTUP\\|VISIBILITY\\):.*\n" s)
- (setq s (substring s (match-end 0))))
- (while (and (> (length s) 0) (eq (aref s 0) ?\n))
- (setq s (substring s 1)))
- s))
-
-(defun cj/gptel-browser--last-message (text)
- "Return a short preview of the last user/AI message in TEXT.
-Returns the empty string when no message body is present."
- (let* ((stripped (cj/gptel-browser--strip-headers text))
- ;; Last org-mode top-level heading body, or the whole text if
- ;; there isn't one.
- (body (if (string-match "\\`\\*+[^\n]*\n\\(\\(?:.\\|\n\\)*\\)\\'" stripped)
- (let* ((all-text stripped)
- ;; Walk backward to find the last '* ' or '** ' heading
- (idx (or (cl-loop for i from (1- (length all-text)) downto 0
- when (and (or (zerop i)
- (eq (aref all-text (1- i)) ?\n))
- (eq (aref all-text i) ?*))
- return i)
- 0)))
- (substring all-text idx))
- stripped)))
- ;; Drop the heading line itself, then collapse whitespace.
- (when (string-match "\\`\\*+[^\n]*\n" body)
- (setq body (substring body (match-end 0))))
- (setq body (replace-regexp-in-string "[\n\t ]+" " " body))
- (string-trim body)))
-
-(defun cj/gptel-browser--preview (text length)
- "Return a LENGTH-char preview from TEXT, ellipsized when truncated."
- (let* ((line (cj/gptel-browser--last-message text))
- (max-len (max 1 length)))
- (cond
- ((string-empty-p line) "")
- ((> (length line) max-len)
- (concat (substring line 0 (1- max-len)) "…"))
- (t line))))
-
-(defun cj/gptel-browser--row-for-file (file dir)
- "Return a propertized row string for FILE under DIR, or nil."
- (let* ((filename (file-name-nondirectory file))
- (topic (cj/gptel-browser--topic-from-filename filename))
- (ts (and topic (cj/gptel--timestamp-from-filename filename))))
- (when (and topic ts)
- (let* ((preview (with-temp-buffer
- (ignore-errors (insert-file-contents file))
- (cj/gptel-browser--preview
- (buffer-string) cj/gptel-browser-preview-length)))
- (row (format "%s %-22s %s"
- (format-time-string "%Y-%m-%d %H:%M" ts)
- topic preview)))
- (propertize row
- 'cj/gptel-browser-file filename
- 'cj/gptel-browser-topic topic)))))
-
-(defun cj/gptel-browser--rows ()
- "Return propertized row strings for every conversation in the directory."
- (when (and (boundp 'cj/gptel-conversations-directory)
- (file-directory-p cj/gptel-conversations-directory))
- (let ((dir cj/gptel-conversations-directory))
- (delq nil
- (mapcar (lambda (f) (cj/gptel-browser--row-for-file f dir))
- (directory-files dir t "\\.gptel\\'"))))))
-
-(defun cj/gptel-browser--render ()
- "Replace the current buffer's contents with the conversation listing.
-Sort newest first."
- (let ((inhibit-read-only t)
- (rows (sort (cj/gptel-browser--rows)
- (lambda (a b)
- (string> (substring-no-properties a 0 16)
- (substring-no-properties b 0 16))))))
- (erase-buffer)
- (insert (propertize
- "Saved GPTel conversations -- RET/l load d delete r rename g refresh q quit\n\n"
- 'face 'header-line))
- (cond
- ((null rows)
- (insert " (no saved conversations)\n"))
- (t
- (dolist (row rows)
- (insert row "\n"))))
- (goto-char (point-min))
- (forward-line 2)))
-
-;; -------------------------- entry point
-
-;;;###autoload
-(defun cj/gptel-browse-conversations ()
- "Open the saved GPTel conversations browser."
- (interactive)
- (let ((buf (get-buffer-create cj/gptel-browser--buffer-name)))
- (with-current-buffer buf
- (cj/gptel-browser-mode)
- (cj/gptel-browser--render))
- (pop-to-buffer buf)))
-
-(defun cj/gptel-browser-refresh ()
- "Re-read the conversations directory and refresh the browser."
- (interactive)
- (cj/gptel-browser--render))
-
-;; -------------------------- row-level actions
-
-(defun cj/gptel-browser--filename-at-point ()
- "Return the conversation filename on the current line, or nil."
- (get-text-property (line-beginning-position) 'cj/gptel-browser-file))
-
-(defun cj/gptel-browser--filepath-at-point ()
- "Return the absolute filepath for the row at point, or nil."
- (when-let ((filename (cj/gptel-browser--filename-at-point)))
- (expand-file-name filename cj/gptel-conversations-directory)))
-
-(defun cj/gptel-browser-load ()
- "Load the conversation on the current row via `cj/gptel-load-conversation'.
-The browser is buried after the load fires."
- (interactive)
- (let ((filepath (cj/gptel-browser--filepath-at-point)))
- (unless filepath
- (user-error "No conversation on this line"))
- (let ((filename (file-name-nondirectory filepath)))
- ;; Stand in for cj/gptel-load-conversation's completing-read so
- ;; the user doesn't get prompted twice.
- (cl-letf (((symbol-function 'completing-read)
- (lambda (_p cands &rest _)
- (or (car (cl-find filename cands
- :key (lambda (c) (cdr c))
- :test #'equal))
- (caar cands))))
- ((symbol-function 'y-or-n-p) (lambda (&rest _) nil)))
- (cj/gptel-load-conversation)))
- (quit-window)))
-
-(defun cj/gptel-browser-delete ()
- "Delete the conversation file on the current row, after confirmation."
- (interactive)
- (let ((filepath (cj/gptel-browser--filepath-at-point)))
- (unless filepath
- (user-error "No conversation on this line"))
- (let ((filename (file-name-nondirectory filepath)))
- (when (y-or-n-p (format "Delete %s? " filename))
- (delete-file filepath)
- (message "Deleted %s" filename)
- (cj/gptel-browser--render)))))
-
-(defun cj/gptel-browser--rename-target (filepath new-topic)
- "Compute the renamed FILEPATH for NEW-TOPIC, preserving the timestamp.
-NEW-TOPIC is slugified. Returns the new absolute filepath."
- (let* ((dir (file-name-directory filepath))
- (filename (file-name-nondirectory filepath))
- (timestamp (and (string-match "_\\([0-9]\\{8\\}-[0-9]\\{6\\}\\)\\.gptel\\'"
- filename)
- (match-string 1 filename)))
- (slug (cj/gptel--slugify-topic new-topic)))
- (unless timestamp
- (error "Cannot extract timestamp from filename: %s" filename))
- (expand-file-name (format "%s_%s.gptel" slug timestamp) dir)))
-
-(defun cj/gptel-browser-rename ()
- "Rename the conversation file on the current row, preserving its timestamp."
- (interactive)
- (let ((filepath (cj/gptel-browser--filepath-at-point)))
- (unless filepath
- (user-error "No conversation on this line"))
- (let* ((old (file-name-nondirectory filepath))
- (current-topic (cj/gptel-browser--topic-from-filename old))
- (new-topic (read-string
- (format "New topic (was %s): " current-topic)
- current-topic))
- (target (cj/gptel-browser--rename-target filepath new-topic)))
- (when (equal target filepath)
- (user-error "Topic unchanged"))
- (when (file-exists-p target)
- (user-error "Target already exists: %s" (file-name-nondirectory target)))
- (rename-file filepath target)
- (message "Renamed to %s" (file-name-nondirectory target))
- (cj/gptel-browser--render))))
-
-(provide 'ai-conversations-browser)
-;;; ai-conversations-browser.el ends here
diff --git a/modules/ai-conversations.el b/modules/ai-conversations.el
deleted file mode 100644
index 8061051a8..000000000
--- a/modules/ai-conversations.el
+++ /dev/null
@@ -1,369 +0,0 @@
-;;; ai-conversations.el --- GPTel conversation persistence and autosave -*- lexical-binding: t; coding: utf-8; -*-
-;; Author: Craig Jennings <c@cjennings.net>
-;; Maintainer: Craig Jennings <c@cjennings.net>
-;; Version 0.1
-;; Package-Requires: ((emacs "27.1"))
-;; Keywords: convenience, tools
-;;
-;;; Commentary:
-;; Provides conversation save/load/delete, autosave after responses, and
-;; org-visibility headers for GPTel-powered assistant buffers.
-;;
-;; Loads lazily via autoloads for the interactive entry points.
-
-;;; Code:
-
-(require 'cj-window-toggle-lib) ;; cj/side-window-display
-
-;; Shared *AI-Assistant* remembered-width state, owned by ai-config.el.
-;; Forward-declared so loading a conversation reopens the panel at the same
-;; width as the F-key toggle without a circular require.
-(defvar cj/--ai-assistant-width)
-
-(defgroup cj/ai-conversations nil
- "Conversation persistence for GPTel (save/load/delete, autosave)."
- :group 'gptel
- :prefix "cj/")
-
-(defcustom cj/gptel-conversations-directory
- (expand-file-name "ai-conversations" user-emacs-directory)
- "Directory where GPTel conversations are stored."
- :type 'directory
- :group 'cj/ai-conversations)
-
-(defcustom cj/gptel-conversations-window-side 'right
- "Side to display the AI-Assistant buffer when loading a conversation."
- :type '(choice (const :tag "Right" right)
- (const :tag "Left" left)
- (const :tag "Bottom" bottom)
- (const :tag "Top" top))
- :group 'cj/ai-conversations)
-
-(defcustom cj/gptel-conversations-window-width 0.4
- "Set the side window width when loading a conversation.
-
-If displaying on the top or bottom, treat this value as a height fraction."
- :type 'number
- :group 'cj/ai-conversations)
-
-(defcustom cj/gptel-conversations-sort-order 'newest-first
- "Sort order for conversation selection prompts."
- :type '(choice (const :tag "Newest first" newest-first)
- (const :tag "Oldest first" oldest-first))
- :group 'cj/ai-conversations)
-
-(defvar-local cj/gptel-autosave-enabled nil
- "Non-nil means auto-save after each AI response in GPTel buffers.")
-
-(defvar-local cj/gptel-autosave-filepath nil
- "File path used for auto-saving the conversation buffer.")
-
-(defvar-local cj/gptel-autosave--timer nil
- "Repeating timer used to auto-save the current GPTel buffer.")
-
-(defcustom cj/gptel-autosave-interval 60
- "Seconds between periodic GPTel conversation autosaves."
- :type 'number
- :group 'cj/ai-conversations)
-
-(defvar cj/gptel-autosave-mode-line-format
- '(:eval (when (bound-and-true-p cj/gptel-autosave-enabled) " [AS]"))
- "Mode-line construct that surfaces autosave state in GPTel buffers.")
-(put 'cj/gptel-autosave-mode-line-format 'risky-local-variable t)
-
-(defun cj/gptel--autosave-active-p ()
- "Return non-nil when the current buffer has an autosave target."
- (and (bound-and-true-p gptel-mode)
- cj/gptel-autosave-enabled
- (stringp cj/gptel-autosave-filepath)
- (> (length cj/gptel-autosave-filepath) 0)))
-
-(defun cj/gptel--autosave-stop-timer ()
- "Cancel the current buffer's periodic autosave timer, if any."
- (when cj/gptel-autosave--timer
- (cancel-timer cj/gptel-autosave--timer)
- (setq-local cj/gptel-autosave--timer nil)))
-
-(defun cj/gptel--autosave-timer-callback (buffer)
- "Auto-save BUFFER from a periodic timer when autosave is still active."
- (when (buffer-live-p buffer)
- (with-current-buffer buffer
- (if (cj/gptel--autosave-active-p)
- (condition-case err
- (cj/gptel--save-buffer-to-file (current-buffer) cj/gptel-autosave-filepath)
- (error (message "cj/gptel periodic autosave failed: %s"
- (error-message-string err))))
- (cj/gptel--autosave-stop-timer)))))
-
-(defun cj/gptel--autosave-start-timer ()
- "Start the current buffer's periodic autosave timer when autosave is active."
- (when (and (cj/gptel--autosave-active-p)
- (not cj/gptel-autosave--timer))
- (setq-local cj/gptel-autosave--timer
- (run-with-timer cj/gptel-autosave-interval
- cj/gptel-autosave-interval
- #'cj/gptel--autosave-timer-callback
- (current-buffer)))))
-
-(defun cj/gptel-autosave-toggle ()
- "Toggle autosave on/off in the current GPTel buffer.
-Flips `cj/gptel-autosave-enabled' and forces a mode-line redisplay so
-the [AS] indicator updates immediately. When turning autosave ON
-without a configured filepath, prompt to save the conversation first
-so a path exists to autosave to."
- (interactive)
- (unless (bound-and-true-p gptel-mode)
- (user-error "Not a GPTel buffer"))
- (if cj/gptel-autosave-enabled
- (progn
- (setq-local cj/gptel-autosave-enabled nil)
- (cj/gptel--autosave-stop-timer)
- (message "Autosave disabled"))
- (cond
- ((and (stringp cj/gptel-autosave-filepath)
- (> (length cj/gptel-autosave-filepath) 0))
- (setq-local cj/gptel-autosave-enabled t)
- (cj/gptel--autosave-start-timer)
- (message "Autosave enabled (saving to %s)"
- (file-name-nondirectory cj/gptel-autosave-filepath)))
- ((y-or-n-p "No save target yet. Save conversation first? ")
- (call-interactively #'cj/gptel-save-conversation))
- (t
- (message "Autosave not enabled (no save target)"))))
- (force-mode-line-update))
-
-(defcustom cj/gptel-conversations-autosave-on-send t
- "Non-nil means auto-save the conversation immediately after `gptel-send'."
- :type 'boolean
- :group 'cj/ai-conversations)
-
-(defun cj/gptel--autosave-after-send (&rest _args)
- "Auto-save current GPTel buffer right after `gptel-send' if enabled."
- (when (and cj/gptel-conversations-autosave-on-send
- (cj/gptel--autosave-active-p))
- (condition-case err
- (cj/gptel--save-buffer-to-file (current-buffer) cj/gptel-autosave-filepath)
- (error (message "cj/gptel autosave-on-send failed: %s" (error-message-string err))))))
-
-(with-eval-after-load 'gptel
- (unless (advice-member-p #'cj/gptel--autosave-after-send #'gptel-send)
- (advice-add 'gptel-send :after #'cj/gptel--autosave-after-send)))
-
-(defun cj/gptel--install-autosave-mode-line ()
- "Add the [AS] autosave indicator to the current buffer's mode-line.
-Idempotent: re-running in the same buffer does not duplicate the
-construct."
- (unless (member 'cj/gptel-autosave-mode-line-format mode-line-format)
- (setq-local mode-line-format
- (append mode-line-format
- (list 'cj/gptel-autosave-mode-line-format)))))
-
-(defun cj/gptel--install-autosave-buffer-hooks ()
- "Install buffer-local cleanup hooks for GPTel autosave."
- (add-hook 'kill-buffer-hook #'cj/gptel--autosave-stop-timer nil t))
-
-(with-eval-after-load 'gptel
- (add-hook 'gptel-mode-hook #'cj/gptel--install-autosave-mode-line)
- (add-hook 'gptel-mode-hook #'cj/gptel--install-autosave-buffer-hooks))
-
-(defun cj/gptel--slugify-topic (s)
- "Return a filesystem-friendly slug for topic string S."
- (let* ((down (downcase (or s "")))
- (repl (replace-regexp-in-string "[^a-z0-9]+" "-" down))
- (trim (replace-regexp-in-string "^-+\\|-+$" "" repl)))
- (or (and (> (length trim) 0) trim) "conversation")))
-
-(defun cj/gptel--existing-topics ()
- "Return topic slugs, without timestamps, present in the conversations directory."
- (when (file-exists-p cj/gptel-conversations-directory)
- (let* ((files (directory-files cj/gptel-conversations-directory nil "\\.gptel$")))
- (delete-dups
- (mapcar
- (lambda (f)
- (replace-regexp-in-string "_[0-9]\\{8\\}-[0-9]\\{6\\}\\.gptel$" "" f))
- files)))))
-
-(defun cj/gptel--latest-file-for-topic (topic-slug)
- "Return the newest saved conversation filename for TOPIC-SLUG, or nil."
- (let* ((rx (format "^%s_[0-9]\\{8\\}-[0-9]\\{6\\}\\.gptel$"
- (regexp-quote topic-slug)))
- (files (and (file-exists-p cj/gptel-conversations-directory)
- (directory-files cj/gptel-conversations-directory nil rx))))
- (car (sort files #'string>))))
-
-(defun cj/gptel--timestamp-from-filename (filename)
- "Return an Emacs timestamp extracted from FILENAME, or nil.
-
-Expect FILENAME to match _YYYYMMDD-HHMMSS.gptel."
- (when (string-match "_\\([0-9]\\{8\\}\\)-\\([0-9]\\{6\\}\\)\\.gptel\\'" filename)
- (let* ((date (match-string 1 filename))
- (time (match-string 2 filename))
- (Y (string-to-number (substring date 0 4)))
- (M (string-to-number (substring date 4 6)))
- (D (string-to-number (substring date 6 8)))
- (h (string-to-number (substring time 0 2)))
- (m (string-to-number (substring time 2 4)))
- (s (string-to-number (substring time 4 6))))
- (encode-time s m h D M Y))))
-
-(defun cj/gptel--conversation-candidates ()
- "Return conversation candidates sorted per `cj/gptel-conversations-sort-order'."
- (unless (file-exists-p cj/gptel-conversations-directory)
- (user-error "Conversations directory doesn't exist: %s" cj/gptel-conversations-directory))
- (let* ((files (directory-files cj/gptel-conversations-directory nil "\\.gptel$"))
- (enriched
- (mapcar
- (lambda (f)
- (let* ((full (expand-file-name f cj/gptel-conversations-directory))
- (ptime (or (cj/gptel--timestamp-from-filename f)
- (nth 5 (file-attributes full))))
- (disp (format "%s [%s]" f (format-time-string "%Y-%m-%d %H:%M" ptime))))
- (list :file f :time ptime :display disp)))
- files))
- (sorted
- (sort enriched
- (lambda (a b)
- (let ((ta (plist-get a :time))
- (tb (plist-get b :time)))
- (if (eq cj/gptel-conversations-sort-order 'newest-first)
- (time-less-p tb ta) ;; tb earlier than ta => a first
- (time-less-p ta tb))))))
- (cands (mapcar (lambda (pl)
- (cons (plist-get pl :display)
- (plist-get pl :file)))
- sorted)))
- cands))
-
-(defun cj/gptel--save-buffer-to-file (buffer filepath)
- "Save BUFFER content to FILEPATH with Org visibility properties."
- (with-current-buffer buffer
- (let ((content (buffer-string)))
- (with-temp-buffer
- (insert "#+STARTUP: showeverything\n")
- (insert "#+VISIBILITY: all\n\n")
- (insert content)
- (write-region (point-min) (point-max) filepath nil 'silent))))
- filepath)
-
-(defun cj/gptel--ensure-ai-buffer ()
- "Return the *AI-Assistant* buffer, creating it via `gptel' if needed."
- (let* ((buf-name "*AI-Assistant*")
- (buffer (get-buffer buf-name)))
- (unless buffer
- (gptel buf-name))
- (or (get-buffer buf-name)
- (user-error "Could not create or find *AI-Assistant* buffer"))))
-
-(defun cj/gptel-save-conversation ()
- "Save the current AI-Assistant buffer to a .gptel file.
-
-Enable autosave for subsequent AI responses to the same file."
- (interactive)
- (let ((buf (get-buffer "*AI-Assistant*")))
- (unless buf
- (user-error "No AI-Assistant buffer found"))
- (unless (file-exists-p cj/gptel-conversations-directory)
- (make-directory cj/gptel-conversations-directory t)
- (message "Created directory: %s" cj/gptel-conversations-directory))
- (let* ((topics (or (cj/gptel--existing-topics) '()))
- (input (completing-read "Conversation topic: " topics nil nil))
- (topic-slug (cj/gptel--slugify-topic input))
- (latest (cj/gptel--latest-file-for-topic topic-slug))
- (use-existing (and latest
- (y-or-n-p (format "Update existing file %s? " latest))))
- (filepath (if use-existing
- (expand-file-name latest cj/gptel-conversations-directory)
- (let* ((timestamp (format-time-string "%Y%m%d-%H%M%S"))
- (filename (format "%s_%s.gptel" topic-slug timestamp)))
- (expand-file-name filename cj/gptel-conversations-directory)))))
- (cj/gptel--save-buffer-to-file buf filepath)
- (with-current-buffer buf
- (setq-local cj/gptel-autosave-filepath filepath)
- (setq-local cj/gptel-autosave-enabled t)
- (cj/gptel--autosave-start-timer))
- (message "Conversation saved to: %s" filepath))))
-
-(defun cj/gptel-delete-conversation ()
- "Delete a saved GPTel conversation file (chronologically sorted candidates)."
- (interactive)
- (unless (file-exists-p cj/gptel-conversations-directory)
- (user-error "Conversations directory doesn't exist: %s" cj/gptel-conversations-directory))
- (let* ((cands (cj/gptel--conversation-candidates)))
- (unless cands
- (user-error "No saved conversations found in %s" cj/gptel-conversations-directory))
- (let* ((completion-extra-properties '(:display-sort-function identity
- :cycle-sort-function identity))
- (selection (completing-read "Delete conversation: " cands nil t))
- (filename (cdr (assoc selection cands)))
- (filepath (and filename
- (expand-file-name filename cj/gptel-conversations-directory))))
- (unless filename
- (user-error "No conversation selected"))
- (when (y-or-n-p (format "Really delete %s? " filename))
- (delete-file filepath)
- (message "Deleted conversation: %s" filename)))))
-
-(defun cj/gptel--strip-visibility-headers ()
- "Strip org visibility headers at the top of the current buffer if present."
- (save-excursion
- (goto-char (point-min))
- (while (looking-at "^#\\+\\(STARTUP\\|VISIBILITY\\):.*\n")
- (delete-region (match-beginning 0) (match-end 0)))
- (when (looking-at "^\n+")
- (delete-region (point) (match-end 0)))))
-
-(defun cj/gptel-load-conversation ()
- "Load a saved GPTel conversation into the AI-Assistant buffer.
-
-Prompt to save the current conversation first when appropriate, then
-enable autosave."
- (interactive)
- (let ((ai-buffer (get-buffer-create "*AI-Assistant*")))
- (when (and (with-current-buffer ai-buffer (> (buffer-size) 0))
- (with-current-buffer ai-buffer (bound-and-true-p gptel-mode)))
- (when (y-or-n-p "Save current conversation before loading new one? ")
- (with-current-buffer ai-buffer
- (call-interactively #'cj/gptel-save-conversation)))))
- (unless (file-exists-p cj/gptel-conversations-directory)
- (user-error "Conversations directory doesn't exist: %s" cj/gptel-conversations-directory))
- (let* ((cands (cj/gptel--conversation-candidates)))
- (unless cands
- (user-error "No saved conversations found in %s" cj/gptel-conversations-directory))
- (let* ((completion-extra-properties '(:display-sort-function identity
- :cycle-sort-function identity))
- (selection (completing-read "Load conversation: " cands nil t))
- (filename (cdr (assoc selection cands)))
- (filepath (and filename
- (expand-file-name filename cj/gptel-conversations-directory))))
- (unless filename
- (user-error "No conversation selected"))
- (with-current-buffer (cj/gptel--ensure-ai-buffer)
- (erase-buffer)
- (insert-file-contents filepath)
- (cj/gptel--strip-visibility-headers)
- (goto-char (point-max))
- (set-buffer-modified-p t)
- (setq-local cj/gptel-autosave-filepath filepath)
- (setq-local cj/gptel-autosave-enabled t)
- (cj/gptel--autosave-start-timer))
- (let ((buf (get-buffer "*AI-Assistant*")))
- (unless (get-buffer-window buf)
- (cj/side-window-display
- buf cj/gptel-conversations-window-side
- 'cj/--ai-assistant-width cj/gptel-conversations-window-width)))
- (select-window (get-buffer-window "*AI-Assistant*"))
- (message "Loaded conversation from: %s" filepath))))
-
-(defun cj/gptel--autosave-after-response (&rest _args)
- "Auto-save the current GPTel buffer when enabled."
- (when (cj/gptel--autosave-active-p)
- (condition-case err
- (cj/gptel--save-buffer-to-file (current-buffer) cj/gptel-autosave-filepath)
- (error (message "cj/gptel autosave failed: %s" (error-message-string err))))))
-
-(with-eval-after-load 'gptel
- (unless (member #'cj/gptel--autosave-after-response gptel-post-response-functions)
- (add-hook 'gptel-post-response-functions #'cj/gptel--autosave-after-response)))
-
-(provide 'ai-conversations)
-;;; ai-conversations.el ends here
diff --git a/modules/ai-mcp.el b/modules/ai-mcp.el
deleted file mode 100644
index 510805be4..000000000
--- a/modules/ai-mcp.el
+++ /dev/null
@@ -1,416 +0,0 @@
-;;; ai-mcp.el --- MCP server integration for GPTel -*- lexical-binding: t; coding: utf-8; -*-
-;; Author: Craig Jennings <c@cjennings.net>
-;; Maintainer: Craig Jennings <c@cjennings.net>
-;; Version 0.1
-;; Package-Requires: ((emacs "30.1") (mcp "0.1.0") (gptel "0.9.8"))
-;; Keywords: convenience, tools, ai
-;;
-;;; Commentary:
-;; Wires mcp.el's MCP server inventory into GPTel. GPTel agents gain
-;; access to the MCP servers Claude Code already uses (linear, notion,
-;; figma, slack-deepsat, drawio, google-calendar, google-docs-personal,
-;; google-docs-work, google-keep), with write-confirmation gating and a
-;; doctor for diagnosing prerequisites.
-;;
-;; Design doc: docs/specs/mcp-el-gptel-integration-spec-doing.org
-;;
-;; File organization (seven sections, populated by phases):
-;; 1. Constants and defcustoms <- this phase
-;; 2. Public commands <- later phase
-;; 3. Pure helpers <- this phase
-;; 4. mcp.el compatibility layer <- later phase
-;; 5. Registration pipeline <- later phase
-;; 6. Async state machine <- later phase
-;; 7. UI <- later phase
-
-;;; Code:
-
-(require 'cl-lib)
-(require 'json)
-
-;;;; --- 1. Constants and defcustoms -----------------------------------
-
-(defgroup cj/ai-mcp nil
- "MCP server integration for GPTel."
- :group 'gptel
- :prefix "cj/")
-
-(defcustom cj/mcp-claude-config
- (expand-file-name "~/.claude.json")
- "Path to the Claude Code config that holds MCP server env vars.
-The config is read at server-spawn time and cached by mtime."
- :type 'file
- :group 'cj/ai-mcp)
-
-(defconst cj/mcp-server-specs
- '((:name "linear"
- :transport http
- :url "https://mcp.linear.app/mcp"
- :auth in-protocol
- :risk write-capable)
- (:name "notion"
- :transport http
- :url "https://mcp.notion.com/mcp"
- :auth in-protocol
- :risk write-capable)
- (:name "figma"
- :transport stdio
- :command "npx"
- :args ("-y" "figma-developer-mcp" "--stdio")
- :secret-args ("--figma-api-key" :figma-api-key)
- :auth args-token
- :risk arg-leak)
- (:name "slack-deepsat"
- :transport sse
- :url "http://127.0.0.1:13080/sse"
- :auth local
- :risk write-capable)
- (:name "drawio"
- :transport stdio
- :command "npx"
- :args ("-y" "@drawio/mcp")
- :auth none
- :risk none)
- (:name "google-calendar"
- :transport stdio
- :command "npx"
- :args ("-y" "@cocal/google-calendar-mcp")
- :env (:GOOGLE_OAUTH_CREDENTIALS t)
- :auth oauth
- :risk write-capable)
- (:name "google-docs-personal"
- :transport stdio
- :command "npx"
- :args ("-y" "@a-bonus/google-docs-mcp")
- :env (:GOOGLE_CLIENT_ID t :GOOGLE_CLIENT_SECRET t :GOOGLE_MCP_PROFILE t)
- :auth oauth
- :risk write-capable)
- (:name "google-docs-work"
- :transport stdio
- :command "npx"
- :args ("-y" "@a-bonus/google-docs-mcp")
- :env (:GOOGLE_CLIENT_ID t :GOOGLE_CLIENT_SECRET t :GOOGLE_MCP_PROFILE t)
- :auth oauth
- :risk write-capable)
- (:name "google-keep"
- :transport stdio
- :command "uvx"
- :args ("--from" "keep-mcp" "python" "-m" "server.cli")
- :env (:GOOGLE_EMAIL t :GOOGLE_MASTER_TOKEN t)
- :auth token
- :risk write-capable))
- "Static, secret-free description of the MCP servers we wire to GPTel.
-Each entry is a plist describing one server. `:env' values are
-placeholders (t) replaced at spawn time from `cj/mcp-claude-config'.
-`:secret-args' (e.g. for figma) names the flag whose value is pulled
-from the Claude config's args at spawn time.")
-
-(defcustom cj/mcp-enabled-servers
- (mapcar (lambda (s) (plist-get s :name)) cj/mcp-server-specs)
- "List of MCP server names to start.
-Defaults to every server in `cj/mcp-server-specs'. Set to a
-shorter list to disable specific servers without editing the
-spec. Changes take effect on next `cj/mcp-restart-failed' or
-Emacs restart."
- :type '(repeat string)
- :group 'cj/ai-mcp)
-
-(defcustom cj/mcp-start-on-entry-points
- '(toggle-gptel)
- "GPTel entry points that trigger MCP startup.
-Symbols correspond to commands: `toggle-gptel', `gptel-send',
-`gptel-quick-ask', `gptel-rewrite-with-directive',
-`gptel-magit-generate-message'. Default: only full chat
-\(`toggle-gptel')."
- :type '(repeat symbol)
- :group 'cj/ai-mcp)
-
-(defcustom cj/mcp-startup-timeout 30
- "Seconds before a still-starting MCP server is marked failed."
- :type 'integer
- :group 'cj/ai-mcp)
-
-(defcustom cj/mcp-tool-timeout 60
- "Seconds before an in-flight MCP tool call times out."
- :type 'integer
- :group 'cj/ai-mcp)
-
-(defcustom cj/mcp-tool-confirm-overrides nil
- "Per-tool confirmation overrides.
-Alist mapping fully qualified MCP tool name (e.g.,
-\"mcp__linear__create_issue\") to t or nil. Wins over the
-pattern-based classifier in `cj/mcp--confirm-p'."
- :type '(alist :key-type string :value-type boolean)
- :group 'cj/ai-mcp)
-
-(defcustom cj/mcp-tool-audit-log-enabled t
- "When non-nil, append metadata for every MCP tool call to the audit log."
- :type 'boolean
- :group 'cj/ai-mcp)
-
-;; Classifier patterns: name prefixes that indicate read vs write.
-
-(defconst cj/mcp--write-name-patterns
- '("\\`create\\b" "\\`update\\b" "\\`delete\\b" "\\`remove\\b"
- "\\`send\\b" "\\`post\\b" "\\`add\\b" "\\`move\\b"
- "\\`invite\\b" "\\`share\\b" "\\`upload\\b" "\\`set\\b"
- "\\`patch\\b" "\\`import\\b" "\\`sync\\b" "\\`merge\\b"
- "\\`close\\b" "\\`reopen\\b" "\\`archive\\b" "\\`unarchive\\b"
- "\\`approve\\b" "\\`reject\\b" "\\`label\\b" "\\`assign\\b"
- "\\`reply\\b" "\\`comment\\b" "\\`trash\\b" "\\`restore\\b"
- "\\`pin\\b" "\\`unpin\\b" "\\`copy\\b" "\\`rename\\b")
- "Tool-name prefixes that indicate a write/mutate operation.
-Matched after the `mcp__SERVER__' prefix is stripped.")
-
-(defconst cj/mcp--read-name-patterns
- '("\\`get\\b" "\\`list\\b" "\\`read\\b" "\\`search\\b"
- "\\`find\\b" "\\`fetch\\b" "\\`view\\b" "\\`query\\b"
- "\\`describe\\b" "\\`show\\b" "\\`check\\b")
- "Tool-name prefixes that indicate a read-only operation.")
-
-;; Secret-pattern list for redaction. Each entry is (REGEX
-;; . GROUP-NUMBER); the substring matched by GROUP-NUMBER is replaced
-;; with "***".
-
-(defconst cj/mcp--secret-redaction-patterns
- '(("\\(--token\\)\\(=\\|\\s-+\\)\\(\\S-+\\)" . 3)
- ("\\(--secret\\)\\(=\\|\\s-+\\)\\(\\S-+\\)" . 3)
- ("\\(--password\\)\\(=\\|\\s-+\\)\\(\\S-+\\)" . 3)
- ("\\(--figma-api-key\\)\\(=\\|\\s-+\\)\\(\\S-+\\)" . 3)
- ("\\(Authorization:\\s-*\\)\\(\\S-[^\"\n]*\\)" . 2)
- ("\\([?&]token=\\)\\([^&[:space:]\"]+\\)" . 2))
- "List of (REGEX . GROUP-NUMBER) for masking secrets in user-facing strings.
-Applied in order by `cj/mcp--redact'.")
-
-;;;; --- 3. Pure helpers -----------------------------------------------
-
-;; ---- secrets redaction ----
-
-(defun cj/mcp--redact (str)
- "Return STR with known secret patterns replaced by `***'.
-Returns nil when STR is not a string. See
-`cj/mcp--secret-redaction-patterns' for the matched patterns."
- (when (stringp str)
- (let ((result str))
- (dolist (entry cj/mcp--secret-redaction-patterns result)
- (let ((re (car entry))
- (group (cdr entry))
- (start 0))
- (while (and (< start (length result))
- (string-match re result start))
- (setq result
- (concat (substring result 0 (match-beginning group))
- "***"
- (substring result (match-end group))))
- (setq start (+ (match-beginning group) 3))))))))
-
-;; ---- confirm-policy classifier ----
-
-(defun cj/mcp--strip-name-prefix (name)
- "Strip the `mcp__SERVER__' prefix from NAME, if present."
- (replace-regexp-in-string "\\`mcp__[^_]+__" "" name))
-
-(defun cj/mcp--name-matches-p (name patterns)
- "Non-nil if NAME matches any regexp in PATTERNS."
- (cl-some (lambda (p) (string-match-p p name)) patterns))
-
-(defun cj/mcp--confirm-p (gptel-name &optional remote-name)
- "Return non-nil if a tool should register with `:confirm t'.
-GPTEL-NAME is the fully qualified `mcp__SERVER__TOOL' string.
-REMOTE-NAME, if provided, overrides the prefix-strip of GPTEL-NAME.
-
-Decision order:
-1. `cj/mcp-tool-confirm-overrides' alist entry wins.
-2. Bare name matches a write pattern → t.
-3. Bare name matches a read pattern → nil.
-4. Neither → t (fail closed)."
- (let ((override (assoc gptel-name cj/mcp-tool-confirm-overrides)))
- (cond
- (override (cdr override))
- (t
- (let ((bare (or remote-name (cj/mcp--strip-name-prefix gptel-name))))
- (cond
- ((cj/mcp--name-matches-p bare cj/mcp--write-name-patterns) t)
- ((cj/mcp--name-matches-p bare cj/mcp--read-name-patterns) nil)
- (t t)))))))
-
-;; ---- description normalizer ----
-
-(defun cj/mcp--normalize-description (server-name raw-tool)
- "Return a normalized description string for RAW-TOOL from SERVER-NAME.
-Prefix `[SERVER]' for reads, `[SERVER WRITE]' for writes,
-`[SERVER ?]' for unknown classification, then the upstream
-description unchanged."
- (let* ((remote-name (plist-get raw-tool :name))
- (upstream (or (plist-get raw-tool :description)
- "(no description provided by server)"))
- (suffix (cond
- ((cj/mcp--name-matches-p remote-name
- cj/mcp--write-name-patterns)
- " WRITE")
- ((cj/mcp--name-matches-p remote-name
- cj/mcp--read-name-patterns)
- "")
- (t " ?"))))
- (format "[%s%s] %s" server-name suffix upstream)))
-
-;; ---- Claude config reader (mtime-cached, structured returns) ----
-
-(defvar cj/mcp--config-cache nil
- "Cache for the parsed Claude config.
-Plist of (:path P :mtime M :data PARSED) or nil when empty.")
-
-(defun cj/mcp--invalidate-config-cache ()
- "Force the next `cj/mcp--read-claude-config' call to reparse."
- (setq cj/mcp--config-cache nil))
-
-(defun cj/mcp--read-claude-config (&optional path)
- "Return a structured plist describing the Claude config state.
-PATH defaults to `cj/mcp-claude-config'.
-
-Result shape:
- (:ok t :data PLIST)
- (:ok nil :reason missing-file)
- (:ok nil :reason unreadable)
- (:ok nil :reason malformed-json :message STR)
-
-The parsed result is cached by (PATH, MTIME); subsequent calls
-reparse only if the file has changed."
- (let ((path (or path cj/mcp-claude-config)))
- (cond
- ((not (file-exists-p path))
- (list :ok nil :reason 'missing-file))
- ((not (file-readable-p path))
- (list :ok nil :reason 'unreadable))
- (t
- (let ((mtime (file-attribute-modification-time
- (file-attributes path))))
- (if (and cj/mcp--config-cache
- (equal (plist-get cj/mcp--config-cache :path) path)
- (equal (plist-get cj/mcp--config-cache :mtime) mtime))
- (list :ok t :data (plist-get cj/mcp--config-cache :data))
- (condition-case err
- (let* ((json-object-type 'plist)
- (json-array-type 'list)
- (data (with-temp-buffer
- (insert-file-contents path)
- (goto-char (point-min))
- (json-read))))
- (setq cj/mcp--config-cache
- (list :path path :mtime mtime :data data))
- (list :ok t :data data))
- (error
- (setq cj/mcp--config-cache nil)
- (list :ok nil :reason 'malformed-json
- :message (error-message-string err))))))))))
-
-;; ---- env / secret-args resolution ----
-
-(defun cj/mcp--get-server-entry (server-name &optional config-result)
- "Return the parsed Claude-config entry plist for SERVER-NAME.
-CONFIG-RESULT, if provided, is a return value from
-`cj/mcp--read-claude-config' (avoids re-reading). Returns nil
-when the config is unavailable or SERVER-NAME is unknown."
- (let ((result (or config-result (cj/mcp--read-claude-config))))
- (when (plist-get result :ok)
- (let* ((data (plist-get result :data))
- (servers (plist-get data :mcpServers))
- (server-key (intern (concat ":" server-name))))
- (plist-get servers server-key)))))
-
-(defun cj/mcp--get-env (server-name &optional config-result)
- "Return the env plist for SERVER-NAME from the parsed Claude config.
-CONFIG-RESULT, if provided, is reused to avoid re-reading the
-config. Returns nil when the config is unavailable, the server
-is unknown, or the server has no env section."
- (plist-get (cj/mcp--get-server-entry server-name config-result) :env))
-
-(defun cj/mcp--get-secret-arg (server-name flag &optional config-result)
- "Return the secret value for SERVER-NAME's FLAG from the Claude config.
-FLAG is the option name (e.g. \"--figma-api-key\"). Returns the
-value following `FLAG=' in the server entry's args, or nil if
-not found."
- (let* ((entry (cj/mcp--get-server-entry server-name config-result))
- (args (plist-get entry :args))
- (prefix (concat flag "=")))
- (cl-some
- (lambda (a)
- (when (and (stringp a) (string-prefix-p prefix a))
- (substring a (length prefix))))
- args)))
-
-;; ---- server-alist builder (pure transform from specs + config) ----
-
-(defun cj/mcp--resolve-env (env-spec server-name config-result)
- "Return a flat (KEY1 VAL1 KEY2 VAL2 ...) list for ENV-SPEC.
-ENV-SPEC is a plist of `(:VAR1 t :VAR2 t)`. Values come from
-SERVER-NAME's env subtree in the parsed Claude config. Vars
-without a value are omitted."
- (let ((source-env (cj/mcp--get-env server-name config-result))
- (result nil))
- (cl-loop for (key _placeholder) on env-spec by #'cddr
- do (let ((value (plist-get source-env key)))
- (when value
- (push key result)
- (push value result))))
- (nreverse result)))
-
-(defun cj/mcp--resolve-args (args secret-args-spec server-name config-result)
- "Return ARGS with `:secret-args' placeholders filled in.
-SECRET-ARGS-SPEC is (FLAG-STRING SLOT-KEYWORD). When the value is
-available in the Claude config, append `FLAG=VALUE' to ARGS;
-otherwise return ARGS unchanged."
- (if (not secret-args-spec)
- args
- (let* ((flag (car secret-args-spec))
- (value (cj/mcp--get-secret-arg server-name flag config-result)))
- (if value
- (append args (list (format "%s=%s" flag value)))
- args))))
-
-(defun cj/mcp--spec-to-alist-entry (spec config-result)
- "Translate one SPEC plist into a `(NAME . PLIST)' alist entry.
-Pulls env values from CONFIG-RESULT; splices `:secret-args' into
-`:args' for stdio specs that declare one."
- (let* ((name (plist-get spec :name))
- (transport (plist-get spec :transport))
- (entry (list :type (symbol-name transport)))
- (env-spec (plist-get spec :env))
- (secret-args-spec (plist-get spec :secret-args)))
- (pcase transport
- ('stdio
- (setq entry (append entry
- (list :command (plist-get spec :command)
- :args (cj/mcp--resolve-args
- (plist-get spec :args)
- secret-args-spec
- name
- config-result)))))
- ((or 'http 'sse)
- (setq entry (append entry
- (list :url (plist-get spec :url))))))
- (when env-spec
- (let ((env-pairs (cj/mcp--resolve-env env-spec name config-result)))
- (when env-pairs
- (setq entry (append entry (list :env env-pairs))))))
- (cons name entry)))
-
-(defun cj/mcp--build-server-alist (&optional specs enabled-names config-result)
- "Return an alist suitable for `mcp-hub-servers'.
-SPECS defaults to `cj/mcp-server-specs'. ENABLED-NAMES defaults
-to `cj/mcp-enabled-servers'. CONFIG-RESULT, if provided, is a
-parsed Claude-config result (reused for env/secret resolution).
-Does not mutate SPECS."
- (let* ((specs (or specs cj/mcp-server-specs))
- (enabled-names (or enabled-names cj/mcp-enabled-servers))
- (config-result (or config-result (cj/mcp--read-claude-config))))
- (delq nil
- (mapcar
- (lambda (spec)
- (let ((name (plist-get spec :name)))
- (when (member name enabled-names)
- (cj/mcp--spec-to-alist-entry spec config-result))))
- specs))))
-
-(provide 'ai-mcp)
-;;; ai-mcp.el ends here
diff --git a/modules/ai-quick-ask.el b/modules/ai-quick-ask.el
deleted file mode 100644
index 16f3afae4..000000000
--- a/modules/ai-quick-ask.el
+++ /dev/null
@@ -1,141 +0,0 @@
-;;; ai-quick-ask.el --- One-shot GPTel quick-ask -*- lexical-binding: t; coding: utf-8; -*-
-
-;; Author: Craig Jennings <c@cjennings.net>
-
-;;; Commentary:
-;; Provides `cj/gptel-quick-ask': read a single prompt in the
-;; minibuffer, stream the response into a transient *GPTel-Quick*
-;; buffer. The transient buffer is dismissible with q or escape and
-;; can be escalated with c into a full *AI-Assistant* conversation
-;; seeded with the prompt + response.
-;;
-;; Designed for impromptu help where the conversation thread doesn't
-;; matter. Doesn't touch the *AI-Assistant* side window unless the
-;; user explicitly escalates, doesn't autosave anywhere.
-
-;;; Code:
-
-(require 'cj-window-toggle-lib) ;; cj/side-window-display
-
-;; Shared *AI-Assistant* panel-width state, owned by ai-config.el. Forward-
-;; declared here so the escalation reopens the panel at the same remembered
-;; width as the F-key toggle without a circular require.
-(defvar cj/ai-assistant-window-width)
-(defvar cj/--ai-assistant-width)
-
-(defvar-local cj/gptel-quick--prompt nil
- "Buffer-local: the prompt used for the current *GPTel-Quick* session.")
-
-(defconst cj/gptel-quick--buffer-name "*GPTel-Quick*"
- "Buffer used for one-shot quick-ask Q&A.")
-
-(defconst cj/gptel-quick--response-marker "A: "
- "String inserted before the response in the quick-ask buffer.")
-
-(defvar-keymap cj/gptel-quick-mode-map
- :doc "Keymap for `cj/gptel-quick-mode'."
- "q" #'cj/gptel-quick-dismiss
- "<escape>" #'cj/gptel-quick-dismiss
- "c" #'cj/gptel-quick-continue)
-
-(define-derived-mode cj/gptel-quick-mode special-mode "GPTel-Quick"
- "Major mode for the one-shot *GPTel-Quick* buffer."
- ;; Allow gptel-request to stream into the buffer despite the
- ;; special-mode read-only default.
- (setq-local buffer-read-only nil))
-
-(defun cj/gptel-quick--initial-text (prompt)
- "Return the initial buffer body for a quick-ask of PROMPT.
-The result is \"Q: <prompt>\\n\\nA: \", with the response marker at
-the end so the streamed response lands right after it."
- (format "Q: %s\n\n%s" prompt cj/gptel-quick--response-marker))
-
-(defun cj/gptel-quick--extract-response (text)
- "Return the response portion of TEXT, or nil if not found.
-TEXT is the contents of a *GPTel-Quick* buffer. The response is
-everything after the first occurrence of `cj/gptel-quick--response-marker'
-on its own line. Returns nil when the marker is absent."
- (when (string-match
- (concat "^" (regexp-quote cj/gptel-quick--response-marker))
- text)
- (substring text (match-end 0))))
-
-(defun cj/gptel-quick--seed-text (prompt response)
- "Format a *AI-Assistant* seed from PROMPT and RESPONSE.
-Matches the org-heading shape that `cj/gptel--fresh-org-prefix' and
-`cj/gptel-insert-model-heading' produce: a user heading followed by
-the prompt body, followed by an AI heading followed by the response."
- (let ((ts (format-time-string "[%Y-%m-%d %H:%M:%S]")))
- (format "* %s %s\n%s\n\n* AI %s\n%s\n"
- user-login-name ts prompt
- ts (or response ""))))
-
-;;;###autoload
-(defun cj/gptel-quick-ask (prompt)
- "Read a one-shot PROMPT in the minibuffer and stream the answer.
-The response lands in a transient *GPTel-Quick* buffer. Press q or
-escape to dismiss, or c to escalate into a full *AI-Assistant*
-conversation seeded with the prompt and response."
- (interactive (list (read-string "Quick ask: ")))
- (when (string-empty-p prompt)
- (user-error "Empty prompt"))
- (let ((buf (get-buffer-create cj/gptel-quick--buffer-name)))
- (with-current-buffer buf
- (cj/gptel-quick-mode)
- (let ((inhibit-read-only t))
- (erase-buffer)
- (insert (cj/gptel-quick--initial-text prompt))
- (setq-local cj/gptel-quick--prompt prompt)))
- (unless (featurep 'gptel)
- (require 'gptel))
- (when (fboundp 'cj/ensure-gptel-backends)
- (cj/ensure-gptel-backends))
- (gptel-request prompt
- :buffer buf
- :position (with-current-buffer buf (point-max))
- :stream t)
- (display-buffer buf
- '((display-buffer-reuse-window
- display-buffer-pop-up-window)
- (window-height . 0.3)))
- buf))
-
-(defun cj/gptel-quick-dismiss ()
- "Kill the *GPTel-Quick* buffer if it exists."
- (interactive)
- (when-let ((buf (get-buffer cj/gptel-quick--buffer-name)))
- (when-let ((win (get-buffer-window buf)))
- (delete-window win))
- (kill-buffer buf)))
-
-(defun cj/gptel-quick-continue ()
- "Escalate the current quick-ask into a full *AI-Assistant* conversation.
-Reads the prompt and response from the *GPTel-Quick* buffer, seeds
-them into *AI-Assistant* under proper org headings, displays the
-side window, then dismisses the quick buffer."
- (interactive)
- (unless (eq major-mode 'cj/gptel-quick-mode)
- (user-error "Not in a *GPTel-Quick* buffer"))
- (let* ((prompt cj/gptel-quick--prompt)
- (response (cj/gptel-quick--extract-response (buffer-string)))
- (seed (cj/gptel-quick--seed-text prompt response)))
- (unless prompt
- (user-error "No prompt recorded in this buffer"))
- ;; Ensure *AI-Assistant* exists in gptel-mode.
- (unless (featurep 'gptel)
- (require 'gptel))
- (let ((ai-buf (get-buffer "*AI-Assistant*")))
- (unless ai-buf
- (when (fboundp 'cj/ensure-gptel-backends)
- (cj/ensure-gptel-backends))
- (gptel "*AI-Assistant*")
- (setq ai-buf (get-buffer "*AI-Assistant*")))
- (with-current-buffer ai-buf
- (goto-char (point-max))
- (insert seed))
- (cj/side-window-display
- ai-buf 'right 'cj/--ai-assistant-width cj/ai-assistant-window-width)
- (cj/gptel-quick-dismiss))))
-
-(provide 'ai-quick-ask)
-;;; ai-quick-ask.el ends here
diff --git a/modules/ai-rewrite.el b/modules/ai-rewrite.el
deleted file mode 100644
index fb25c1379..000000000
--- a/modules/ai-rewrite.el
+++ /dev/null
@@ -1,108 +0,0 @@
-;;; ai-rewrite.el --- Directive-picker wrappers for gptel-rewrite -*- lexical-binding: t; coding: utf-8; -*-
-
-;; Author: Craig Jennings <c@cjennings.net>
-
-;;; Commentary:
-;; Adds two ergonomic wrappers around `gptel-rewrite':
-;;
-;; cj/gptel-rewrite-with-directive Pick a named directive,
-;; then rewrite the region.
-;; cj/gptel-rewrite-redo-with-different-directive
-;; Re-run the previous region
-;; with a different directive.
-;;
-;; A directive is a short system-message snippet attached to a name
-;; (e.g. "terse", "fix-grammar"). The directive body is injected
-;; into the rewrite via `gptel-rewrite-directives-hook' just for that
-;; call -- no global state changes.
-
-;;; Code:
-
-;; Declare the hook variable special so our `let'-binding below is
-;; dynamic (visible across the `call-interactively' that follows)
-;; rather than lexical when this file is byte-compiled.
-(defvar gptel-rewrite-directives-hook)
-
-(defcustom cj/gptel-rewrite-directives
- '(("terse"
- . "Rewrite the text to be as terse as possible without losing meaning.\nDo not add commentary. Return only the rewritten text.")
- ("fix-grammar"
- . "Fix grammar and spelling errors only. Do not rephrase, restructure,\nor change tone. Return only the corrected text.")
- ("refactor-readability"
- . "Refactor the code for readability. Improve naming, split long\nfunctions when appropriate, remove unnecessary complexity, and preserve\nbehavior exactly. Return only the refactored code.")
- ("add-docstring"
- . "Add or improve docstrings for every function in the region. Use the\nidiomatic docstring style for the language. Do not change executable\ncode. Return the whole region with the updated docstrings.")
- ("explain-as-comment"
- . "Replace the region with the original code, preceded by a concise\nblock comment explaining what the code does. Use the language's\nidiomatic comment syntax. Return code + comment, nothing else.")
- ("shorten"
- . "Shorten the text while preserving meaning, technical accuracy, and\nthe author's voice. Remove rhetorical padding. Return only the\nshortened text."))
- "Named system-message directives for `cj/gptel-rewrite-with-directive'.
-Each entry is a (NAME . BODY) pair where NAME is the directive label
-presented in the completing-read prompt and BODY is the system
-message injected into the next `gptel-rewrite' call."
- :type '(alist :key-type string :value-type string)
- :group 'cj)
-
-(defvar-local cj/gptel-rewrite--last-region nil
- "Cons (BEG-MARKER . END-MARKER) of the last directive-driven rewrite.")
-
-(defvar-local cj/gptel-rewrite--last-directive nil
- "Name of the directive used in the last directive-driven rewrite.")
-
-(defun cj/gptel-rewrite--call-with-directive (directive-name beg end)
- "Run `gptel-rewrite' over BEG..END with DIRECTIVE-NAME's system message.
-Stores the region (as markers) and directive name on buffer-local
-variables so `cj/gptel-rewrite-redo-with-different-directive' can
-revisit them."
- (let ((body (alist-get directive-name cj/gptel-rewrite-directives
- nil nil #'equal)))
- (unless body
- (user-error "Unknown rewrite directive: %s" directive-name))
- (setq-local cj/gptel-rewrite--last-region
- (cons (copy-marker beg) (copy-marker end)))
- (setq-local cj/gptel-rewrite--last-directive directive-name)
- (let ((gptel-rewrite-directives-hook
- (cons (lambda () body) gptel-rewrite-directives-hook)))
- (save-excursion
- (goto-char beg)
- (push-mark end t t)
- (call-interactively #'gptel-rewrite)))))
-
-;;;###autoload
-(defun cj/gptel-rewrite-with-directive (directive-name)
- "Pick DIRECTIVE-NAME from `cj/gptel-rewrite-directives' and rewrite the region.
-Requires an active region. The directive is applied only to this
-call -- it does not modify global `gptel-directives'."
- (interactive
- (progn
- (unless (use-region-p)
- (user-error "No region selected"))
- (list (completing-read
- "Rewrite directive: "
- (mapcar #'car cj/gptel-rewrite-directives) nil t))))
- (cj/gptel-rewrite--call-with-directive
- directive-name (region-beginning) (region-end)))
-
-;;;###autoload
-(defun cj/gptel-rewrite-redo-with-different-directive ()
- "Re-run the previous directive-driven rewrite with a different directive.
-The region is restored from the markers captured at the last call;
-the user picks a new directive from the remaining choices."
- (interactive)
- (unless cj/gptel-rewrite--last-region
- (user-error "No previous rewrite to redo in this buffer"))
- (let* ((beg-mk (car cj/gptel-rewrite--last-region))
- (end-mk (cdr cj/gptel-rewrite--last-region))
- (current cj/gptel-rewrite--last-directive)
- (others (cl-remove
- current
- (mapcar #'car cj/gptel-rewrite-directives)
- :test #'equal))
- (chosen (completing-read
- (format "Re-rewrite with (was %s): " current)
- others nil t)))
- (cj/gptel-rewrite--call-with-directive
- chosen (marker-position beg-mk) (marker-position end-mk))))
-
-(provide 'ai-rewrite)
-;;; ai-rewrite.el ends here
diff --git a/modules/ai-term.el b/modules/ai-term.el
index 25e56c508..3beabe6b5 100644
--- a/modules/ai-term.el
+++ b/modules/ai-term.el
@@ -52,15 +52,21 @@
;; picker, even when an agent buffer is currently displayed.
;; Used when the user wants to start a new project session
;; instead of toggling the current one.
+;; - s-F9 `cj/ai-term-next' -- step to the next active agent in the
+;; queue. The queue is every active agent in buffer-name order
+;; (a stable rotation): attached agents (a live buffer) and
+;; detached ones (a live tmux session with no Emacs buffer).
+;; Stepping onto a detached agent attaches it. When an agent
+;; window is on screen, swap it to the next agent and focus it,
+;; wrapping after the last; when none is shown but agents exist,
+;; show the first. This is the "switch among existing agents"
+;; surface F9 deliberately doesn't provide.
;; - M-F9 `cj/ai-term-close' -- gracefully close an agent: kill its
;; tmux session (stopping the agent process), then its terminal
;; buffer. Its window stays in the layout (swapped to the
;; working buffer), so closing never collapses a split. Confirms
;; first. Targets the current agent, the sole live agent, or
;; prompts among several.
-;; - C-S-F9 `cj/ai-term-close' -- same close command, second binding.
-;; (M-F9 is the primary; C-S-F9 may be swallowed by the
-;; Wayland/PGTK layer on some machines.)
;;
;; Existing windmove (Shift-arrows) handles code <-> agent focus
;; toggling. Buffer-move (C-M-arrows) handles side-swap. Neither
@@ -73,17 +79,14 @@
(require 'cj-window-geometry-lib)
(require 'cj-window-toggle-lib)
(require 'host-environment)
+(require 'keybindings) ;; provides cj/register-prefix-map (C-; a)
-(declare-function ghostel "ghostel" (&optional arg))
-(declare-function ghostel-send-string "ghostel" (string))
-(declare-function ghostel--rebuild-semi-char-keymap "ghostel" ())
-(defvar ghostel-keymap-exceptions)
-(defvar ghostel-mode-map)
-(defvar ghostel-buffer-name)
-(defvar ghostel-buffer-name-function)
+(declare-function eat "eat" (&optional program arg))
+(defvar eat-buffer-name)
+(defvar eat-semi-char-mode-map)
(defgroup ai-term nil
- "In-Emacs AI-agent launcher with a vertical-split ghostel terminal."
+ "In-Emacs AI-agent launcher with a vertical-split EAT terminal."
:group 'tools)
(defcustom cj/ai-term-agent-command
@@ -95,15 +98,6 @@ agent you run (aider, an open-source LLM TUI, etc.)."
:type 'string
:group 'ai-term)
-(defvar cj/--ai-term-suppress-tmux nil
- "When non-nil, the generic ghostel tmux-launch hook skips its auto-tmux step.
-
-ai-term dynamically binds this around `(ghostel)' so the hook in
-term-config.el doesn't send a bare \"tmux\\n\" before the named
-session launch command runs. The hook reads the variable via
-`bound-and-true-p' so loading order between the two modules doesn't
-matter.")
-
(defcustom cj/ai-term-project-roots
(list (expand-file-name "~/.emacs.d"))
"Directories that are themselves AI-agent projects.
@@ -181,6 +175,40 @@ recently-selected first. Non-AI-term buffers are filtered out via
`cj/--ai-term-buffer-p'."
(seq-filter #'cj/--ai-term-buffer-p (buffer-list)))
+(defun cj/--ai-term-next-agent-dir (current dirs)
+ "Return the project dir after CURRENT in DIRS, wrapping to the first.
+
+DIRS is an ordered list of active-agent project dirs. When CURRENT is
+the last element, wrap to the first. When CURRENT is nil or not a member
+of DIRS, return the first dir. Returns nil when DIRS is empty. Matches
+with `member' (string equality) since dirs are paths.
+
+Pure decision helper (no buffer or window side effects) so the cycle
+order driving `cj/ai-term-next' is exercisable in tests."
+ (when dirs
+ (if (member current dirs)
+ (or (cadr (member current dirs))
+ (car dirs))
+ (car dirs))))
+
+(defun cj/--ai-term-active-agent-dirs ()
+ "Return project dirs that have a live agent buffer or a live tmux session.
+
+Sorted by the agent buffer name, so the rotation is stable and matches
+what the picker shows. This is the queue `cj/ai-term-next' steps through:
+it includes detached sessions (alive in tmux but with no Emacs buffer),
+which the step materializes by attaching."
+ (let* ((sessions (cj/--ai-term-live-tmux-sessions))
+ (live-names (mapcar #'buffer-name (cj/--ai-term-agent-buffers))))
+ (sort
+ (seq-filter
+ (lambda (dir)
+ (or (member (cj/--ai-term-buffer-name dir) live-names)
+ (cj/--ai-term-session-active-p dir sessions)))
+ (cj/--ai-term-candidates))
+ (lambda (a b)
+ (string< (cj/--ai-term-buffer-name a) (cj/--ai-term-buffer-name b))))))
+
(defun cj/--ai-term-most-recent-non-agent-buffer ()
"Return the most-recently-selected live non-agent buffer, or nil.
@@ -628,19 +656,26 @@ split) when the user is focused in agent and switches projects."
(dolist (entry (cj/--ai-term-display-rule-list))
(add-to-list 'display-buffer-alist entry))
+(defun cj/--ai-term-send-string (buffer string)
+ "Send STRING to BUFFER's terminal process (the agent's shell).
+Sends to the pty directly so the launch command reaches the shell EAT runs."
+ (let ((proc (get-buffer-process buffer)))
+ (when (process-live-p proc)
+ (process-send-string proc string))))
+
(defun cj/--ai-term-show-or-create (dir name)
"Show or create the AI-term buffer for project DIR with buffer NAME.
If a buffer named NAME exists with a live process, display it. If
the buffer exists but its process is dead, kill it and recreate. If
-no such buffer exists, create a new ghostel terminal in DIR and send
+no such buffer exists, create a new EAT terminal in DIR and send
the project's tmux launch command (see `cj/--ai-term-launch-command') so
the same project basename reattaches across Emacs restarts.
-The dynamic binding of `cj/--ai-term-suppress-tmux' around `(ghostel)'
-suppresses the generic tmux-launch hook in term-config.el so
-it doesn't fire a bare \"tmux\\n\" before the project-named launch
-command runs.
+EAT runs a plain shell with no auto-tmux hook, so the named
+`tmux new-session -A' launch command is the only thing that starts the
+session -- the spike confirmed EAT + tmux detach and reattach exactly
+like ghostel + tmux did.
Records DIR in `cj/--ai-term-mru' (whichever branch runs) so the
project picker can list recently-opened projects first. Returns the
@@ -654,28 +689,22 @@ buffer."
(t
(when existing
(kill-buffer existing))
- ;; `ghostel' switches to its buffer in the selected window before our
+ ;; `eat' switches to its buffer in the selected window before our
;; display-buffer-alist rule can route it; `save-window-excursion'
;; reverts that, and the explicit display-buffer below routes the buffer
- ;; through the alist into the agent slot. `ghostel-buffer-name' is bound
- ;; to NAME so the terminal is created under the agent name, and
- ;; `ghostel-buffer-name-function' is pinned nil (dynamically during
- ;; creation, then buffer-locally) so OSC title escapes from the agent
- ;; don't rename it out from under the "agent [" prefix that buffer
- ;; detection and the display rule key on.
+ ;; through the alist into the agent slot. `eat-buffer-name' is bound to
+ ;; NAME so the terminal is created under the agent name; EAT (unlike
+ ;; ghostel) does not rename the buffer from the terminal's OSC title, so
+ ;; the "agent [" prefix that buffer detection and the display rule key on
+ ;; stays put.
(save-window-excursion
(let ((default-directory dir)
- (ghostel-buffer-name name)
- (ghostel-buffer-name-function nil)
- (cj/--ai-term-suppress-tmux t))
- (let ((buf (ghostel)))
- (when (buffer-live-p buf)
- (with-current-buffer buf
- (setq-local ghostel-buffer-name-function nil))))))
+ (eat-buffer-name name))
+ (eat)))
(let ((buf (get-buffer name)))
(with-current-buffer buf
- (ghostel-send-string (cj/--ai-term-launch-command dir))
- (ghostel-send-string "\n"))
+ (cj/--ai-term-send-string
+ buf (concat (cj/--ai-term-launch-command dir) "\n")))
(display-buffer buf)
buf)))))
@@ -777,7 +806,7 @@ without firing real `display-buffer' or `quit-window' calls."
(t '(pick-project))))))))
(defun cj/ai-term-pick-project (&optional arg)
- "Pick an AI-agent project and open or reuse its ghostel terminal.
+ "Pick an AI-agent project and open or reuse its EAT terminal.
The project is picked from a filtered completing-read list of dirs
that contain .ai/protocols.org. The terminal buffer is named
@@ -790,8 +819,8 @@ With prefix ARG, display the buffer without selecting its window.
Bound to C-F9 -- always shows the project picker, even when an agent
buffer is currently displayed.
-ghostel renders in terminal frames as well as GUI frames, so this
-launches from either (only kitty inline-graphics degrade in a TTY)."
+EAT renders in terminal frames as well as GUI frames, so this
+launches from either."
(interactive "P")
(let* ((dir (cj/--ai-term-pick-project))
(name (cj/--ai-term-buffer-name dir))
@@ -882,7 +911,7 @@ With prefix ARG, display the buffer without selecting its window
when a buffer is being shown (no effect on the toggle-off branch).
See `cj/ai-term-pick-project' (C-F9) to force the project picker.
-M-F9 (and C-S-F9) close an agent via `cj/ai-term-close'."
+M-F9 closes an agent via `cj/ai-term-close'."
(interactive "P")
(pcase (cj/--ai-term-dispatch)
(`(toggle-off . ,win)
@@ -952,7 +981,7 @@ buffers; nil when none are alive."
Targets the current agent buffer, the sole live agent, or prompts when
several are alive (see `cj/--ai-term-close-target'). Asks for
confirmation first -- this kills the running agent process, which can
-interrupt work in progress. Bound to M-<f9> (primary) and C-S-<f9>."
+interrupt work in progress. Bound to M-<f9>."
(interactive)
(let ((buffer (cj/--ai-term-close-target)))
(unless buffer
@@ -963,31 +992,165 @@ interrupt work in progress. Bound to M-<f9> (primary) and C-S-<f9>."
(cj/--ai-term-close-buffer buffer)
(message "Closed agent %s." name)))))
-(keymap-global-set "<f9>" #'cj/ai-term)
-(keymap-global-set "C-<f9>" #'cj/ai-term-pick-project)
-(keymap-global-set "M-<f9>" #'cj/ai-term-close)
-(keymap-global-set "C-S-<f9>" #'cj/ai-term-close)
-
-;; ghostel's semi-char mode forwards keys not in `ghostel-keymap-exceptions' to
-;; the terminal program, so a plain <f9> typed while point is inside an agent
-;; buffer would be sent to the program instead of toggling the agent -- which
-;; bites hard when the agent buffer is the only window in the frame. Re-bind
-;; the F9 family in `ghostel-mode-map' so the toggle reaches Emacs from there
-;; too. (C-<f9> / M-<f9> are bound here as well so the behaviour is uniform.)
-(with-eval-after-load 'ghostel
- (keymap-set ghostel-mode-map "<f9>" #'cj/ai-term)
- (keymap-set ghostel-mode-map "C-<f9>" #'cj/ai-term-pick-project)
- (keymap-set ghostel-mode-map "M-<f9>" #'cj/ai-term-close)
- (keymap-set ghostel-mode-map "C-S-<f9>" #'cj/ai-term-close)
- ;; The bindings above live in `ghostel-mode-map', but in semi-char mode
- ;; ghostel's own `ghostel-semi-char-mode-map' forwards every key not in
- ;; `ghostel-keymap-exceptions' to the pty -- and that map outranks the
- ;; major-mode map, so it would swallow the F9 family before the bindings
- ;; above fire. Add the family to the exceptions and rebuild the semi-char
- ;; map so the keys fall through to `ghostel-mode-map' inside agent buffers.
- (dolist (key '("<f9>" "C-<f9>" "M-<f9>" "C-S-<f9>"))
- (add-to-list 'ghostel-keymap-exceptions key))
- (ghostel--rebuild-semi-char-keymap))
+;; ------------------------- Step to the next agent ----------------------------
+
+(defun cj/ai-term-next ()
+ "Step to the next open AI-term agent in the queue.
+
+The queue is every active agent ordered by buffer name -- a stable
+rotation, unaffected by which agent was most recently selected. Active
+means a live agent buffer (attached) OR a live tmux session with no Emacs
+buffer (detached); stepping onto a detached agent attaches it (recreates
+its terminal, which reattaches the session). When an agent window is on
+screen, swap it to the next agent (wrapping after the last) and select it.
+When no agent is displayed but agents exist, show the first. When none
+are open, open the project picker to launch the first agent rather than
+erroring.
+
+Bound to M-SPC. Unlike C-; a a (toggle the most-recent agent on/off), this
+is the \"switch among existing agents\" surface; C-; a s opens the project
+picker and C-; a k closes an agent."
+ (interactive)
+ (let* ((dirs (cj/--ai-term-active-agent-dirs))
+ (win (cj/--ai-term-displayed-agent-window))
+ (current-name (and win (buffer-name (window-buffer win))))
+ (current-dir (and current-name
+ (seq-find (lambda (d)
+ (equal (cj/--ai-term-buffer-name d) current-name))
+ dirs)))
+ (next-dir (cj/--ai-term-next-agent-dir current-dir dirs)))
+ (if (not next-dir)
+ ;; No agents open: launch the first via the project picker instead of
+ ;; erroring, so the swap key doubles as a "start an agent" key.
+ (cj/ai-term-pick-project)
+ (let* ((name (cj/--ai-term-buffer-name next-dir))
+ (existing (get-buffer name)))
+ ;; Live agent and an agent window is up: swap it into that window in
+ ;; place (faithful to the prior buffer-only behavior). Detached, or no
+ ;; window yet: show-or-create attaches the tmux session / displays it.
+ (if (and win existing (cj/--ai-term-process-live-p existing))
+ (progn (set-window-buffer win existing) (select-window win))
+ (cj/--ai-term-show-or-create next-dir name)
+ (let ((w (get-buffer-window name)))
+ (when w (select-window w))))
+ (message "Agent: %s" name)))))
+
+;; ai-term lives under the C-; a prefix (vacated when gptel was archived).
+;; The frequent "swap to the next agent" also gets M-SPC for a fast chord.
+(defvar-keymap cj/ai-term-keymap
+ :doc "Keymap for ai-term agent commands (C-; a)."
+ "a" #'cj/ai-term ;; toggle the most-recent agent on/off
+ "s" #'cj/ai-term-pick-project ;; select / launch via the project picker
+ "n" #'cj/ai-term-next ;; swap to the next open agent
+ "k" #'cj/ai-term-close) ;; kill the current agent
+(cj/register-prefix-map "a" cj/ai-term-keymap "ai-term")
+(keymap-global-set "M-SPC" #'cj/ai-term-next)
+
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements
+ "C-; a" "ai-term menu"
+ "C-; a a" "toggle agent"
+ "C-; a s" "select / launch"
+ "C-; a n" "next agent"
+ "C-; a k" "kill agent"
+ "M-SPC" "ai-term: next agent"))
+
+;; In EAT's semi-char mode, keys not bound in `eat-semi-char-mode-map' are
+;; forwarded to the pty. M-SPC (swap to the next agent) must reach Emacs from
+;; inside an agent buffer, so bind it in that map -- no exception-list or rebuild
+;; dance like ghostel needed. C-; is already bound there (eat-config), so the
+;; C-; a family resolves through the global prefix without extra wiring.
+(with-eval-after-load 'eat
+ (keymap-set eat-semi-char-mode-map "M-SPC" #'cj/ai-term-next))
+
+;; ------------------- Wrap-it-up teardown + shutdown -------------------------
+;;
+;; Headless entry points the rulesets wrap-it-up workflow calls via
+;; `emacsclient -e' (its Stop hook ~/.claude/hooks/ai-wrap-teardown.sh). All
+;; three must work with no interactive frame guaranteed. rulesets owns the
+;; workflow + hook that call these; this module owns the aiv- session naming,
+;; the agent buffer, and the geometry restore, so the functions live here.
+;; See docs/design/2026-06-23-wrap-teardown-shutdown-proposal.org (rulesets).
+
+(defcustom cj/ai-term-shutdown-command "sudo shutdown now"
+ "Shell command run when the shutdown countdown completes uncancelled.
+A defcustom so development and tests can stub it instead of powering off
+\(sudo is NOPASSWD on Craig's machines, so the default really shuts down)."
+ :type 'string
+ :group 'cj)
+
+(defun cj/ai-term-quit (&optional project)
+ "Tear down PROJECT's AI-term: kill its tmux session, buffer, and restore layout.
+PROJECT is a project basename (as the rulesets Stop hook passes) or a directory;
+nil means the current project (`default-directory'). Kills the `aiv-<name>'
+tmux session (taking the agent process with it), then, when the agent buffer is
+live, swaps its window back to the working buffer and kills it. Idempotent and
+safe headless: a session or buffer already gone is a no-op, not an error."
+ (let* ((key (or project default-directory))
+ (session (cj/--ai-term-tmux-session-name key))
+ (buffer (get-buffer (cj/--ai-term-buffer-name key))))
+ (cj/--ai-term-kill-tmux-session session)
+ (when (cj/--ai-term-buffer-p buffer)
+ (let ((win (get-buffer-window buffer)))
+ (when (window-live-p win)
+ (cj/--ai-term-swap-to-working-buffer win)))
+ (let ((kill-buffer-query-functions nil))
+ (kill-buffer buffer)))
+ session))
+
+(defun cj/ai-term-live-count ()
+ "Return the integer count of live AI-term (aiv-*) tmux sessions.
+0 when tmux has no server or no AI-term sessions. The shutdown safety gate:
+`emacsclient -e (cj/ai-term-live-count)' prints the integer for the hook."
+ (length (cj/--ai-term-live-tmux-sessions)))
+
+(defvar cj/--ai-term-shutdown-timer nil
+ "The active shutdown-countdown repeating timer, or nil when none is running.")
+
+(defun cj/--ai-term-shutdown-clear-timer ()
+ "Cancel and forget the shutdown-countdown timer, if any."
+ (when (timerp cj/--ai-term-shutdown-timer)
+ (cancel-timer cj/--ai-term-shutdown-timer))
+ (setq cj/--ai-term-shutdown-timer nil))
+
+(defun cj/ai-term-shutdown-cancel ()
+ "Cancel an in-progress AI-term shutdown countdown."
+ (interactive)
+ (when cj/--ai-term-shutdown-timer
+ (cj/--ai-term-shutdown-clear-timer)
+ (message "Shutdown cancelled.")))
+
+(defun cj/ai-term-shutdown-countdown (&optional seconds)
+ "Count down SECONDS (default 10) in the echo area, then shut the machine down.
+Re-checks the safety gate first (a TOCTOU guard against the workflow's earlier
+check): aborts with a message when more than one `aiv-*' session is live. The
+countdown is an abort-able `run-at-time' timer -- `C-g' (while the countdown
+owns the keymap) or \\[cj/ai-term-shutdown-cancel] stops it. On reaching zero
+uncancelled it runs `cj/ai-term-shutdown-command'. Returns immediately so the
+Stop hook does not block; the daemon ticks the timer asynchronously."
+ (if (> (cj/ai-term-live-count) 1)
+ (progn
+ (message "Shutdown aborted: %d AI-term sessions still live."
+ (cj/ai-term-live-count))
+ nil)
+ (cj/--ai-term-shutdown-clear-timer)
+ (let ((remaining (or seconds 10)))
+ (set-transient-map
+ (let ((m (make-sparse-keymap)))
+ (define-key m (kbd "C-g") #'cj/ai-term-shutdown-cancel)
+ m)
+ (lambda () (and cj/--ai-term-shutdown-timer t)))
+ (setq cj/--ai-term-shutdown-timer
+ (run-at-time
+ 0 1
+ (lambda ()
+ (if (<= remaining 0)
+ (progn
+ (cj/--ai-term-shutdown-clear-timer)
+ (shell-command cj/ai-term-shutdown-command))
+ (message "Shutting down in %d… (C-g to cancel)" remaining)
+ (setq remaining (1- remaining))))))
+ nil)))
;; ---------- emacsclient: keep opened files off the agent terminal ----------
;;
diff --git a/modules/auth-config.el b/modules/auth-config.el
index f18c0c1fd..62d773057 100644
--- a/modules/auth-config.el
+++ b/modules/auth-config.el
@@ -35,6 +35,15 @@
(require 'system-lib)
(require 'user-constants) ;; defines authinfo-file, read at load time below
+;; Lazily-loaded oauth2-auto / plstore internals used by the cache-fix advice
+;; below. oauth2-auto is required at runtime inside the advised function; these
+;; declarations satisfy the byte-compiler without forcing an eager load.
+(declare-function oauth2-auto--compute-id "oauth2-auto")
+(declare-function plstore-get "plstore")
+(declare-function plstore-close "plstore")
+(defvar oauth2-auto--plstore-cache)
+(defvar oauth2-auto-plstore)
+
(defcustom cj/auth-source-debug-enabled nil
"Non-nil means enable verbose auth-source debug logging.
diff --git a/modules/auto-dim-config.el b/modules/auto-dim-config.el
index a143f8fe0..efae5341b 100644
--- a/modules/auto-dim-config.el
+++ b/modules/auto-dim-config.el
@@ -19,11 +19,10 @@
;; auto-dim-other-buffers-hide) live in the active theme (the generated
;; theme-studio theme) so they track theme switches.
;;
-;; Terminal buffers (ghostel) do not participate in window dimming: ghostel
-;; bakes its color palette into the native module per-terminal, not per-window,
-;; so there is no per-window color hook to dim through (the vterm engine had
-;; one via `vterm--get-color', which this module used to advise). See the
-;; terminal-migration follow-up task in todo.org for revisiting this.
+;; EAT terminals render in real Emacs faces and use the `default' face for the
+;; terminal background, so -- unlike the old ghostel/vterm engines, which baked
+;; color per-terminal with no per-window hook -- they follow the per-window
+;; dimmed background like any other buffer.
;;; Code:
diff --git a/modules/browser-config.el b/modules/browser-config.el
index 0312cdd18..d596b9e9d 100644
--- a/modules/browser-config.el
+++ b/modules/browser-config.el
@@ -145,7 +145,8 @@ Persists the choice for future sessions."
(defun cj/--do-initialize-browser ()
"Initialize browser configuration.
Returns: (cons \\='loaded browser-plist) if saved choice was loaded,
- (cons \\='first-available browser-plist) if using first discovered browser,
+ (cons \\='first-available browser-plist) if using first
+ discovered browser,
(cons \\='no-browsers nil) if no browsers found."
(let ((saved-choice (cj/load-browser-choice)))
(if saved-choice
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el
index 2ff535668..c0e0e935a 100644
--- a/modules/calendar-sync.el
+++ b/modules/calendar-sync.el
@@ -223,7 +223,7 @@ Example: -21600 for CST (UTC-6), -28800 for PST (UTC-8)."
(defun calendar-sync--format-timezone-offset (offset)
"Format timezone OFFSET (in seconds) as human-readable string.
-Example: -21600 → 'UTC-6' or 'UTC-6:00'."
+Example: -21600 → `UTC-6' or `UTC-6:00'."
(if (null offset)
"unknown"
(let* ((hours (/ offset 3600))
@@ -255,8 +255,10 @@ Example: -21600 → 'UTC-6' or 'UTC-6:00'."
(dir (file-name-directory calendar-sync--state-file)))
(unless (file-directory-p dir)
(make-directory dir t))
- (with-temp-file calendar-sync--state-file
- (prin1 state (current-buffer)))))
+ (let ((tmp (make-temp-file (expand-file-name ".calendar-sync-state-" dir))))
+ (with-temp-file tmp
+ (prin1 state (current-buffer)))
+ (rename-file tmp calendar-sync--state-file t))))
(defun calendar-sync--load-state ()
"Load sync state from disk."
@@ -289,7 +291,7 @@ Example: -21600 → 'UTC-6' or 'UTC-6:00'."
"Normalize line endings in CONTENT to Unix format (LF only).
Removes all carriage return characters (\\r) from CONTENT.
The iCalendar format (RFC 5545) uses CRLF line endings, but Emacs
-and 'org-mode' expect LF only. This function ensures consistent line
+and `org-mode' expect LF only. This function ensures consistent line
endings throughout the parsing pipeline.
Returns CONTENT with all \\r characters removed."
@@ -423,14 +425,16 @@ Handles both simple values and values with parameters like TZID."
(defun calendar-sync--get-recurrence-id-line (event-str)
"Extract full RECURRENCE-ID line from EVENT-STR, including parameters.
-Returns the complete line like 'RECURRENCE-ID;TZID=Europe/Tallinn:20260203T170000'.
+Returns the complete line like
+`RECURRENCE-ID;TZID=Europe/Tallinn:20260203T170000'.
Returns nil if not found."
(when (and event-str (stringp event-str))
(calendar-sync--get-property-line event-str "RECURRENCE-ID")))
(defun calendar-sync--parse-ics-datetime (value)
"Parse iCal datetime VALUE into (year month day hour minute) list.
-Returns nil for invalid input. For date-only values, returns (year month day nil nil).
+Returns nil for invalid input. For date-only values, returns
+(year month day nil nil).
Handles formats: 20260203T090000Z, 20260203T090000, 20260203."
(when (and value
(stringp value)
@@ -493,7 +497,8 @@ start time fail to parse. The plist holds :recurrence-id (localized),
(defun calendar-sync--collect-recurrence-exceptions (ics-content)
"Collect all RECURRENCE-ID events from ICS-CONTENT.
Returns hash table mapping UID to list of exception event plists.
-Each exception plist contains :recurrence-id (parsed), :start, :end, :summary, etc."
+Each exception plist contains :recurrence-id (parsed), :start, :end,
+:summary, etc."
(let ((exceptions (make-hash-table :test 'equal)))
(when (and ics-content (stringp ics-content))
(dolist (event-str (calendar-sync--split-events ics-content))
@@ -537,7 +542,15 @@ Compares year, month, day, hour, minute."
(plist-put result :location (plist-get exception :location)))
;; Pass through new fields if exception overrides them
(when (plist-get exception :attendees)
- (plist-put result :attendees (plist-get exception :attendees)))
+ (plist-put result :attendees (plist-get exception :attendees))
+ ;; Re-derive the user's status from the overridden attendees so a
+ ;; singly-declined occurrence drops its inherited series "accepted"
+ ;; (otherwise `calendar-sync--filter-declined' can't drop it). Leave the
+ ;; inherited status when the override doesn't name the user.
+ (let ((status (calendar-sync--find-user-status
+ (plist-get exception :attendees) calendar-sync-user-emails)))
+ (when status
+ (plist-put result :status status))))
(when (plist-get exception :organizer)
(plist-put result :organizer (plist-get exception :organizer)))
(when (plist-get exception :url)
@@ -571,7 +584,8 @@ Returns new list with matching occurrences replaced by exception times."
(defun calendar-sync--get-exdates (event-str)
"Extract all EXDATE values from EVENT-STR.
-Returns list of datetime strings (without TZID parameters), or nil if none found.
+Returns list of datetime strings (without TZID parameters), or nil if
+none found.
Handles both simple values and values with parameters like TZID."
(when (and event-str (stringp event-str) (not (string-empty-p event-str)))
(let ((exdates '())
@@ -584,7 +598,8 @@ Handles both simple values and values with parameters like TZID."
(defun calendar-sync--get-exdate-line (event-str exdate-value)
"Find the full EXDATE line containing EXDATE-VALUE from EVENT-STR.
-Returns the complete line like 'EXDATE;TZID=America/New_York:20260210T130000'.
+Returns the complete line like
+`EXDATE;TZID=America/New_York:20260210T130000'.
Returns nil if not found."
(when (and event-str (stringp event-str) exdate-value)
(let ((pattern (format "^\\(EXDATE[^:]*:%s\\)" (regexp-quote exdate-value))))
@@ -618,7 +633,8 @@ Converts TZID-qualified and UTC times to local time."
(defun calendar-sync--exdate-matches-p (occurrence-start exdate)
"Check if OCCURRENCE-START matches EXDATE.
OCCURRENCE-START is (year month day hour minute).
-EXDATE is (year month day hour minute) or (year month day nil nil) for date-only.
+EXDATE is (year month day hour minute) or (year month day nil nil) for
+date-only.
Date-only EXDATE matches any time on that day."
(and occurrence-start exdate
(= (nth 0 occurrence-start) (nth 0 exdate)) ; year
@@ -682,7 +698,8 @@ Returns nil if property not found."
(defun calendar-sync--get-property-line (event property)
"Extract full PROPERTY line from EVENT string, including parameters.
-Returns the complete line like 'DTSTART;TZID=Europe/Lisbon:20260202T190000'.
+Returns the complete line like
+`DTSTART;TZID=Europe/Lisbon:20260202T190000'.
Returns nil if property not found."
(when (string-match (format "^\\(%s[^\n]*\\)$" (regexp-quote property)) event)
(match-string 1 event)))
@@ -790,8 +807,8 @@ Returns URL string or nil."
(defun calendar-sync--extract-tzid (property-line)
"Extract TZID parameter value from PROPERTY-LINE.
-PROPERTY-LINE is like 'DTSTART;TZID=Europe/Lisbon:20260202T190000'.
-Returns timezone string like 'Europe/Lisbon', or nil if no TZID.
+PROPERTY-LINE is like `DTSTART;TZID=Europe/Lisbon:20260202T190000'.
+Returns timezone string like `Europe/Lisbon', or nil if no TZID.
Returns nil for malformed lines (missing colon separator)."
(when (and property-line
(stringp property-line)
@@ -813,7 +830,7 @@ Returns list (year month day hour minute) in local timezone."
(defun calendar-sync--convert-tz-to-local (year month day hour minute source-tz)
"Convert datetime from SOURCE-TZ timezone to local time.
-SOURCE-TZ is a timezone name like 'Europe/Lisbon' or 'Asia/Yerevan'.
+SOURCE-TZ is a timezone name like `Europe/Lisbon' or `Asia/Yerevan'.
Returns list (year month day hour minute) in local timezone, or nil on error.
Uses Emacs built-in timezone support (encode-time/decode-time with ZONE
@@ -837,8 +854,10 @@ TZ database as the `date' command."
"Convert PARSED datetime to local time using timezone info.
PARSED is (year month day hour minute) or (year month day nil nil).
IS-UTC non-nil means the value had a Z suffix.
+
TZID is a timezone string like \"Europe/Lisbon\", or nil.
-Returns PARSED converted to local time, or PARSED unchanged if no conversion needed."
+Returns PARSED converted to local time, or PARSED unchanged if no
+conversion needed."
(cond
(is-utc
(calendar-sync--convert-utc-to-local
@@ -856,7 +875,8 @@ Returns PARSED converted to local time, or PARSED unchanged if no conversion nee
"Parse iCal timestamp string TIMESTAMP-STR.
Returns (year month day hour minute) or (year month day) for all-day events.
Converts UTC times (ending in Z) to local time.
-If TZID is provided (e.g., 'Europe/Lisbon'), converts from that timezone to local.
+If TZID is provided (e.g., `Europe/Lisbon'), converts from that timezone
+to local.
Returns nil if parsing fails."
(cond
;; DateTime format: 20251116T140000Z or 20251116T140000
@@ -913,7 +933,8 @@ Returns string like '<2025-11-16 Sun 14:00-15:00>' or '<2025-11-16 Sun>'."
(defun calendar-sync--date-to-time (date)
"Convert DATE to time value for comparison.
DATE should be a list starting with (year month day ...).
-Only the first three elements are used; extra elements (hour, minute) are ignored."
+Only the first three elements are used; extra elements (hour, minute) are
+ignored."
(let ((day (nth 2 date))
(month (nth 1 date))
(year (nth 0 date)))
@@ -1082,7 +1103,8 @@ Returns nil if event lacks required fields (DTSTART, SUMMARY).
Skips events with RECURRENCE-ID (individual instances of recurring events
are handled separately via exception collection).
Handles TZID-qualified timestamps by converting to local time.
-Cleans text fields (description, location, summary) via `calendar-sync--clean-text'."
+Cleans text fields (description, location, summary) via
+`calendar-sync--clean-text'."
;; Skip individual instances of recurring events (they're collected as exceptions)
(unless (calendar-sync--get-property event-str "RECURRENCE-ID")
(let* ((uid (calendar-sync--get-property event-str "UID"))
@@ -1228,11 +1250,19 @@ RECURRENCE-ID exceptions are applied to override specific occurrences."
(time-less-p (calendar-sync--event-start-time a)
(calendar-sync--event-start-time b)))))
(org-entries (mapcar #'calendar-sync--event-to-org sorted-events)))
- (if org-entries
- (concat "# Calendar Events\n\n"
- (string-join org-entries "\n\n")
- "\n")
- nil)))
+ ;; Distinguish a healthy zero-event calendar from garbage: a real
+ ;; iCalendar (carries BEGIN:VCALENDAR) with no in-window events
+ ;; returns the header alone, so the caller writes an empty calendar
+ ;; and reports success. Non-iCalendar content (an HTML error page, a
+ ;; truncated download) has no VCALENDAR and returns nil -- a failure.
+ (cond
+ (org-entries
+ (concat "# Calendar Events\n\n"
+ (string-join org-entries "\n\n")
+ "\n"))
+ ((string-match-p "BEGIN:VCALENDAR" ics-content)
+ "# Calendar Events\n\n")
+ (t nil))))
(error
(calendar-sync--log-silently "calendar-sync: Parse error: %s" (error-message-string err))
nil)))
@@ -1251,7 +1281,7 @@ invoked when the fetch completes, either successfully or with an error."
(make-process
:name "calendar-sync-curl"
:buffer buffer
- :command (list "curl" "-s" "-L"
+ :command (list "curl" "-s" "-L" "--fail"
"--connect-timeout" "10"
"--max-time" (number-to-string calendar-sync-fetch-timeout)
url)
@@ -1283,7 +1313,7 @@ owns deleting the temp file after a successful callback."
(make-process
:name "calendar-sync-curl"
:buffer buffer
- :command (list "curl" "-s" "-L"
+ :command (list "curl" "-s" "-L" "--fail"
"--connect-timeout" "10"
"--max-time" (number-to-string calendar-sync-fetch-timeout)
"-o" temp-file
@@ -1309,13 +1339,17 @@ owns deleting the temp file after a successful callback."
(funcall callback nil))))
(defun calendar-sync--write-file (content file)
- "Write CONTENT to FILE.
-Creates parent directories if needed."
+ "Write CONTENT to FILE atomically.
+Creates parent directories if needed, then writes a temp file in the same
+directory and renames it into place, so org-agenda or chime reading mid-write
+never sees a half-written calendar."
(let ((dir (file-name-directory file)))
(unless (file-directory-p dir)
- (make-directory dir t)))
- (with-temp-file file
- (insert content)))
+ (make-directory dir t))
+ (let ((tmp (make-temp-file (expand-file-name ".calendar-sync-" dir))))
+ (with-temp-file tmp
+ (insert content))
+ (rename-file tmp file t))))
(defun calendar-sync--emacs-binary ()
"Return the Emacs executable to use for calendar conversion workers."
diff --git a/modules/calibredb-epub-config.el b/modules/calibredb-epub-config.el
index 6d5963515..1e6437d26 100644
--- a/modules/calibredb-epub-config.el
+++ b/modules/calibredb-epub-config.el
@@ -77,6 +77,13 @@
(defvar calibredb-show-entry-switch) ; from calibredb-show.el
(defvar calibredb-sort-by) ; from calibredb-core.el
(defvar calibredb-search-filter) ; from calibredb-search.el
+;; calibredb filter-state vars (set by cj/calibredb-clear-filters and friends)
+(defvar calibredb-tag-filter-p) ; from calibredb-search.el
+(defvar calibredb-favorite-filter-p) ; from calibredb-search.el
+(defvar calibredb-author-filter-p) ; from calibredb-search.el
+(defvar calibredb-date-filter-p) ; from calibredb-search.el
+(defvar calibredb-format-filter-p) ; from calibredb-search.el
+(defvar calibredb-search-current-page) ; from calibredb-search.el
;; -------------------------- CalibreDB Ebook Manager --------------------------
@@ -306,11 +313,11 @@ A positive DELTA narrows the text column; a negative DELTA widens it."
"Apply preferences after nov-mode has launched."
(interactive)
;; Use Merriweather for comfortable reading with appropriate scaling.
- ;; Darker sepia color (#E8DCC0) is easier on the eyes than pure white.
- (let ((sepia "#E8DCC0"))
- (face-remap-add-relative 'variable-pitch :family "Merriweather" :height 1.0 :foreground sepia)
- (face-remap-add-relative 'default :family "Merriweather" :height 180 :foreground sepia)
- (face-remap-add-relative 'fixed-pitch :height 180 :foreground sepia))
+ ;; (Reading fg color stripped; falls back to the theme default until a
+ ;; themeable reading face exists -- see todo.org.)
+ (face-remap-add-relative 'variable-pitch :family "Merriweather" :height 1.0)
+ (face-remap-add-relative 'default :family "Merriweather" :height 180)
+ (face-remap-add-relative 'fixed-pitch :height 180)
;; Enable visual-line-mode for proper text wrapping
(visual-line-mode 1)
;; Set fill-column as a fallback
diff --git a/modules/chrono-tools.el b/modules/chrono-tools.el
index 6f88b2018..744781268 100644
--- a/modules/chrono-tools.el
+++ b/modules/chrono-tools.el
@@ -22,6 +22,11 @@
(require 'user-constants)
+;; Declared by the lazily-loaded `tmr' package; quiet the byte-compiler
+;; without forcing the package to load.
+(defvar tmr-sound-file)
+(defvar tmr-descriptions-list)
+
;; -------------------------------- Time Zones ---------------------------------
(use-package time-zones
diff --git a/modules/config-utilities.el b/modules/config-utilities.el
index b3eec5d3d..f448327c1 100644
--- a/modules/config-utilities.el
+++ b/modules/config-utilities.el
@@ -21,6 +21,19 @@
(require 'find-lisp)
(require 'profiler)
+;; External variables referenced at runtime only (org and the native
+;; compiler are loaded lazily; declare to quiet the byte-compiler).
+(defvar comp-async-report-warnings-errors)
+(defvar org-ts-regexp)
+(defvar org-agenda-files)
+
+;; External functions referenced at runtime only.
+(declare-function org-element-parse-buffer "org-element")
+(declare-function org-element-map "org-element")
+(declare-function org-element-property "org-element-ast")
+(declare-function org-time-string-to-absolute "org")
+(declare-function org-alert-check "org-alert")
+
;;; -------------------------------- Debug Keymap -------------------------------
(defvar-keymap cj/debug-config-keymap
@@ -65,13 +78,15 @@
(with-eval-after-load 'emacsql-sqlite-builtin
(cl-defmethod emacsql-close :around
((connection emacsql-sqlite-builtin-connection))
- (when (oref connection handle)
+ ;; The class is loaded lazily, so the slot is unknown at compile time.
+ (when (with-no-warnings (oref connection handle))
(cl-call-next-method))))
(with-eval-after-load 'emacsql-sqlite-module
(cl-defmethod emacsql-close :around
((connection emacsql-sqlite-module-connection))
- (when (oref connection handle)
+ ;; The class is loaded lazily, so the slot is unknown at compile time.
+ (when (with-no-warnings (oref connection handle))
(cl-call-next-method))))
;;; -------------------------------- Benchmarking -------------------------------
diff --git a/modules/coverage-core.el b/modules/coverage-core.el
index 687a042fe..e8f7a4740 100644
--- a/modules/coverage-core.el
+++ b/modules/coverage-core.el
@@ -25,6 +25,15 @@
(require 'subr-x)
(require 'system-lib)
+;; Make json.el's reader variables visible to the byte/native compiler so the
+;; `let' bindings of `json-object-type' / `json-array-type' / `json-key-type'
+;; in the parse helpers below bind dynamically. Without this the compiler
+;; treats them as lexical (this file is lexical-binding), the bindings never
+;; reach `json-read-file', and it returns json.el's default alist instead of
+;; the hash tables the parsers maphash over. The runtime `(require 'json)'
+;; inside each helper still keeps json off the load-time path.
+(eval-when-compile (require 'json))
+
(defvar cj/coverage-backends nil
"Registry of coverage backends in priority order.
Each entry is a plist with at least :name, :detect, :run, and :report-path.
@@ -249,6 +258,27 @@ Signals `user-error' for any other SCOPE."
(maphash (lambda (k _v) (push k keys)) table)
(sort keys #'<)))
+(defun cj/--coverage-relativize-keys (table root)
+ "Return a copy of TABLE with each file-path key made relative to ROOT.
+An absolute key is relativized against ROOT via `file-relative-name'; an
+already-relative key is kept as-is. Line-set values are shared, not copied.
+
+`cj/--coverage-parse-simplecov' emits absolute path keys (simplecov reports
+absolute source paths) while `cj/--coverage-parse-diff-output' emits
+repo-relative keys (git's \"+++ b/<path>\"). Both must be normalized to
+repo-relative before `cj/--coverage-intersect' joins them by key, or every
+diff-aware match misses and each changed file reads `:tracked nil'."
+ (let ((result (make-hash-table :test 'equal)))
+ (when table
+ (maphash
+ (lambda (path lines)
+ (let ((key (if (file-name-absolute-p path)
+ (file-relative-name path root)
+ path)))
+ (puthash key lines result)))
+ table))
+ result))
+
(defun cj/--coverage-intersect (covered changed)
"Combine COVERED (LCOV) with CHANGED (git diff) into per-file records.
COVERED and CHANGED are each hash tables from file path to a hash table
@@ -479,10 +509,14 @@ line in the simplecov data — the intersect then classifies each line
as covered or uncovered. For diff-aware scopes, the changed set
comes from `git diff' via `cj/--coverage-changed-lines'."
(let* ((report-path (funcall (plist-get backend :report-path)))
- (covered (cj/--coverage-parse-simplecov report-path))
- (changed (if (eq scope 'whole-project)
- (cj/--coverage-simplecov-executable-lines report-path)
- (cj/--coverage-changed-lines scope)))
+ (root (cj/--coverage-project-root))
+ (covered (cj/--coverage-relativize-keys
+ (cj/--coverage-parse-simplecov report-path) root))
+ (changed (cj/--coverage-relativize-keys
+ (if (eq scope 'whole-project)
+ (cj/--coverage-simplecov-executable-lines report-path)
+ (cj/--coverage-changed-lines scope))
+ root))
(records (cj/--coverage-intersect covered changed)))
(cj/--coverage-render-to-buffer records scope)))
diff --git a/modules/custom-ordering.el b/modules/custom-ordering.el
index a2423742d..0a499a35a 100644
--- a/modules/custom-ordering.el
+++ b/modules/custom-ordering.el
@@ -49,10 +49,10 @@ buffer region and must reject an inverted one before reading it."
(defun cj/--ordering-replace-region (start end insertion)
"Replace the buffer text between START and END with INSERTION.
-Point is left after the inserted text. Shared tail for the interactive ordering commands,
-which all compute a transformed string from the original region then swap it
-in. INSERTION is evaluated by the caller before this runs, so the transform
-reads the pre-deletion text."
+Point is left after the inserted text. Shared tail for the interactive
+ordering commands, which all compute a transformed string from the
+original region then swap it in. INSERTION is evaluated by the caller
+before this runs, so the transform reads the pre-deletion text."
(delete-region start end)
(goto-char start)
(insert insertion))
diff --git a/modules/dashboard-config.el b/modules/dashboard-config.el
index 38510e801..17a0e2c4a 100644
--- a/modules/dashboard-config.el
+++ b/modules/dashboard-config.el
@@ -21,7 +21,58 @@
(eval-when-compile (require 'undead-buffers))
(declare-function cj/make-buffer-undead "undead-buffers" (string))
(autoload 'cj/make-buffer-undead "undead-buffers" nil t)
-(declare-function ghostel "ghostel" (&optional arg))
+(declare-function cj/term-toggle "eat-config")
+
+;; ------------------------------ Declarations -------------------------------
+;; These functions and variables belong to lazily-loaded packages or to other
+;; cj modules; declaring them keeps the byte-compiler quiet without forcing an
+;; eager require. Behavior is unchanged -- the symbols still resolve at runtime
+;; once their owning package/module loads.
+
+;; dashboard package internals used by the bookmark-insertion override.
+(declare-function dashboard-insert-section "dashboard")
+(declare-function dashboard-subseq "dashboard")
+(declare-function dashboard-get-shortcut "dashboard")
+(declare-function dashboard-shorten-path "dashboard")
+(declare-function dashboard--align-length-by-type "dashboard")
+(declare-function dashboard--generate-align-format "dashboard")
+(declare-function dashboard-refresh-buffer "dashboard")
+(declare-function dashboard-open "dashboard")
+(defvar dashboard-bookmarks-show-path)
+(defvar dashboard--bookmarks-cache-item-format)
+
+;; bookmark.el (required at runtime inside `dashboard-insert-bookmarks').
+(declare-function bookmark-all-names "bookmark")
+(declare-function bookmark-get-filename "bookmark")
+
+;; recentf.el (required at runtime inside the exclude helper).
+(defvar recentf-exclude)
+
+;; nerd-icons glyph functions used in the launcher table.
+(declare-function nerd-icons-faicon "nerd-icons")
+(declare-function nerd-icons-devicon "nerd-icons")
+(declare-function nerd-icons-mdicon "nerd-icons")
+(declare-function nerd-icons-codicon "nerd-icons")
+(declare-function nerd-icons-octicon "nerd-icons")
+
+;; user-constants.el provides the home-directory constant.
+(defvar user-home-dir)
+
+;; Launcher actions defined in other cj modules.
+(declare-function cj/main-agenda-display "org-agenda-config")
+(declare-function cj/elfeed-open "elfeed-config")
+(declare-function cj/drill-start "org-drill-config")
+(declare-function cj/music-playlist-toggle "music-config")
+(declare-function cj/music-playlist-load "music-config")
+(declare-function cj/erc-switch-to-buffer-with-completion "erc-config")
+(declare-function cj/telega "telega-config")
+(declare-function cj/slack-start "slack-config")
+(declare-function cj/signel-message "signal-config")
+(declare-function cj/kill-all-other-buffers-and-windows "undead-buffers")
+
+;; External package commands invoked by launchers.
+(declare-function mu4e "mu4e")
+(declare-function pearl-list-issues "pearl")
;; ------------------------ Dashboard Bookmarks Override -----------------------
;; overrides the bookmark insertion from the dashboard package to provide an
@@ -35,8 +86,11 @@
;; `el' is bound dynamically by dashboard's section-insertion machinery, which the
;; override below plugs into. Declare it so the byte-compiler reads the
-;; references as that special variable rather than a free variable.
-(defvar el)
+;; references as that special variable rather than a free variable. The name is
+;; dashboard's, not ours, so the missing-prefix lint is suppressed rather than
+;; renamed (renaming would break the dynamic binding dashboard supplies).
+(with-suppressed-warnings ((lexical el))
+ (defvar el))
(defun dashboard-insert-bookmarks (list-size)
"Add the list of LIST-SIZE items of bookmarks."
@@ -83,10 +137,10 @@ Adjust this if the title doesn't appear centered under the banner image.")
(list
(list "c" #'nerd-icons-faicon "nf-fa-code" "Code" "Switch Project" (lambda () (projectile-switch-project)))
(list "d" #'nerd-icons-faicon "nf-fa-folder_o" "Files" "Dirvish File Manager" (lambda () (dirvish user-home-dir)))
- (list "t" #'nerd-icons-devicon "nf-dev-terminal" "Terminal" "Launch Terminal" (lambda () (ghostel)))
+ (list "t" #'nerd-icons-devicon "nf-dev-terminal" "Terminal" "Launch Terminal" (lambda () (cj/term-toggle)))
(list "a" #'nerd-icons-mdicon "nf-md-calendar" "Agenda" "Main Org Agenda" (lambda () (cj/main-agenda-display)))
(list "r" #'nerd-icons-faicon "nf-fa-rss_square" "Feeds" "Elfeed Feed Reader" (lambda () (cj/elfeed-open)))
- (list "b" #'nerd-icons-faicon "nf-fae-book_open_o" "Books" "Calibre Ebook Reader" (lambda () (calibredb)))
+ (list "b" #'nerd-icons-codicon "nf-cod-library" "Books" "Calibre Ebook Reader" (lambda () (calibredb)))
(list "f" #'nerd-icons-mdicon "nf-md-school" "Flashcards" "Org-Drill" (lambda () (cj/drill-start)))
(list "m" #'nerd-icons-mdicon "nf-md-music" "Music" "EMMS Music Player" (lambda () (cj/music-playlist-toggle) (cj/music-playlist-load)))
(list "e" #'nerd-icons-faicon "nf-fa-envelope" "Email" "Mu4e Email Client" (lambda () (mu4e)))
@@ -219,6 +273,7 @@ system-defaults) are preserved rather than overwritten."
(setq dashboard-display-icons-p t) ;; display icons on both GUI and terminal
(setq dashboard-icon-type 'nerd-icons) ;; use `nerd-icons' package
(setq dashboard-set-file-icons t) ;; per-filetype icons on the list items (nerd-icons colors them by type)
+ (setq dashboard-set-heading-icons t) ;; nerd-icons on the section titles (Projects/Bookmarks/Recent)
(setq dashboard-center-content t) ;; horizontally center dashboard content
(setq dashboard-bookmarks-show-path nil) ;; don't show paths in bookmarks
(setq dashboard-recentf-show-base t) ;; show filename, not full path
diff --git a/modules/diff-config.el b/modules/diff-config.el
index 75869a73f..0c09b9516 100644
--- a/modules/diff-config.el
+++ b/modules/diff-config.el
@@ -28,6 +28,12 @@
;;; Code:
+(declare-function ediff-setup-keymap "ediff")
+(declare-function ediff-next-difference "ediff")
+(declare-function ediff-previous-difference "ediff")
+(declare-function cj/ediff-hook "diff-config")
+(declare-function winner-undo "winner")
+
(use-package ediff
:ensure nil ;; built-in
:defer t
diff --git a/modules/dirvish-config.el b/modules/dirvish-config.el
index c86f3d1bf..81d352dbd 100644
--- a/modules/dirvish-config.el
+++ b/modules/dirvish-config.el
@@ -41,6 +41,24 @@
(declare-function cj/drill-this-file "org-drill-config")
+;; Dirvish/Dired functions called from lazy-loaded packages.
+(declare-function dirvish-peek-mode "dirvish")
+(declare-function dirvish-side-follow-mode "dirvish")
+(declare-function dirvish-quit "dirvish")
+(declare-function dired-get-marked-files "dired")
+(declare-function dired-dwim-target-directory "dired-aux")
+(declare-function dired-get-file-for-visit "dired")
+(declare-function dired-get-filename "dired")
+(declare-function dired-mark "dired")
+(declare-function dired-current-directory "dired")
+(declare-function dired-file-name-at-point "dired-x")
+(declare-function dired-find-file "dired")
+(declare-function project-roots "project")
+
+;; External package variables referenced before their package loads.
+(defvar ediff-after-quit-hook-internal)
+(defvar dirvish-side-attributes)
+
;; mark files in dirvish, attach in mu4e
(add-hook 'dired-mode-hook 'turn-on-gnus-dired-mode)
@@ -349,7 +367,8 @@ Shadows dired's `P' (`dired-do-print') with this type-aware version."
(defun cj/dirvish-drill-file ()
"Open the Org file at point and start an `org-drill' session on it.
-Bound to `S' (\"study\") in `dirvish-mode-map'; refuses anything but a `.org' file."
+Bound to `S' (\"study\") in `dirvish-mode-map'; refuses anything but
+a `.org' file."
(interactive)
(let ((file (dired-get-filename nil t)))
(unless (and file (not (file-directory-p file)) (string-suffix-p ".org" file t))
@@ -381,18 +400,19 @@ regardless of what file or subdirectory the point is on."
"Return the (PROGRAM PRE-FILE-ARG...) list for setting wallpaper under ENV.
ENV is a display-server symbol: `x11' picks feh with --bg-fill, `wayland'
-picks swww with the img subcommand. Any other value returns nil so the
-caller can surface an \"unknown display server\" error.
+picks the `set-wallpaper' script (on PATH from dotfiles; it wraps the awww
+backend and persists the choice to waypaper's config). Any other value
+returns nil so the caller can surface an \"unknown display server\" error.
Pure helper used by `cj/set-wallpaper'."
(pcase env
('x11 '("feh" "--bg-fill"))
- ('wayland '("swww" "img"))
+ ('wayland '("set-wallpaper"))
(_ nil)))
(defun cj/set-wallpaper ()
"Set the image at point as the desktop wallpaper.
-Uses feh on X11, swww on Wayland."
+Uses feh on X11, the `set-wallpaper' script on Wayland."
(interactive)
(let* ((raw (dired-file-name-at-point))
(file (and raw (expand-file-name raw)))
@@ -411,6 +431,117 @@ Uses feh on X11, swww on Wayland."
(message "Wallpaper set: %s (%s)"
(file-name-nondirectory file) (car cmd))))))
+;;; ------------------------- Dirvish Hyprland Popup ----------------------------
+
+;; The Hyprland Super+F popup opens an emacsclient frame named "dirvish" (window
+;; rules float/size/center it by that name) and runs `cj/dirvish-popup', rooted
+;; at home. `q' in that frame runs `cj/dirvish-popup-quit', which quits Dirvish
+;; and deletes the popup frame so a stray launch never orphans it; `q' in any
+;; other frame quits Dirvish normally. The launcher script calls this command
+;; instead of plain `dirvish'. This mirrors the Super+Shift+N quick-capture
+;; popup (see `cj/quick-capture' in org-capture-config.el).
+
+(defun cj/--dirvish-popup-frame ()
+ "Return a live frame named \"dirvish\" (the Hyprland popup), or nil."
+ (seq-find (lambda (f)
+ (and (frame-live-p f)
+ (equal (frame-parameter f 'name) "dirvish")))
+ (frame-list)))
+
+(defun cj/dirvish-popup ()
+ "Open Dirvish in the Hyprland popup frame (frame \"dirvish\"), rooted at home.
+The launcher script calls this through =emacsclient -c -e=. `q'
+(`cj/dirvish-popup-quit') closes the frame.
+
+Selects the \"dirvish\" frame by name before opening rather than trusting the
+ambient selected frame: the launching =emacsclient -c -e= runs before Hyprland
+settles focus on the new float, so =(selected-frame)= is still the daemon's main
+frame and Dirvish would otherwise open there."
+ (interactive)
+ (let ((frame (cj/--dirvish-popup-frame)))
+ (when frame (select-frame-set-input-focus frame))
+ (dirvish (expand-file-name "~/"))))
+
+(defun cj/dirvish-popup-focus-existing ()
+ "Raise and focus the live dirvish popup frame, returning t; nil if none.
+The launcher script calls this before creating a frame, so a second Super+F
+re-uses the open popup instead of spawning a second one (the popup is a
+single-instance, transient launcher -- use =C-x d= for several independent
+Dirvish sessions)."
+ (let ((popup (cj/--dirvish-popup-frame)))
+ (when popup
+ (select-frame-set-input-focus popup)
+ t)))
+
+(defun cj/dirvish-popup-quit ()
+ "Quit Dirvish. In the Hyprland popup frame (\"dirvish\"), delete the frame too.
+Bound to `q' in `dirvish-mode-map'. A normal Dirvish session (any other frame)
+quits as usual; only the popup frame is torn down, so the Super+F launch never
+leaves an empty frame behind."
+ (interactive)
+ (let ((popup (cj/--dirvish-popup-frame)))
+ (if (and popup (eq popup (selected-frame)))
+ (progn
+ (ignore-errors (dirvish-quit))
+ (when (frame-live-p popup) (delete-frame popup)))
+ (dirvish-quit))))
+
+(defun cj/--dirvish-popup-reap-on-delete (frame)
+ "Quit the Dirvish session when the Super+F popup FRAME is closed any way.
+`q' runs `cj/dirvish-popup-quit', but closing the Hyprland float directly (or
+letting it lose focus) bypasses that and orphans the session's dired buffers --
+the \"leaves a load of buffers around\" symptom. As a `delete-frame-functions'
+hook this fires on every close path; `dirvish-quit' reaps the session's buffers
+(verified: a navigated session drops back to baseline on quit). Scoped to the
+popup frame so ordinary `C-x d' sessions -- where multiple dired buffers are
+wanted for mark-and-move -- are untouched."
+ (when (and (frame-live-p frame)
+ (equal (frame-parameter frame 'name) "dirvish"))
+ (with-selected-frame frame
+ (ignore-errors (dirvish-quit)))))
+
+(add-hook 'delete-frame-functions #'cj/--dirvish-popup-reap-on-delete)
+
+(defun cj/--dirvish-popup-selected-p ()
+ "Return non-nil when the selected frame is the dirvish popup frame."
+ (let ((popup (cj/--dirvish-popup-frame)))
+ (and popup (eq popup (selected-frame)))))
+
+(defun cj/dirvish-popup-find-file ()
+ "Open the file at point.
+In the Hyprland popup frame the popup is a context-free launcher: files open
+through the OS handler (`cj/xdg-open' -> xdg-open), so nothing lands inside the
+throwaway frame and the launch is independent of the running Emacs session (a
+text/code file opens its own new emacsclient frame, not your working session --
+use =C-x d= when you want a file in the session you're in). Directories are
+entered normally so you can keep browsing. The popup then dismisses itself on
+focus loss. Outside the popup this is exactly `dired-find-file'."
+ (interactive)
+ (if (cj/--dirvish-popup-selected-p)
+ (let ((file (dired-get-file-for-visit)))
+ (if (file-directory-p file)
+ (dired-find-file)
+ (cj/xdg-open file)))
+ (dired-find-file)))
+
+(defun cj/--dirvish-popup-focus-watch (&rest _)
+ "Dismiss the dirvish popup frame once it loses focus.
+Armed only after the popup has actually held focus (a per-frame flag), so the
+frame is never torn down during its own creation, before Hyprland settles focus
+on the new float. Installed on `after-focus-change-function'; a no-op whenever
+no popup frame is live."
+ (let ((popup (cj/--dirvish-popup-frame)))
+ (when popup
+ (if (frame-focus-state popup)
+ (set-frame-parameter popup 'cj-dirvish-popup-had-focus t)
+ (when (frame-parameter popup 'cj-dirvish-popup-had-focus)
+ (delete-frame popup))))))
+
+;; Install idempotently: remove any prior copy before adding, so re-loading the
+;; module updates the watch rather than stacking duplicate copies.
+(remove-function after-focus-change-function #'cj/--dirvish-popup-focus-watch)
+(add-function :after after-focus-change-function #'cj/--dirvish-popup-focus-watch)
+
;;; ---------------------------------- Dirvish ----------------------------------
(use-package dirvish
@@ -515,7 +646,8 @@ Uses feh on X11, swww on Wayland."
("bg" . cj/set-wallpaper)
("/" . dirvish-narrow)
("<left>" . dired-up-directory)
- ("<right>" . dired-find-file)
+ ("RET" . cj/dirvish-popup-find-file) ; popup: launch file externally; else normal
+ ("<right>" . cj/dirvish-popup-find-file)
("C-," . dirvish-history-go-backward)
("C-." . dirvish-history-go-forward)
("F" . dirvish-file-info-menu)
@@ -537,6 +669,7 @@ Uses feh on X11, swww on Wayland."
("O" . cj/open-file-with-command) ; Prompts for command to run
("p" . (lambda () (interactive) (cj/dired-copy-path-as-kill nil t)))
("P" . cj/dirvish-print-file)
+ ("q" . cj/dirvish-popup-quit) ; quit; in the Hyprland popup frame, close it
("r" . dirvish-rsync)
("S" . cj/dirvish-drill-file) ; Study: org-drill the .org file at point
("s" . dirvish-quicksort)
diff --git a/modules/dwim-shell-config.el b/modules/dwim-shell-config.el
index 230a8532c..014194c7b 100644
--- a/modules/dwim-shell-config.el
+++ b/modules/dwim-shell-config.el
@@ -100,6 +100,16 @@
(require 'cl-lib)
(require 'system-lib) ;; cj/confirm-strong (permanent file destruction confirm)
+;; Function declarations (lazily-loaded packages and sibling modules).
+(declare-function dwim-shell-command-on-marked-files "dwim-shell-command")
+(declare-function dwim-shell-command-read-file-name "dwim-shell-command")
+(declare-function dwim-shell-command--files "dwim-shell-command")
+(declare-function cj/xdg-open "external-open")
+(declare-function dwim-shell-commands-menu "dwim-shell-config")
+
+;; Forward declaration: external variable provided by the dirvish package.
+(defvar dirvish-mode-map)
+
;; --------------------------- Password-file helpers ---------------------------
(defun cj/dwim-shell--password-cleanup-callback (temp-file)
diff --git a/modules/eat-config.el b/modules/eat-config.el
new file mode 100644
index 000000000..de919a00a
--- /dev/null
+++ b/modules/eat-config.el
@@ -0,0 +1,443 @@
+;;; eat-config.el --- EAT terminal emulator and the F12 eshell toggle -*- lexical-binding: t; coding: utf-8; -*-
+
+;;; Commentary:
+;;
+;; EAT (Emulate A Terminal, pure elisp) is the terminal emulator. Because EAT
+;; renders entirely in elisp, its whole palette is real Emacs faces, so it themes
+;; from the theme. This module owns the eat package configuration, the keymap
+;; wiring that lets F12 and C-; reach Emacs from inside a terminal, and the F12
+;; dock-and-remember toggle.
+;;
+;; F12 opens eshell, which runs through EAT (eat-eshell-mode, set up in
+;; eshell-config.el): the shell is eshell -- elisp functions as commands, TRAMP
+;; transparency -- and EAT renders its visual commands. eshell-config.el holds
+;; the shell itself; this module holds the emulator and the toggle.
+;;
+;; The toggle reuses the geometry-preservation pattern from cj-window-toggle-lib:
+;; capture direction + body size at toggle-off, replay them via a custom display
+;; action using frame-edge directions and body-relative sizes, so the docked
+;; terminal returns at the same size and the result is divider-independent.
+
+;;; Code:
+
+(require 'keybindings)
+(require 'cj-window-geometry-lib)
+(require 'cj-window-toggle-lib)
+
+(declare-function eat "eat" (&optional program arg))
+(declare-function eshell "eshell" (&optional arg))
+(defvar eat-mode-map)
+(defvar eat-semi-char-mode-map)
+(defvar eshell-buffer-name)
+(defvar cj/custom-keymap)
+
+(defun cj/turn-off-chrome-for-term ()
+ "Turn off line numbers and hl-line in a terminal buffer."
+ (hl-line-mode -1)
+ (display-line-numbers-mode -1))
+
+;; ------------------------------- eat package ---------------------------------
+
+(use-package eat
+ :ensure t
+ :commands (eat)
+ :hook (eat-mode . cj/turn-off-chrome-for-term)
+ :custom
+ ;; Close the EAT buffer when its shell exits.
+ (eat-kill-buffer-on-exit t)
+ ;; Shell-integration UX. These are EAT defaults, set explicitly to document
+ ;; intent and survive default changes. They only light up once the shell
+ ;; sources EAT's integration script -- see the EAT block in the zsh rc.
+ (eat-enable-directory-tracking t) ; Emacs follows the terminal's cwd
+ (eat-enable-shell-prompt-annotation t) ; the success/running/failure prompt glyphs
+ (eat-enable-shell-command-history t) ; terminal history into EAT line-mode isearch
+ ;; Interaction.
+ (eat-enable-mouse t) ; mouse clicks + selection in TUIs (default)
+ (eat-enable-kill-from-terminal t) ; terminal selection -> Emacs kill-ring (default)
+ (eat-enable-yank-to-terminal t) ; Emacs kill-ring -> the terminal (off by default)
+ ;; Fidelity.
+ (eat-enable-alternative-display t) ; alt-screen so TUIs restore scrollback on exit (default)
+ (eat-term-scrollback-size (* 10 1024 1024)) ; ~10MB of scrollback, matching the old ghostel
+ ;; Truecolor is already on: eat-term-name auto-selects the compiled eat-truecolor terminfo.
+ ;; Niceties.
+ (eat-sixel-render-formats '(xpm svg half-block background none)) ; inline images (on by default)
+ (eat-query-before-killing-running-terminal 'auto) ; confirm before killing a terminal with a live process
+ :config
+ ;; F12 and C-; must reach Emacs from inside EAT. In semi-char mode (EAT's
+ ;; default) EAT forwards unbound keys to the terminal -- a letter runs
+ ;; `eat-self-input' -- so bind these explicitly or they never reach Emacs:
+ ;; F12 toggles the terminal window, C-; opens the global prefix map.
+ (keymap-set eat-semi-char-mode-map "<f12>" #'cj/term-toggle)
+ (keymap-set eat-semi-char-mode-map "C-;" cj/custom-keymap)
+ (keymap-set eat-mode-map "<f12>" #'cj/term-toggle)
+ (keymap-set eat-mode-map "C-;" cj/custom-keymap))
+
+;; ----------------------- F12 toggle (custom) -----------------------
+;;
+;; Mirrors the geometry-preservation pattern shared with ai-term.el: capture
+;; direction + body size at toggle-off, replay them via a custom display action
+;; using frame-edge directions and body-relative sizes so the result is
+;; divider-independent and layout-stable. Manages the EAT terminal only;
+;; ai-term.el's agent buffers are separate (M-SPC).
+
+(defcustom cj/term-toggle-window-height 0.7
+ "Default fraction of frame height for the F12 terminal window.
+Used as the size fallback when F12 docks the terminal as a bottom split."
+ :type 'number
+ :group 'term)
+
+(defcustom cj/term-toggle-window-width 0.5
+ "Default fraction of frame width for the F12 terminal window.
+Used as the size fallback when F12 docks the terminal as a right-side
+column (see `cj/--term-toggle-default-direction')."
+ :type 'number
+ :group 'term)
+
+(defun cj/--term-toggle-default-direction ()
+ "Return the default dock direction for the F12 terminal: `right' or `below'.
+Docks as a right-side column only when a side-by-side split would leave
+both panes at least `cj/window-dock-min-columns' wide (the terminal's
+share is `cj/term-toggle-window-width'); otherwise stacks below. See
+`cj/preferred-dock-direction'."
+ (cj/preferred-dock-direction (frame-width) cj/term-toggle-window-width))
+
+(defun cj/--term-toggle-default-size (direction)
+ "Return the default size fraction paired with DIRECTION for the F12 terminal.
+`cj/term-toggle-window-width' for `right', `cj/term-toggle-window-height'
+otherwise."
+ (if (eq direction 'right)
+ cj/term-toggle-window-width
+ cj/term-toggle-window-height))
+
+(defvar cj/--term-toggle-last-direction nil
+ "Last user-chosen direction for the F12 terminal display.
+Symbol: right, left, or below. `above' is never stored. nil means use the
+default `below' for F12's traditional bottom split.")
+
+(defvar cj/--term-toggle-last-size nil
+ "Last user-chosen size for the F12 terminal display.
+Positive integer: body-cols (right/left) or total-lines (below/above) -- see
+`cj/window-replay-size' for why the vertical axis uses total, not body.
+nil means fall back to `cj/term-toggle-window-height' as a fraction.")
+
+(defun cj/--term-toggle-buffer-p (buffer)
+ "Return non-nil when BUFFER is an eshell terminal F12 should manage.
+
+F12 opens eshell, which runs through EAT via eat-eshell-mode. ai-term's
+agent buffers are managed separately via M-SPC, not F12."
+ (and (bufferp buffer)
+ (buffer-live-p buffer)
+ (with-current-buffer buffer
+ (derived-mode-p 'eshell-mode))))
+
+(defun cj/--term-toggle-buffers ()
+ "Return live F12-managed terminal buffers in `buffer-list' (MRU) order."
+ (seq-filter #'cj/--term-toggle-buffer-p (buffer-list)))
+
+(defun cj/--term-toggle-displayed-window (&optional frame)
+ "Return a window in FRAME currently displaying an F12 terminal buffer, or nil.
+FRAME defaults to the selected frame. Minibuffer is excluded."
+ (seq-find (lambda (w)
+ (cj/--term-toggle-buffer-p (window-buffer w)))
+ (window-list (or frame (selected-frame)) 'never)))
+
+(defun cj/--term-toggle-capture-state (window)
+ "Capture WINDOW's direction + body size into module-level state.
+The default direction (used when WINDOW fills its frame) is the
+column-rule choice from `cj/--term-toggle-default-direction'."
+ (cj/window-toggle-capture-state
+ window (cj/--term-toggle-default-direction)
+ 'cj/--term-toggle-last-direction
+ 'cj/--term-toggle-last-size
+ '(right below left)))
+
+(defun cj/--term-toggle-display-saved (buffer alist)
+ "Display-buffer action: split per saved direction and body size.
+Delegates to `cj/window-toggle-display-saved' against the F12 state vars,
+falling back to the column-rule default direction
+\(`cj/--term-toggle-default-direction') and its paired size."
+ (let ((dir (cj/--term-toggle-default-direction)))
+ (cj/window-toggle-display-saved
+ buffer alist
+ 'cj/--term-toggle-last-direction dir
+ 'cj/--term-toggle-last-size (cj/--term-toggle-default-size dir))))
+
+(defun cj/--term-toggle-display-rule-list ()
+ "Return the `display-buffer-alist' entry list installed by F12.
+Routes any terminal buffer satisfying `cj/--term-toggle-buffer-p' through
+reuse-window then the saved-geometry action. Excludes agent buffers."
+ '(((lambda (buffer-or-name _)
+ (cj/--term-toggle-buffer-p (get-buffer buffer-or-name)))
+ (display-buffer-reuse-window
+ cj/--term-toggle-display-saved)
+ (inhibit-same-window . t))))
+
+(dolist (entry (cj/--term-toggle-display-rule-list))
+ (add-to-list 'display-buffer-alist entry))
+
+(defun cj/--term-toggle-dispatch ()
+ "Compute the F12 (`cj/term-toggle') action without performing it.
+
+Returns one of:
+- (toggle-off . WINDOW) -- terminal displayed in WINDOW; hide it.
+- (show-recent . BUFFER) -- terminal alive but not shown; redisplay.
+- (create-new) -- no terminal buffer alive; create one."
+ (let ((win (cj/--term-toggle-displayed-window)))
+ (cond
+ (win (cons 'toggle-off win))
+ (t
+ (let ((buffers (cj/--term-toggle-buffers)))
+ (cond
+ (buffers (cons 'show-recent (car buffers)))
+ (t '(create-new))))))))
+
+(defun cj/term-toggle ()
+ "Toggle the F12 eshell terminal (the primary `*eshell*', run through EAT).
+
+- If it is displayed in this frame, capture its geometry and delete its window
+ (toggle off). Falls back to burying when it is the only window in the frame.
+- Otherwise, if it is alive, display it via the saved-geometry action.
+- Otherwise, open eshell, displaying it through the same saved-geometry action.
+
+eshell runs through EAT via eat-eshell-mode, so visual commands render in a real
+terminal. ai-term's agent buffers are managed separately via M-SPC."
+ (interactive)
+ (pcase (cj/--term-toggle-dispatch)
+ (`(toggle-off . ,win)
+ (cj/--term-toggle-capture-state win)
+ (if (one-window-p)
+ (bury-buffer (window-buffer win))
+ (delete-window win))
+ nil)
+ (`(show-recent . ,buf)
+ (display-buffer buf)
+ (let ((w (get-buffer-window buf)))
+ (when w (select-window w)))
+ buf)
+ (`(create-new)
+ ;; Open the primary eshell without stealing the layout, then display it
+ ;; through the saved-geometry dock rule (same path as show-recent).
+ (save-window-excursion (eshell))
+ (let ((buf (get-buffer (or (bound-and-true-p eshell-buffer-name) "*eshell*"))))
+ (when buf
+ (display-buffer buf)
+ (let ((w (get-buffer-window buf)))
+ (when w (select-window w))))
+ buf))))
+
+(keymap-global-set "<f12>" #'cj/term-toggle)
+
+;; ------------------- terminal copy mode + tmux history -----------------------
+;; Carried over from the ghostel era for the EAT agent terminals (ai-term).
+;; Agents run EAT over tmux, so copy-mode is tmux's own copy-mode -- the same UX
+;; ghostel-over-tmux had. C-<up> enters it and scrolls up in one stroke; C-; x c
+;; enters it via the menu, and C-; x h grabs the whole pane history into a buffer.
+
+(declare-function cj/register-prefix-map "keybindings")
+(declare-function eat-emacs-mode "eat")
+(defvar eat--semi-char-mode)
+(defvar eat--char-mode)
+(defvar eat--line-mode)
+
+(defun cj/--term-send-string (string)
+ "Send STRING to the current terminal buffer's process (the pty)."
+ (let ((proc (get-buffer-process (current-buffer))))
+ (when (process-live-p proc)
+ (process-send-string proc string))))
+
+(defun cj/term-send-escape ()
+ "Send ESC to the terminal.
+In tmux copy-mode this cancels it (tmux binds Escape to cancel); in a TUI like
+vim it forwards ESC normally. EAT's semi-char mode leaves the bare escape key
+unbound and treats `ESC' only as the Meta prefix, so without this the key never
+reaches the pty -- which is why C-<up>'s tmux copy-mode could not be exited with
+Escape."
+ (interactive)
+ (cj/--term-send-string "\e"))
+
+(defun cj/term--tmux-output (&rest args)
+ "Run tmux with ARGS and return its stdout.
+Signal `user-error' when tmux exits with a non-zero status."
+ (with-temp-buffer
+ (let ((exit-code (apply #'process-file "tmux" nil t nil args)))
+ (unless (zerop exit-code)
+ (user-error "tmux failed: %s" (string-trim (buffer-string))))
+ (buffer-string))))
+
+(defun cj/term--tmux-pane-id-for-tty (tty)
+ "Return the tmux pane id for client TTY."
+ (let* ((output (cj/term--tmux-output
+ "list-clients" "-F" "#{client_tty}\t#{pane_id}"))
+ (lines (split-string output "\n" t))
+ (match (seq-find
+ (lambda (line)
+ (let ((fields (split-string line "\t")))
+ (equal (car fields) tty)))
+ lines)))
+ (unless match
+ (user-error "No tmux client found for terminal tty %s" tty))
+ (cadr (split-string match "\t"))))
+
+(defun cj/term--tmux-capture-pane (pane-id)
+ "Return full joined tmux history for PANE-ID."
+ (cj/term--tmux-output
+ "capture-pane" "-p" "-J" "-S" "-" "-E" "-" "-t" pane-id))
+
+(defun cj/term--current-tmux-pane-id ()
+ "Return the tmux pane id for the current EAT terminal buffer."
+ (unless (derived-mode-p 'eat-mode)
+ (user-error "Current buffer is not an EAT terminal"))
+ (let* ((proc (get-buffer-process (current-buffer)))
+ (tty (and proc (process-tty-name proc))))
+ (unless (and tty (not (string-empty-p tty)))
+ (user-error "Could not determine terminal tty"))
+ (cj/term--tmux-pane-id-for-tty tty)))
+
+(defvar-local cj/term-tmux-history--origin-buffer nil
+ "Buffer active before opening the tmux history buffer.")
+(defvar-local cj/term-tmux-history--origin-window nil
+ "Window active before opening the tmux history buffer.")
+(defvar-local cj/term-tmux-history--origin-point nil
+ "Point in the origin buffer before opening the tmux history buffer.")
+
+(defun cj/term-tmux-history-quit ()
+ "Quit tmux history and return to its origin buffer."
+ (interactive)
+ (let ((history-buffer (current-buffer))
+ (origin-buffer cj/term-tmux-history--origin-buffer)
+ (origin-window cj/term-tmux-history--origin-window)
+ (origin-point cj/term-tmux-history--origin-point))
+ (when (buffer-live-p origin-buffer)
+ (if (window-live-p origin-window)
+ (progn
+ (set-window-buffer origin-window origin-buffer)
+ (select-window origin-window))
+ (pop-to-buffer origin-buffer))
+ (with-current-buffer origin-buffer
+ (when (integer-or-marker-p origin-point)
+ (goto-char origin-point))))
+ (when (buffer-live-p history-buffer)
+ (kill-buffer history-buffer))))
+
+(defvar-keymap cj/term-tmux-history-mode-map
+ :doc "Keymap for `cj/term-tmux-history-mode'.
+M-w copies the active region without leaving the buffer; C-g, <escape>, or q
+returns to the terminal without copying. RET is left unbound."
+ "M-w" #'kill-ring-save
+ "C-g" #'cj/term-tmux-history-quit
+ "<escape>" #'cj/term-tmux-history-quit
+ "q" #'cj/term-tmux-history-quit)
+
+(define-derived-mode cj/term-tmux-history-mode special-mode "Tmux History"
+ "Mode for copying captured tmux pane history with normal Emacs keys."
+ (setq-local truncate-lines t)
+ (goto-address-mode 1))
+
+(defun cj/term-tmux-history ()
+ "Open full tmux pane history in a temporary Emacs buffer.
+
+The history buffer uses normal Emacs navigation and selection. `M-w' copies
+the active region and stays open, so several pieces can be copied in a row;
+`q', `<escape>', or `C-g' returns point to the terminal buffer that launched
+it. The history view replaces the origin terminal buffer in the same window."
+ (interactive)
+ (let* ((origin-buffer (current-buffer))
+ (origin-window (selected-window))
+ (origin-point (point))
+ (pane-id (cj/term--current-tmux-pane-id))
+ (history (cj/term--tmux-capture-pane pane-id))
+ (buffer (get-buffer-create
+ (format "*terminal tmux history: %s*" (buffer-name origin-buffer)))))
+ (with-current-buffer buffer
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (insert history))
+ (cj/term-tmux-history-mode)
+ (setq-local cj/term-tmux-history--origin-buffer origin-buffer)
+ (setq-local cj/term-tmux-history--origin-window origin-window)
+ (setq-local cj/term-tmux-history--origin-point origin-point)
+ (goto-char (point-max)))
+ (switch-to-buffer buffer)))
+
+(defun cj/term--in-tmux-p ()
+ "Return non-nil when the current EAT buffer has a tmux client attached.
+Lookup errors (not eat-mode, no tty, no client, tmux absent) are treated as
+nil so callers can use this as a cheap boolean predicate."
+ (and (derived-mode-p 'eat-mode)
+ (condition-case _
+ (and (cj/term--current-tmux-pane-id) t)
+ (error nil))))
+
+(defun cj/--term-in-emacs-mode-p ()
+ "Return non-nil when the current EAT buffer is in emacs (navigation) mode.
+EAT has no dedicated emacs-mode flag; emacs mode is the absence of the
+semi-char, char, and line input modes."
+ (and (derived-mode-p 'eat-mode)
+ (not (or (bound-and-true-p eat--semi-char-mode)
+ (bound-and-true-p eat--char-mode)
+ (bound-and-true-p eat--line-mode)))))
+
+(defun cj/term-copy-mode-dwim ()
+ "Enter copy-mode using the engine appropriate to this terminal.
+
+When tmux is attached (an agent terminal), write tmux's prefix sequence (C-b [)
+into the pty so the user lands in tmux's copy-mode with the full pane history,
+then C-a to land the cursor at column 0 so scrolling up runs up the left edge.
+Without tmux, falls through to EAT's emacs mode (a navigable view of the
+scrollback) and moves point to the start of the line."
+ (interactive)
+ (if (cj/term--in-tmux-p)
+ (cj/--term-send-string "\C-b[\C-a")
+ (eat-emacs-mode)
+ (beginning-of-line)))
+
+(defun cj/term--tmux-pane-in-copy-mode-p (pane-id)
+ "Return non-nil when tmux PANE-ID is currently displaying a mode.
+tmux's `pane_in_mode' is 1 while a pane is in any mode; copy-mode is the only
+mode this config enters. tmux failures are treated as nil."
+ (condition-case nil
+ (equal "1" (string-trim
+ (cj/term--tmux-output
+ "display-message" "-p" "-t" pane-id "#{pane_in_mode}")))
+ (error nil)))
+
+(defun cj/term-copy-mode-up ()
+ "Enter copy-mode if needed, then scroll up one line.
+A single C-<up> lands in the terminal's copy-mode already moving up. Pressed
+again while already in copy-mode it just moves up another line, so it never
+re-enters and resets the cursor. In tmux, writes the up-arrow escape into the
+pty; without tmux, moves point up in EAT's emacs-mode buffer."
+ (interactive)
+ (let ((pane (ignore-errors (cj/term--current-tmux-pane-id))))
+ (cond
+ (pane
+ (unless (cj/term--tmux-pane-in-copy-mode-p pane)
+ (cj/term-copy-mode-dwim))
+ (cj/--term-send-string "\e[A"))
+ (t
+ (unless (cj/--term-in-emacs-mode-p)
+ (cj/term-copy-mode-dwim))
+ (forward-line -1)))))
+
+;; The C-; x terminal prefix (copy-mode, tmux history, the F12 toggle). C-<up>
+;; enters copy-mode + scrolls in one stroke; bound in EAT's semi-char map so it
+;; reaches Emacs from inside an agent terminal.
+(defvar-keymap cj/term-map
+ :doc "Personal terminal command map.")
+(cj/register-prefix-map "x" cj/term-map)
+(keymap-set cj/term-map "c" #'cj/term-copy-mode-dwim)
+(keymap-set cj/term-map "h" #'cj/term-tmux-history)
+(keymap-set cj/term-map "t" #'cj/term-toggle)
+
+(defvar eat-mode-map)
+(declare-function eat-semi-char-mode "eat")
+(with-eval-after-load 'eat
+ (keymap-set eat-semi-char-mode-map "C-<up>" #'cj/term-copy-mode-up)
+ ;; Escape forwards ESC to the pty, so it cancels tmux copy-mode (tmux binds
+ ;; Escape to cancel) and works in TUIs; in EAT's own emacs/char mode it returns
+ ;; to semi-char. One key gets out of either copy view.
+ (keymap-set eat-semi-char-mode-map "<escape>" #'cj/term-send-escape)
+ (keymap-set eat-mode-map "<escape>" #'eat-semi-char-mode))
+
+(provide 'eat-config)
+;;; eat-config.el ends here
diff --git a/modules/elfeed-config.el b/modules/elfeed-config.el
index 7712f48db..eb2659ab5 100644
--- a/modules/elfeed-config.el
+++ b/modules/elfeed-config.el
@@ -29,21 +29,26 @@
(require 'system-lib)
(require 'media-utils)
+(declare-function elfeed "elfeed")
+(declare-function elfeed-update "elfeed")
+(declare-function elfeed-entry-link "elfeed")
+(declare-function elfeed-untag "elfeed")
+(declare-function elfeed-search-selected "elfeed")
+(declare-function elfeed-search-tag-all "elfeed")
+(declare-function elfeed-search-update-entry "elfeed")
+(declare-function elfeed-search-update--force "elfeed")
+(declare-function elfeed-search-untag-all-unread "elfeed")
+(declare-function eww-browse-url "eww")
+(declare-function eww-readable "eww")
+
;; ------------------------------- Elfeed Config -------------------------------
(use-package elfeed
:bind
- ("M-S-r" . cj/elfeed-open) ;; was M-R
(:map elfeed-show-mode-map
("w" . eww-open-in-new-buffer))
(:map elfeed-search-mode-map
- ("w" . cj/elfeed-eww-open) ;; opens in eww
- ("b" . cj/elfeed-browser-open) ;; opens in external browser
- ("d" . cj/elfeed-youtube-dl) ;; async download with yt-dlp and tsp
- ("v" . cj/play-with-video-player)) ;; async play with mpv
- ("V" . cj/select-media-player) ;; Capital V to select player
- ("R" . cj/elfeed-mark-all-as-read) ;; capital marks all as read, since upper case marks one as read
- ("U" . cj/elfeed-mark-all-as-unread) ;; capital marks all as unread, since lower case marks one as unread
+ ("V" . cj/select-media-player)) ;; Capital V to select player
:config
(setq elfeed-db-directory (concat user-emacs-directory ".elfeed-db"))
(setq-default elfeed-search-title-max-width 150)
@@ -60,11 +65,26 @@
;; Pivot with Kara Swisher and Scott Galloway
("https://www.youtube.com/feeds/videos.xml?channel_id=UCBHGZpDF2fsqPIPi0pNyuTg" yt pivot)
+ ;; Platypus Economics with Justin Wolfers
+ ("https://www.youtube.com/feeds/videos.xml?channel_id=UCB5eaPWEwR6wR2MxRx64s0g" yt platypus)
+
+ ;; Conversations with Tyler (Tyler Cowen)
+ ("https://www.youtube.com/feeds/videos.xml?channel_id=UC_AnpBvnhXTcipgGEHLWoOg" yt cwt)
+
+ ;; Plain English with Derek Thompson
+ ("https://www.youtube.com/feeds/videos.xml?channel_id=UCoOUW7SiXzLbc_O3nSDOBYA" yt plain-english)
+
+ ;; Odd Lots (Bloomberg) -- Joe Weisenthal & Tracy Alloway
+ ("https://www.youtube.com/feeds/videos.xml?playlist_id=PLe4PRejZgr0MuA6M0zkZyy-99-qc87wKV" yt oddlots)
+
+ ;; All-In Podcast
+ ("https://www.youtube.com/feeds/videos.xml?channel_id=UCESLZhusAkFfsNsApnjF_Cg" yt allin)
+
;; The Prof G Pod
("https://www.youtube.com/feeds/videos.xml?playlist_id=PLtQ-jBytlXCasRuBG86m22rOQfrEPcctq" yt profg)
;; On with Kara Swisher
- ("https://www.youtube.com/feeds/videos.xml?playlist_id=PLKof9YSAshgxI6odrEJFKsJbxamwoQBju" yt)
+ ("https://www.youtube.com/feeds/videos.xml?playlist_id=PLKof9YSAshgxI6odrEJFKsJbxamwoQBju" yt on)
;; Raging Moderates
("https://www.youtube.com/feeds/videos.xml?channel_id=UCcvDWzvxz6Kn1iPQHMl2teA" yt raging-moderates)
@@ -76,7 +96,7 @@
("https://www.youtube.com/feeds/videos.xml?playlist_id=PL45Mc1cDgnsB-u1iLPBYNF1fk-y1cVzTJ" yt trae)
;; Tropical Tidbits
- ("https://www.youtube.com/feeds/videos.xml?channel_id=UCrFIk7g_riIm2G2Vi90pxDA" yt)
+ ("https://www.youtube.com/feeds/videos.xml?channel_id=UCrFIk7g_riIm2G2Vi90pxDA" yt tropical)
;; If You're Listening | ABC News In-depth
("https://www.youtube.com/feeds/videos.xml?playlist_id=PLDTPrMoGHssAfgMMS3L5LpLNFMNp1U_Nq" yt listening)
@@ -90,19 +110,22 @@
(elfeed)
(elfeed-update)
(elfeed-search-update--force))
+(keymap-global-set "M-S-r" #'cj/elfeed-open) ;; was M-R
;; -------------------------- Elfeed Filter Functions --------------------------
(defun cj/elfeed-mark-all-as-read ()
"Remove the \='unread\=' tag from all visible entries in search buffer."
(interactive)
- (mark-whole-buffer)
+ (goto-char (point-min))
+ (push-mark (point-max) nil t)
(elfeed-search-untag-all-unread))
(defun cj/elfeed-mark-all-as-unread ()
"Add the \='unread\=' tag from all visible entries in the search buffer."
(interactive)
- (mark-whole-buffer)
+ (goto-char (point-min))
+ (push-mark (point-max) nil t)
(elfeed-search-tag-all 'unread))
(defun cj/elfeed-set-filter-and-update (filterstring)
@@ -302,5 +325,18 @@ TYPE should be either \='channel or \='playlist."
(insert result))
result))
+;; --------------------------- Search-Mode Keybindings -------------------------
+;; Bound here (not in use-package :bind) because these commands are defined in
+;; this file; a :bind autoload stub plus the defun triggers a "defined multiple
+;; times" byte-compile warning.
+
+(with-eval-after-load 'elfeed
+ (keymap-set elfeed-search-mode-map "w" #'cj/elfeed-eww-open) ;; opens in eww
+ (keymap-set elfeed-search-mode-map "b" #'cj/elfeed-browser-open) ;; opens in external browser
+ (keymap-set elfeed-search-mode-map "d" #'cj/elfeed-youtube-dl) ;; async download with yt-dlp and tsp
+ (keymap-set elfeed-search-mode-map "v" #'cj/play-with-video-player) ;; async play with mpv
+ (keymap-set elfeed-search-mode-map "R" #'cj/elfeed-mark-all-as-read) ;; capital R marks all read (lower case marks one)
+ (keymap-set elfeed-search-mode-map "U" #'cj/elfeed-mark-all-as-unread)) ;; capital U marks all unread (lower case marks one)
+
(provide 'elfeed-config)
;;; elfeed-config.el ends here.
diff --git a/modules/erc-config.el b/modules/erc-config.el
index c0fa9c325..3e98a66a3 100644
--- a/modules/erc-config.el
+++ b/modules/erc-config.el
@@ -33,6 +33,33 @@
;; is read at load time below (erc-user-full-name), so a standalone .elc needs it.
(require 'user-constants)
+;; ERC loads lazily (use-package :commands), so these symbols aren't bound at
+;; this file's compile time. Declare them to keep the byte-compiler quiet
+;; without forcing an eager require.
+
+;; Functions provided by the erc package.
+(declare-function erc-buffer-list "erc")
+(declare-function erc-server-process-alive "erc")
+(declare-function erc-server-or-unjoined-channel-buffer-p "erc")
+(declare-function erc-current-nick "erc")
+(declare-function erc-join-channel "erc")
+(declare-function erc-part-from-channel "erc")
+(declare-function erc-quit-server "erc")
+
+;; Variables read/set in the use-package :config block below.
+(defvar erc-log-channels-directory)
+(defvar erc-track-exclude-types)
+(defvar erc-track-exclude-server-buffer)
+(defvar erc-track-visibility)
+(defvar erc-track-switch-direction)
+(defvar erc-track-showcount)
+;; NOTE: erc-unique-buffers and erc-generate-buffer-name-function are not ERC
+;; variables in Emacs 30.x (no defcustom/defvar in the package); the setq below
+;; only creates inert globals. Declared here to silence the warning without
+;; changing the existing (no-op) behavior -- see the SUSPICIOUS note.
+(defvar erc-unique-buffers)
+(defvar erc-generate-buffer-name-function)
+
;; ------------------------------------ ERC ------------------------------------
;; Server definitions and connection settings
@@ -99,7 +126,7 @@ Change this value to use a different nickname.")
(let ((server-buffers '()))
(dolist (buf (erc-buffer-list))
(with-current-buffer buf
- (when (and (erc-server-buffer-p) (erc-server-process-alive))
+ (when (and (erc-server-or-unjoined-channel-buffer-p) (erc-server-process-alive))
(unless (member (buffer-name) server-buffers)
(push (buffer-name) server-buffers)))))
@@ -132,7 +159,7 @@ Buffer names are shown with server context for clarity."
"Return t if the current buffer is an active ERC server buffer."
(and (derived-mode-p 'erc-mode)
(erc-server-process-alive)
- (erc-server-buffer-p)))
+ (erc-server-or-unjoined-channel-buffer-p)))
(defun cj/erc-get-channels-for-current-server ()
@@ -158,7 +185,7 @@ Auto-adds # prefix if missing. Offers completion from configured channels."
(let ((server-buffers (cl-remove-if-not
(lambda (buf)
(with-current-buffer buf
- (and (erc-server-buffer-p)
+ (and (erc-server-or-unjoined-channel-buffer-p)
(erc-server-process-alive))))
(erc-buffer-list))))
(if server-buffers
@@ -338,16 +365,15 @@ NICK is the sender and MESSAGE is the message text."
:after erc
:hook (erc-mode . erc-nicks-mode))
-;; ------------------------------ ERC Yank To Gist -----------------------------
-;; automatically create a Gist if pasting more than 5 lines
-;; this module requires https://github.com/defunkt/gist
-;; via ruby: 'gem install gist' via the aur: yay -S gist
-
-(use-package erc-yank
- :after erc
- :bind
- (:map erc-mode-map
- ("C-y" . erc-yank)))
+;; -------------------------------- ERC Yank ----------------------------------
+;; The erc-yank package was dropped 2026-06-20: a paste over 5 lines became a
+;; PUBLIC gist (it called `gist -P', the clipboard paste flag, with no
+;; `--private'), behind only a single y-or-n-p and with no guard if the `gist'
+;; binary was absent -- a one-keystroke path to publishing whatever sat on the
+;; system clipboard. No replacement binding is needed: erc-mode-map defines no
+;; C-y of its own, so with erc-yank gone C-y falls through to the ordinary
+;; global `yank' and a paste stays local. Gist a large snippet by hand when
+;; that's actually wanted.
(provide 'erc-config)
;;; erc-config.el ends here
diff --git a/modules/eshell-config.el b/modules/eshell-config.el
index d3c8ccdfd..7379795d2 100644
--- a/modules/eshell-config.el
+++ b/modules/eshell-config.el
@@ -26,6 +26,35 @@
(require 'system-utils)
+;; Eshell is loaded lazily (:commands eshell), so its vars and functions are
+;; not defined when this file is byte-compiled standalone. Declare them to
+;; silence compile-time free-variable / undefined-function warnings.
+(defvar eshell-banner-message)
+(defvar eshell-scroll-to-bottom-on-input)
+(defvar eshell-error-if-no-glob)
+(defvar eshell-hist-ignoredups)
+(defvar eshell-save-history-on-exit)
+(defvar eshell-prefer-lisp-functions)
+(defvar eshell-destroy-buffer-when-process-dies)
+(defvar eshell-prompt-function)
+(defvar eshell-cmpl-cycle-completions)
+(defvar eshell-modules-list)
+(defvar eshell-hist-mode-map)
+(defvar eshell-visual-commands)
+(defvar eshell-visual-subcommands)
+(defvar eshell-visual-options)
+(defvar eshell-history-ring)
+(defvar eshell-preoutput-filter-functions)
+(defvar eshell-output-filter-functions)
+
+(declare-function ring-elements "ring")
+(declare-function eshell-send-input "esh-mode")
+(declare-function eshell/pwd "em-dirs")
+(declare-function eshell/alias "em-alias")
+(declare-function eshell/cd "em-dirs")
+(declare-function eshell-stringify "esh-util")
+(declare-function eat-eshell-mode "eat")
+
(defgroup cj/eshell nil
"Personal Eshell configuration."
:group 'eshell)
@@ -57,6 +86,59 @@ pairs where COMMAND is the `cd' string `eshell/alias' should run."
(dolist (pair (cj/--eshell-ssh-alias-commands hosts))
(eshell/alias (car pair) (cdr pair))))
+;; ---------------------------- prompt segments --------------------------------
+
+(defun cj/--eshell-git-branch ()
+ "Return the current git branch for `default-directory', or nil.
+Reads .git/HEAD directly so it adds no subprocess per prompt, and skips remote
+directories so a TRAMP prompt stays fast."
+ (unless (file-remote-p default-directory)
+ (when-let* ((root (locate-dominating-file default-directory ".git"))
+ (head (expand-file-name ".git/HEAD" root)))
+ (when (file-readable-p head)
+ (with-temp-buffer
+ (insert-file-contents head)
+ (when (looking-at "ref: refs/heads/\\(.*\\)")
+ (string-trim (match-string 1))))))))
+
+(defun cj/--eshell-prompt-status-segment ()
+ "Return the eshell prompt's exit-status segment, or an empty string.
+Shows the last command's exit code in brackets when it was non-zero, mirroring
+the zsh prompt's failure indicator."
+ (let ((status (bound-and-true-p eshell-last-command-status)))
+ (if (or (null status) (zerop status))
+ ""
+ (format " [%d]" status))))
+
+;; ------------------------------- zoxide --------------------------------------
+;; Share the same frecency database as the zsh shell by calling the zoxide
+;; binary: `z' jumps to a remembered directory, and every eshell directory
+;; change feeds `zoxide add' so eshell visits accrue in the same database.
+
+(defun eshell/z (&rest args)
+ "Jump to a directory via zoxide, sharing the zsh zoxide database.
+With no ARGS, cd home. Otherwise query zoxide for the best match and cd there."
+ (if (null args)
+ (eshell/cd)
+ (let ((dir (string-trim
+ (shell-command-to-string
+ (concat "zoxide query -- "
+ (mapconcat #'shell-quote-argument
+ (mapcar #'eshell-stringify args) " "))))))
+ (if (and (not (string-empty-p dir)) (file-directory-p dir))
+ (eshell/cd dir)
+ (error "zoxide: no match for %s"
+ (string-join (mapcar #'eshell-stringify args) " "))))))
+
+(defun cj/--eshell-zoxide-add ()
+ "Record `default-directory' in the zoxide database (skips remote dirs)."
+ (when (and (not (file-remote-p default-directory))
+ (executable-find "zoxide"))
+ (call-process "zoxide" nil 0 nil "add" "--"
+ (expand-file-name default-directory))))
+
+(add-hook 'eshell-directory-change-hook #'cj/--eshell-zoxide-add)
+
(use-package eshell
:ensure nil ;; built-in
:commands (eshell)
@@ -75,15 +157,18 @@ pairs where COMMAND is the `cd' string `eshell/alias' should run."
(setq eshell-prompt-function
(lambda ()
(concat
- (propertize (format-time-string "[%d-%m-%y %T]") 'face '(:foreground "gray"))
+ (propertize (format-time-string "[%d-%m-%y %T]") 'face 'default)
" "
- (propertize (user-login-name) 'face '(:foreground "gray"))
+ (propertize (user-login-name) 'face 'default)
" "
- (propertize (system-name) 'face '(:foreground "gray"))
+ (propertize (system-name) 'face 'default)
":"
- (propertize (abbreviate-file-name (eshell/pwd)) 'face '(:foreground "gray"))
+ (propertize (abbreviate-file-name (eshell/pwd)) 'face 'default)
+ (let ((branch (cj/--eshell-git-branch)))
+ (if branch (propertize (concat " (" branch ")") 'face 'default) ""))
+ (propertize (cj/--eshell-prompt-status-segment) 'face 'default)
"\n"
- (propertize "%" 'face '(:foreground "white"))
+ (propertize "%" 'face 'default)
" ")))
(add-hook
@@ -153,35 +238,20 @@ pairs where COMMAND is the `cd' string `eshell/alias' should run."
(delete-window)))
(advice-add 'eshell-life-is-too-much :after 'cj/eshell-delete-window-on-exit)
-(use-package eshell-toggle
- :custom
- (eshell-toggle-size-fraction 2)
- (eshell-toggle-run-command nil)
- (eshell-toggle-init-function #'eshell-toggle-init-eshell)
- :bind
- ("C-<f12>" . eshell-toggle))
+;; Run eshell's external commands through EAT (a real terminal): visual commands
+;; (vim, htop, less) render properly and ANSI output is faithful, while eshell
+;; stays the shell -- elisp functions as commands + TRAMP transparency. EAT
+;; handles color itself, so it supersedes xterm-color for eshell; the
+;; xterm-color block below stays for now and steps aside if colors double up.
+(with-eval-after-load 'esh-mode
+ (require 'eat)
+ (eat-eshell-mode 1))
-(use-package xterm-color
- :after eshell
- ;; Two hooks. eshell-before-prompt is the real hook name; use-package appends
- ;; "-hook", so writing eshell-before-prompt-hook here registered on a
- ;; nonexistent eshell-before-prompt-hook-hook and never ran. The eshell-mode
- ;; hook scopes TERM=xterm-256color to eshell-spawned processes only (a global
- ;; setenv would leak it to every start-process regardless of terminal).
- :hook
- ((eshell-before-prompt . (lambda ()
- (setq xterm-color-preserve-properties t)))
- (eshell-mode . (lambda ()
- (setq-local process-environment
- (cons "TERM=xterm-256color"
- process-environment)))))
- :config
- ;; Wire xterm-color into eshell's output pipeline (per its README): install
- ;; the filter and drop eshell's own ANSI handler. Without this the escapes are
- ;; never interpreted and TERM=xterm-256color only leaks raw codes.
- (add-to-list 'eshell-preoutput-filter-functions 'xterm-color-filter)
- (setq eshell-output-filter-functions
- (remove 'eshell-handle-ansi-color eshell-output-filter-functions)))
+;; eshell-toggle and xterm-color are retired. F12 opens eshell now (the
+;; dock-and-remember toggle in eat-config.el), and eat-eshell-mode renders
+;; eshell's output through EAT, which handles ANSI color natively -- so
+;; xterm-color's filter and its TERM=xterm-256color override are redundant and
+;; would fight EAT's own TERM=eat-truecolor.
(use-package eshell-syntax-highlighting
:after esh-mode
diff --git a/modules/eww-config.el b/modules/eww-config.el
index a41a9a76e..ff7ddc211 100644
--- a/modules/eww-config.el
+++ b/modules/eww-config.el
@@ -32,6 +32,8 @@
(require 'cl-lib)
+(declare-function eww-add-bookmark "eww")
+
(defgroup my-eww-user-agent nil
"EWW-only User-Agent management."
:group 'eww)
@@ -42,6 +44,13 @@
:type 'string
:group 'my-eww-user-agent)
+;; This file is lexical-binding, so `let'-binding url.el's special var below
+;; needs it declared special at compile time. Without this the byte-compiled
+;; advice binds `url-request-extra-headers' lexically and the injected
+;; User-Agent never reaches `url-retrieve' (it reads the dynamic value) -- the
+;; UA injection silently no-ops in compiled production, and the test sees nil.
+(defvar url-request-extra-headers)
+
(defun my-eww--inject-user-agent (orig-fun &rest args)
"Set a User-Agent only when making requests from an EWW buffer."
(if (derived-mode-p 'eww-mode)
diff --git a/modules/external-open.el b/modules/external-open.el
index 22e56a290..811c32c28 100644
--- a/modules/external-open.el
+++ b/modules/external-open.el
@@ -42,15 +42,33 @@
"Open certain files with the OS default handler."
:group 'files)
-(defcustom default-open-extensions
- '(
- ;; Video
- "\\.3g2\\'" "\\.3gp\\'" "\\.asf\\'" "\\.avi\\'" "\\.divx\\'" "\\.dv\\'"
+(defcustom cj/video-extensions
+ '("\\.3g2\\'" "\\.3gp\\'" "\\.asf\\'" "\\.avi\\'" "\\.divx\\'" "\\.dv\\'"
"\\.f4v\\'" "\\.flv\\'" "\\.m1v\\'" "\\.m2ts\\'" "\\.m2v\\'" "\\.m4v\\'"
"\\.mkv\\'" "\\.mov\\'" "\\.mpe\\'" "\\.mpeg\\'" "\\.mpg\\'" "\\.mp4\\'"
"\\.mts\\'" "\\.ogv\\'" "\\.rm\\'" "\\.rmvb\\'" "\\.vob\\'"
- "\\.webm\\'" "\\.wmv\\'"
+ "\\.webm\\'" "\\.wmv\\'")
+ "Regexps matching video files opened in a looping player.
+These route through `cj/open-video-looping' (mpv --loop-file=inf by default)
+instead of the OS default handler, so a video opened from dirvish plays on
+repeat."
+ :type '(repeat (regexp :tag "Video extension regexp"))
+ :group 'external-open)
+
+(defcustom cj/video-open-command "mpv"
+ "Player command used to open local video files on repeat.
+Launched detached from Emacs with `cj/video-open-args' before the file name."
+ :type 'string
+ :group 'external-open)
+
+(defcustom cj/video-open-args '("--loop-file=inf")
+ "Arguments passed to `cj/video-open-command' before the file name.
+Defaults to mpv's infinite single-file loop so the video plays on repeat."
+ :type '(repeat string)
+ :group 'external-open)
+(defcustom default-open-extensions
+ '(
;; Audio
"\\.aac\\'" "\\.ac3\\'" "\\.aif\\'" "\\.aifc\\'" "\\.aiff\\'"
"\\.alac\\'" "\\.amr\\'" "\\.ape\\'" "\\.caf\\'"
@@ -142,18 +160,49 @@ Logs output and exit code to buffer *external-open.log*."
nil 0)))))
+;; -------------------------- Open Videos On Repeat ----------------------------
+
+(defun cj/--video-file-p (file)
+ "Return non-nil when FILE matches a regexp in `cj/video-extensions'."
+ (and (stringp file)
+ (let ((case-fold-search t))
+ (cl-some (lambda (re) (string-match-p re file)) cj/video-extensions))))
+
+(defun cj/--video-open-arglist (file)
+ "Return the argument list to play FILE on repeat: `cj/video-open-args' + FILE."
+ (append cj/video-open-args (list file)))
+
+(defun cj/open-video-looping (&optional filename)
+ "Open FILENAME (or the file at point) in a looping video player, detached.
+Uses `cj/video-open-command' and `cj/video-open-args' (mpv --loop-file=inf by
+default) so the video plays on repeat. Launched asynchronously so it never
+blocks Emacs."
+ (interactive)
+ (let* ((file (expand-file-name
+ (or (cj/file-from-context filename)
+ (user-error "No file associated with this buffer"))))
+ (args (cj/--video-open-arglist file)))
+ (if (env-windows-p)
+ (w32-shell-execute "open" cj/video-open-command
+ (mapconcat (lambda (a) (format "\"%s\"" a)) args " "))
+ (apply #'call-process cj/video-open-command nil 0 nil args))))
+
;; -------------------- Open Files With Default File Handler -------------------
(defun cj/find-file-auto (orig-fun &rest args)
- "If file has an extension in `default-open-extensions', open externally.
-Else call ORIG-FUN with ARGS."
+ "Open FILE externally based on its extension, else call ORIG-FUN with ARGS.
+A video (`cj/video-extensions') opens in a looping player; any other extension
+in `default-open-extensions' opens with the OS default handler."
(let* ((file (car args))
(case-fold-search t))
- (if (and (stringp file)
- (cl-some (lambda (re) (string-match-p re file))
- default-open-extensions))
- (cj/xdg-open file)
- (apply orig-fun args))))
+ (cond
+ ((cj/--video-file-p file)
+ (cj/open-video-looping file))
+ ((and (stringp file)
+ (cl-some (lambda (re) (string-match-p re file))
+ default-open-extensions))
+ (cj/xdg-open file))
+ (t (apply orig-fun args)))))
(defun cj/external-open-install-advice ()
"Install the `cj/find-file-auto' advice on `find-file'.
diff --git a/modules/face-diagnostic.el b/modules/face-diagnostic.el
index 6b1b547f1..6f0722099 100644
--- a/modules/face-diagnostic.el
+++ b/modules/face-diagnostic.el
@@ -36,7 +36,7 @@ Return one of `theme-faced', `terminal-ansi', `document-shr', or
best-effort dump rather than a full provenance trace."
(with-current-buffer (or buffer (current-buffer))
(cond
- ((derived-mode-p 'term-mode 'comint-mode 'eshell-mode 'ghostel-mode)
+ ((derived-mode-p 'term-mode 'comint-mode 'eshell-mode 'eat-mode)
'terminal-ansi)
((derived-mode-p 'eww-mode 'nov-mode 'elfeed-show-mode 'mu4e-view-mode)
'document-shr)
@@ -298,6 +298,18 @@ mutation."
;; ------------------------------- Rendering -----------------------------------
+(defun cj/--face-diag-face-button (face)
+ "Render FACE as a button that runs `describe-face' on it.
+A real, named face becomes a `buttonize'd string (RET or mouse opens its
+`describe-face' help); anything else -- an anonymous (:attr val ...) spec or a
+symbol that is not a face -- is returned as a plain string so the report still
+reads cleanly."
+ (let ((label (format "%s" face)))
+ (if (and (symbolp face) (facep face))
+ (buttonize label (lambda (f) (describe-face f)) face
+ (format "describe-face: %s" face))
+ label)))
+
(defun cj/--face-diag-render-banner (classification)
"Return a one-line banner for an out-of-scope CLASSIFICATION, or \"\"."
(pcase classification
@@ -320,8 +332,9 @@ mutation."
(or (plist-get char :script) "none"))))
(defun cj/--face-diag-render-faces (faces)
- "Render a list of FACES (symbols or specs) comma-separated, or \"(none)\"."
- (if faces (mapconcat (lambda (f) (format "%s" f)) faces ", ") "(none)"))
+ "Render a list of FACES (symbols or specs) comma-separated, or \"(none)\".
+Real faces render as `describe-face' buttons (see `cj/--face-diag-face-button')."
+ (if faces (mapconcat #'cj/--face-diag-face-button faces ", ") "(none)"))
(defun cj/--face-diag-render-stack (stack)
"Render the STACK plist (faces by source) as a block."
@@ -329,18 +342,21 @@ mutation."
"Face stack (highest priority first):\n"
(format " text properties: %s\n"
(cj/--face-diag-render-faces (plist-get stack :text-property)))
- (format " overlays: %s\n"
- (let ((ov (plist-get stack :overlays)))
- (if ov
- (mapconcat (lambda (e)
- (format "%s (priority %s)"
- (plist-get e :face)
- (or (plist-get e :priority) "nil")))
- ov ", ")
- "(none)")))
- (format " active remaps: %s\n"
- (let ((rm (plist-get stack :remaps)))
- (if rm (mapconcat (lambda (e) (format "%s" (car e))) rm ", ") "(none)")))
+ " overlays: "
+ (let ((ov (plist-get stack :overlays)))
+ (if ov
+ (mapconcat (lambda (e)
+ (concat (cj/--face-diag-face-button (plist-get e :face))
+ (format " (priority %s)"
+ (or (plist-get e :priority) "nil"))))
+ ov ", ")
+ "(none)"))
+ "\n"
+ " active remaps: "
+ (let ((rm (plist-get stack :remaps)))
+ (if rm (mapconcat (lambda (e) (cj/--face-diag-face-button (car e))) rm ", ")
+ "(none)"))
+ "\n"
" default: default\n\n"))
(defun cj/--face-diag-render-attributes (attrs)
@@ -372,13 +388,15 @@ mutation."
(if prov
(mapconcat
(lambda (p)
- (format (concat " %s\n themes: %s\n config: %s\n"
- " inherits: %s\n unspecified (-> default): %s")
- (plist-get p :face)
- (or (plist-get p :themes) "(none)")
- (or (plist-get p :config) "(none)")
- (or (plist-get p :inherit-chain) "(none)")
- (or (plist-get p :unspecified) "(none)")))
+ (concat
+ " "
+ (cj/--face-diag-face-button (plist-get p :face))
+ (format (concat "\n themes: %s\n config: %s\n"
+ " inherits: %s\n unspecified (-> default): %s")
+ (or (plist-get p :themes) "(none)")
+ (or (plist-get p :config) "(none)")
+ (or (plist-get p :inherit-chain) "(none)")
+ (or (plist-get p :unspecified) "(none)"))))
prov "\n")
" (no named faces)")
"\n"))
diff --git a/modules/flycheck-config.el b/modules/flycheck-config.el
index 5626095c5..1afd3ae6c 100644
--- a/modules/flycheck-config.el
+++ b/modules/flycheck-config.el
@@ -45,6 +45,14 @@
(require 'keybindings) ;; provides cj/custom-keymap (use-package :map below)
+;; ------------------------------- Declarations --------------------------------
+
+(declare-function flycheck-mode "flycheck")
+(declare-function flycheck-list-errors "flycheck")
+(declare-function flycheck-add-mode "flycheck")
+(declare-function flycheck-buffer "flycheck")
+(declare-function cj/flycheck-prose-on-demand "flycheck-config")
+
(defun cj/prose-helpers-on ()
"Ensure that `abbrev-mode' and `flycheck-mode' are on in the current buffer."
(interactive)
diff --git a/modules/font-config.el b/modules/font-config.el
index 4821b89e1..3272a946e 100644
--- a/modules/font-config.el
+++ b/modules/font-config.el
@@ -56,6 +56,9 @@
(require 'host-environment)
(require 'keybindings) ;; establishes the C-z prefix used for "C-z F" below
+(defvar text-scale-mode-step)
+(declare-function cj/disable-emojify-mode "font-config")
+
;; ---------------------- HarfBuzz Font Cache Crash Fix -----------------------
;; Prevents Emacs from compacting font caches during GC. Without this, GC can
;; free font cache entries that HarfBuzz still references, causing SIGSEGV
@@ -265,13 +268,12 @@ the fontset repeatedly is harmless, so it can be called from
(setq emojify-display-style (if (env-gui-p) 'image 'unicode))
(setq emojify-emoji-styles '(ascii unicode github))
- ;; Disable emojify in programming and gptel modes
+ ;; Disable emojify in programming modes
(defun cj/disable-emojify-mode ()
"Disable emojify-mode in the current buffer."
(emojify-mode -1))
- (add-hook 'prog-mode-hook #'cj/disable-emojify-mode)
- (add-hook 'gptel-mode-hook #'cj/disable-emojify-mode))
+ (add-hook 'prog-mode-hook #'cj/disable-emojify-mode))
;; -------------------------- Display Available Fonts --------------------------
;; display all available fonts on the system in a side panel
diff --git a/modules/games-config.el b/modules/games-config.el
index 9aa598168..0ff01c809 100644
--- a/modules/games-config.el
+++ b/modules/games-config.el
@@ -5,32 +5,30 @@
;;
;; Layer: 4 (Optional).
;; Category: O.
-;; Load shape: eager.
-;; Eager reason: none; optional games, a command-loaded deferral candidate.
-;; Top-level side effects: package configuration via use-package.
-;; Runtime requires: none.
+;; Load shape: command (deferred).
+;; Eager reason: none; loaded by init.el when malyon loads.
+;; Top-level side effects: sets malyon-stories-directory after malyon loads.
+;; Runtime requires: user-constants.
;; Direct test load: yes.
;;
;; Configuration for game packages.
;;
-;; - Malyon for playing interactive fiction and text adventures in Z-machine format
-;; (stories directory: ~/sync/org/text.games/)
-;; - 2048 number-tile puzzle game
+;; - Malyon: interactive fiction / Z-machine player (stories under ~/sync/org/text.games/).
+;; - 2048: number-tile puzzle.
+;;
+;; malyon and 2048-game autoload their own commands via package.el, so this
+;; module owns neither command -- it only supplies malyon's stories directory.
+;; init.el loads it via `with-eval-after-load 'malyon', so it loads on first
+;; use rather than at startup.
;;
;;; Code:
-;; ----------------------------------- Malyon ----------------------------------
-;; text based adventure player
+(require 'user-constants) ;; org-dir
-(use-package malyon
- :defer 1
- :config
- (setq malyon-stories-directory (concat org-dir "text.games/")))
+(defvar malyon-stories-directory)
-;; ------------------------------------ 2048 -----------------------------------
-;; combine numbered tiles to create the elusive number 2048.
-(use-package 2048-game
- :defer 1)
+(with-eval-after-load 'malyon
+ (setq malyon-stories-directory (concat org-dir "text.games/")))
(provide 'games-config)
;;; games-config.el ends here.
diff --git a/modules/gcmh-config.el b/modules/gcmh-config.el
new file mode 100644
index 000000000..beceb1a01
--- /dev/null
+++ b/modules/gcmh-config.el
@@ -0,0 +1,30 @@
+;;; gcmh-config.el --- Garbage collection strategy via gcmh -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; gcmh (the Garbage Collector Magic Hack) owns `gc-cons-threshold' for the
+;; session. It keeps the threshold very high while you are active so GC never
+;; pauses mid-edit, then drops it and collects on idle, when a pause is
+;; invisible. This replaces the old hand-rolled scheme -- a stock-800KB restore
+;; in early-init.el plus a minibuffer setup/exit bump -- which pinned GC at
+;; 800000 (Emacs's bare-editor default), far too low for a config this size and
+;; the cause of frequent GC pauses during completion, agenda builds, and LSP/AI
+;; activity.
+;;
+;; Kept in its own module, not system-defaults.el: that module is pre-loaded by
+;; the comp-errors test harness, which has no package system, so an `:ensure'
+;; package there errors at load time. early-init.el bumps the threshold to
+;; `most-positive-fixnum' for startup and deliberately does not restore it;
+;; `gcmh-mode' takes ownership from here on.
+
+;;; Code:
+
+(use-package gcmh
+ :ensure t
+ :demand t
+ :config
+ (setq gcmh-idle-delay 'auto ; scale the idle GC delay to GC cost
+ gcmh-high-cons-threshold (* 1 1024 1024 1024)) ; 1 GB during activity
+ (gcmh-mode 1))
+
+(provide 'gcmh-config)
+;;; gcmh-config.el ends here
diff --git a/modules/google-keep-config.el b/modules/google-keep-config.el
new file mode 100644
index 000000000..1738fa6e0
--- /dev/null
+++ b/modules/google-keep-config.el
@@ -0,0 +1,210 @@
+;;; google-keep-config.el --- Google Keep -> org integration -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;; A read-only view of Google Keep notes as an org page. `cj/keep-refresh'
+;; runs a Python gkeepapi bridge (scripts/google-keep/keep-bridge.py), parses
+;; its JSON, and regenerates `keep-file' with one org header per note. Editing
+;; the file does NOT sync back to Keep -- that is v2.
+;;
+;; The pure JSON-to-org core (the cj/keep--render* / --note-* helpers) is kept
+;; free of .emacs.d specifics so it can later extract to a standalone package;
+;; the IO layer and this module supply paths, auth, and keys.
+;;
+;; One-time setup: install the client (pip install gkeepapi), obtain a Google
+;; master token, set `cj/keep-email', and store the token in authinfo.gpg as
+;; machine google-keep login <you@gmail.com> password <master-token>
+;; See docs/specs/google-keep-emacs-integration-spec.org.
+
+;;; Code:
+
+(require 'json)
+(require 'subr-x)
+(require 'system-lib) ;; cj/auth-source-secret-value, cj/executable-find-or-warn
+(require 'user-constants) ;; keep-file
+
+;; ------------------------------ Configuration --------------------------------
+
+(defgroup cj/keep nil
+ "Google Keep to org integration."
+ :group 'applications
+ :prefix "cj/keep-")
+
+(defcustom cj/keep-email nil
+ "Google account email for the Keep bridge, also the authinfo login.
+Unset until the one-time setup is done; `cj/keep-refresh' warns when nil."
+ :type '(choice (const :tag "Unset" nil) string)
+ :group 'cj/keep)
+
+(defcustom cj/keep-auth-host "google-keep"
+ "The authinfo.gpg machine entry holding the Keep master token."
+ :type 'string
+ :group 'cj/keep)
+
+(defcustom cj/keep-python "python3"
+ "Python interpreter used to run the Keep bridge."
+ :type 'string
+ :group 'cj/keep)
+
+(defvar cj/keep--bridge-script
+ (expand-file-name "scripts/google-keep/keep-bridge.py" user-emacs-directory)
+ "Path to the gkeepapi bridge script.")
+
+(defconst cj/keep--web-base "https://keep.google.com/#NOTE/"
+ "Base URL for a Keep note back-link.")
+
+;; --------------------------- Pure core: JSON -> org --------------------------
+;; These take plain data and return strings -- no IO, no .emacs.d paths -- so
+;; they unit-test directly and lift out to a package unchanged.
+
+(defun cj/keep--parse-json (json-string)
+ "Parse the bridge JSON-STRING into a list of note alists."
+ (json-parse-string json-string
+ :object-type 'alist :array-type 'list
+ :false-object nil :null-object nil))
+
+(defun cj/keep--label-to-tag (label)
+ "Sanitize LABEL into a valid org tag (alphanumerics / _ / @ / # / %)."
+ (replace-regexp-in-string "[^[:alnum:]_@#%]" "_" label))
+
+(defun cj/keep--note-tags (note)
+ "Return the trailing org-tag string for NOTE (labels + archived), or \"\"."
+ (let ((tags (append (mapcar #'cj/keep--label-to-tag (alist-get 'labels note))
+ (and (alist-get 'archived note) '("archived")))))
+ (if tags (concat " :" (string-join tags ":") ":") "")))
+
+(defun cj/keep--note-heading (note)
+ "Render NOTE (an alist) as one org subtree string."
+ (let* ((id (alist-get 'id note))
+ (title (alist-get 'title note))
+ (text (alist-get 'text note))
+ (heading (if (and title (> (length title) 0)) title "(untitled)")))
+ (concat
+ "* " heading (cj/keep--note-tags note) "\n"
+ ":PROPERTIES:\n"
+ ":KEEP_ID: " (or id "") "\n"
+ ":PINNED: " (if (alist-get 'pinned note) "t" "nil") "\n"
+ ":COLOR: " (or (alist-get 'color note) "") "\n"
+ ":ARCHIVED: " (if (alist-get 'archived note) "t" "nil") "\n"
+ ":UPDATED: " (or (alist-get 'updated note) "") "\n"
+ ":END:\n"
+ (if (and id (> (length id) 0))
+ (concat "[[" cj/keep--web-base id "][open in Keep]]\n")
+ "")
+ "\n"
+ (if (and text (> (length text) 0)) (concat text "\n") ""))))
+
+(defun cj/keep--sort-pinned-first (notes)
+ "Return NOTES with pinned ones first, original order otherwise preserved."
+ (let (pinned rest)
+ (dolist (n notes)
+ (if (alist-get 'pinned n) (push n pinned) (push n rest)))
+ (append (nreverse pinned) (nreverse rest))))
+
+(defun cj/keep--render (notes &optional generated-at)
+ "Render NOTES (a list of alists) into the full org page string.
+GENERATED-AT is an optional last-refresh timestamp string for the header."
+ (concat
+ "# Generated by cj/keep-refresh -- read-only view; edits here do NOT sync to Keep.\n"
+ "#+TITLE: Google Keep\n"
+ (if generated-at (concat "# Last refresh: " generated-at "\n") "")
+ "\n"
+ (mapconcat #'cj/keep--note-heading (cj/keep--sort-pinned-first notes) "")))
+
+;; ------------------------------- IO: run + write -----------------------------
+
+(defun cj/keep--write-atomically (content file)
+ "Write CONTENT to FILE via a temp file in FILE's directory + atomic rename."
+ (let ((tmp (make-temp-file
+ (expand-file-name (concat "." (file-name-nondirectory file) ".")
+ (file-name-directory file))
+ nil nil content)))
+ (rename-file tmp file t)))
+
+(defun cj/keep--warn (token)
+ "Surface a Keep bridge failure TOKEN as a `display-warning'."
+ (display-warning
+ 'cj/keep
+ (pcase token
+ ("no-gkeepapi" "Keep bridge: gkeepapi is not installed (pip install gkeepapi).")
+ ("no-token" "Keep bridge: no master token in authinfo.gpg, or `cj/keep-email' is unset.")
+ ("auth-failed" "Keep bridge: Google rejected the credentials (token expired or revoked?).")
+ ("network" "Keep bridge: network error reaching Google Keep.")
+ (_ (format "Keep bridge failed: %s" (if (string-empty-p token) "unknown error" token))))
+ :error))
+
+(defun cj/keep--write-notes (json)
+ "Parse bridge JSON, render, and write `keep-file' atomically.
+Returns the note count."
+ (let* ((notes (cj/keep--parse-json json))
+ (org (cj/keep--render notes (format-time-string "%Y-%m-%d %H:%M"))))
+ (cj/keep--write-atomically org keep-file)
+ (length notes)))
+
+;;;###autoload
+(defun cj/keep-refresh ()
+ "Fetch Google Keep notes and regenerate `keep-file' (a read-only view)."
+ (interactive)
+ (let ((token (and cj/keep-email
+ (cj/auth-source-secret-value cj/keep-auth-host cj/keep-email))))
+ (cond
+ ((not (file-exists-p cj/keep--bridge-script))
+ (user-error "Keep bridge script not found: %s" cj/keep--bridge-script))
+ ((or (not cj/keep-email) (not token))
+ (cj/keep--warn "no-token"))
+ (t
+ (let* ((out (generate-new-buffer " *keep-bridge-out*"))
+ (err (generate-new-buffer " *keep-bridge-err*"))
+ (process-environment
+ (append (list (concat "KEEP_EMAIL=" cj/keep-email)
+ (concat "KEEP_MASTER_TOKEN=" token))
+ process-environment)))
+ (message "Keep: fetching...")
+ (make-process
+ :name "keep-bridge"
+ :buffer out
+ :stderr err
+ :command (list cj/keep-python cj/keep--bridge-script)
+ :sentinel
+ (lambda (proc _event)
+ (when (memq (process-status proc) '(exit signal))
+ (unwind-protect
+ (if (and (eq (process-status proc) 'exit)
+ (= (process-exit-status proc) 0))
+ (let ((n (cj/keep--write-notes
+ (with-current-buffer out (buffer-string)))))
+ (message "Keep: wrote %d notes to %s" n keep-file))
+ (cj/keep--warn
+ (string-trim (if (buffer-live-p err)
+ (with-current-buffer err (buffer-string))
+ ""))))
+ (when (buffer-live-p out) (kill-buffer out))
+ (when (buffer-live-p err) (kill-buffer err)))))))))))
+
+;;;###autoload
+(defun cj/keep-open ()
+ "Open the generated Keep org file, offering to refresh when it's absent."
+ (interactive)
+ (if (file-exists-p keep-file)
+ (find-file keep-file)
+ (if (y-or-n-p "Keep file doesn't exist yet. Refresh now? ")
+ (cj/keep-refresh)
+ (message "Run M-x cj/keep-refresh to generate it"))))
+
+;; --------------------------------- Glue / keys -------------------------------
+
+(defvar cj/keep-prefix-map
+ (let ((map (make-sparse-keymap)))
+ (define-key map "r" #'cj/keep-refresh)
+ (define-key map "o" #'cj/keep-open)
+ map)
+ "Prefix keymap for Google Keep commands (bound to \\=`C-c k').")
+
+(keymap-global-set "C-c k" cj/keep-prefix-map)
+
+;; Warn at load if the interpreter is missing; gkeepapi/token failures surface
+;; at refresh time via the bridge's stderr reason token.
+(cj/executable-find-or-warn cj/keep-python "Google Keep bridge" 'google-keep-config)
+
+(provide 'google-keep-config)
+;;; google-keep-config.el ends here
diff --git a/modules/help-utils.el b/modules/help-utils.el
index f9f5d1427..3e31efffe 100644
--- a/modules/help-utils.el
+++ b/modules/help-utils.el
@@ -32,6 +32,10 @@
;;
;;; Code:
+;; Lazily-loaded functions referenced below.
+(declare-function devdocs-go-back "devdocs")
+(declare-function devdocs-go-forward "devdocs")
+
;; ---------------------------------- Devdocs ----------------------------------
(use-package devdocs
diff --git a/modules/httpd-config.el b/modules/httpd-config.el
index c90399425..60baf7e82 100644
--- a/modules/httpd-config.el
+++ b/modules/httpd-config.el
@@ -19,13 +19,13 @@
(use-package simple-httpd
:defer 1
:preface
- (defconst wwwdir (concat user-emacs-directory "www"))
- (defun check-or-create-wwwdir ()
- (unless (file-exists-p wwwdir)
- (make-directory wwwdir)))
- :init (check-or-create-wwwdir)
+ (defconst cj/httpd-wwwdir (concat user-emacs-directory "www"))
+ (defun cj/httpd-check-or-create-wwwdir ()
+ (unless (file-exists-p cj/httpd-wwwdir)
+ (make-directory cj/httpd-wwwdir)))
+ :init (cj/httpd-check-or-create-wwwdir)
:config
- (setq httpd-root wwwdir)
+ (setq httpd-root cj/httpd-wwwdir)
(setq httpd-show-backtrace-when-error t)
(setq httpd-serve-files t))
diff --git a/modules/jumper.el b/modules/jumper.el
index de270de66..3dc00aa18 100644
--- a/modules/jumper.el
+++ b/modules/jumper.el
@@ -114,7 +114,8 @@ marker's buffer with point at the marker (within `save-current-buffer' and
marker."
(let* ((reg (aref jumper--registers index))
(marker (get-register reg)))
- (when (and marker (markerp marker))
+ (when (and marker (markerp marker)
+ (buffer-live-p (marker-buffer marker)))
(save-current-buffer
(set-buffer (marker-buffer marker))
(save-excursion
@@ -156,6 +157,20 @@ Indices whose marker is no longer valid are skipped (their
for fmt = (jumper--format-location i)
when fmt collect (cons fmt i)))
+(defun jumper--first-free-register ()
+ "Return the lowest register char in 0..N-1 not held by a live slot.
+N is `jumper-max-locations'. Only the live slice (indices 0 through
+`jumper--next-index' minus 1) is consulted, so a char freed by a removal is
+reused on the next store instead of colliding with a surviving slot's
+register and silently overwriting its marker."
+ (let ((used (make-hash-table :test 'eql)))
+ (dotimes (i jumper--next-index)
+ (let ((r (aref jumper--registers i)))
+ (when r (puthash r t used))))
+ (cl-loop for c from ?0 below (+ ?0 jumper-max-locations)
+ unless (gethash c used)
+ return c)))
+
(defun jumper--do-store-location ()
"Store current location in the next free register.
Returns: \\='already-exists if location is already stored,
@@ -165,7 +180,7 @@ Returns: \\='already-exists if location is already stored,
((jumper--location-exists-p) 'already-exists)
((not (jumper--register-available-p)) 'no-space)
(t
- (let ((reg (+ ?0 jumper--next-index)))
+ (let ((reg (jumper--first-free-register)))
(point-to-register reg)
(aset jumper--registers jumper--next-index reg)
(setq jumper--next-index (1+ jumper--next-index))
@@ -190,7 +205,13 @@ Returns: \\='no-locations if no locations stored,
;; Toggle behavior when target-idx is nil and only 1 location
((and (null target-idx) (= jumper--next-index 1))
(if (jumper--location-exists-p)
- 'already-there
+ ;; Already at the only location: toggle back to where we came from
+ ;; when a last-location is recorded, otherwise report no movement.
+ (if (get-register jumper--last-location-register)
+ (progn
+ (jump-to-register jumper--last-location-register)
+ 'jumped-back)
+ 'already-there)
(let ((reg (aref jumper--registers 0)))
(point-to-register jumper--last-location-register)
(jump-to-register reg)
@@ -217,6 +238,7 @@ Returns: \\='no-locations if no locations stored,
((= jumper--next-index 1)
(pcase (jumper--do-jump-to-location nil)
('already-there (message "You're already at the stored location"))
+ ('jumped-back (message "Jumped back to previous location"))
('jumped (message "Jumped to location"))))
;; Multiple locations - prompt user
(t
@@ -233,13 +255,16 @@ Returns: \\='no-locations if no locations stored,
(message "Jumped to location")))))
(defun jumper--reorder-registers (removed-idx)
- "Reorder registers after removing the one at REMOVED-IDX."
- (when (< removed-idx (1- jumper--next-index))
- ;; Shift all higher registers down
- (cl-loop for i from removed-idx below (1- jumper--next-index)
- do (let ((next-reg (aref jumper--registers (1+ i))))
- (aset jumper--registers i next-reg))))
- (setq jumper--next-index (1- jumper--next-index)))
+ "Reorder registers after removing the one at REMOVED-IDX.
+Shift the higher registers down and clear the freed register so its marker
+no longer pins its buffer."
+ (let ((freed (aref jumper--registers removed-idx)))
+ (when (< removed-idx (1- jumper--next-index))
+ ;; Shift all higher registers down
+ (cl-loop for i from removed-idx below (1- jumper--next-index)
+ do (aset jumper--registers i (aref jumper--registers (1+ i)))))
+ (setq jumper--next-index (1- jumper--next-index))
+ (when freed (set-register freed nil))))
(defun jumper--do-remove-location (index)
"Remove location at INDEX.
@@ -278,16 +303,12 @@ Returns: \\='no-locations if no locations stored,
(interactive)
(keymap-global-set jumper-prefix-key jumper-map))
-;; Call jumper-setup-keys when the package is loaded
-(jumper-setup-keys)
-
-;; which-key integration
-(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements
- "M-SPC" "jumper menu"
- "M-SPC SPC" "store location"
- "M-SPC j" "jump to location"
- "M-SPC d" "remove location"))
+;; Jumper's M-SPC prefix was removed 2026-06-23 so M-SPC could go to
+;; `cj/ai-term-next'. A cleverer home for jumper (numbers or F-keys) is
+;; pending review; until then its commands are reachable via M-x
+;; (jumper-store-location / jumper-jump-to-location / jumper-remove-location).
+;; To re-home: set `jumper-prefix-key' to the new prefix and call
+;; `jumper-setup-keys' (and restore the which-key labels for that prefix).
(provide 'jumper)
;;; jumper.el ends here.
diff --git a/modules/latex-config.el b/modules/latex-config.el
index 0db21f2f2..f2a586704 100644
--- a/modules/latex-config.el
+++ b/modules/latex-config.el
@@ -63,7 +63,10 @@ single entry."
:ensure auctex
:defer t
:hook
- (TeX-mode-hook . (lambda () (setq TeX-command-default "latexmk"))) ; use latexmk by default
+ ;; Name the mode, not the hook: use-package appends "-hook" to any symbol not
+ ;; ending in "-mode", so `TeX-mode' becomes `TeX-mode-hook' while the literal
+ ;; `TeX-mode-hook' would expand to the unbound `TeX-mode-hook-hook'.
+ (TeX-mode . (lambda () (setq TeX-command-default "latexmk"))) ; use latexmk by default
(LaTeX-mode . (lambda () (TeX-fold-mode 1))) ; automatically activate TeX-fold-mode.
(LaTeX-mode . flyspell-mode) ; turn on flyspell-mode by default
(LaTeX-mode . TeX-PDF-mode)
@@ -78,7 +81,9 @@ single entry."
(setq-default TeX-master t)) ; Assume the file is the master file itself
(use-package auctex-latexmk
- :defer t
+ ;; Load with AUCTeX, not deferred: `:defer t' has no autoload trigger here, so
+ ;; `auctex-latexmk-setup' never runs and "latexmk" never joins TeX-command-list.
+ :after tex
:config
(auctex-latexmk-setup)
(setq auctex-latexmk-inherit-TeX-PDF-mode t))
diff --git a/modules/ledger-config.el b/modules/ledger-config.el
index c268fa368..018601043 100644
--- a/modules/ledger-config.el
+++ b/modules/ledger-config.el
@@ -2,6 +2,24 @@
;; author Craig Jennings <c@cjennings.net>
;;; Commentary:
+;; Editing support for ledger-format plain-text accounting files: ledger-mode,
+;; flycheck linting, company completion, clean-on-save, and a small report set.
+;; The reports and reconcile shell out to the `ledger' CLI; a load-time check
+;; warns when it is missing rather than letting a report fail cryptically.
+
+;;; Code:
+
+;; ------------------------------- Declarations --------------------------------
+
+(declare-function ledger-mode-clean-buffer "ledger-mode")
+(declare-function cj/executable-find-or-warn "system-lib")
+(defvar ledger-mode-map)
+(defvar company-backends)
+
+(defcustom cj/ledger-clean-on-save t
+ "When non-nil, tidy a ledger buffer with `ledger-mode-clean-buffer' before save."
+ :type 'boolean
+ :group 'ledger)
;; -------------------------------- Ledger Mode --------------------------------
;; edit files in ledger format
@@ -11,35 +29,38 @@
"\\.ledger\\'"
"\\.journal\\'")
:preface
- (defun cj/ledger-save ()
- "Automatically clean the ledger buffer at each save."
- (interactive)
- (save-excursion
- (when (buffer-modified-p)
- (with-demoted-errors (ledger-mode-clean-buffer))
- (save-buffer))))
- :bind
- (:map ledger-mode-map
- ("C-x C-s" . cj/ledger-save))
+ (defun cj/ledger--clean-before-save ()
+ "Tidy the ledger buffer before save when `cj/ledger-clean-on-save' is set.
+Errors are demoted so a malformed buffer still saves."
+ (when cj/ledger-clean-on-save
+ (with-demoted-errors "Error cleaning ledger buffer: %S"
+ (ledger-mode-clean-buffer))))
+ (defun cj/ledger--enable-clean-on-save ()
+ "Install the clean-on-save hook buffer-locally so it fires on every save path."
+ (add-hook 'before-save-hook #'cj/ledger--clean-before-save nil t))
+ :hook (ledger-mode . cj/ledger--enable-clean-on-save)
:custom
(ledger-clear-whole-transactions t)
(ledger-reconcile-default-commodity "$")
(ledger-report-use-header-line nil)
+ (ledger-highlight-xact-under-point t)
(ledger-reports
'(("bal" "%(binary) --strict -f %(ledger-file) bal")
("bal this month" "%(binary) --strict -f %(ledger-file) bal -p %(month) -S amount")
("bal this year" "%(binary) --strict -f %(ledger-file) bal -p 'this year'")
("net worth" "%(binary) --strict -f %(ledger-file) bal Assets Liabilities")
- ("account" "%(binary) --strict -f %(ledger-file) reg %(account)"))))
+ ("account" "%(binary) --strict -f %(ledger-file) reg %(account)")))
+ :config
+ (cj/executable-find-or-warn "ledger" 'ledger-mode))
;; ------------------------------ Flycheck Ledger ------------------------------
-;; syntax and unbalanced transaction linting
+;; syntax and unbalanced-transaction linting
(use-package flycheck-ledger
:after ledger-mode)
;; ------------------------------- Company Ledger ------------------------------
-;; autocompletion for ledger
+;; account/payee autocompletion for ledger
(use-package company-ledger
:after (company ledger-mode)
diff --git a/modules/local-repository.el b/modules/local-repository.el
index b97b74f41..6376d9f73 100644
--- a/modules/local-repository.el
+++ b/modules/local-repository.el
@@ -25,23 +25,33 @@
;; ------------------------------- Customizations ------------------------------
+(defgroup localrepo nil
+ "Local last-known-good package repository."
+ :group 'package)
+
(defcustom localrepo-repository-id "localrepo"
"The name used to identify the local repository internally.
-Used for the package-archive and package-archive-priorities lists.")
+Used for the package-archive and package-archive-priorities lists."
+ :type 'string
+ :group 'localrepo)
(defcustom localrepo-repository-priority 100
"The value for the local repository in the package-archive-priority list.
A higher value means higher priority. If you want your local packages to be
-preferred, this must be a higher number than any other repositories.")
+preferred, this must be a higher number than any other repositories."
+ :type 'integer
+ :group 'localrepo)
(defcustom localrepo-repository-location
(concat user-emacs-directory "/.localrepo")
"The location of the local repository.
It's a good idea to keep this with the rest of your configuration files and
-keep them in source control.")
+keep them in source control."
+ :type 'directory
+ :group 'localrepo)
(defun cj/update-localrepo-repository ()
"Update the local repository with currently installed packages."
diff --git a/modules/mail-config.el b/modules/mail-config.el
index 08f50b12f..1d8a98c97 100644
--- a/modules/mail-config.el
+++ b/modules/mail-config.el
@@ -50,6 +50,31 @@
(declare-function mu4e-message-field "mu4e-message")
+;; ----------------------------- Declarations ----------------------------------
+;; mu4e/org-msg load lazily, so the byte-compiler can't see these package
+;; functions and variables when this module is compiled standalone. Declare
+;; them to silence free-variable / undefined-function warnings without forcing
+;; an eager require (which would defeat lazy loading). The cj/... entries are
+;; forward references: defined later in this file's `:config' block, or in
+;; mu4e-org-contacts-integration (required at load time inside that block).
+
+(declare-function mu4e-headers-mark-for-each-if "mu4e-mark")
+(declare-function mu4e-search "mu4e-search")
+(declare-function mu4e-view-refresh "mu4e-view")
+(declare-function message-add-header "message")
+(declare-function org-msg-edit-mode "org-msg")
+(declare-function no-auto-fill "mail-config")
+(declare-function cj/disable-company-in-mu4e-compose "mail-config")
+(declare-function cj/disable-ispell-in-email-headers "mail-config")
+(declare-function cj/activate-mu4e-org-contacts-integration
+ "mu4e-org-contacts-integration")
+
+;; Package variables assigned in the lazy `:config' blocks below.
+(defvar mu4e-compose-keep-self-cc)
+(defvar mu4e-root-maildir)
+(defvar mu4e-show-images)
+(defvar org-msg-extra-css)
+
;; Refile (archive) target dispatch. A per-context `mu4e-refile-folder' string
;; is unsafe: mu4e context :vars are sticky, so a value set when one context is
;; active leaks into a later context that doesn't set its own -- archiving one
@@ -197,12 +222,16 @@ Prompts user for the action when executing."
;; (setq mu4e-compose-format-flowed t) ;; plain text mails must flow correctly for recipients
(setq mu4e-compose-keep-self-cc t) ;; keep me in the cc list
- (setq mu4e-compose-signature-auto-include nil) ;; don't include signature by default
+ (with-suppressed-warnings ((obsolete mu4e-compose-signature-auto-include)
+ (free-vars mu4e-compose-signature-auto-include))
+ (setq mu4e-compose-signature-auto-include nil)) ;; don't include signature by default
(setq mu4e-confirm-quit nil) ;; don't ask when quitting
(setq mu4e-context-policy 'pick-first) ;; start with the first (default) context
(setq mu4e-headers-auto-update nil) ;; updating headers buffer on email is too jarring
(setq mu4e-root-maildir mail-dir) ;; root directory for all email accounts
- (setq mu4e-maildir mail-dir) ;; same as above (for newer mu4e)
+ (with-suppressed-warnings ((obsolete mu4e-maildir)
+ (free-vars mu4e-maildir))
+ (setq mu4e-maildir mail-dir)) ;; same as above (for newer mu4e)
(setq mu4e-sent-messages-behavior 'delete) ;; don't save to "Sent", IMAP does this already
(setq mu4e-show-images t) ;; show embedded images
;; (setq mu4e-update-interval 600) ;; check for new mail every 10 minutes (600 seconds)
@@ -214,12 +243,16 @@ Prompts user for the action when executing."
;; This will be automatically disabled when org-msg is active
(setq mu4e-compose-format-flowed t)
- (setq mu4e-html2text-command 'mu4e-shr2text) ;; email conversion to html via shr2text
+ (with-suppressed-warnings ((obsolete mu4e-html2text-command)
+ (free-vars mu4e-html2text-command))
+ (setq mu4e-html2text-command 'mu4e-shr2text)) ;; email conversion to html via shr2text
(setq mu4e-mu-binary (executable-find "mu"))
(setq mu4e-get-mail-command (cj/mail--mbsync-command)) ;; command to sync mail
- (setq mu4e-user-mail-address-list '("c@cjennings.net"
- "craigmartinjennings@gmail.com"
- "craig.jennings@deepsat.com"))
+ (with-suppressed-warnings ((obsolete mu4e-user-mail-address-list)
+ (free-vars mu4e-user-mail-address-list))
+ (setq mu4e-user-mail-address-list '("c@cjennings.net"
+ "craigmartinjennings@gmail.com"
+ "craig.jennings@deepsat.com")))
(setq mu4e-index-update-error-warning nil) ;; don't warn me about spurious sync issues
;; ------------------------------ Mu4e Contexts ------------------------------
@@ -295,7 +328,7 @@ Prompts user for the action when executing."
:key ?d)))
(defun no-auto-fill ()
- "Turn off \'auto-fill-mode\'."
+ "Turn off `auto-fill-mode'."
(auto-fill-mode -1))
(add-hook 'mu4e-compose-mode-hook #'no-auto-fill)
@@ -317,19 +350,23 @@ Prompts user for the action when executing."
;; also see org-msg below
;; Prefer HTML over plain text when both are available
- (setq mu4e-view-prefer-html t)
+ (with-suppressed-warnings ((obsolete mu4e-view-prefer-html)
+ (free-vars mu4e-view-prefer-html))
+ (setq mu4e-view-prefer-html t))
;; Use a better HTML renderer with more control
- (setq mu4e-html2text-command
- (cond
- ;; Best option: pandoc (if available)
- ((executable-find "pandoc")
- "pandoc -f html -t plain --reference-links")
- ;; Good option: w3m (better tables/formatting)
- ((executable-find "w3m")
- "w3m -dump -T text/html -cols 72 -o display_link_number=true")
- ;; Fallback: built-in shr
- (t 'mu4e-shr2text)))
+ (with-suppressed-warnings ((obsolete mu4e-html2text-command)
+ (free-vars mu4e-html2text-command))
+ (setq mu4e-html2text-command
+ (cond
+ ;; Best option: pandoc (if available)
+ ((executable-find "pandoc")
+ "pandoc -f html -t plain --reference-links")
+ ;; Good option: w3m (better tables/formatting)
+ ((executable-find "w3m")
+ "w3m -dump -T text/html -cols 72 -o display_link_number=true")
+ ;; Fallback: built-in shr
+ (t 'mu4e-shr2text))))
;; Configure shr (built-in HTML renderer) for better display
(setq shr-use-colors nil) ; Don't use colors in terminal
@@ -339,8 +376,10 @@ Prompts user for the action when executing."
(setq shr-bullet "• ") ; Nice bullet points
;; Block remote images by default (privacy/security)
- (setq mu4e-view-show-images t)
- (setq mu4e-view-image-max-width 800)
+ (with-suppressed-warnings ((obsolete mu4e-view-show-images mu4e-view-image-max-width)
+ (free-vars mu4e-view-show-images mu4e-view-image-max-width))
+ (setq mu4e-view-show-images t)
+ (setq mu4e-view-image-max-width 800))
;; ------------------------------- View Actions ------------------------------
;; define view and article menus
diff --git a/modules/markdown-config.el b/modules/markdown-config.el
index 16935425d..424c09cc8 100644
--- a/modules/markdown-config.el
+++ b/modules/markdown-config.el
@@ -20,14 +20,13 @@
:mode (("README\\.md\\'" . gfm-mode)
("\\.md\\'" . markdown-mode)
("\\.markdown\\'" . markdown-mode))
- :bind (:map markdown-mode-map
- ("<f2>" . cj/markdown-preview)) ;; use same key as compile for consistency
:init (setq markdown-command "multimarkdown"))
;; Register markdown as a known org-src-block language so `org-lint'
;; stops warning on `#+begin_src markdown ... #+end_src' and `C-c ''
;; inside such a block opens it in `markdown-mode' instead of falling
;; back to fundamental-mode.
+(defvar org-src-lang-modes)
(with-eval-after-load 'org
(add-to-list 'org-src-lang-modes '("markdown" . markdown)))
@@ -40,6 +39,8 @@
;;;; --------------------- WIP: Markdown-Preview ---------------------
+(declare-function imp--notify-clients "impatient-mode")
+
(defun cj/markdown-preview-server-start ()
"Start the simple-httpd listener that serves the live markdown preview.
Idempotent: re-running while the server is already up is a no-op."
@@ -75,5 +76,12 @@ lives in a separate command."
(buffer-substring-no-properties (point-min) (point-max))))
(current-buffer)))
+;; Bind the preview key after the defun so use-package's `:bind' autoload
+;; stub doesn't collide with this file's own definition of the command
+;; (that collision is the "defined multiple times" byte-compile warning).
+;; Same key as compile, for consistency.
+(with-eval-after-load 'markdown-mode
+ (keymap-set markdown-mode-map "<f2>" #'cj/markdown-preview))
+
(provide 'markdown-config)
;;; markdown-config.el ends here
diff --git a/modules/media-utils.el b/modules/media-utils.el
index 685530d89..1abbc1b2b 100644
--- a/modules/media-utils.el
+++ b/modules/media-utils.el
@@ -86,9 +86,11 @@ strings."
:value-type sexp))
:group 'media)
-(defcustom cj/default-media-player 'vlc
+(defcustom cj/default-media-player 'mpv
"The default media player to use for videos.
-Should be a key from `cj/media-players'."
+Should be a key from `cj/media-players'. mpv is the default because it
+resolves streaming-site URLs itself via yt-dlp, so it needs no pre-extracted
+stream URL (see the :needs-stream-url flag in `cj/media-players')."
:type 'symbol
:group 'media)
diff --git a/modules/mousetrap-mode.el b/modules/mousetrap-mode.el
index 99475fcde..3817e0081 100644
--- a/modules/mousetrap-mode.el
+++ b/modules/mousetrap-mode.el
@@ -67,7 +67,8 @@ Categories can be combined in profiles to allow specific interaction patterns.")
"Mouse interaction profiles for different use cases.
Each profile specifies which event categories are allowed.
-Available categories: primary-click, secondary-click, drags, multi-clicks, scroll.
+Available categories: primary-click, secondary-click, drags,
+multi-clicks, scroll.
Profiles:
- disabled: Block all mouse events
@@ -88,7 +89,7 @@ Modes not listed here will use `mouse-trap-default-profile'.
When checking, the mode hierarchy is respected via `derived-mode-p'.")
(defvar mouse-trap-default-profile 'disabled
- "Default profile to use when current major mode is not in `mouse-trap-mode-profiles'.")
+ "Default profile when the major mode is not in `mouse-trap-mode-profiles'.")
;;; Keymap Builder
@@ -187,6 +188,11 @@ Used via `emulation-mode-map-alists' so each buffer gets its own keymap.")
;;; Minor Mode Definition
+;; Forward declaration: the minor-mode variable is defined by the
+;; `define-minor-mode' form below, but referenced earlier in the lighter
+;; keymap and lighter-string helpers.
+(defvar mouse-trap-mode)
+
(defvar mouse-trap--lighter-keymap
(let ((map (make-sparse-keymap)))
(define-key map [mode-line mouse-1]
diff --git a/modules/mu4e-org-contacts-integration.el b/modules/mu4e-org-contacts-integration.el
index 6aed3d4cf..daa12701a 100644
--- a/modules/mu4e-org-contacts-integration.el
+++ b/modules/mu4e-org-contacts-integration.el
@@ -32,7 +32,6 @@ This function is designed to work with mu4e's compose buffers."
(re-search-backward "\\(\\`\\|[\n:,]\\)[ \t]*" nil t)
(goto-char (match-end 0))
(point)))
- (initial (buffer-substring-no-properties start end))
(contacts (cj/get-all-contact-emails)))
(when contacts
(list start end
diff --git a/modules/mu4e-org-contacts-setup.el b/modules/mu4e-org-contacts-setup.el
index 034e74574..64e9a611f 100644
--- a/modules/mu4e-org-contacts-setup.el
+++ b/modules/mu4e-org-contacts-setup.el
@@ -7,6 +7,10 @@
;;; Code:
+(defvar mu4e-compose-complete-only-personal)
+(defvar mu4e-compose-complete-only-after)
+(declare-function cj/activate-mu4e-org-contacts-integration "mu4e-org-contacts-integration")
+
;; Load the integration module. Activation only runs when the module loaded
;; cleanly AND mu4e is present; otherwise this file is a no-op so the rest
;; of the config can load without mu4e installed.
diff --git a/modules/music-config.el b/modules/music-config.el
index 55eb47d25..76fff283b 100644
--- a/modules/music-config.el
+++ b/modules/music-config.el
@@ -98,6 +98,43 @@
(require 'cj-window-toggle-lib) ;; side-window size memory (F10 toggle)
(require 'system-lib) ;; cj/confirm-strong (overwrite confirms)
+;; Declare these foreign package vars special so `let'-binding them below
+;; compiles as a dynamic bind, not a dead lexical local -- otherwise emms /
+;; orderless never see the binding (the lexical-binding foreign-special-var trap).
+(defvar orderless-smart-case)
+(defvar emms-source-playlist-ask-before-overwrite)
+(defvar emms-playlist-buffer-p)
+(defvar emms-playlist-buffer)
+(defvar emms-random-playlist)
+(defvar emms-playlist-selected-marker)
+(defvar emms-source-file-default-directory)
+(defvar emms-player-mpv-parameters)
+(defvar emms-player-mpv-regexp)
+(defvar emms-player-playing-p)
+(defvar emms-player-paused-p)
+(defvar emms-playlist-mode-map)
+(defvar dirvish-mode-map)
+
+;; Foreign functions used lazily after their packages load.
+(declare-function emms-playlist-mode "emms-playlist-mode")
+(declare-function emms-playlist-track-at "emms-playlist-mode")
+(declare-function emms-playlist-mode-kill-track "emms-playlist-mode")
+(declare-function emms-track-name "emms")
+(declare-function emms-track-type "emms")
+(declare-function emms-track-get "emms")
+(declare-function emms-track-simple-description "emms")
+(declare-function emms-playlist-current-selected-track "emms")
+(declare-function emms-playlist-select "emms")
+(declare-function emms-playlist-clear "emms")
+(declare-function emms-playlist-save "emms-source-playlist")
+(declare-function emms-start "emms")
+(declare-function emms-random "emms")
+(declare-function emms-next "emms")
+(declare-function emms-previous "emms")
+(declare-function dired-get-marked-files "dired")
+(declare-function dired-get-file-for-visit "dired")
+(declare-function face-remap-remove-relative "face-remap")
+
;;; Settings (no Customize)
(defvar cj/music-root music-dir
@@ -613,26 +650,26 @@ Initializes EMMS if needed."
;;; Dired/Dirvish integration
-(with-eval-after-load 'dirvish
- (defun cj/music-add-dired-selection ()
- "Add selected files/dirs in Dired/Dirvish to the EMMS playlist.
+(defun cj/music-add-dired-selection ()
+ "Add selected files/dirs in Dired/Dirvish to the EMMS playlist.
Dirs added recursively."
- (interactive)
- (unless (derived-mode-p 'dired-mode)
- (user-error "This command must be run in a Dired buffer"))
- (cj/music--ensure-playlist-buffer)
- (let ((files (if (use-region-p)
- (dired-get-marked-files)
- (list (dired-get-file-for-visit)))))
- (when (null files)
- (user-error "No files selected"))
- (dolist (file files)
- (cond
- ((file-directory-p file) (cj/music-add-directory-recursive file))
- ((cj/music--valid-file-p file) (emms-add-file file))
- (t (message "Skipping non-music file: %s" file))))
- (message "Added %d item(s) to playlist" (length files))))
+ (interactive)
+ (unless (derived-mode-p 'dired-mode)
+ (user-error "This command must be run in a Dired buffer"))
+ (cj/music--ensure-playlist-buffer)
+ (let ((files (if (use-region-p)
+ (dired-get-marked-files)
+ (list (dired-get-file-for-visit)))))
+ (when (null files)
+ (user-error "No files selected"))
+ (dolist (file files)
+ (cond
+ ((file-directory-p file) (cj/music-add-directory-recursive file))
+ ((cj/music--valid-file-p file) (emms-add-file file))
+ (t (message "Skipping non-music file: %s" file))))
+ (message "Added %d item(s) to playlist" (length files))))
+(with-eval-after-load 'dirvish
(keymap-set dirvish-mode-map "+" #'cj/music-add-dired-selection))
;;; EMMS setup and keybindings
@@ -674,6 +711,130 @@ Dirs added recursively."
"C-; m z" "random"
"C-; m x" "consume"))
+;;; Playlist display helpers
+;;
+;; Defined at top level (not inside the `emms' use-package `:config') so the
+;; byte-compiler sees them; they touch EMMS only at call time, after load.
+
+(defun cj/music--after-playlist-clear (&rest _)
+ "Forget the associated M3U file after the playlist is cleared."
+ (when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
+ (with-current-buffer buf
+ (setq cj/music-playlist-file nil))))
+
+(defun cj/music--format-duration (seconds)
+ "Convert SECONDS to a \"M:SS\" string."
+ (when (and seconds (numberp seconds) (> seconds 0))
+ (format "%d:%02d" (/ seconds 60) (mod seconds 60))))
+
+(defun cj/music--track-description (track)
+ "Return a human-readable description of TRACK.
+For tagged tracks: \"Artist - Title [M:SS]\".
+For file tracks without tags: filename without path or extension.
+For URL tracks: decoded URL."
+ (let ((type (emms-track-type track))
+ (title (emms-track-get track 'info-title))
+ (artist (emms-track-get track 'info-artist))
+ (duration (emms-track-get track 'info-playing-time))
+ (name (emms-track-name track)))
+ (cond
+ ;; Tagged track with title
+ (title
+ (let ((dur-str (cj/music--format-duration duration))
+ (parts '()))
+ (when artist (push artist parts))
+ (push title parts)
+ (let ((desc (string-join (nreverse parts) " - ")))
+ (if dur-str (format "%s [%s]" desc dur-str) desc))))
+ ;; File without tags — show clean filename
+ ((eq type 'file)
+ (file-name-sans-extension (file-name-nondirectory name)))
+ ;; URL — decode percent-encoded characters
+ ((eq type 'url)
+ (decode-coding-string (url-unhex-string name) 'utf-8))
+ ;; Fallback
+ (t (emms-track-simple-description track)))))
+
+;; Multi-line header overlay
+(defvar-local cj/music--header-overlay nil
+ "Overlay displaying the playlist header.")
+
+(defun cj/music--header-text ()
+ "Build a multi-line header string for the playlist buffer overlay."
+ (let* ((pl-name (if cj/music-playlist-file
+ (file-name-sans-extension
+ (file-name-nondirectory cj/music-playlist-file))
+ "Untitled"))
+ (track-count (count-lines (point-min) (point-max)))
+ (now-playing (cond
+ ((not emms-player-playing-p) "Stopped")
+ (emms-player-paused-p "Paused")
+ (t (let ((track (emms-playlist-current-selected-track)))
+ (if track
+ (cj/music--track-description track)
+ "Playing")))))
+ (mode-indicator
+ (lambda (key label active)
+ (let ((face (if active 'cj/music-mode-on-face 'cj/music-mode-off-face)))
+ (propertize (format "[%s] %s" key label) 'face face)))))
+ (concat
+ (propertize "Playlist" 'face 'cj/music-header-face)
+ (propertize " : " 'face 'cj/music-header-face)
+ (propertize (format "%s (%d)" pl-name track-count) 'face 'cj/music-header-value-face)
+ "\n"
+ (propertize "Current " 'face 'cj/music-header-face)
+ (propertize " : " 'face 'cj/music-header-face)
+ (propertize now-playing 'face 'cj/music-header-value-face)
+ "\n"
+ (propertize "Mode " 'face 'cj/music-header-face)
+ (propertize " : " 'face 'cj/music-header-face)
+ (funcall mode-indicator "r" "repeat" (bound-and-true-p emms-repeat-playlist))
+ " "
+ (funcall mode-indicator "t" "single" (bound-and-true-p emms-repeat-track))
+ " "
+ (funcall mode-indicator "z" "random" (bound-and-true-p emms-random-playlist))
+ " "
+ (funcall mode-indicator "x" "consume" cj/music-consume-mode)
+ "\n"
+ (propertize "Keys " 'face 'cj/music-header-face)
+ (propertize " : " 'face 'cj/music-header-face)
+ (propertize "a:add c:clear L:load S:save SPC:pause <>:skip ↑↓:move C-↑↓:reorder q:dismiss"
+ 'face 'cj/music-keyhint-face)
+ "\n\n")))
+
+(defun cj/music--update-header ()
+ "Insert or update the multi-line header overlay in the playlist buffer."
+ (when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
+ (with-current-buffer buf
+ (unless cj/music--header-overlay
+ (setq cj/music--header-overlay (make-overlay (point-min) (point-min)))
+ (overlay-put cj/music--header-overlay 'priority 100))
+ (move-overlay cj/music--header-overlay (point-min) (point-min))
+ (overlay-put cj/music--header-overlay 'before-string
+ (cj/music--header-text)))))
+
+(defvar-local cj/music--bg-remap-cookie nil
+ "Cookie for the active-window background face remapping.")
+
+(defun cj/music--update-active-bg (&rest _)
+ "Toggle playlist buffer background based on whether its window is selected."
+ (when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
+ (with-current-buffer buf
+ (let ((active (eq buf (window-buffer (selected-window)))))
+ (cond
+ ((and active (not cj/music--bg-remap-cookie))
+ (setq cj/music--bg-remap-cookie
+ (face-remap-add-relative 'default)))
+ ((and (not active) cj/music--bg-remap-cookie)
+ (face-remap-remove-relative cj/music--bg-remap-cookie)
+ (setq cj/music--bg-remap-cookie nil)))))))
+
+(defun cj/music--setup-playlist-display ()
+ "Set up header overlay and focus tracking in the playlist buffer."
+ (setq header-line-format nil)
+ (cj/music--update-header)
+ (add-hook 'window-selection-change-functions #'cj/music--update-active-bg nil t))
+
(use-package emms
:defer t
:init
@@ -698,7 +859,7 @@ Dirs added recursively."
(emms-all)
;; Disable modeline display (keep modeline clean)
- (emms-playing-time-disable-display)
+ (emms-playing-time-display-mode -1)
(emms-mode-line-mode -1)
;; MPV configuration
@@ -712,134 +873,16 @@ Dirs added recursively."
(regexp-opt cj/music-file-extensions)
"\\'\\)"))
- ;; Keep cj/music-playlist-file in sync if playlist is cleared
- (defun cj/music--after-playlist-clear (&rest _)
- (when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
- (with-current-buffer buf
- (setq cj/music-playlist-file nil))))
-
- ;; Ensure we don't stack duplicate advice on reload
+ ;; Keep cj/music-playlist-file in sync if playlist is cleared.
+ ;; Ensure we don't stack duplicate advice on reload.
(advice-remove 'emms-playlist-clear #'cj/music--after-playlist-clear)
(advice-add 'emms-playlist-clear :after #'cj/music--after-playlist-clear)
;;; Playlist display
;; Track description: show "Artist - Title [M:SS]" instead of file paths
- (defun cj/music--format-duration (seconds)
- "Convert SECONDS to a \"M:SS\" string."
- (when (and seconds (numberp seconds) (> seconds 0))
- (format "%d:%02d" (/ seconds 60) (mod seconds 60))))
-
- (defun cj/music--track-description (track)
- "Return a human-readable description of TRACK.
-For tagged tracks: \"Artist - Title [M:SS]\".
-For file tracks without tags: filename without path or extension.
-For URL tracks: decoded URL."
- (let ((type (emms-track-type track))
- (title (emms-track-get track 'info-title))
- (artist (emms-track-get track 'info-artist))
- (duration (emms-track-get track 'info-playing-time))
- (name (emms-track-name track)))
- (cond
- ;; Tagged track with title
- (title
- (let ((dur-str (cj/music--format-duration duration))
- (parts '()))
- (when artist (push artist parts))
- (push title parts)
- (let ((desc (string-join (nreverse parts) " - ")))
- (if dur-str (format "%s [%s]" desc dur-str) desc))))
- ;; File without tags — show clean filename
- ((eq type 'file)
- (file-name-sans-extension (file-name-nondirectory name)))
- ;; URL — decode percent-encoded characters
- ((eq type 'url)
- (decode-coding-string (url-unhex-string name) 'utf-8))
- ;; Fallback
- (t (emms-track-simple-description track)))))
-
(setq emms-track-description-function #'cj/music--track-description)
- ;; Multi-line header overlay
- (defvar-local cj/music--header-overlay nil
- "Overlay displaying the playlist header.")
-
- (defun cj/music--header-text ()
- "Build a multi-line header string for the playlist buffer overlay."
- (let* ((pl-name (if cj/music-playlist-file
- (file-name-sans-extension
- (file-name-nondirectory cj/music-playlist-file))
- "Untitled"))
- (track-count (count-lines (point-min) (point-max)))
- (now-playing (cond
- ((not emms-player-playing-p) "Stopped")
- (emms-player-paused-p "Paused")
- (t (let ((track (emms-playlist-current-selected-track)))
- (if track
- (cj/music--track-description track)
- "Playing")))))
- (mode-indicator
- (lambda (key label active)
- (let ((face (if active 'cj/music-mode-on-face 'cj/music-mode-off-face)))
- (propertize (format "[%s] %s" key label) 'face face)))))
- (concat
- (propertize "Playlist" 'face 'cj/music-header-face)
- (propertize " : " 'face 'cj/music-header-face)
- (propertize (format "%s (%d)" pl-name track-count) 'face 'cj/music-header-value-face)
- "\n"
- (propertize "Current " 'face 'cj/music-header-face)
- (propertize " : " 'face 'cj/music-header-face)
- (propertize now-playing 'face 'cj/music-header-value-face)
- "\n"
- (propertize "Mode " 'face 'cj/music-header-face)
- (propertize " : " 'face 'cj/music-header-face)
- (funcall mode-indicator "r" "repeat" (bound-and-true-p emms-repeat-playlist))
- " "
- (funcall mode-indicator "t" "single" (bound-and-true-p emms-repeat-track))
- " "
- (funcall mode-indicator "z" "random" (bound-and-true-p emms-random-playlist))
- " "
- (funcall mode-indicator "x" "consume" cj/music-consume-mode)
- "\n"
- (propertize "Keys " 'face 'cj/music-header-face)
- (propertize " : " 'face 'cj/music-header-face)
- (propertize "a:add c:clear L:load S:save SPC:pause <>:skip ↑↓:move C-↑↓:reorder q:dismiss"
- 'face 'cj/music-keyhint-face)
- "\n\n")))
-
- (defun cj/music--update-header ()
- "Insert or update the multi-line header overlay in the playlist buffer."
- (when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
- (with-current-buffer buf
- (unless cj/music--header-overlay
- (setq cj/music--header-overlay (make-overlay (point-min) (point-min)))
- (overlay-put cj/music--header-overlay 'priority 100))
- (move-overlay cj/music--header-overlay (point-min) (point-min))
- (overlay-put cj/music--header-overlay 'before-string
- (cj/music--header-text)))))
-
- (defvar-local cj/music--bg-remap-cookie nil
- "Cookie for the active-window background face remapping.")
-
- (defun cj/music--update-active-bg (&rest _)
- "Toggle playlist buffer background based on whether its window is selected."
- (when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
- (with-current-buffer buf
- (let ((active (eq buf (window-buffer (selected-window)))))
- (cond
- ((and active (not cj/music--bg-remap-cookie))
- (setq cj/music--bg-remap-cookie
- (face-remap-add-relative 'default :background "#1d1b19")))
- ((and (not active) cj/music--bg-remap-cookie)
- (face-remap-remove-relative cj/music--bg-remap-cookie)
- (setq cj/music--bg-remap-cookie nil)))))))
-
- (defun cj/music--setup-playlist-display ()
- "Set up header overlay and focus tracking in the playlist buffer."
- (setq header-line-format nil)
- (cj/music--update-header)
- (add-hook 'window-selection-change-functions #'cj/music--update-active-bg nil t))
-
(add-hook 'emms-playlist-mode-hook #'cj/music--setup-playlist-display)
(add-hook 'emms-player-started-hook #'cj/music--record-random-history)
(add-hook 'emms-player-started-hook #'cj/music--update-header)
@@ -891,8 +934,6 @@ For URL tracks: decoded URL."
("S-<down>" . emms-playlist-mode-shift-track-down)
("C-<up>" . emms-playlist-mode-shift-track-up)
("C-<down>" . emms-playlist-mode-shift-track-down)
- ;; Radio
- ("R" . cj/music-create-radio-station)
;; Volume
("+" . emms-volume-raise)
("=" . emms-volume-raise)
@@ -921,5 +962,10 @@ For URL tracks: decoded URL."
(insert content))
(message "Created radio station: %s" (file-name-nondirectory file))))
+;; Bound here rather than in the emms `:bind' so use-package does not emit a
+;; redundant autoload that collides with this same-file definition.
+(with-eval-after-load 'emms
+ (keymap-set emms-playlist-mode-map "R" #'cj/music-create-radio-station))
+
(provide 'music-config)
;;; music-config.el ends here
diff --git a/modules/nerd-icons-config.el b/modules/nerd-icons-config.el
index d3d55b864..e2edb0717 100644
--- a/modules/nerd-icons-config.el
+++ b/modules/nerd-icons-config.el
@@ -1,4 +1,4 @@
-;;; nerd-icons-config.el --- Nerd-icons setup, integrations, and tinting -*- lexical-binding: t; -*-
+;;; nerd-icons-config.el --- Nerd-icons setup and integrations -*- lexical-binding: t; -*-
;; author: Craig Jennings <c@cjennings.net>
;;; Commentary:
@@ -16,51 +16,21 @@
;; - the package itself
;; - completion integration (`nerd-icons-completion')
;; - ibuffer integration (`nerd-icons-ibuffer')
-;; - bulk color tinting of every `nerd-icons-*' color face
;; - dir-icon color advice (so directory glyphs carry a color face like
;; file glyphs do, instead of falling through to the buffer default
;; face)
;;
+;; Icon colors are theme-driven: nerd-icons' 34 `nerd-icons-*' color faces are
+;; owned by the theme (themeable in theme-studio), not overwritten at load time.
+;;
;; Per-feature USE of nerd-icons stays in the feature module that consumes
;; it: `dashboard-icon-type', `dirvish-attributes', and the keyboard-compat
;; terminal-frame icon-blanking advice are not centralized here.
;;; Code:
-;; ----------------------------- Customization ---------------------------------
-
-(defcustom cj/nerd-icons-tint-color "darkgoldenrod"
- "Single foreground color applied to every `nerd-icons-*' color face.
-Set via Customize or by `setq' before this module loads, then call
-`cj/nerd-icons-apply-tint' to re-apply on demand."
- :type 'string
- :group 'cj)
-
-(defconst cj/--nerd-icons-color-faces
- '(nerd-icons-red nerd-icons-lred nerd-icons-dred nerd-icons-red-alt
- nerd-icons-green nerd-icons-lgreen nerd-icons-dgreen
- nerd-icons-yellow nerd-icons-lyellow nerd-icons-dyellow
- nerd-icons-orange nerd-icons-lorange nerd-icons-dorange
- nerd-icons-blue nerd-icons-blue-alt nerd-icons-lblue nerd-icons-dblue
- nerd-icons-cyan nerd-icons-cyan-alt nerd-icons-lcyan nerd-icons-dcyan
- nerd-icons-purple nerd-icons-purple-alt nerd-icons-lpurple nerd-icons-dpurple
- nerd-icons-pink nerd-icons-lpink nerd-icons-dpink
- nerd-icons-maroon nerd-icons-lmaroon nerd-icons-dmaroon
- nerd-icons-silver nerd-icons-lsilver nerd-icons-dsilver)
- "Every color face nerd-icons attaches to glyphs via `:inherit'.")
-
;; ------------------------------- Helpers -------------------------------------
-(defun cj/nerd-icons-apply-tint (&optional color)
- "Set every face in `cj/--nerd-icons-color-faces' to foreground COLOR.
-COLOR defaults to `cj/nerd-icons-tint-color'. Faces that are not yet
-defined (nerd-icons not loaded) are silently skipped."
- (interactive)
- (let ((c (or color cj/nerd-icons-tint-color)))
- (dolist (f cj/--nerd-icons-color-faces)
- (when (facep f)
- (set-face-foreground f c)))))
-
(defun cj/--nerd-icons-color-dir (icon)
"Layer `nerd-icons-yellow' onto ICON's face stack and return ICON.
ICON is the propertized string returned by `nerd-icons-icon-for-dir'.
@@ -87,17 +57,15 @@ every call. The `memq' check skips when the face is already present."
(use-package nerd-icons
:demand t
:config
- (advice-add 'nerd-icons-icon-for-dir :filter-return #'cj/--nerd-icons-color-dir)
- (cj/nerd-icons-apply-tint))
+ (advice-add 'nerd-icons-icon-for-dir :filter-return #'cj/--nerd-icons-color-dir))
;; Safety net: if this module is re-evaluated in a running Emacs where
;; nerd-icons is already loaded, `:config' above won't fire again --
-;; ensure the advice and tint still apply.
+;; ensure the dir advice still applies.
(with-eval-after-load 'nerd-icons
(unless (advice-member-p #'cj/--nerd-icons-color-dir 'nerd-icons-icon-for-dir)
(advice-add 'nerd-icons-icon-for-dir
- :filter-return #'cj/--nerd-icons-color-dir))
- (cj/nerd-icons-apply-tint))
+ :filter-return #'cj/--nerd-icons-color-dir)))
(use-package nerd-icons-completion
:demand t
diff --git a/modules/org-agenda-config-debug.el b/modules/org-agenda-config-debug.el
index a9c713a13..4c1b1dd84 100644
--- a/modules/org-agenda-config-debug.el
+++ b/modules/org-agenda-config-debug.el
@@ -18,6 +18,9 @@
(require 'user-constants)
(require 'system-lib)
+(defvar org-agenda-files)
+(declare-function cj/build-org-agenda-list "org-agenda-config")
+
;; ---------------------------- Debug Functions --------------------------------
;;;###autoload
diff --git a/modules/org-agenda-config.el b/modules/org-agenda-config.el
index d5d610f27..3234cc929 100644
--- a/modules/org-agenda-config.el
+++ b/modules/org-agenda-config.el
@@ -47,9 +47,10 @@
;; M-f8 - TASK LIST containing all tasks from just the current org-mode buffer.
;;
;; NOTE:
-;; Files that contain information relevant to the agenda will be found in the
-;; following places: the schedule-file, org-roam notes tagged as 'Projects' and
-;; project todo.org files found in project-dir and code-dir.
+;; Files that contain information relevant to the agenda are: the inbox, the
+;; schedule-file, the synced calendars, and the per-project todo.org files found
+;; in immediate subdirectories of projects-dir. (org-roam notes are refile
+;; targets, not agenda sources -- see org-refile-config.el.)
;;; Code:
(require 'user-constants)
@@ -58,7 +59,8 @@
(defcustom cj/org-agenda-window-height 0.75
"Fraction of the selected frame used for the org agenda window."
- :type 'number)
+ :type 'number
+ :group 'org-agenda)
(defun cj/--org-agenda-display-rule ()
"Return the display-buffer rule for the org agenda buffer."
@@ -89,6 +91,12 @@
(setq org-agenda-skip-scheduled-if-done nil)
(setq org-agenda-remove-tags t)
(setq org-agenda-compact-blocks t)
+ ;; Backstop against a non-existent agenda file (e.g. a calendar not yet synced
+ ;; on a fresh machine): skip it silently instead of prompting to create it --
+ ;; the interactive-prompt class that once hung the chime daemon.
+ ;; `cj/--org-agenda-base-files' already filters the list; this catches any path
+ ;; that reaches `org-agenda-files' another way.
+ (setq org-agenda-skip-unavailable-files t)
;; display the agenda from the bottom
(add-to-list 'display-buffer-alist
@@ -177,14 +185,18 @@ Only checks DIRECTORY/*/todo.org — does not recurse deeper."
;; ---------------------------- Rebuild Org Agenda ---------------------------
;; builds the org agenda list from all agenda targets with caching.
-;; agenda targets is the schedule, contacts, project todos,
-;; inbox, and org roam projects.
+;; agenda targets are the inbox, the schedule, the synced calendars,
+;; and the per-project todo.org files under projects-dir.
(defun cj/--org-agenda-base-files ()
- "Return the fixed base files for the agenda: inbox, schedule, and calendars.
+ "Return the existing base files for the agenda: inbox, schedule, and calendars.
The single source of the base list shared by the agenda builders and the chime
initializer, so adding a calendar source is a one-place change. Per-project
-todo.org files are layered on separately."
- (list inbox-file schedule-file gcal-file pcal-file dcal-file))
+todo.org files are layered on separately. Files that do not exist are dropped
+\(a fresh machine may lack the synced calendars or the inbox) so org-agenda
+never prompts to create them -- the interactive-prompt class that once hung the
+chime daemon; `org-agenda-skip-unavailable-files' is the backstop."
+ (seq-filter #'file-exists-p
+ (list inbox-file schedule-file gcal-file pcal-file dcal-file)))
(defun cj/--org-agenda-scan-files ()
"Scan disk for the agenda files list. Pure-ish: no caching, no logging.
@@ -243,8 +255,8 @@ Bypasses cache and scans directories from scratch."
(defun cj/todo-list-all-agenda-files ()
"Displays an \\='org-agenda\\=' todo list.
-The contents of the agenda will be built from org-project-files and org-roam
-files that have project in their filetag."
+The contents of the agenda are built from the base files (inbox, schedule, and
+the synced calendars) plus the per-project todo.org files under projects-dir."
(interactive)
(cj/build-org-agenda-list)
(org-agenda "a" "t"))
@@ -385,8 +397,7 @@ This uses all org-agenda targets and presents three sections:
- Today's schedule, including habits with consistency graphs
- All priority B and C unscheduled/undeadlined tasks
The agenda is rebuilt from all sources before display, including:
-- inbox-file and schedule-file
-- Org-roam nodes tagged as \"Project\"
+- inbox-file, schedule-file, and the synced calendars
- All todo.org files in immediate subdirectories of projects-dir"
(interactive)
(cj/build-org-agenda-list)
diff --git a/modules/org-babel-config.el b/modules/org-babel-config.el
index 821403a0d..bc7ccb806 100644
--- a/modules/org-babel-config.el
+++ b/modules/org-babel-config.el
@@ -29,6 +29,12 @@
(setq org-src-fontify-natively t) ;; fontify the code in blocks
(setq org-src-tab-acts-natively t) ;; tabs act like in language major mode buffer
(setq org-src-window-setup 'current-window) ;; don't split window when source editing wih C-c '
+ ;; Treat cj comment blocks (#+begin_src cj: comment ...) as org for editing
+ ;; and fontification: the "cj:" language token maps to org-mode, so C-c '
+ ;; opens an org buffer and the block's prose gets org font-lock in place.
+ ;; The block stays a src block (the cj: grep marker is unchanged); org markup
+ ;; is highlighted and editable, though links are followed from the C-c ' buffer.
+ (add-to-list 'org-src-lang-modes '("cj:" . org))
(setq org-confirm-babel-evaluate t) ;; confirm before running babel; toggle with C-; k
(setq org-babel-default-header-args
(cons '(:tangle . "yes")
diff --git a/modules/org-capture-config.el b/modules/org-capture-config.el
index 2f245185f..9f5bfbe7f 100644
--- a/modules/org-capture-config.el
+++ b/modules/org-capture-config.el
@@ -30,6 +30,7 @@
(defvar org-complex-heading-regexp-format)
(declare-function cj/--drill-pick-file "org-drill-config")
+(declare-function cj/org-capture--date-prefix "org-capture-config")
(declare-function org-at-encrypted-entry-p "org-crypt")
(declare-function org-at-heading-p "org")
(declare-function org-back-to-heading "org")
@@ -170,7 +171,7 @@ letter upcased: \"~/.emacs.d/\" -> \"Emacs.d\", \"~/code/duet/\" -> \"Duet\"."
ROOT is the projectile project root (or nil); INBOX is the global inbox
file path. Return a plist (:file F :open-work BOOL :project NAME :warn MSG):
- ROOT with a todo.org -> F is that todo.org, :open-work t.
-- ROOT without a todo.org -> F is INBOX, :open-work nil, :warn names the project.
+- ROOT without a todo.org -> F is INBOX, :open-work nil, :warn names project.
- ROOT nil -> F is INBOX, :open-work nil, :warn nil."
(if (and (stringp root) (not (string-empty-p root)))
(let ((todo (expand-file-name "todo.org" root))
diff --git a/modules/org-config.el b/modules/org-config.el
index 8d722ad46..f316ee0df 100644
--- a/modules/org-config.el
+++ b/modules/org-config.el
@@ -17,6 +17,72 @@
(require 'keybindings) ;; provides cj/custom-keymap (used in :init below)
+;; Declare org variables and functions used before org is loaded so this module
+;; byte-compiles standalone. Plain `defvar' (no value) marks the symbol special
+;; without assigning anything, so org's own defaults still apply at runtime.
+(defvar org-dir)
+(defvar org-mode-map)
+(defvar org-mouse-map)
+(defvar org-modules)
+(defvar org-startup-folded)
+(defvar org-cycle-open-archived-trees)
+(defvar org-cycle-hide-drawers)
+(defvar org-id-locations-file)
+(defvar org-return-follows-link)
+(defvar org-list-allow-alphabetical)
+(defvar org-startup-indented)
+(defvar org-adapt-indentation)
+(defvar org-startup-with-inline-images)
+(defvar org-image-actual-width)
+(defvar org-yank-image-save-method)
+(defvar org-bookmark-names-plist)
+(defvar org-file-apps)
+(defvar org-ellipsis)
+(defvar org-hide-emphasis-markers)
+(defvar org-hide-leading-stars)
+(defvar org-pretty-entities)
+(defvar org-pretty-entities-include-sub-superscripts)
+(defvar org-fontify-emphasized-text)
+(defvar org-fontify-whole-heading-line)
+(defvar org-tags-column)
+(defvar org-agenda-tags-column)
+(defvar org-todo-keywords)
+(defvar org-highest-priority)
+(defvar org-lowest-priority)
+(defvar org-default-priority)
+(defvar org-enforce-todo-dependencies)
+(defvar org-enforce-todo-checkbox-dependencies)
+(defvar org-deadline-warning-days)
+(defvar org-treat-insert-todo-heading-as-state-change)
+(defvar org-log-into-drawer)
+(defvar org-log-done)
+(defvar org-use-property-inheritance)
+
+(declare-function org-current-level "org")
+(declare-function org-add-planning-info "org")
+(declare-function org-get-heading "org")
+(declare-function org-edit-headline "org")
+(declare-function org-priority "org")
+(declare-function org-heading-components "org")
+(declare-function org-todo "org")
+(declare-function org-get-todo-state "org")
+(declare-function org-back-to-heading "org")
+(declare-function org-sort-entries "org")
+(declare-function org-eval-in-calendar "org")
+(declare-function org-open-at-point "org")
+(declare-function org-backward-heading-same-level "org")
+(declare-function org-forward-heading-same-level "org")
+(declare-function org-reveal "org")
+(declare-function org-show-todo-tree "org")
+(declare-function org-fold-show-all "org-fold")
+(declare-function outline-next-heading "outline")
+(declare-function org-element-cache-reset "org-element")
+(declare-function org-element-context "org-element")
+(declare-function org-element-type "org-element-ast")
+(declare-function org-superstar-configure-like-org-bullets "org-superstar")
+(declare-function cj/--org-follow-link-same-window "org-config")
+(declare-function cj/org-follow-link-at-mouse-same-window "org-config")
+
;; ---------------------------- Org General Settings ---------------------------
(defun cj/org-general-settings ()
@@ -250,14 +316,14 @@ whole row line."
(keymap-set cj/org-map "<" #'cj/org-narrow-backwards)
;; Sparse trees: lowercase creates, capital of the same letter cancels.
- ;; Both `S' and `T' resolve to `org-show-all' -- same cancel command,
+ ;; Both `S' and `T' resolve to `org-fold-show-all' -- same cancel command,
;; paired with each lowercase create so the mental model is "capital
;; cancels the lowercase command I just ran" without having to recall
;; which letter the cancel actually lives on.
(keymap-set cj/org-map "s" #'org-match-sparse-tree)
- (keymap-set cj/org-map "S" #'org-show-all)
+ (keymap-set cj/org-map "S" #'org-fold-show-all)
(keymap-set cj/org-map "t" #'org-show-todo-tree)
- (keymap-set cj/org-map "T" #'org-show-all)
+ (keymap-set cj/org-map "T" #'org-fold-show-all)
(keymap-set cj/org-map "R" #'org-reveal)
:bind
("C-c c" . org-capture)
@@ -273,8 +339,7 @@ whole row line."
("C-c N" . org-narrow-to-subtree)
("C-c >" . cj/org-narrow-forward)
("C-c <" . cj/org-narrow-backwards)
- ("C-c <ESC>" . widen)
- ("C-c C-a" . cj/org-appear-toggle))
+ ("C-c <ESC>" . widen))
(:map cj/org-map
("r i" . org-table-insert-row)
("r d" . org-table-kill-row)
@@ -401,6 +466,11 @@ especially in tables with long URLs)."
(org-appear-mode 1)
(message "org-appear enabled (links/emphasis show when editing)")))
+;; Bound here (after the defun) rather than in the org use-package `:bind' so
+;; the command isn't autoloaded into a stub that shadows this definition.
+(with-eval-after-load 'org
+ (keymap-set org-mode-map "C-c C-a" #'cj/org-appear-toggle))
+
;; --------------------------------- Org-Tidy ----------------------------------
;; Hide :PROPERTIES: drawers behind a small inline marker so headings stay
@@ -444,7 +514,7 @@ with a file, the function will throw an error."
"Clear the org-element cache for the current buffer or all buffers.
By default, clear cache for all org buffers. With prefix argument, clear only
the current buffer's cache. Useful when encountering parsing errors like
-'wrong-type-argument stringp nil' during agenda generation."
+\"wrong-type-argument stringp nil\" during agenda generation."
(interactive)
(if current-prefix-arg
(if (derived-mode-p 'org-mode)
diff --git a/modules/org-contacts-config.el b/modules/org-contacts-config.el
index 556530eb2..64abb9fb5 100644
--- a/modules/org-contacts-config.el
+++ b/modules/org-contacts-config.el
@@ -22,6 +22,36 @@
(require 'user-constants)
+;; Function declarations -- these live in lazily-loaded packages, so the
+;; byte-compiler can't see their definitions when this module compiles
+;; standalone.
+(declare-function org-contacts-db "org-contacts")
+(declare-function org-contacts-anniversaries "org-contacts")
+(declare-function org-contacts-files "org-contacts")
+(declare-function org-columns "org-colview")
+(declare-function org-reveal "org")
+(declare-function org-fold-show-entry "org-fold")
+(declare-function org-heading-components "org")
+(declare-function org-map-entries "org")
+(declare-function org-entry-get "org")
+(declare-function outline-next-heading "outline")
+(declare-function calendar-current-date "calendar")
+(declare-function mu4e-message-at-point "mu4e-message")
+(declare-function mu4e-message-field "mu4e-message")
+(declare-function which-key-add-key-based-replacements "which-key")
+
+;; External package variables referenced below; declared so the compiler
+;; treats them as special rather than free.
+(defvar org-capture-plist)
+(defvar org-capture-templates)
+(defvar mu4e~view-message)
+(defvar org-agenda-include-diary)
+(defvar org-agenda-custom-commands)
+(defvar mu4e-org-contacts-file)
+(defvar mu4e-headers-actions)
+(defvar mu4e-view-actions)
+(defvar mu4e-compose-complete-addresses)
+
;; Set `org-contacts-files' eagerly at require time. Setting it in the
;; `use-package' form below would only apply when org-contacts loads, which is
;; deferred behind `:after (org mu4e)' -- later than the first
@@ -42,10 +72,13 @@
(defun cj/org-contacts-anniversaries-safe ()
"Safely call org-contacts-anniversaries with required bindings."
(require 'diary-lib)
- ;; These need to be dynamically bound for diary functions
- (defvar date)
- (defvar entry)
- (defvar original-date)
+ ;; `date', `entry', and `original-date' are diary special vars that the
+ ;; diary functions read dynamically. Declare them special locally; the
+ ;; suppressed warning is the unprefixed-name lint on these calendar names.
+ (with-suppressed-warnings ((lexical date entry original-date))
+ (defvar date)
+ (defvar entry)
+ (defvar original-date))
(let ((date (calendar-current-date))
(entry "")
(original-date (calendar-current-date)))
@@ -186,9 +219,10 @@ Added: %U"
(defun cj/--parse-email-string (name email-string)
"Parse EMAIL-STRING and return formatted entries for NAME.
-EMAIL-STRING may contain multiple emails separated by commas, semicolons, or spaces.
-Returns a list of strings formatted as 'Name <email>'.
-Returns nil if EMAIL-STRING is nil or contains only whitespace."
+EMAIL-STRING may contain multiple emails separated by commas,
+semicolons, or spaces. Returns a list of strings formatted as
+\"Name <email>\". Returns nil if EMAIL-STRING is nil or contains only
+whitespace."
(when (and email-string (string-match-p "[^[:space:]]" email-string))
(let ((emails (split-string email-string "[,;[:space:]]+" t)))
(mapcar (lambda (email)
diff --git a/modules/org-faces-config.el b/modules/org-faces-config.el
index e0dfa83fd..dfbfe9d0d 100644
--- a/modules/org-faces-config.el
+++ b/modules/org-faces-config.el
@@ -35,72 +35,72 @@
;; --------------------------- Keyword faces (focused) -------------------------
-(defface org-faces-todo '((t (:foreground "#8fbf73" :weight bold)))
+(defface org-faces-todo '((t (:weight bold)))
"Face for the TODO keyword." :group 'org-faces-config)
-(defface org-faces-project '((t (:foreground "#7a9abe" :weight bold)))
+(defface org-faces-project '((t (:weight bold)))
"Face for the PROJECT keyword." :group 'org-faces-config)
-(defface org-faces-doing '((t (:foreground "#e8c668" :weight bold)))
+(defface org-faces-doing '((t (:weight bold)))
"Face for the DOING keyword." :group 'org-faces-config)
-(defface org-faces-waiting '((t (:foreground "#c9b08a" :weight bold)))
+(defface org-faces-waiting '((t (:weight bold)))
"Face for the WAITING keyword." :group 'org-faces-config)
-(defface org-faces-verify '((t (:foreground "#d98a5a" :weight bold)))
+(defface org-faces-verify '((t (:weight bold)))
"Face for the VERIFY keyword." :group 'org-faces-config)
-(defface org-faces-stalled '((t (:foreground "#9a8fb0" :weight bold)))
+(defface org-faces-stalled '((t (:weight bold)))
"Face for the STALLED keyword." :group 'org-faces-config)
-(defface org-faces-delegated '((t (:foreground "#7fc0a8" :weight bold)))
+(defface org-faces-delegated '((t (:weight bold)))
"Face for the DELEGATED keyword." :group 'org-faces-config)
-(defface org-faces-failed '((t (:foreground "#d05a5a" :weight bold)))
+(defface org-faces-failed '((t (:weight bold)))
"Face for the FAILED keyword." :group 'org-faces-config)
-(defface org-faces-done '((t (:foreground "#6f7a82" :weight bold)))
+(defface org-faces-done '((t (:weight bold)))
"Face for the DONE keyword." :group 'org-faces-config)
-(defface org-faces-cancelled '((t (:foreground "#6f7a82" :weight bold :strike-through t)))
+(defface org-faces-cancelled '((t (:weight bold :strike-through t)))
"Face for the CANCELLED keyword." :group 'org-faces-config)
;; -------------------------- Priority faces (focused) -------------------------
-(defface org-faces-priority-a '((t (:foreground "#7aa0d0" :weight bold)))
+(defface org-faces-priority-a '((t (:weight bold)))
"Face for the [#A] priority cookie." :group 'org-faces-config)
-(defface org-faces-priority-b '((t (:foreground "#e8c668")))
+(defface org-faces-priority-b '((t ()))
"Face for the [#B] priority cookie." :group 'org-faces-config)
-(defface org-faces-priority-c '((t (:foreground "#8fbf73")))
+(defface org-faces-priority-c '((t ()))
"Face for the [#C] priority cookie." :group 'org-faces-config)
-(defface org-faces-priority-d '((t (:foreground "#8a8a8a")))
+(defface org-faces-priority-d '((t ()))
"Face for the [#D] priority cookie." :group 'org-faces-config)
;; ----------------------------- Keyword faces (dim) ---------------------------
;; auto-dim-config.el remaps the focused faces above to these in non-selected
;; windows; a darker shade of the same hue keeps the keyword recognizable.
-(defface org-faces-todo-dim '((t (:foreground "#5f7a4d" :weight bold)))
+(defface org-faces-todo-dim '((t (:weight bold)))
"Dimmed TODO keyword for non-selected windows." :group 'org-faces-config)
-(defface org-faces-project-dim '((t (:foreground "#4f6680" :weight bold)))
+(defface org-faces-project-dim '((t (:weight bold)))
"Dimmed PROJECT keyword for non-selected windows." :group 'org-faces-config)
-(defface org-faces-doing-dim '((t (:foreground "#9a8544" :weight bold)))
+(defface org-faces-doing-dim '((t (:weight bold)))
"Dimmed DOING keyword for non-selected windows." :group 'org-faces-config)
-(defface org-faces-waiting-dim '((t (:foreground "#87745c" :weight bold)))
+(defface org-faces-waiting-dim '((t (:weight bold)))
"Dimmed WAITING keyword for non-selected windows." :group 'org-faces-config)
-(defface org-faces-verify-dim '((t (:foreground "#8f5a3c" :weight bold)))
+(defface org-faces-verify-dim '((t (:weight bold)))
"Dimmed VERIFY keyword for non-selected windows." :group 'org-faces-config)
-(defface org-faces-stalled-dim '((t (:foreground "#665e75" :weight bold)))
+(defface org-faces-stalled-dim '((t (:weight bold)))
"Dimmed STALLED keyword for non-selected windows." :group 'org-faces-config)
-(defface org-faces-delegated-dim '((t (:foreground "#547d6c" :weight bold)))
+(defface org-faces-delegated-dim '((t (:weight bold)))
"Dimmed DELEGATED keyword for non-selected windows." :group 'org-faces-config)
-(defface org-faces-failed-dim '((t (:foreground "#8a3c3c" :weight bold)))
+(defface org-faces-failed-dim '((t (:weight bold)))
"Dimmed FAILED keyword for non-selected windows." :group 'org-faces-config)
-(defface org-faces-done-dim '((t (:foreground "#4a5158" :weight bold)))
+(defface org-faces-done-dim '((t (:weight bold)))
"Dimmed DONE keyword for non-selected windows." :group 'org-faces-config)
-(defface org-faces-cancelled-dim '((t (:foreground "#4a5158" :weight bold :strike-through t)))
+(defface org-faces-cancelled-dim '((t (:weight bold :strike-through t)))
"Dimmed CANCELLED keyword for non-selected windows." :group 'org-faces-config)
;; ---------------------------- Priority faces (dim) ---------------------------
-(defface org-faces-priority-a-dim '((t (:foreground "#4f6a8a" :weight bold)))
+(defface org-faces-priority-a-dim '((t (:weight bold)))
"Dimmed [#A] priority cookie for non-selected windows." :group 'org-faces-config)
-(defface org-faces-priority-b-dim '((t (:foreground "#9a8544")))
+(defface org-faces-priority-b-dim '((t ()))
"Dimmed [#B] priority cookie for non-selected windows." :group 'org-faces-config)
-(defface org-faces-priority-c-dim '((t (:foreground "#5f7a4d")))
+(defface org-faces-priority-c-dim '((t ()))
"Dimmed [#C] priority cookie for non-selected windows." :group 'org-faces-config)
-(defface org-faces-priority-d-dim '((t (:foreground "#5a5a5a")))
+(defface org-faces-priority-d-dim '((t ()))
"Dimmed [#D] priority cookie for non-selected windows." :group 'org-faces-config)
;; ---------------------------------- Wiring -----------------------------------
diff --git a/modules/org-noter-config.el b/modules/org-noter-config.el
index 4e5bd1778..f28f61bb7 100644
--- a/modules/org-noter-config.el
+++ b/modules/org-noter-config.el
@@ -39,9 +39,32 @@
;; Forward declarations
(declare-function org-id-uuid "org-id")
+(declare-function org-entry-get "org")
(declare-function nov-mode "ext:nov")
(declare-function pdf-view-mode "ext:pdf-view")
+;; pdf-tools fit commands (lazily loaded with pdf-tools)
+(declare-function pdf-view-fit-width-to-window "pdf-view")
+(declare-function pdf-view-fit-height-to-window "pdf-view")
+(declare-function pdf-view-fit-page-to-window "pdf-view")
+;; face-remap is built in but loaded lazily
+(declare-function face-remap-remove-relative "face-remap")
+;; org-noter session/sync/skeleton commands (lazily loaded with org-noter)
+(declare-function org-noter--get-notes-window "org-noter")
+(declare-function org-noter--get-doc-window "org-noter")
+(declare-function org-noter-insert-note "org-noter")
+(declare-function org-noter-enable-org-roam-integration "org-noter")
+(declare-function org-noter-sync-next-note "org-noter")
+(declare-function org-noter-sync-prev-note "org-noter")
+(declare-function org-noter-sync-current-note "org-noter")
+(declare-function org-noter-create-skeleton "org-noter")
+(declare-function org-noter-kill-session "org-noter")
+(declare-function org-noter-toggle-notes-window-location "org-noter")
(defvar nov-file-name)
+;; org-noter package variables assigned at session start / config time
+(defvar org-noter-notes-window-location)
+(defvar org-noter-use-pdftools-link-location)
+(defvar org-noter-use-org-id)
+(defvar org-noter-use-unique-org-id)
;;; Configuration Variables
(defvar cj/org-noter-notes-directory roam-dir
@@ -284,7 +307,7 @@ From a PDF/EPUB: starts org-noter session if inactive, then inserts note."
(cond
((and active (not cj/org-noter--bg-remap-cookie))
(setq cj/org-noter--bg-remap-cookie
- (face-remap-add-relative 'default :background "#1d1b19")))
+ (face-remap-add-relative 'default)))
((and (not active) cj/org-noter--bg-remap-cookie)
(face-remap-remove-relative cj/org-noter--bg-remap-cookie)
(setq cj/org-noter--bg-remap-cookie nil))))))))
diff --git a/modules/org-refile-config.el b/modules/org-refile-config.el
index a6b7ac3a4..5f826cacf 100644
--- a/modules/org-refile-config.el
+++ b/modules/org-refile-config.el
@@ -36,7 +36,8 @@
;; ----------------------------- Org Refile Targets ----------------------------
;; sets refile targets
-;; - adds project files in org-roam to the refile targets
+;; - adds org-roam notes tagged "Topic" to the refile targets
+;; (roam "Project" notes were dropped as refile targets 2026-06-24)
;; - adds todo.org files in subdirectories of the code and project directories
(defvar cj/--org-refile-targets-cache (cj/cache-make :ttl 3600)
@@ -100,11 +101,9 @@ Returns the list to assign to `org-refile-targets'. Slow -- walks
(cons schedule-file '(:maxlevel . 1)))))
(when (and (fboundp 'cj/org-roam-list-notes-by-tag)
(fboundp 'org-roam-node-list))
- (let* ((project-and-topic-files
- (append (cj/org-roam-list-notes-by-tag "Project")
- (cj/org-roam-list-notes-by-tag "Topic")))
- (file-rule '(:maxlevel . 1)))
- (dolist (file project-and-topic-files)
+ (let ((topic-files (cj/org-roam-list-notes-by-tag "Topic"))
+ (file-rule '(:maxlevel . 1)))
+ (dolist (file topic-files)
(unless (assoc file new-files)
(push (cons file file-rule) new-files)))))
(let ((file-rule '(:maxlevel . 1)))
diff --git a/modules/org-roam-config.el b/modules/org-roam-config.el
index 218f37d68..eca867df8 100644
--- a/modules/org-roam-config.el
+++ b/modules/org-roam-config.el
@@ -27,6 +27,29 @@
(require 'user-constants)
+;; Declared special so the `let'-binding in `cj/org-roam-copy-todo-to-today'
+;; compiles as a dynamic bind, not a dead lexical local -- otherwise the custom
+;; capture template never reaches org-roam-dailies (the foreign-special-var trap).
+(defvar org-roam-dailies-capture-templates)
+
+;; External variables, declared special so byte-compilation doesn't treat them
+;; as free references/assignments. Owned by org and org-roam-dailies.
+(defvar org-agenda-timegrid-use-ampm)
+(defvar org-roam-dailies-map)
+(defvar org-last-state)
+
+;; External functions, declared so the byte-compiler knows they're defined at
+;; runtime by their respective packages.
+(declare-function org-roam-node-tags "org-roam")
+(declare-function org-roam-node-file "org-roam")
+(declare-function org-roam-node-list "org-roam")
+(declare-function org-roam-dailies--capture "org-roam-dailies")
+(declare-function org-capture-get "org-capture")
+(declare-function org-at-heading-p "org")
+(declare-function org-heading-components "org")
+(declare-function org-copy-subtree "org")
+(declare-function org-cut-subtree "org")
+
;; ---------------------------------- Org Roam ---------------------------------
(defconst cj/--org-roam-dailies-head
@@ -71,8 +94,6 @@ FILETAGS and TITLE must sit on separate lines so Org parses the
:bind (("C-c n l" . org-roam-buffer-toggle)
("C-c n f" . org-roam-node-find)
("C-c n p" . cj/org-roam-find-node-project)
- ("C-c n r" . cj/org-roam-find-node-recipe)
- ("C-c n t" . cj/org-roam-find-node-topic)
("C-c n i" . org-roam-node-insert)
("C-c n w" . cj/org-roam-find-node-webclip)
:map org-mode-map
@@ -81,8 +102,10 @@ FILETAGS and TITLE must sit on separate lines so Org parses the
;; org-log-done is set once in org-config.el (cj/org-todo-settings).
(setq org-agenda-timegrid-use-ampm t)
- (when (fboundp 'cj/build-org-refile-targets)
- (cj/build-org-refile-targets))
+ ;; Don't build the org-refile targets cache here. org-refile-config.el
+ ;; already schedules it on a 5s idle timer; doing it in org-roam's :config
+ ;; (which fires at the 1s :defer) ran the same multi-file scan synchronously
+ ;; at first idle and froze Emacs on a cold cache. The 5s timer covers it.
;; remove/disable if performance slows
;; (setq org-element-use-cache nil) ;; disables caching org files
@@ -186,6 +209,11 @@ created in that subdirectory of `org-roam-directory'."
(interactive)
(cj/org-roam-find-node "Recipe" "r" (concat roam-dir "templates/recipe.org") "recipes/"))
+;; Bound after their defuns (not in the use-package :bind) so the byte-compiler
+;; doesn't see both a :bind autoload and the real defun as two definitions.
+(keymap-global-set "C-c n r" #'cj/org-roam-find-node-recipe)
+(keymap-global-set "C-c n t" #'cj/org-roam-find-node-topic)
+
;; ---------------------- Org Capture After Finalize Hook ----------------------
(defun cj/org-roam-add-node-to-agenda-files-finalize-hook ()
diff --git a/modules/pdf-config.el b/modules/pdf-config.el
index ca2312307..56b397df3 100644
--- a/modules/pdf-config.el
+++ b/modules/pdf-config.el
@@ -14,6 +14,22 @@
;;
;;; Code:
+;; ------------------------------- Declarations --------------------------------
+
+(declare-function pdf-tools-install "pdf-tools")
+(declare-function pdf-view-midnight-minor-mode "pdf-view")
+(declare-function pdf-view-enlarge "pdf-view")
+(declare-function pdf-view-shrink "pdf-view")
+(declare-function pdf-view-next-page "pdf-view")
+(declare-function pdf-view-previous-page "pdf-view")
+(declare-function image-next-line "image-mode")
+(declare-function image-previous-line "image-mode")
+(declare-function image-bob "image-mode")
+(declare-function image-eob "image-mode")
+(declare-function org-store-link "ol")
+(declare-function cj/open-file-with-command "system-utils")
+(declare-function cj/org-noter-insert-note-dwim "org-noter-config")
+
;; --------------------------------- PDF Tools ---------------------------------
(use-package pdf-tools
@@ -24,7 +40,6 @@
:custom
(pdf-view-display-size 'fit-page)
(pdf-view-resize-factor 1.1)
- (pdf-view-midnight-colors '("#F1D5AC" . "#0F0E06")) ;; fg . bg
;; Avoid searching for unicodes to speed up pdf-tools.
;; ... and yes, 'ligther' is not a typo
(pdf-view-use-unicode-ligther nil)
@@ -61,9 +76,9 @@
(define-key pdf-view-mode-map "i" #'cj/org-noter-insert-note-dwim)
;; Page change: C-up/C-down go to top of prev/next page
(define-key pdf-view-mode-map (kbd "C-<down>")
- (lambda () (interactive) (pdf-view-next-page-command) (image-bob)))
+ (lambda () (interactive) (pdf-view-next-page) (image-bob)))
(define-key pdf-view-mode-map (kbd "C-<up>")
- (lambda () (interactive) (pdf-view-previous-page-command) (image-eob))))
+ (lambda () (interactive) (pdf-view-previous-page) (image-eob))))
;; ------------------------------ PDF View Restore -----------------------------
diff --git a/modules/prog-general.el b/modules/prog-general.el
index 968032831..f22f89923 100644
--- a/modules/prog-general.el
+++ b/modules/prog-general.el
@@ -59,13 +59,16 @@
(declare-function treesit-auto-add-to-auto-mode-alist "treesit-auto")
(declare-function treesit-auto-recipe-lang "treesit-auto")
(declare-function highlight-indent-guides-mode "highlight-indent-guides")
+(declare-function electric-pair-default-inhibit "elec-pair")
+(declare-function yas-reload-all "yasnippet")
+(declare-function yas-activate-extra-mode "yasnippet")
;; Forward declarations for treesit-auto variables
(defvar treesit-auto-recipe-list)
+(defvar electric-pair-inhibit-predicate)
;; Forward declarations for functions defined later in this file
(declare-function cj/project-switch-actions "prog-general")
-(declare-function cj/deadgrep--initial-term "prog-general")
(defun cj/find-project-root-file (regexp)
"Return first file in the current Projectile project root matching REGEXP.
@@ -287,6 +290,16 @@ seeded by `cj/deadgrep--initial-term'. Shared tail of the deadgrep commands."
(with-eval-after-load 'dired
(keymap-set dired-mode-map "G" #'cj/deadgrep-here))
+;; ------------------------------------ wgrep ----------------------------------
+;; Make a grep buffer editable, then write the edits back across files -- turns
+;; a consult-grep / embark-export result into a project-wide find-and-replace.
+;; In a grep buffer: C-c C-p to start editing, C-c C-c to apply.
+
+(use-package wgrep
+ :custom
+ (wgrep-auto-save-buffer t) ;; save the touched files when applying
+ (wgrep-change-readonly-file t)) ;; let edits flow into read-only buffers
+
;; ---------------------------------- Snippets ---------------------------------
;; reusable code and text
@@ -371,16 +384,7 @@ defer to `electric-pair-default-inhibit' for any other CHAR."
(use-package hl-todo
:defer 1
:hook
- (prog-mode . hl-todo-mode)
- :config
- (setq hl-todo-keyword-faces
- '(("FIXME" . "#FF0000")
- ("BUG" . "#FF0000")
- ("HACK" . "#FF0000")
- ("ISSUE" . "#DAA520")
- ("TASK" . "#DAA520")
- ("NOTE" . "#2C780E")
- ("WIP" . "#1E90FF"))))
+ (prog-mode . hl-todo-mode))
;; --------------------------- Whitespace Management ---------------------------
;; trims trailing whitespace only from lines you've modified when saving buffer
diff --git a/modules/selection-framework.el b/modules/selection-framework.el
index a567e8003..464654a20 100644
--- a/modules/selection-framework.el
+++ b/modules/selection-framework.el
@@ -26,6 +26,12 @@
;;
;;; Code:
+;; External variables and lazily-loaded functions referenced below.
+(defvar xref-show-xrefs-function)
+(defvar xref-show-definitions-function)
+(declare-function consult-dir-projectile-dirs "consult-dir")
+(declare-function prescient-persist-mode "prescient")
+
;; ---------------------------------- Vertico ----------------------------------
;; Vertical completion UI
diff --git a/modules/system-defaults.el b/modules/system-defaults.el
index 0062a82cf..6d9c811a6 100644
--- a/modules/system-defaults.el
+++ b/modules/system-defaults.el
@@ -212,18 +212,13 @@ appears only once per session."
(setq custom-safe-themes t) ;; treat all themes as safe (stop asking)
(setq server-client-instructions nil) ;; I already know what to do when done with the frame
-;; ------------------ Reduce Garbage Collections In Minibuffer -----------------
-
-(defun cj/minibuffer-setup-hook ()
- "Hook to prevent garbage collection while user's in minibuffer."
- (setq gc-cons-threshold most-positive-fixnum))
-
-(defun cj/minibuffer-exit-hook ()
- "Hook to trigger garbage collection when exiting minibuffer."
- (setq gc-cons-threshold 800000))
-
-(add-hook 'minibuffer-setup-hook #'cj/minibuffer-setup-hook)
-(add-hook 'minibuffer-exit-hook #'cj/minibuffer-exit-hook)
+;; ----------------------------- Garbage Collection ----------------------------
+;; GC is managed by gcmh in modules/gcmh-config.el: it keeps gc-cons-threshold
+;; high during activity and collects on idle, replacing the old stock-800KB
+;; scheme (an early-init restore plus a minibuffer setup/exit bump). gcmh lives
+;; in its own module rather than here because system-defaults.el is pre-loaded
+;; by the comp-errors test harness, which has no package system -- an `:ensure'
+;; package loaded here would error at load time and break those tests.
;; ----------------------------- Bookmark Settings -----------------------------
diff --git a/modules/system-utils.el b/modules/system-utils.el
index 7cf958674..00be88906 100644
--- a/modules/system-utils.el
+++ b/modules/system-utils.el
@@ -102,7 +102,7 @@ detached from Emacs."
(interactive)
(save-some-buffers)
(kill-emacs))
-(keymap-global-set "C-<f10>" #'cj/server-shutdown)
+(keymap-global-set "C-x C" #'cj/server-shutdown)
;;; ---------------------------- History Persistence ----------------------------
@@ -123,7 +123,8 @@ detached from Emacs."
read-char-history
face-name-history
bookmark-history
- file-name-history))
+ file-name-history
+ wttrin--location-history))
(put 'minibuffer-history 'history-length 50)
(put 'file-name-history 'history-length 50)
@@ -146,6 +147,16 @@ detached from Emacs."
;; in `nerd-icons-config'.
(keymap-global-set "<remap> <list-buffers>" #'ibuffer)
+;; Swap delete and diff in the ibuffer list: d diffs the buffer at point against
+;; its saved file (was on =), and D marks it for deletion (was on d; `x' still
+;; executes the marks).
+(defvar ibuffer-mode-map)
+(declare-function ibuffer-diff-with-file "ibuffer")
+(declare-function ibuffer-mark-for-delete "ibuffer")
+(with-eval-after-load 'ibuffer
+ (keymap-set ibuffer-mode-map "d" #'ibuffer-diff-with-file)
+ (keymap-set ibuffer-mode-map "D" #'ibuffer-mark-for-delete))
+
;;; -------------------------- Scratch Buffer Happiness -------------------------
(defvar scratch-emacs-version-and-system
diff --git a/modules/term-config.el b/modules/term-config.el
deleted file mode 100644
index 0a7991409..000000000
--- a/modules/term-config.el
+++ /dev/null
@@ -1,478 +0,0 @@
-;;; term-config.el --- Settings for ghostel and the F12 toggle -*- lexical-binding: t; coding: utf-8; -*-
-;; author Craig Jennings <c@cjennings.net>
-
-;;; Commentary:
-;;
-;; Layer: 3 (Domain Workflow).
-;; Category: D/P.
-;; Load shape: eager.
-;; Eager reason: registers terminal keymaps and the F12 toggle.
-;; Top-level side effects: defines two keymaps (one under cj/custom-keymap), one
-;; global key, two add-hook, package config.
-;; Runtime requires: keybindings, seq, subr-x, cj-window-geometry-lib,
-;; cj-window-toggle-lib.
-;; Direct test load: yes (requires keybindings explicitly).
-;;
-;; GHOSTEL
-;; ghostel is a native Emacs terminal emulator over libghostty-vt (the Ghostty
-;; engine). Like a real terminal, in its default semi-char mode most keys are
-;; sent to the running program; `ghostel-keymap-exceptions' lists the keys that
-;; reach Emacs instead. We add C-; so the personal prefix keymap works inside
-;; ghostel buffers.
-;;
-;; The module degrades gracefully when ghostel is unavailable (D6 of the
-;; migration spec): the package installs via use-package, the native module
-;; auto-downloads on first use, and ghostel emits its own warning if the module
-;; cannot load. A machine without a prebuilt binary needs Zig to build it; the
-;; terminal commands stay defined either way.
-;;
-;; Two ways to lift text out of a terminal, both with the same key story:
-;; - C-; x c enters copy-mode via `cj/term-copy-mode-dwim'. When a tmux
-;; client is attached (typical -- `cj/term-launch-tmux' auto-starts tmux),
-;; sends tmux's prefix C-b [ then C-a, so the user lands in tmux's own
-;; copy-mode with the full pane history and the cursor at column 0 (so
-;; scrolling up runs up the left, not the right). Without tmux, falls back to
-;; `ghostel-copy-mode' (read-only standard-Emacs navigation over the
-;; scrollback; M-w copies and stays, q / C-g exit) and moves point to the
-;; start of the line for the same column-0 reason.
-;; - C-; x h captures the current tmux pane's full history into a temporary
-;; Emacs buffer.
-;; In both copy surfaces, M-w copies the active region and stays open so several
-;; pieces can be grabbed in a row; C-g / q leave without copying.
-
-;;; Code:
-
-(require 'keybindings)
-(require 'seq)
-(require 'subr-x)
-(require 'cj-window-geometry-lib)
-(require 'cj-window-toggle-lib)
-
-(declare-function ghostel "ghostel" (&optional directory))
-(declare-function ghostel-send-string "ghostel" (string))
-(declare-function ghostel-copy-mode "ghostel" ())
-(declare-function ghostel-clear-scrollback "ghostel" ())
-(declare-function ghostel-next-prompt "ghostel" (&optional n))
-(declare-function ghostel-previous-prompt "ghostel" (&optional n))
-(declare-function ghostel-send-next-key "ghostel" ())
-(declare-function ghostel--rebuild-semi-char-keymap "ghostel" ())
-(defvar ghostel-mode-map)
-(defvar ghostel-keymap-exceptions)
-(defvar ghostel-buffer-name)
-
-(defvar-keymap cj/term-map
- :doc "Personal terminal command map.")
-;; Lowercase x picked over T for fewer Shift presses; t is the toggle leaf.
-(cj/register-prefix-map "x" cj/term-map)
-
-;; ----------------------------- tmux history ----------------------------------
-
-(defvar-local cj/term-tmux-history--origin-buffer nil
- "Buffer active before opening the tmux history buffer.")
-
-(defvar-local cj/term-tmux-history--origin-window nil
- "Window active before opening the tmux history buffer.")
-
-(defvar-local cj/term-tmux-history--origin-point nil
- "Point in the origin buffer before opening the tmux history buffer.")
-
-(defun cj/term--tmux-output (&rest args)
- "Run tmux with ARGS and return its stdout.
-Signal `user-error' when tmux exits with a non-zero status."
- (with-temp-buffer
- (let ((exit-code (apply #'process-file "tmux" nil t nil args)))
- (unless (zerop exit-code)
- (user-error "tmux failed: %s" (string-trim (buffer-string))))
- (buffer-string))))
-
-(defun cj/term--tmux-pane-id-for-tty (tty)
- "Return the tmux pane id for client TTY."
- (let* ((output (cj/term--tmux-output
- "list-clients" "-F" "#{client_tty}\t#{pane_id}"))
- (lines (split-string output "\n" t))
- (match (seq-find
- (lambda (line)
- (let ((fields (split-string line "\t")))
- (equal (car fields) tty)))
- lines)))
- (unless match
- (user-error "No tmux client found for terminal tty %s" tty))
- (cadr (split-string match "\t"))))
-
-(defun cj/term--tmux-capture-pane (pane-id)
- "Return full joined tmux history for PANE-ID."
- (cj/term--tmux-output
- "capture-pane" "-p" "-J" "-S" "-" "-E" "-" "-t" pane-id))
-
-(defun cj/term--current-tmux-pane-id ()
- "Return the tmux pane id for the current ghostel buffer."
- (unless (eq major-mode 'ghostel-mode)
- (user-error "Current buffer is not a ghostel buffer"))
- (let* ((proc (get-buffer-process (current-buffer)))
- (tty (and proc (process-tty-name proc))))
- (unless (and tty (not (string-empty-p tty)))
- (user-error "Could not determine terminal tty"))
- (cj/term--tmux-pane-id-for-tty tty)))
-
-(defvar-keymap cj/term-tmux-history-mode-map
- :doc "Keymap for `cj/term-tmux-history-mode'.
-M-w copies the active region without leaving the buffer; C-g, <escape>, or q
-returns to the terminal without copying. RET is left unbound."
- "M-w" #'kill-ring-save
- "C-g" #'cj/term-tmux-history-quit
- "<escape>" #'cj/term-tmux-history-quit
- "q" #'cj/term-tmux-history-quit)
-
-(define-derived-mode cj/term-tmux-history-mode special-mode "Tmux History"
- "Mode for copying captured tmux pane history with normal Emacs keys."
- (setq-local truncate-lines t)
- (goto-address-mode 1))
-
-(defun cj/term-tmux-history-quit ()
- "Quit tmux history and return to its origin buffer."
- (interactive)
- (let ((history-buffer (current-buffer))
- (origin-buffer cj/term-tmux-history--origin-buffer)
- (origin-window cj/term-tmux-history--origin-window)
- (origin-point cj/term-tmux-history--origin-point))
- (when (buffer-live-p origin-buffer)
- (if (window-live-p origin-window)
- (progn
- (set-window-buffer origin-window origin-buffer)
- (select-window origin-window))
- (pop-to-buffer origin-buffer))
- (with-current-buffer origin-buffer
- (when (integer-or-marker-p origin-point)
- (goto-char origin-point))))
- (when (buffer-live-p history-buffer)
- (kill-buffer history-buffer))))
-
-(defun cj/term-tmux-history ()
- "Open full tmux pane history in a temporary Emacs buffer.
-
-The history buffer uses normal Emacs navigation and selection. `M-w'
-copies the active region and stays open, so several pieces can be
-copied in a row; `q', `<escape>', or `C-g' returns point to the
-terminal buffer that launched it.
-
-The history view replaces the origin terminal buffer in the same window
-\(via `switch-to-buffer'), not a split or a popped-up window."
- (interactive)
- (let* ((origin-buffer (current-buffer))
- (origin-window (selected-window))
- (origin-point (point))
- (pane-id (cj/term--current-tmux-pane-id))
- (history (cj/term--tmux-capture-pane pane-id))
- (buffer (get-buffer-create
- (format "*terminal tmux history: %s*" (buffer-name origin-buffer)))))
- (with-current-buffer buffer
- (let ((inhibit-read-only t))
- (erase-buffer)
- (insert history))
- (cj/term-tmux-history-mode)
- (setq-local cj/term-tmux-history--origin-buffer origin-buffer)
- (setq-local cj/term-tmux-history--origin-window origin-window)
- (setq-local cj/term-tmux-history--origin-point origin-point)
- (goto-char (point-max)))
- (switch-to-buffer buffer)))
-
-;; ----------------------------- copy mode -------------------------------------
-
-(defun cj/term--in-tmux-p ()
- "Return non-nil when the current ghostel buffer has a tmux client attached.
-Errors from the pane-id lookup (not in ghostel-mode, no tty, no matching
-client, tmux not installed) are treated as nil so callers can use this as a
-cheap boolean predicate."
- (and (eq major-mode 'ghostel-mode)
- (condition-case _
- (and (cj/term--current-tmux-pane-id) t)
- (error nil))))
-
-(defun cj/term-copy-mode-dwim ()
- "Enter copy-mode using the engine appropriate to this terminal.
-
-When tmux is attached, write tmux's default prefix sequence (C-b [) into the
-pty so the user lands in tmux's copy-mode with the full pane history, then
-C-a to land the cursor at the start of the line. Without the trailing C-a
-the copy cursor inherits the live column (far right after a prompt) and
-scrolling up runs up the right edge; tmux's emacs copy-mode binds C-a to
-start-of-line, so column 0 makes it run up the left. Without tmux, falls
-through to `ghostel-copy-mode' (a read-only standard-Emacs view of the
-scrollback; M-w copies and stays, q / C-g exit), then moves point to the
-start of the line for the same column-0 reason."
- (interactive)
- (if (cj/term--in-tmux-p)
- (ghostel-send-string "\C-b[\C-a")
- (ghostel-copy-mode)
- (beginning-of-line)))
-
-;; ----------------------------- ghostel package -------------------------------
-
-(defun cj/turn-off-chrome-for-term ()
- "Turn off line numbers and hl-line in a terminal buffer."
- (hl-line-mode -1)
- (display-line-numbers-mode -1))
-
-(defun cj/term-launch-tmux ()
- "Auto-launch tmux in a ghostel buffer unless already inside tmux.
-
-Skipped when `cj/--ai-term-suppress-tmux' is non-nil so the AI-agent flow can
-run its own project-named tmux session instead of a bare, auto-named one.
-`bound-and-true-p' keeps this safe whether or not ai-term.el is loaded."
- (let ((proc (get-buffer-process (current-buffer))))
- (when (and proc
- (not (getenv "TMUX"))
- (not (bound-and-true-p cj/--ai-term-suppress-tmux)))
- (ghostel-send-string "tmux\n"))))
-
-(use-package ghostel
- ;; PINNED at module 0.33.0 (ghostel-20260604.2049, the last pre-rework June-4
- ;; build), installed directly into elpa/ rather than from MELPA. The 0.35.0-0.35.2
- ;; native-PTY rework (worker threads + mutex-outside-read-loop) hard-crashes the
- ;; whole Emacs process when a ghostel buffer is displayed: on Linux/glibc a
- ;; SIGSETXID handler calls malloc while the main thread holds the arena lock
- ;; (ghostel upstream #422); on macOS a recursive os_unfair_lock via
- ;; run_window_change_functions (#423). `:ensure t' is satisfied by the present
- ;; 0.33.0 dir and will NOT upgrade it -- do NOT `package-upgrade' ghostel until
- ;; #422/#423 are fixed upstream, or it returns to the crashing 0.35.x.
- :ensure t
- :commands (ghostel)
- :init
- ;; These keys must reach Emacs (not the terminal program) inside ghostel
- ;; buffers. In semi-char mode ghostel forwards every key NOT in
- ;; `ghostel-keymap-exceptions' to the pty, and `ghostel-semi-char-mode-map'
- ;; is rebuilt from that list by `ghostel--rebuild-semi-char-keymap' --
- ;; `add-to-list' alone updates the list but not the already-built map, so the
- ;; rebuild is what actually lets the key through to `ghostel-mode-map' / the
- ;; global map. C-; and F12 are the prefix + toggle; the modified arrows are
- ;; windmove (S-arrows, focus) and buffer-move (C-M-arrows, swap), which the
- ;; ai-term workflow expects to work from inside an agent buffer. F8, F10 and
- ;; C-F10 are global bindings (org agenda, music-playlist toggle, server
- ;; shutdown) that reach Emacs by falling through to the global map once the
- ;; semi-char map stops forwarding them.
- (with-eval-after-load 'ghostel
- (dolist (key '("C-;" "<f8>" "<f12>" "<f10>" "C-<f10>"
- "S-<up>" "S-<down>" "S-<left>" "S-<right>"
- "C-M-<up>" "C-M-<down>" "C-M-<left>" "C-M-<right>"))
- (add-to-list 'ghostel-keymap-exceptions key))
- (ghostel--rebuild-semi-char-keymap))
- :hook
- ((ghostel-mode . cj/turn-off-chrome-for-term)
- (ghostel-mode . cj/term-launch-tmux))
- :custom
- (ghostel-kill-buffer-on-exit t)
- ;; Auto-download the prebuilt native module on first launch instead of the
- ;; default `ask' prompt -- it fetches the platform release asset from GitHub
- ;; (for the pinned 0.33.0 source this resolves to the matching v0.33.0 module).
- ;; The compile-from-source fallback also works here: zig 0.15.2 is installed at
- ;; /usr/local/bin/zig (see M-x ghostel-module-compile).
- (ghostel-module-auto-install 'download)
- ;; Byte analog of the prior 100000-line vterm setting (~100 bytes/line) -- D7.
- (ghostel-max-scrollback (* 10 1024 1024)))
-
-;; ----------------------- F12 toggle (custom) -----------------------
-;;
-;; Mirrors the geometry-preservation pattern shared with ai-term.el: capture
-;; direction + body size at toggle-off, replay them via a custom display action
-;; using frame-edge directions and body-relative sizes so the result is
-;; divider-independent and layout-stable. Excludes agent-prefixed buffers,
-;; which ai-term.el owns via F9.
-
-(defcustom cj/term-toggle-window-height 0.7
- "Default fraction of frame height for the F12 terminal window.
-Used as the size fallback when F12 docks the terminal as a bottom split."
- :type 'number
- :group 'term)
-
-(defcustom cj/term-toggle-window-width 0.5
- "Default fraction of frame width for the F12 terminal window.
-Used as the size fallback when F12 docks the terminal as a right-side
-column (see `cj/--term-toggle-default-direction')."
- :type 'number
- :group 'term)
-
-(defun cj/--term-toggle-default-direction ()
- "Return the default dock direction for the F12 terminal: `right' or `below'.
-Docks as a right-side column only when a side-by-side split would leave
-both panes at least `cj/window-dock-min-columns' wide (the terminal's
-share is `cj/term-toggle-window-width'); otherwise stacks below. See
-`cj/preferred-dock-direction'."
- (cj/preferred-dock-direction (frame-width) cj/term-toggle-window-width))
-
-(defun cj/--term-toggle-default-size (direction)
- "Return the default size fraction paired with DIRECTION for the F12 terminal.
-`cj/term-toggle-window-width' for `right', `cj/term-toggle-window-height'
-otherwise."
- (if (eq direction 'right)
- cj/term-toggle-window-width
- cj/term-toggle-window-height))
-
-(defvar cj/--term-toggle-last-direction nil
- "Last user-chosen direction for the F12 terminal display.
-Symbol: right, left, or below. `above' is never stored. nil means use the
-default `below' for F12's traditional bottom split.")
-
-(defvar cj/--term-toggle-last-size nil
- "Last user-chosen size for the F12 terminal display.
-Positive integer: body-cols (right/left) or total-lines (below/above) -- see
-`cj/window-replay-size' for why the vertical axis uses total, not body.
-nil means fall back to `cj/term-toggle-window-height' as a fraction.")
-
-(defun cj/--term-toggle-buffer-p (buffer)
- "Return non-nil when BUFFER is a terminal buffer F12 should manage.
-
-Qualifies when BUFFER is alive and has `ghostel-mode' (or its name starts with
-the ghostel buffer-name prefix), AND its name does NOT start with the agent
-prefix used by ai-term.el."
- (and (bufferp buffer)
- (buffer-live-p buffer)
- (with-current-buffer buffer
- (and (or (eq major-mode 'ghostel-mode)
- (string-prefix-p (or (bound-and-true-p ghostel-buffer-name)
- "*ghostel*")
- (buffer-name buffer)))
- (not (string-prefix-p "agent [" (buffer-name buffer)))))))
-
-(defun cj/--term-toggle-buffers ()
- "Return live F12-managed terminal buffers in `buffer-list' (MRU) order."
- (seq-filter #'cj/--term-toggle-buffer-p (buffer-list)))
-
-(defun cj/--term-toggle-displayed-window (&optional frame)
- "Return a window in FRAME currently displaying an F12 terminal buffer, or nil.
-FRAME defaults to the selected frame. Minibuffer is excluded."
- (seq-find (lambda (w)
- (cj/--term-toggle-buffer-p (window-buffer w)))
- (window-list (or frame (selected-frame)) 'never)))
-
-(defun cj/--term-toggle-capture-state (window)
- "Capture WINDOW's direction + body size into module-level state.
-The default direction (used when WINDOW fills its frame) is the
-column-rule choice from `cj/--term-toggle-default-direction'."
- (cj/window-toggle-capture-state
- window (cj/--term-toggle-default-direction)
- 'cj/--term-toggle-last-direction
- 'cj/--term-toggle-last-size
- '(right below left)))
-
-(defun cj/--term-toggle-display-saved (buffer alist)
- "Display-buffer action: split per saved direction and body size.
-Delegates to `cj/window-toggle-display-saved' against the F12 state vars,
-falling back to the column-rule default direction
-\(`cj/--term-toggle-default-direction') and its paired size."
- (let ((dir (cj/--term-toggle-default-direction)))
- (cj/window-toggle-display-saved
- buffer alist
- 'cj/--term-toggle-last-direction dir
- 'cj/--term-toggle-last-size (cj/--term-toggle-default-size dir))))
-
-(defun cj/--term-toggle-display-rule-list ()
- "Return the `display-buffer-alist' entry list installed by F12.
-Routes any terminal buffer satisfying `cj/--term-toggle-buffer-p' through
-reuse-window then the saved-geometry action. Excludes agent buffers."
- '(((lambda (buffer-or-name _)
- (cj/--term-toggle-buffer-p (get-buffer buffer-or-name)))
- (display-buffer-reuse-window
- cj/--term-toggle-display-saved)
- (inhibit-same-window . t))))
-
-(dolist (entry (cj/--term-toggle-display-rule-list))
- (add-to-list 'display-buffer-alist entry))
-
-(defun cj/--term-toggle-dispatch ()
- "Compute the F12 (`cj/term-toggle') action without performing it.
-
-Returns one of:
-- (toggle-off . WINDOW) -- terminal displayed in WINDOW; hide it.
-- (show-recent . BUFFER) -- terminal alive but not shown; redisplay.
-- (create-new) -- no terminal buffer alive; create one."
- (let ((win (cj/--term-toggle-displayed-window)))
- (cond
- (win (cons 'toggle-off win))
- (t
- (let ((buffers (cj/--term-toggle-buffers)))
- (cond
- (buffers (cons 'show-recent (car buffers)))
- (t '(create-new))))))))
-
-(defun cj/term-toggle ()
- "Toggle a normal (non-agent) ghostel terminal buffer.
-
-- If an F12-managed terminal is displayed in this frame, capture its geometry
- and delete its window (toggle off). Falls back to burying when it is the
- only window in the frame.
-- Otherwise, if any F12-managed terminal buffer is alive, display the most
- recent one via the saved-geometry action.
-- Otherwise, create a new terminal via `(ghostel)' which routes through the
- same display action.
-
-Excludes agent-prefixed buffers; those have their own F9 dispatch via
-`cj/ai-term'."
- (interactive)
- (pcase (cj/--term-toggle-dispatch)
- (`(toggle-off . ,win)
- (cj/--term-toggle-capture-state win)
- (if (one-window-p)
- (bury-buffer (window-buffer win))
- (delete-window win))
- nil)
- (`(show-recent . ,buf)
- (display-buffer buf)
- (let ((w (get-buffer-window buf)))
- (when w (select-window w)))
- buf)
- (`(create-new)
- (ghostel))))
-
-(keymap-global-set "<f12>" #'cj/term-toggle)
-
-;; ----------------------------- prefix menu -----------------------------------
-
-(keymap-set cj/term-map "c" #'cj/term-copy-mode-dwim)
-(keymap-set cj/term-map "h" #'cj/term-tmux-history)
-(keymap-set cj/term-map "l" #'ghostel-clear-scrollback)
-(keymap-set cj/term-map "N" #'ghostel)
-(keymap-set cj/term-map "n" #'ghostel-next-prompt)
-(keymap-set cj/term-map "p" #'ghostel-previous-prompt)
-(keymap-set cj/term-map "q" #'ghostel-send-next-key)
-(keymap-set cj/term-map "t" #'cj/term-toggle)
-
-(defun cj/term-send-C-SPC ()
- "Forward C-SPC (NUL) to the terminal instead of setting an Emacs mark.
-
-ghostel forwards the `C-@' event but not the distinct `C-SPC' event GUI
-Emacs produces, so a bare C-SPC in a ghostel buffer falls through to the
-global `set-mark-command'. That sets an Emacs region in the terminal buffer
-that follows point as output streams (a stuck \"selection\" C-g / Escape
-can't clear) and, worse, never reaches tmux -- so tmux copy-mode's
-begin-selection (C-Space) never starts and M-w then copies nothing.
-Forwarding NUL makes C-Space behave like a terminal key."
- (interactive)
- (ghostel-send-string "\C-@"))
-
-(defun cj/term-install-keys ()
- "Make `C-;' resolve as the personal keymap inside ghostel buffers, bind the
-F12 toggle, and forward C-SPC so it reaches the terminal (see
-`cj/term-send-C-SPC')."
- (when (boundp 'ghostel-mode-map)
- (keymap-set ghostel-mode-map "C-;" cj/custom-keymap)
- (keymap-set ghostel-mode-map "<f12>" #'cj/term-toggle)
- (keymap-set ghostel-mode-map "C-SPC" #'cj/term-send-C-SPC)))
-
-(cj/term-install-keys)
-(with-eval-after-load 'ghostel
- (cj/term-install-keys))
-
-(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements
- "C-; x" "terminal menu"
- "C-; x c" "copy mode (tmux/ghostel)"
- "C-; x h" "tmux scrollback history"
- "C-; x l" "clear scrollback"
- "C-; x N" "new terminal"
- "C-; x n" "next prompt"
- "C-; x p" "previous prompt"
- "C-; x q" "send next key to terminal"
- "C-; x t" "toggle terminal"))
-
-(provide 'term-config)
-;;; term-config.el ends here.
diff --git a/modules/tramp-config.el b/modules/tramp-config.el
index 23010b3e4..e3b835f1f 100644
--- a/modules/tramp-config.el
+++ b/modules/tramp-config.el
@@ -23,6 +23,15 @@
;;; Code:
+;; Silence byte-compiler "assignment to free variable" warnings for vars
+;; defined by lazily-loaded packages (tramp, dirtrack, magit). These are
+;; only set inside the use-package :config block, after the package loads.
+(defvar tramp-copy-size-limit)
+(defvar tramp-use-ssh-controlmaster-options)
+(defvar tramp-cleanup-idle-time)
+(defvar dirtrack-list)
+(defvar magit-git-executable)
+
(use-package tramp
:defer .5
:ensure nil ;; built-in
diff --git a/modules/transcription-config.el b/modules/transcription-config.el
index 566cea499..e00306d1e 100644
--- a/modules/transcription-config.el
+++ b/modules/transcription-config.el
@@ -173,13 +173,17 @@ TITLE and MESSAGE are strings. URGENCY is normal or critical."
:body message
:urgency (or urgency 'normal))))
-(defun cj/--start-transcription-process (audio-file &optional cleanup-file)
+(defun cj/--start-transcription-process (audio-file &optional cleanup-file output-base)
"Start async transcription process for AUDIO-FILE.
Returns the process object.
When CLEANUP-FILE is non-nil, delete that path once the transcription
sentinel fires (success or failure). Used by the video flow to drop
-the temp audio file produced by ffmpeg after transcription completes."
+the temp audio file produced by ffmpeg after transcription completes.
+
+OUTPUT-BASE, when non-nil, is the path the .txt/.log outputs derive from
+instead of AUDIO-FILE. The video flow passes the original video so the
+transcript lands alongside the source, not next to the temp /tmp audio."
(unless (file-exists-p audio-file)
(user-error "Audio file does not exist: %s" audio-file))
@@ -187,7 +191,7 @@ the temp audio file produced by ffmpeg after transcription completes."
(user-error "Not an audio file: %s" audio-file))
(let* ((script (cj/--transcription-script-path))
- (outputs (cj/--transcription-output-files audio-file))
+ (outputs (cj/--transcription-output-files (or output-base audio-file)))
(txt-file (car outputs))
(log-file (cdr outputs))
(buffer-name (format " *transcribe-%s*" (file-name-nondirectory audio-file)))
@@ -371,7 +375,9 @@ FILE.log with process logs. Uses the backend in
(cj/--extract-audio-from-video
path extracted
(lambda ()
- (cj/--start-transcription-process extracted extracted))))))))
+ ;; Pass the source video as the output base so the .txt/.log land
+ ;; alongside it, not next to the temp /tmp audio.
+ (cj/--start-transcription-process extracted extracted path))))))))
;;;###autoload
(defun cj/transcribe-media-at-point ()
diff --git a/modules/ui-navigation.el b/modules/ui-navigation.el
index c099e0834..cb0fc5697 100644
--- a/modules/ui-navigation.el
+++ b/modules/ui-navigation.el
@@ -283,5 +283,15 @@ With numeric prefix ARG, re-open the ARGth most-recently-killed file
:config
(winner-mode 1))
+;; ------------------------------- Cursor Jump (avy) ---------------------------
+;; Jump anywhere visible by typing a few of the target's characters, then the
+;; decision-tree key avy overlays. Fills the in-buffer motion gap that windmove
+;; (windows) and isearch (text) leave.
+
+(use-package avy
+ :bind (("C-:" . avy-goto-char-timer) ;; type chars, pause, jump to a match
+ ("M-g w" . avy-goto-word-1)
+ ("M-g l" . avy-goto-line)))
+
(provide 'ui-navigation)
;;; ui-navigation.el ends here
diff --git a/modules/user-constants.el b/modules/user-constants.el
index dab12dcbe..570b142fb 100644
--- a/modules/user-constants.el
+++ b/modules/user-constants.el
@@ -154,15 +154,24 @@ Syncthing-synced `org-dir' — see the 2026-06-10 transport migration.")
(defvar gcal-file (expand-file-name "data/gcal.org" user-emacs-directory)
"The location of the org file containing Google Calendar information.
-Stored in .emacs.d/data/ so each machine syncs independently from Google Calendar.")
+Stored in .emacs.d/data/ so each machine syncs independently from
+Google Calendar.")
(defvar pcal-file (expand-file-name "data/pcal.org" user-emacs-directory)
"The location of the org file containing Proton Calendar information.
-Stored in .emacs.d/data/ so each machine syncs independently from Proton Calendar.")
+Stored in .emacs.d/data/ so each machine syncs independently from
+Proton Calendar.")
(defvar dcal-file (expand-file-name "data/dcal.org" user-emacs-directory)
"The location of the org file containing DeepSat Calendar information.
-Stored in .emacs.d/data/ so each machine syncs independently from Google Calendar.")
+Stored in .emacs.d/data/ so each machine syncs independently from
+Google Calendar.")
+
+(defvar keep-file (expand-file-name "data/keep.org" user-emacs-directory)
+ "The location of the generated org file containing Google Keep notes.
+A read-only view regenerated by `cj/keep-refresh'; edits here do not
+sync back to Keep. Stored in .emacs.d/data/ so each machine syncs
+independently.")
(defvar reference-file (expand-file-name "reference.org" org-dir)
"The location of the org file containing reference information.")
diff --git a/modules/vc-config.el b/modules/vc-config.el
index 654116c59..fcca7e07b 100644
--- a/modules/vc-config.el
+++ b/modules/vc-config.el
@@ -27,6 +27,27 @@
(require 'user-constants) ;; provides code-dir
(require 'keybindings) ;; provides cj/custom-keymap
+;; Forward declaration: cj/vc-map is defined later in this file (see
+;; `defvar-keymap' below) but referenced earlier in a use-package :bind form.
+(defvar cj/vc-map)
+
+;; External package variables (assigned in :config blocks of lazily-loaded
+;; packages, so not loaded at byte-compile time).
+(defvar forge-pull-notifications)
+(defvar forge-topic-list-limit)
+
+;; External package functions (from lazily-loaded packages).
+(declare-function git-gutter:next-hunk "git-gutter")
+(declare-function git-gutter:previous-hunk "git-gutter")
+(declare-function git-timemachine--start "git-timemachine")
+(declare-function git-timemachine--revisions "git-timemachine")
+(declare-function git-timemachine-show-revision "git-timemachine")
+(declare-function forge-current-repository "forge")
+(declare-function forge-create-issue "forge")
+
+;; Defined later in this file; referenced earlier in `cj/git-timemachine'.
+(declare-function cj/git-timemachine-show-selected-revision "vc-config")
+
;; ---------------------------- Magit Configuration ----------------------------
(use-package magit
diff --git a/modules/video-audio-recording.el b/modules/video-audio-recording.el
index 4c934ef17..1672529f7 100644
--- a/modules/video-audio-recording.el
+++ b/modules/video-audio-recording.el
@@ -174,9 +174,10 @@ Checks if process is actually alive, not just if variable is set."
(defun cj/recording-process-sentinel (process event)
"Sentinel for recording processes — handles unexpected exits.
PROCESS is the ffmpeg shell process, EVENT describes what happened.
-This is called by Emacs when the process changes state (exits, is killed, etc.).
-It clears the process variable and updates the modeline so the recording indicator
-disappears even if the recording crashes or is killed externally."
+This is called by Emacs when the process changes state (exits, is
+killed, etc.). It clears the process variable and updates the modeline
+so the recording indicator disappears even if the recording crashes or
+is killed externally."
(when (memq (process-status process) '(exit signal))
(cond
((eq process cj/audio-recording-ffmpeg-process)
diff --git a/modules/weather-config.el b/modules/weather-config.el
index 93b0a6148..04531350f 100644
--- a/modules/weather-config.el
+++ b/modules/weather-config.el
@@ -32,7 +32,18 @@
("M-S-w" . wttrin) ;; was M-W, overrides kill-ring-save
:config
(setopt wttrin-unit-system "u")
+ ;; Drop the "Follow @igor_chubin for wttr.in updates" footer. "F" is the
+ ;; wttr.in flag for "no Follow line"; everything else (forecast, header,
+ ;; colors) is unchanged.
+ (setopt wttrin-display-options "F")
(setopt wttrin-favorite-location "New Orleans, LA")
+ ;; Higher-accuracy geolocation via the whereami WiFi-scan script (Google-backed),
+ ;; far better than IP behind a VPN or cellular hotspot. Used by the picker's
+ ;; "Current location (detect)" entry; wttrin falls back to its IP provider if the
+ ;; command is missing or fails. setq (not setopt): wttrin-geolocation-command is
+ ;; defined in the lazily-loaded wttrin-geolocation sub-module, so it may be unbound
+ ;; at :config time; the later defcustom won't clobber an already-set value.
+ (setq wttrin-geolocation-command "/home/cjennings/.local/bin/whereami --json")
(setopt wttrin-mode-line-refresh-interval (* 30 60)) ;; thirty minutes
(setq wttrin-default-locations '(
"New Orleans, LA"