main
1;;; init --- vdemeester's emacs configuration -*- lexical-binding: t -*-
2
3;; Copyright (C) 2025 Vincent Demeester
4;; Author: Vincent Demeester <vincent@sbr.pm>
5
6;; This file is NOT part of GNU Emacs.
7;;; Commentary:
8;; This is the "mini" version for now, but aims to become the default one.
9;;; Code:
10
11;;; Ensure critical directories exist
12;; Create auto-save and backup directories if they don't exist
13(let ((auto-save-dir (expand-file-name "~/.local/share/emacs/auto-saves/"))
14 (backup-dir (expand-file-name "~/.local/share/emacs/backups/")))
15 (unless (file-directory-p auto-save-dir)
16 (make-directory auto-save-dir t))
17 (unless (file-directory-p backup-dir)
18 (make-directory backup-dir t)))
19
20;;; Silence native-comp warnings to avoid cascading display-warning loops.
21(setq native-comp-async-report-warnings-errors 'silent)
22
23;;; Some constants I am using across the configuration.
24(defconst org-directory "~/desktop/org/"
25 "`org-mode' directory, where most of the org-mode file lives.")
26(defconst org-notes-directory (expand-file-name "notes" org-directory)
27 "`org-mode' notes directory, for notes obviously, most likely managed by denote.")
28(defconst org-inbox-file (expand-file-name "inbox.org" org-directory)
29 "`org-mode' inbox file, where we collect entries to be triaged.")
30(defconst org-todos-file (expand-file-name "todos.org" org-directory)
31 "`org-mode' file for TODOs. This is the main file for the org angenda entries.")
32(defconst org-habits-file (expand-file-name "habits.org" org-directory)
33 "`org-mode' file for habits. This is the file for habits that I need to
34share elsewhere, with Flat habits on iOS for example.")
35(defconst org-reading-list-file (expand-file-name "reading-list.org" org-directory)
36 "`org-mode' file for list of things to read.
37Most likely these needs to be added to readwise reader or ditch.")
38(defconst org-calendar-file (expand-file-name "calendar.org" org-directory)
39 "`org-mode' calendar file, auto-generated from Google Calendar (read-only).")
40(defconst org-journal-file (expand-file-name "20250620T144103--journal__journal.org" org-notes-directory)
41 "`org-mode' journal file, for journal-ling.")
42(defconst org-archive-dir (expand-file-name "archive" org-directory)
43 "`org-mode' directory of archived files.")
44(defconst org-people-dir (expand-file-name "people" org-notes-directory)
45 "`org-mode' people files directory, most likely managed by denote.")
46
47;;; The configuration.
48
49;;; Quick access to certain key file using registers
50(set-register ?e `(file . ,(locate-user-emacs-file "init.el")))
51(set-register ?i `(file . ,org-inbox-file))
52(set-register ?t `(file . ,org-todos-file))
53(set-register ?c `(file . ,org-calendar-file))
54(set-register ?j `(file . ,org-journal-file))
55(set-register ?o `(file . ,org-directory))
56(set-register ?n `(file . ,org-notes-directory))
57(set-register ?P `(file . ,org-people-dir))
58
59;;; Some GC optimizations
60(defun my-minibuffer-setup-hook ()
61 (setq gc-cons-threshold most-positive-fixnum))
62
63(defun my-minibuffer-exit-hook ()
64 (setq gc-cons-threshold 800000000))
65
66(setq gc-cons-threshold most-positive-fixnum)
67
68(run-with-idle-timer 1.2 t 'garbage-collect)
69
70(defconst emacs-start-time (current-time))
71
72(let ((minver 29))
73 (unless (>= emacs-major-version minver)
74 (error "Your Emacs is too old -- this configuration requires v%s or higher" minver)))
75
76(setq inhibit-default-init t) ; Disable the site default settings
77
78(setq confirm-kill-emacs #'y-or-n-p)
79
80(setq custom-file (locate-user-emacs-file "custom.el"))
81(require 'cus-edit)
82(setq
83 custom-buffer-done-kill nil ; Kill when existing
84 custom-buffer-verbose-help nil ; Remove redundant help text
85 custom-unlispify-tag-names nil ; Show me the real variable name
86 custom-unlispify-menu-entries nil)
87;; Create the custom-file if it doesn't exists
88(unless (file-exists-p custom-file)
89 (write-region "" nil custom-file))
90(load custom-file :no-error-if-file-is-missing)
91
92(setq echo-keystrokes 0.1) ;; display command keystrokes quickly
93
94(global-unset-key (kbd "C-z"))
95(global-unset-key (kbd "C-x C-z"))
96(global-unset-key (kbd "C-h h"))
97
98;; Make C-w behave like in terminal: delete word when no region is selected
99(defun kill-region-or-backward-word ()
100 "If the region is active and non-empty, call `kill-region'.
101Otherwise, call `backward-kill-word'."
102 (interactive)
103 (call-interactively
104 (if (use-region-p) 'kill-region 'backward-kill-word)))
105(global-set-key (kbd "C-w") 'kill-region-or-backward-word)
106
107;; Disable owerwrite-mode, iconify-frame and diary
108(mapc
109 (lambda (command)
110 (put command 'disabled t))
111 '(overwrite-mode iconify-frame diary))
112;; And enable those commands (disabled by default)
113(mapc
114 (lambda (command)
115 (put command 'disabled nil))
116 '(list-timers narrow-to-region narrow-to-page upcase-region downcase-region))
117
118(unless noninteractive
119 (defconst font-height 130
120 "Default font-height to use.")
121 ;; 2024-10-05: Switching from Ubuntu Mono to Cascadia Mono
122 ;; 2024-96-06: Switching from Cascadia Mono to JetBrains Mono
123 (defconst font-family-mono
124 (font-spec :family "MonaspiceNe Nerd Font" :features '(calt liga ss01 ss02 ss03 ss04 ss05 ss06 ss07 ss08))
125 ;; "JetBrains Mono"
126 "Default monospace font-family to use.")
127 (defconst font-family-sans "Ubuntu Sans"
128 "Default sans font-family to use.")
129 ;; Middle/Near East: שלום, السّلام عليكم
130 (when (and (fboundp 'set-fontset-font) (member "Noto Sans Arabic" (font-family-list)))
131 (set-fontset-font t 'arabic "Noto Sans Arabic"))
132 (when (and (fboundp 'set-fontset-font) (member "Noto Sans Hebrew" (font-family-list)))
133 (set-fontset-font t 'arabic "Noto Sans Hebrew"))
134 ;; Africa: ሠላም
135 (when (and (fboundp 'set-fontset-font) (member "Noto Sans Ethiopic" (font-family-list)))
136 (set-fontset-font t 'ethiopic "Noto Sans Ethiopic"))
137
138 ;; Use Monaspace Radon (handwriting) for italic faces — matches Kitty config
139 (defconst font-family-mono-italic
140 (font-spec :family "MonaspiceRn Nerd Font" :features '(calt liga ss01 ss02 ss03 ss04 ss05 ss06 ss07 ss08))
141 "Italic monospace font-family (Monaspace Radon handwriting variant).")
142
143 ;; Font setup must run when a GUI frame exists (daemon starts with no frame)
144 (defun vd/setup-fonts (&optional frame)
145 "Configure fonts for graphical frames."
146 (when-let* ((f (or frame (selected-frame))))
147 (when (display-graphic-p f)
148 (set-face-attribute 'default f
149 :font font-family-mono
150 :height font-height
151 :weight 'regular)
152 (set-face-attribute 'fixed-pitch f
153 :font font-family-mono
154 :weight 'medium
155 :height font-height)
156 (set-face-attribute 'variable-pitch f
157 :family font-family-sans
158 :weight 'regular)
159 (set-face-attribute 'italic f
160 :font font-family-mono-italic
161 :slant 'italic)
162 (set-face-attribute 'bold-italic f
163 :font font-family-mono-italic
164 :weight 'bold
165 :slant 'italic))))
166 ;; Apply to future frames (daemon mode)
167 (add-hook 'server-after-make-frame-hook #'vd/setup-fonts)
168 ;; Apply now if already in a GUI frame (non-daemon)
169 (vd/setup-fonts)
170
171 (when (fboundp 'set-fontset-font)
172 (set-fontset-font t 'symbol "Apple Color Emoji")
173 (set-fontset-font t 'symbol "Noto Color Emoji" nil 'append)
174 (set-fontset-font t 'symbol "Segoe UI Emoji" nil 'append)
175 (set-fontset-font t 'symbol "Symbola" nil 'append))
176
177 (require 'modus-themes)
178 (require 'modus-alabaster-bg)
179 (defvar modus-themes-preset-overrides-cooler)
180 (setopt modus-themes-common-palette-overrides
181 `((border-mode-line-active unspecified)
182 (border-mode-line-inactive unspecified)
183 ,@modus-themes-preset-overrides-cooler)
184 modus-themes-to-rotate '(modus-operandi modus-vivendi)
185 modus-themes-mixed-fonts t
186 modus-themes-headings '((0 . (variable-pitch semilight 1.5))
187 (1 . (regular 1.4))
188 (2 . (regular 1.3))
189 (3 . (regular 1.2))
190 (agenda-structure . (variable-pitch light 2.2))
191 (agenda-date . (variable-pitch regular 1.3))
192 (t . (regular 1.15))))
193
194 ;; Color scheme management - sync with system dconf settings
195 (defvar vde/themes-plist
196 '(:dark modus-vivendi :light modus-operandi)
197 "Themes to use for dark and light modes.")
198
199 (defun vde/color-scheme-get-system ()
200 "Get current system color scheme from dconf."
201 (when (eq system-type 'gnu/linux)
202 (let* ((raw-scheme (shell-command-to-string
203 "/run/current-system/sw/bin/dconf read /org/gnome/desktop/interface/color-scheme"))
204 (scheme (string-trim raw-scheme)))
205 (if (string-match-p "prefer-dark" scheme)
206 :dark
207 :light))))
208
209 (defun vde/color-scheme-set-emacs (scheme)
210 "Set Emacs theme based on SCHEME (:dark or :light)."
211 (let ((theme (plist-get vde/themes-plist scheme)))
212 (when theme
213 (mapc #'disable-theme custom-enabled-themes)
214 (load-theme theme :no-confirm))))
215
216 (defun vde/color-scheme-sync ()
217 "Sync Emacs theme with system color scheme."
218 (interactive)
219 (let ((scheme (vde/color-scheme-get-system)))
220 (when scheme
221 (vde/color-scheme-set-emacs scheme))))
222
223 (defun vde/color-scheme-toggle ()
224 "Toggle system color scheme and sync Emacs."
225 (interactive)
226 (when (eq system-type 'gnu/linux)
227 (start-process "toggle-color-scheme" nil "toggle-color-scheme")
228 ;; Wait a bit for the system to update, then sync
229 (run-with-timer 0.5 nil #'vde/color-scheme-sync)))
230
231 (declare-function vde/color-scheme-sync "init")
232 (declare-function vde/color-scheme-get-system "init")
233 (declare-function vde/color-scheme-set-emacs "init")
234
235 ;; Initial theme setup
236 (if (display-graphic-p)
237 (vde/color-scheme-sync)
238 (load-theme 'modus-vivendi :no-confirm))
239
240 ;; Watch for dbus signals when color-scheme changes
241 (when (and (eq system-type 'gnu/linux)
242 (require 'dbus nil t))
243 (declare-function dbus-register-signal "dbus")
244 (dbus-register-signal
245 :session
246 "ca.desrt.dconf"
247 "/ca/desrt/dconf/Writer/user"
248 "ca.desrt.dconf.Writer"
249 "Notify"
250 (lambda (&rest args)
251 (when (string-match-p "color-scheme" (format "%s" args))
252 (vde/color-scheme-sync)))))
253
254 ;; Keybinding for toggling color scheme
255 (declare-function vde/color-scheme-toggle "init")
256 (global-set-key (kbd "C-c t t") #'vde/color-scheme-toggle))
257
258(setopt load-prefer-newer t) ; Always load newer compiled files
259(setopt ad-redefinition-action 'accept) ; Silence advice redefinition warnings
260;; (setopt debug-on-error t)
261(setopt byte-compile-debug t)
262
263;; Configure `use-package' prior to loading it.
264(eval-and-compile
265 (setq use-package-always-ensure nil)
266 (setq use-package-always-defer nil)
267 (setq use-package-always-demand nil)
268 (setq use-package-expand-minimally nil)
269 (setq use-package-enable-imenu-support t)
270 (setq use-package-compute-statistics t))
271
272(eval-when-compile
273 (require 'use-package))
274
275(require 'info) ;; XXX ensure the var exists even before loading `info.el'.
276
277;; Add site-lisp to load-path for local elisp files
278(add-to-list 'load-path (expand-file-name "site-lisp" user-emacs-directory))
279(add-to-list 'custom-theme-load-path (expand-file-name "site-lisp" user-emacs-directory))
280
281(use-package vde-toggle
282 :bind ("C-c t m" . vde-toggle-modes))
283
284;; TODO: Re-enable init-func when needed for custom macros
285;; (use-package init-func)
286;; ;; TODO: do useful stuff with the macro instead
287;; (vde/run-and-delete-frame my-greet-and-close ()
288;; "Displays a greeting and closes the frame after a short delay."
289;; (message "Hello from a macro-defined function! Closing soon...")
290;; (sit-for 2))
291
292(use-package emacs
293 :bind
294 ("C-x m" . mark-defun)
295 ("C-x C-b" . bs-show)
296 ("M-o" . other-window)
297 ("C-c j d" . duplicate-dwim)
298 ("<f5>" . revert-buffer)
299 ;; (:map completion-preview-active-mode-map
300 ;; ("M-n" . #'completion-preview-next-candidate)
301 ;; ("M-p" . #'completion-preview-prev-candidate))
302 :custom
303 (create-lockfiles nil)
304 ;; Versioned backups in central directory
305 (make-backup-files t)
306 (version-control t) ; Numbered backups
307 (kept-new-versions 10) ; Keep 10 newest
308 (kept-old-versions 2) ; Keep 2 oldest
309 (delete-old-versions t) ; Auto-delete excess
310 (backup-by-copying t) ; Don't clobber symlinks
311 (backup-directory-alist '(("." . "~/.local/share/emacs/backups/")))
312 ;; Auto-save files in central directory
313 (auto-save-file-name-transforms '((".*" "~/.local/share/emacs/auto-saves/" t)))
314 (use-short-answers t "Use short answer y/n")
315 ;; (tab-always-indent 'complete)
316 ;; (tab-first-completion 'word-or-paren-or-punct)
317 (enable-local-variables :all)
318 ;; (select-enable-clipboard t)
319 ;; (select-enable-primary t)
320 (comment-multi-line t)
321 (read-extended-command-predicate #'command-completion-default-include-p)
322 (mouse-autoselect 1)
323 (completion-cycle-threshold 2)
324 (completion-ignore-case t)
325 (completion-show-inline-help nil)
326 (completions-detailed t)
327 (enable-recursive-minibuffers t)
328 (read-buffer-completion-ignore-case t)
329 (read-file-name-completion-ignore-case t)
330 (find-ls-option '("-exec ls -ldh {} +" . "-ldh")) ; find-dired results with human readable sizes
331 (global-auto-revert-non-file-buffers t "Auto revert non-file buffers")
332 (switch-to-buffer-obey-display-actions t "Don't distuingish automatic and manual window switching")
333 (window-combination-resize t "Resize window proportionally")
334 (isearch-lazy-count t "Show size of search results")
335 (lazy-count-prefix-format "(%s/%s) " "Format of search results")
336 (lazy-highlight-initial-delay 0 "No delay before highlight search matches")
337 (isearch-allow-scroll t "Allow scrolling while searching")
338 (isearch-allow-motion t "Allow movement commands while searching")
339 :hook
340 (after-init . global-hl-line-mode)
341 (after-init . global-completion-preview-mode)
342 (after-init . auto-insert-mode)
343 (after-init . pixel-scroll-mode)
344 :config
345 (with-current-buffer "*Messages*" (emacs-lock-mode 'kill))
346 (with-current-buffer "*scratch*" (emacs-lock-mode 'kill))
347 (display-time-mode -1)
348 (when (fboundp 'tooltip-mode) (tooltip-mode -1))
349 (when (fboundp 'blink-cursor-mode) (blink-cursor-mode -1))
350 (setenv "GIT_EDITOR" (format "emacs --init-dir=%s " (shell-quote-argument user-emacs-directory)))
351 (setenv "EDITOR" (format "emacs --init-dir=%s " (shell-quote-argument user-emacs-directory)))
352 (delete-selection-mode 1)
353 (declare-function minibuffer-keyboard-quit "delsel")
354 (defun er-keyboard-quit ()
355 "Smater version of the built-in `keyboard-quit'.
356
357The generic `keyboard-quit' does not do the expected thing when
358the minibuffer is open. Whereas we want it to close the
359minibuffer, even without explicitly focusing it."
360 (interactive)
361 (if (active-minibuffer-window)
362 (if (minibufferp)
363 (minibuffer-keyboard-quit)
364 (abort-recursive-edit))
365 (keyboard-quit)))
366 (declare-function er-keyboard-quit "init")
367 (global-set-key [remap keyboard-quit] #'er-keyboard-quit))
368
369(use-package view
370 :commands (view-mode)
371 :init
372 (declare-function View-scroll-page-backward "view")
373 (declare-function View-scroll-page-forward "view")
374 :custom
375 (view-read-only t "Enable view-mode when entering read-only")
376 :bind (("C-<escape>" . view-mode)
377 :map view-mode-map
378 ;; Navigation
379 ("n" . next-line)
380 ("p" . previous-line)
381 ("f" . forward-char)
382 ("b" . backward-char)
383
384 ;; Beginning/end of line
385 ("a" . beginning-of-line)
386 ("e" . end-of-line)
387
388 ;; Quick exit to edit mode
389 ("i" . View-exit)
390 ("j" . next-line)
391 ("k" . previous-line)
392 ("h" . backward-char)
393 ("l" . forward-char)
394
395 ;; Page movement
396 ("u" . (lambda()
397 (interactive)
398 (View-scroll-page-backward 3)))
399 ("d" . (lambda()
400 (interactive)
401 (View-scroll-page-forward 3)))
402
403 ;; Beginning/end of line (Vim style)
404 ("0" . beginning-of-line)
405 ("$" . end-of-line)
406
407 ;; Beginning/end of buffers
408 ("g" . beginning-of-buffer)
409 ("G" . end-of-buffer)
410
411 ;; Other bespoke bindings
412 (";" . other-window)
413
414 ("SPC" . nil))
415 :hook
416 ;; Visual feedback - box cursor in view mode, bar when editing
417 (view-mode . view-mode-hookee+)
418 :config
419 (defun view-mode-hookee+ ()
420 (setq cursor-type (if view-mode 'box 'bar))))
421
422(use-package tramp
423 :custom
424 ;; Tramp
425 (remote-file-name-inhibit-locks t "No lock files on remote files")
426 (tramp-use-scp-direct-remote-copying t "Use direct copying between two remote hosts")
427 (remote-file-name-inhibit-auto-save-visited t "Do not auto-save remote files")
428 (tramp-copy-size-limit (* 1024 1024)) ;; 1MB
429 (tramp-verbose 2)
430 :config
431 (declare-function tramp-compile-disable-ssh-controlmaster-options "tramp")
432 ;; Add custom SSH options to disable YubiKey agent for TRAMP connections
433 (with-eval-after-load 'tramp-sh
434 (defvar tramp-use-ssh-controlmaster-options)
435 (setq tramp-use-ssh-controlmaster-options nil)
436 (let ((ssh-method (assoc "ssh" tramp-methods)))
437 (when ssh-method
438 (let* ((login-args (cadr (assq 'tramp-login-args (cdr ssh-method))))
439 (new-login-args (append login-args
440 '(("-o" "IdentitiesOnly=yes")
441 ("-o" "IdentityAgent=none")))))
442 (setf (cadr (assq 'tramp-login-args (cdr ssh-method))) new-login-args)))))
443 (connection-local-set-profile-variables
444 'remote-direct-async-process
445 '((tramp-direct-async-process . t)))
446
447 (connection-local-set-profiles
448 '(:application tramp :protocol "scp")
449 'remote-direct-async-process)
450
451 (defvar magit-tramp-pipe-stty-settings)
452 (with-eval-after-load 'magit
453 (setq magit-tramp-pipe-stty-settings 'pty))
454 (with-eval-after-load 'tramp
455 (with-eval-after-load 'compile
456 (remove-hook 'compilation-mode-hook #'tramp-compile-disable-ssh-controlmaster-options)))
457
458 (add-to-list 'tramp-remote-path 'tramp-own-remote-path)
459 (add-to-list 'tramp-remote-path "/home/vincent/.local/state/nix/profile/bin/")
460 (add-to-list 'tramp-remote-path "~/bin/")
461 ;; (add-to-list 'tramp-connection-properties
462 ;; (list (regexp-quote "/ssh:aomi.home:")
463 ;; "remote-shell" "/home/vincent/.local/state/nix/profile/bin/zsh"))
464 )
465
466(use-package passage
467 :commands (passage-get))
468
469(use-package find-file
470 :bind ("C-x C-g" . ff-find-other-file))
471
472(use-package icomplete
473 :unless noninteractive
474 :hook
475 (icomplete-minibuffer-setup
476 . (lambda()(interactive)
477 (setq-local completion-styles '(flex partial-completion initials basic))))
478 (after-init . fido-vertical-mode)
479 :custom
480 (icomplete-compute-delay 0.01)
481 :config
482 ;; Unbind C-, from icomplete to allow embark-act to work
483 (define-key icomplete-minibuffer-map (kbd "C-,") nil)
484 (define-key icomplete-fido-mode-map (kbd "C-,") nil))
485
486(use-package display-line-numbers
487 :unless noninteractive
488 :hook (prog-mode . display-line-numbers-mode)
489 :config
490 (setq-default display-line-numbers-type 'relative)
491 (defun vde/toggle-line-numbers ()
492 "Toggles the display of line numbers. Applies to all buffers."
493 (interactive)
494 (if (bound-and-true-p display-line-numbers-mode)
495 (display-line-numbers-mode -1)
496 (display-line-numbers-mode)))
497 :bind ("<f7>" . vde/toggle-line-numbers))
498
499(use-package kkp
500 :if (not (display-graphic-p))
501 :hook (after-init . global-kkp-mode))
502
503(use-package helpful
504 :unless noninteractive
505 :bind (("C-h f" . helpful-callable)
506 ("C-h F" . helpful-function)
507 ("C-h M" . helpful-macro)
508 ("C-c h S" . helpful-at-point)
509 ("C-h k" . helpful-key)
510 ("C-h v" . helpful-variable)
511 ("C-h C" . helpful-command)))
512
513(use-package flymake
514 :bind
515 ("C-c f b" . flymake-show-buffer-diagnostics)
516 (:map flymake-mode-map
517 ("M-n" . flymake-goto-next-error)
518 ("M-p" . flymake-goto-prev-error))
519 :hook
520 (prog-mode . flymake-mode))
521
522(use-package treesit-fold
523 :hook
524 (after-init . global-treesit-fold-mode)
525 :custom
526 (treesit-fold-line-count-show t) ; Show line count in folded regions
527 (treesit-fold-line-count-format " <%d lines> ")
528 :config
529 (global-set-key (kbd "C-c f f") 'treesit-fold-close)
530 (global-set-key (kbd "C-c f o") 'treesit-fold-open))
531
532(use-package scopeline
533 :hook prog-mode
534 :custom-face
535 (scopeline-face ((t (:height 0.8 :inherit shadow)))))
536
537(use-package aggressive-indent
538 :commands (aggressive-indent-mode)
539 :hook
540 (emacs-lisp-mode . aggressive-indent-mode))
541
542(use-package save-place
543 :defer 1
544 :config (save-place-mode 1))
545
546(use-package symbol-overlay
547 :custom
548 (symbol-overlay-idle-time 0.2)
549 :bind
550 ("M-s s i" . symbol-overlay-put)
551 ("M-N" . symbol-overlay-jump-next)
552 ("M-P" . symbol-overlay-jump-prev)
553 ("M-s s r" . symbol-overlay-rename)
554 ("M-s s c" . symbol-overlay-remove-all)
555 :hook
556 (prog-mode . symbol-overlay-mode))
557
558(use-package savehist
559 :unless noninteractive
560 :hook (after-init . savehist-mode)
561 :custom
562 (history-length 10000)
563 (savehist-save-minibuffer-history t)
564 (savehist-delete-duplicates t)
565 (savehist-autosave-interval 180)
566 (savehist-additional-variables '(extended-command-history
567 search-ring
568 regexp-search-ring
569 comint-input-ring
570 compile-history
571 last-kbd-macro
572 shell-command-history)))
573
574(use-package newcomment
575 :unless noninteractive
576 :custom
577 (comment-empty-lines t)
578 (comment-fill-column nil)
579 (comment-multi-line t)
580 (comment-style 'multi-line)
581 :config
582 (defun prot/comment-dwim (&optional arg)
583 "Alternative to `comment-dwim': offers a simple wrapper
584 around `comment-line' and `comment-dwim'.
585
586 If the region is active, then toggle the comment status of the
587 region or, if the major mode defines as much, of all the lines
588 implied by the region boundaries.
589
590 Else toggle the comment status of the line at point."
591 (interactive "*P")
592 (if (use-region-p)
593 (comment-dwim arg)
594 (save-excursion
595 (comment-line arg))))
596 :bind (("C-;" . prot/comment-dwim)
597 ("C-:" . comment-kill)
598 ("M-;" . comment-indent)
599 ("C-x C-;" . comment-box)))
600
601(use-package dired
602 :custom
603 (dired-hide-details-hide-information-lines 'nil)
604 (dired-kill-when-opening-new-dired-buffer 't)
605 (dired-dwim-target t)
606 :bind
607 (:map dired-mode-map
608 ("E" . wdired-change-to-wdired-mode)
609 ("l" . dired-find-file))
610 :custom
611 (dired-vc-rename-file t)
612 :hook
613 (dired-mode . dired-omit-mode)
614 (dired-mode . dired-hide-details-mode)
615 ;; (dired-mode . dired-sort-toggle-or-edit) ; I don't like the "default by date" behavior
616 )
617
618(use-package alert
619 :defer 2
620 :init
621 (declare-function alert "alert")
622 (defun alert-after-finish-in-background (buf str)
623 (when (or (not (get-buffer-window buf 'visible)) (not (frame-focus-state)))
624 (alert str :buffer buf)))
625 :config
626 (defvar alert-default-style)
627 (setq alert-default-style 'libnotify))
628
629(use-package elec-pair
630 :hook (after-init-hook . electric-pair-mode))
631
632(use-package uniquify
633 :custom
634 (uniquify-buffer-name-style 'forward)
635 (uniquify-strip-common-suffix t)
636 (uniquify-after-kill-buffer-p t))
637
638(use-package compile
639 :unless noninteractive
640 :commands (compile)
641 :custom
642 (compilation-always-kill t)
643 (compilation-scroll-output t)
644 (ansi-color-for-compilation-mode t)
645 :config
646 (declare-function alert-after-finish-in-background "init")
647 (add-hook 'compilation-finish-functions #'alert-after-finish-in-background))
648
649(use-package subword
650 :diminish
651 :hook (prog-mode-hook . subword-mode))
652
653;; Recentf
654(use-package recentf
655 :defer t
656 :hook
657 (after-init . recentf-mode)
658 :bind (("C-x C-r" . recentf-open)))
659
660(use-package autorevert
661 :ensure nil
662 :custom
663 (global-auto-revert-non-file-buffers t)
664 (auto-revert-verbose nil)
665 :init
666 (global-auto-revert-mode 1))
667
668(use-package prog-mode
669 :hook
670 (prog-mode . eldoc-mode)
671 :custom
672 (eldoc-idle-delay 0.2))
673
674(use-package eglot
675 :bind
676 (:map eglot-mode-map
677 ("C-c e a" . eglot-code-actions)
678 ("C-c e r" . eglot-reconnect)
679 ("<f2>" . eglot-rename)
680 ("C-c e ?" . eldoc-print-current-symbol-info))
681 :custom
682 (eglot-autoshutdown t)
683 (eglot-confirm-server-initiated-edits nil)
684 :config
685 (declare-function eglot-format-buffer "eglot")
686 (declare-function eglot-managed-p "eglot")
687 (add-to-list 'eglot-ignored-server-capabilities :documentHighlightProvider)
688 (add-to-list 'eglot-server-programs `(json-mode "vscode-json-language-server" "--stdio"))
689 (add-to-list 'eglot-server-programs '(nix-mode . ("nil")))
690 ;; (add-to-list 'eglot-server-programs
691 ;; '(go-mode . ("harper-ls" "--stdio")))
692 ;; (add-to-list 'eglot-server-programs
693 ;; '(text-mode . ("harper-ls" "--stdio")))
694 ;; (add-to-list 'eglot-server-programs
695 ;; '(org-mode . ("harper-ls" "--stdio")))
696 (add-to-list 'eglot-server-programs
697 '(markdown-mode . ("harper-ls" "--stdio")))
698 (setq-default eglot-workspace-configuration
699 '(
700 :gopls (
701 :usePlaceholders t
702 ;; See https://github.com/golang/tools/blob/master/gopls/doc/analyzers.md
703 :analyses (
704 :QF1006 t
705 :QF1007 t
706 :S1002 t
707 :S1005 t
708 :S1006 t
709 :S1008 t
710 :S1025 t
711 :SA1003 t
712 :SA1014 t
713 :SA1015 t
714 :SA1023 t
715 :SA1032 t
716 :SA2002 t
717 :SA4023 t
718 :SA4031 t
719 :SA5000 t
720 :SA5010 t
721 :SA5000 t
722 :SA6000 t
723 :SA6001 t
724 :SA6002 t
725 :SA6003 t
726 :SA9003 t
727 :SA9007 t
728 :ST1000 t
729 :ST1001 t
730 :ST1005 t
731 :ST1013 t
732 :ST1015 t
733 :ST1016 t
734 :ST1017 t
735 :ST1019 t
736 :ST1020 t
737 :ST1021 t
738 :ST1022 t
739 :ST1023 t
740 :shadow t
741 )
742 ;; See https://github.com/golang/tools/blob/master/gopls/doc/inlayHints.md
743 :hints (:constantValues t :compositeLiteralTypes t :compositeLiteralFields t))
744 :nil (
745 :formatting (:command ["nixfmt"])
746 :nix (
747 :maxMemoryMB 2560
748 :autoEvalInputs t
749 :nixpkgsInputName "nixpkgs"
750 )
751 )
752 :pylsp (
753 :configurationSources ["flake8"]
754 :plugins (:pycodestyle (:enabled nil)
755 :black (:enabled t)
756 :mccabe (:enabled nil)
757 :flake8 (:enabled t)))))
758 (defun eglot-format-buffer-on-save ()
759 (if (and (project-current) (eglot-managed-p))
760 (add-hook 'before-save-hook #'eglot-format-buffer nil 'local)
761 (remove-hook 'before-save-hook #'eglot-format-buffer 'local)))
762 (declare-function eglot-format-buffer-on-save "init")
763 (add-hook 'eglot-managed-mode-hook #'eglot-format-buffer-on-save)
764
765 ;; Don't auto-start eglot on TRAMP files (too slow/unreliable)
766 (defun eglot-ensure-unless-tramp ()
767 "Start eglot unless we're on a TRAMP remote file."
768 (unless (file-remote-p default-directory)
769 (eglot-ensure)))
770 (declare-function eglot-ensure-unless-tramp "init")
771
772 :hook
773 ;; (before-save . gofmt-before-save)
774 ;; (before-save . eglot-format-buffer)
775 (nix-mode . eglot-ensure-unless-tramp)
776 (nix-ts-mode . eglot-ensure-unless-tramp)
777 (rust-mode . eglot-ensure-unless-tramp)
778 (rust-ts-mode . eglot-ensure-unless-tramp)
779 (python-mode . eglot-ensure-unless-tramp)
780 (python-ts-mode . eglot-ensure-unless-tramp)
781 (go-mode . eglot-ensure-unless-tramp)
782 (go-ts-mode . eglot-ensure-unless-tramp)
783 (sh-mode . eglot-ensure-unless-tramp)
784 (sh-script-mode . eglot-ensure-unless-tramp)
785 (org-mode . eglot-ensure-unless-tramp)
786 (markdown-mode . eglot-ensure-unless-tramp)
787 (js-ts-mode . eglot-ensure-unless-tramp)
788 (typescript-ts-mode . eglot-ensure-unless-tramp)
789 (tsx-ts-mode . eglot-ensure-unless-tramp))
790
791(setq major-mode-remap-alist
792 '((python-mode . python-ts-mode)
793 (go-mode . go-ts-mode)
794 (javascript-mode . js-ts-mode)
795 (js-mode . js-ts-mode)
796 (typescript-mode . typescript-ts-mode)))
797
798(use-package markdown-mode
799 :mode (("README\\.md\\'" . gfm-mode)
800 ("\\.md\\'" . markdown-mode)
801 ("\\.markdown\\'" . markdown-mode))
802 :hook ((markdown-mode . visual-line-mode)
803 (mardown-mode . visual-wrap-prefix-mode)
804 (gfm-mode . visual-line-mode)
805 (gfm-mode . visual-wrap-prefix-mode)))
806
807
808(use-package yaml-ts-mode
809 :mode "\\.ya?ml\\'"
810 :hook ((yaml-ts-mode . display-line-numbers-mode)
811 (yaml-ts-mode . outline-minor-mode)
812 (yaml-ts-mode . electric-pair-local-mode))
813 :config
814 (setq-local outline-regexp "^ *\\([A-Za-z0-9_-]*: *[>|]?$\\|-\\b\\)")
815 (font-lock-add-keywords
816 'yaml-ts-mode
817 '(("\\($(\\(workspaces\\|context\\|params\\)\.[^)]+)\\)" 1 'font-lock-constant-face prepend)
818 ("kind:\s*\\(.*\\)\n" 1 'font-lock-keyword-face prepend))))
819
820;; Tekton LSP for YAML files containing tekton.dev apiVersion
821;; TODO: replace with just "tekton-lsp" once it's in $PATH
822(with-eval-after-load 'eglot
823 (add-to-list 'eglot-server-programs
824 `(yaml-ts-mode . (,(expand-file-name "~/src/tektoncd/tekton-lsp-go/tekton-lsp")))))
825(defun vde/maybe-start-tekton-lsp ()
826 "Start tekton-lsp via eglot if the buffer contains a Tekton apiVersion."
827 (when (and buffer-file-name
828 (not (file-remote-p default-directory))
829 (save-excursion
830 (goto-char (point-min))
831 (re-search-forward "tekton\\.dev\\|triggers\\.tekton\\.dev" nil t)))
832 (eglot-ensure)))
833(add-hook 'yaml-ts-mode-hook #'vde/maybe-start-tekton-lsp)
834
835(use-package orgalist
836 :commands (orgalist-mode)
837 :hook ((markdown-mode . orgalist-mode)
838 (gfm-mode . orgalist-mode)))
839
840(use-package dockerfile-ts-mode
841 :mode (("Dockerfile\\'" . dockerfile-ts-mode)
842 ("\\.Dockerfile\\'" . dockerfile-ts-mode-map)
843 ("Containerfile\\'" . dockerfile-ts-mode)
844 ("\\.Containerfile\\'" . dockerfile-ts-mode-map)))
845
846(use-package go-ts-mode
847 :mode (("\\.go$" . go-ts-mode)
848 ("\\.go" . go-ts-mode)
849 ("\\.go\\'" . go-ts-mode))
850 :hook ((go-ts-mode . vde/go-mode-setup))
851 :config
852 (defun vde/go-mode-setup ()
853 "Setup for go-mode."
854 (setq-local ff-other-file-alist
855 '(("_test\\.go\\'" (".go"))
856 ("\\.go\\'" ("_test.go"))
857 ))))
858
859(use-package gotest-ts
860 :after go-ts-mode
861 :hook (go-ts-mode . gotest-ts-setup))
862
863(use-package lua-ts-mode
864 :mode "\\.lua\\'"
865 :hook (lua-ts-mode . eglot-ensure-unless-tramp))
866
867(use-package nix-ts-mode
868 :if (executable-find "nix")
869 :mode ("\\.nix\\'" "\\.nix.in\\'"))
870
871(use-package terraform-ts-mode
872 :if (executable-find "terraform-ls")
873 :mode ("\\.tf\\'" "\\.tfvars\\'"))
874
875(use-package js-ts-mode
876 :mode (("\\.js\\'" . js-ts-mode)
877 ("\\.mjs\\'" . js-ts-mode)
878 ("\\.cjs\\'" . js-ts-mode)))
879
880(use-package typescript-ts-mode
881 :mode (("\\.ts\\'" . typescript-ts-mode)
882 ("\\.mts\\'" . typescript-ts-mode)
883 ("\\.cts\\'" . typescript-ts-mode)))
884
885(use-package tsx-ts-mode
886 :mode ("\\.tsx\\'" "\\.jsx\\'"))
887
888(use-package nix-drv-mode
889 :if (executable-find "nix")
890 :after nix-mode
891 :mode "\\.drv\\'")
892
893(use-package nix-shell
894 :if (executable-find "nix")
895 :after nix-mode
896 :commands (nix-shell-unpack nix-shell-configure nix-shell-build))
897
898(use-package nixpkgs-fmt
899 :if (executable-find "nix")
900 :after nix-ts-mode
901 :custom
902 (nixpkgs-fmt-command "nixfmt")
903 :config
904 (add-hook 'nix-ts-mode-hook 'nixpkgs-fmt-on-save-mode))
905
906(use-package minions
907 :hook (after-init . minions-mode)
908 :config
909 (defvar minions-prominent-modes)
910 (add-to-list 'minions-prominent-modes 'flymake-mode))
911
912(use-package vundo
913 :bind (("M-u" . undo)
914 ("M-U" . undo-redo)
915 ("C-x u" . vundo)))
916
917(use-package vde-vcs
918 :commands (vde/gh-get-current-repo vde/vc-browse-remote)
919 :bind (("C-x v B" . vde/vc-browse-remote)))
920
921(use-package project-func
922 :commands (vde/project-magit-status vde/project-eat vde/project-vterm vde/project-run-in-vterm vde/project-ghostel vde/project-try-local vde/open-readme))
923
924(use-package project
925 :commands (project-find-file project-find-regexp)
926 :custom
927 (project-switch-commands '((?f "File" project-find-file)
928 (?g "Grep" project-find-regexp)
929 (?d "Dired" project-dired)
930 (?b "Buffer" project-switch-to-buffer)
931 (?q "Query replace" project-query-replace-regexp)
932 (?v "VC dir" project-vc-dir)
933 (?m "Magit" vde/project-magit-status)
934 (?e "Eshell" project-eshell)
935 (?E "Eat" vde/project-eat)
936 (?s "Vterm" vde/project-vterm)
937 (?t "Ghostel" vde/project-ghostel)
938 (?R "README" vde/open-readme)
939 (?g "Checkout GitHub PR" checkout-github-pr)))
940 (project-mode-line t)
941 (project-compilation-buffer-name-function 'project-prefixed-buffer-name)
942 ;; (project-vc-extra-root-markers '(".project" "Cargo.toml" "pyproject.toml" "requirements.txt" "go.mod"))
943 :bind
944 ("C-x p v" . vde/project-magit-status)
945 ("C-x p s" . vde/project-vterm)
946 ("C-x p X" . vde/project-run-in-vterm)
947 ("C-x p E" . vde/project-eat)
948 ("C-x p t" . vde/project-ghostel)
949 ("C-x p G" . checkout-github-pr)
950 ("C-x p F" . flymake-show-project-diagnostics)
951 ("C-x p R" . vde/project-refresh-list)
952 :config
953 (setq project-list-exclude
954 '("/build\\(/\\|\\'\\)" "/node_modules\\(/\\|\\'\\)"
955 "/.cache\\(/\\|\\'\\)" "/target\\(/\\|\\'\\)" "/.direnv\\(/\\|\\'\\)"))
956
957 (defun vde/project-refresh-list ()
958 "Rescan ~/src for projects."
959 (interactive)
960 (setq project--list nil) ; Clear instead of checking each zombie
961 (project-remember-projects-under "~/src" t)
962 (message "Project list refreshed"))
963
964 ;; Populate project list on first run if empty
965 (unless project--list
966 (project-remember-projects-under "~/src" t)))
967
968(use-package eshell
969 :commands (eshell eshell-here)
970 :config
971 (defvar eshell-last-dir-ring)
972 (defvar consult-dir-sources)
973 (declare-function eshell-find-previous-directory "eshell")
974 (declare-function consult-dir--pick "consult-dir")
975 (declare-function ring-elements "ring")
976 (declare-function eshell/cd "eshell")
977 (add-to-list 'eshell-modules-list 'eshell-rebind)
978
979 (defun eshell-here ()
980 "Open eshell in the directory of the current buffer's file."
981 (interactive)
982 (let* ((parent (if (buffer-file-name)
983 (file-name-directory (buffer-file-name))
984 default-directory))
985 (name (car (last (split-string parent "/" t)))))
986 (eshell "new")
987 (rename-buffer (concat "*eshell: " name "*"))))
988
989 ;; Handy aliases
990 (defalias 'ff 'find-file)
991 (defalias 'e 'find-file)
992 (defalias 'd 'dired)
993
994 (defun eshell/cdg ()
995 "Change directory to the project's root."
996 (eshell/cd (locate-dominating-file default-directory ".git")))
997
998 (defun eshell/j (&optional regexp)
999 "Navigate to a previously visited directory in eshell."
1000 (let ((eshell-dirs (delete-dups
1001 (mapcar 'abbreviate-file-name
1002 (ring-elements eshell-last-dir-ring)))))
1003 (cond
1004 ((and (not regexp) (featurep 'consult-dir))
1005 (let* ((consult-dir--source-eshell `(:name "Eshell"
1006 :narrow ?e
1007 :category file
1008 :face consult-file
1009 :items ,eshell-dirs))
1010 (consult-dir-sources (cons consult-dir--source-eshell
1011 consult-dir-sources)))
1012 (eshell/cd (substring-no-properties
1013 (consult-dir--pick "Switch directory: ")))))
1014 (t (eshell/cd (if regexp (eshell-find-previous-directory regexp)
1015 (completing-read "cd: " eshell-dirs)))))))
1016
1017 ;; Use system su/sudo instead of eshell builtins
1018 (with-eval-after-load "em-unix"
1019 (unintern 'eshell/su nil)
1020 (unintern 'eshell/sudo nil))
1021
1022 :hook (eshell-mode . with-editor-export-editor))
1023
1024(use-package eat
1025 :commands (eat)
1026 :init
1027 (defvar eat-kill-buffer-on-exit)
1028 (defvar eat-enable-yank-to-terminal)
1029 (setq-default explicit-shell-file-name "zsh"
1030 shell-file-name "zsh")
1031 (setq eat-kill-buffer-on-exit t
1032 eat-enable-yank-to-terminal t)
1033 :hook ((eshell-mode . eat-eshell-mode)
1034 (eshell-mode . eat-eshell-visual-command-mode)))
1035
1036(use-package vterm
1037 :commands (vterm)
1038 :custom
1039 (vterm-kill-buffer-on-exit t)
1040 (vterm-max-scrollback 100000))
1041
1042(use-package ghostel
1043 :commands (ghostel)
1044 :bind ("C-c t g" . vde/ghostel-toggle)
1045 :custom
1046 (ghostel-keymap-exceptions
1047 '("C-c" "C-x" "C-u" "C-h" "M-x" "M-:" "C-\\"
1048 "S-<up>" "S-<down>" "S-<left>" "S-<right>"
1049 "M-S-<up>" "M-S-<down>" "M-S-<left>" "M-S-<right>"))
1050 :config
1051 (defun vde/ghostel-buffer ()
1052 "Return the active ghostel buffer, or nil if none exists."
1053 (seq-find (lambda (buf)
1054 (string-match-p "\\*ghostel:" (buffer-name buf)))
1055 (buffer-list)))
1056 (defun vde/ghostel-toggle ()
1057 "Toggle ghostel terminal buffer."
1058 (interactive)
1059 (let ((buf (vde/ghostel-buffer)))
1060 (cond
1061 ((not buf) (ghostel))
1062 ((eq (current-buffer) buf) (bury-buffer))
1063 (t (switch-to-buffer buf))))))
1064
1065;; TODO adapt this to my needs
1066;; (defun tkj/vc-git-grep-current-line ()
1067;; "Search Git project for the current line."
1068;; (interactive)
1069;; (let ((current-line (string-trim (thing-at-point 'line t))))
1070;; (if (string-empty-p current-line)
1071;; (message "No line in sight")
1072;; (vc-git-grep current-line "*" (vc-git-root default-directory)))))
1073;; (global-set-key (kbd "C-c p l") 'tkj/vc-git-grep-current-line)
1074;;
1075;; (defun tkj/vc-git-grep-symbol ()
1076;; "Search Git project for the symbol at point. This could be any word or
1077;; programming symbol, like a function or variable."
1078;; (interactive)
1079;; (let ((symbol (string-trim (thing-at-point 'symbol t))))
1080;; (if (string-empty-p symbol)
1081;; (message "You point at nothing")
1082;; (vc-git-grep symbol "*" (vc-git-root default-directory)))))
1083;; (global-set-key (kbd "C-c p s") 'tkj/vc-git-grep-symbol)
1084
1085(use-package vc
1086 :custom
1087 (vc-allow-rewriting-published-history nil "safety guard for history rewriting")
1088 (vc-async-checkin t "async commits for git/hg")
1089 (vc-no-confirm-moving-changes nil "ask before moving changes between worktrees"))
1090
1091(use-package vc-dir
1092 :bind (("C-x v D" . project-vc-dir)
1093 :map vc-dir-mode-map
1094 ("=" . vc-diff)
1095 ("v" . vc-next-action)
1096 ("C-c C-c" . vc-next-action))
1097 :custom
1098 (vc-dir-hide-up-to-date-on-revert t "hide up-to-date files after refresh"))
1099
1100(use-package vc-git
1101 :custom
1102 (vc-git-diff-switches '("--histogram" "--stat"))
1103 (vc-git-print-log-follow t "follow renames in file log"))
1104
1105(use-package log-edit
1106 :custom
1107 (log-edit-hook '(log-edit-insert-cvs-template
1108 log-edit-show-files
1109 log-edit-maybe-show-diff))
1110 :bind (:map log-edit-mode-map
1111 ("C-c C-d" . log-edit-show-diff)))
1112
1113(use-package diff-mode
1114 :bind (:map diff-mode-map
1115 ("v" . vc-next-action)))
1116
1117(use-package magit
1118 :unless noninteractive
1119 :commands (magit-status magit-clone magit-pull magit-blame magit-log-buffer-file magit-log magit-file-dispatch)
1120 :bind (("C-c v c" . magit-commit)
1121 ("C-c v C" . magit-checkout)
1122 ("C-c v b" . magit-branch)
1123 ("C-c v d" . magit-dispatch)
1124 ("C-c v f" . magit-fetch)
1125 ("C-c v g" . magit-blame)
1126 ("C-c v l" . magit-log-buffer-file)
1127 ("C-c v L" . magit-log)
1128 ("C-c v p" . magit-pull)
1129 ("C-c v P" . magit-push)
1130 ("C-c v r" . magit-rebase)
1131 ("C-c v s" . magit-stage)
1132 ("C-c v v" . magit-status)
1133 )
1134 :custom
1135 (magit-save-repository-buffers 'dontask)
1136 (magit-refs-show-commit-count 'all)
1137 (magit-branch-prefer-remote-upstream '("main"))
1138 (magit-display-buffer-function #'magit-display-buffer-fullframe-status-v1)
1139 (magit-bury-buffer-function #'magit-restore-window-configuration)
1140 (magit-refresh-status-buffer nil "don't automatically refresh the status buffer after running a git command")
1141 (magit-commit-show-diff nil "don't show the diff by default in the commit buffer. Use `C-c C-d' to display it")
1142 (magit-branch-direct-configure nil "don't show git variables in magit branch")
1143 (magit-diff-visit-prefer-worktree t "prefer visiting the worktree file")
1144 :config
1145 (declare-function magit-insert-worktrees "magit")
1146 (add-hook 'magit-status-sections-hook #'magit-insert-worktrees t)
1147 ;; cargo-culted from https://github.com/magit/magit/issues/3717#issuecomment-734798341
1148 ;; valid gitlab options are defined in https://docs.gitlab.com/ee/user/project/push_options.html
1149 ;;
1150 ;; the second argument to transient-append-suffix is where to append
1151 ;; to, not sure what -u is, but this works
1152 (transient-append-suffix 'magit-push "-u"
1153 '(1 "=s" "Skip gitlab pipeline" "--push-option=ci.skip"))
1154 (transient-append-suffix 'magit-push "=s"
1155 '(1 "=m" "Create gitlab merge-request" "--push-option=merge_request.create"))
1156 (transient-append-suffix 'magit-push "=m"
1157 '(1 "=o" "Set push option" "--push-option=")) ;; Will prompt, can only set one extra
1158 )
1159
1160(use-package git-commit
1161 :init
1162 (defvar git-commit-mode-map)
1163 :bind (:map git-commit-mode-map
1164 ("C-c C-h" . git-commit-co-authored)))
1165
1166(use-package ediff
1167 :commands (ediff ediff-files ediff-merge ediff3 ediff-files3 ediff-merge3)
1168 :custom
1169 (ediff-window-setup-function 'ediff-setup-windows-plain)
1170 (ediff-split-window-function 'split-window-horizontally)
1171 (ediff-diff-options "-w")
1172 :hook
1173 (ediff-after-quit-hook-internal . winner-undo))
1174
1175(use-package diff
1176 :custom
1177 (diff-default-read-only nil)
1178 (diff-advance-after-apply-hunk t)
1179 (diff-update-on-the-fly t)
1180 (diff-refine 'font-lock)
1181 (diff-font-lock-prettify nil)
1182 (diff-font-lock-syntax nil))
1183
1184(use-package gitconfig-mode
1185 :commands (gitconfig-mode)
1186 :mode (("/\\.gitconfig\\'" . gitconfig-mode)
1187 ("/\\.git/config\\'" . gitconfig-mode)
1188 ("/git/config\\'" . gitconfig-mode)
1189 ("/\\.gitmodules\\'" . gitconfig-mode)))
1190
1191(use-package gitignore-mode
1192 :commands (gitignore-mode)
1193 :mode (("/\\.gitignore\\'" . gitignore-mode)
1194 ("/\\.git/info/exclude\\'" . gitignore-mode)
1195 ("/git/ignore\\'" . gitignore-mode)))
1196
1197(use-package gitattributes-mode
1198 :commands (gitattributes-mode)
1199 :mode (("/\\.gitattributes" . gitattributes-mode)))
1200
1201(use-package diff-hl
1202 :init
1203 (defvar diff-hl-command-map)
1204 :hook (find-file . diff-hl-mode)
1205 :hook (prog-mode . diff-hl-mode)
1206 :hook (magit-post-refresh . diff-hl-magit-post-refresh)
1207 :bind
1208 (:map diff-hl-command-map
1209 ("n" . diff-hl-next-hunk)
1210 ("p" . diff-hl-previous-hunk)
1211 ("[" . nil)
1212 ("]" . nil)
1213 ("DEL" . diff-hl-revert-hunk)
1214 ("<delete>" . diff-hl-revert-hunk)
1215 ("SPC" . diff-hl-mark-hunk)
1216 :map vc-prefix-map
1217 ("n" . diff-hl-next-hunk)
1218 ("p" . diff-hl-previous-hunk)
1219 ("s" . diff-hl-stage-dwim)
1220 ("DEL" . diff-hl-revert-hunk)
1221 ("<delete>" . diff-hl-revert-hunk)
1222 ("SPC" . diff-hl-mark-hunk))
1223 :config
1224 (put 'diff-hl-inline-popup-hide
1225 'repeat-map 'diff-hl-command-map))
1226
1227(use-package diff-hl-show-hunk
1228 :after (diff-hl))
1229
1230(use-package diff-hl-dired
1231 :after (diff-hl)
1232 :hook (dired-mode . diff-hl-dired-mode))
1233
1234(use-package corfu
1235 :init
1236 (defvar corfu-map)
1237 :custom
1238 (corfu-auto 't)
1239 (corfu-auto-delay 1)
1240 (corfu-cycle 't)
1241 (corfu-preselect 'prompt)
1242 :bind
1243 (:map corfu-map
1244 ("TAB" . corfu-next)
1245 ("C-c" . corfu-quit)
1246 ([tab] . corfu-next)
1247 ("S-TAB" . corfu-previous)
1248 ([backtab] . corfu-previous))
1249 :hook
1250 (after-init . global-corfu-mode))
1251
1252(use-package corfu-history
1253 :after (corfu)
1254 :hook
1255 (after-init . corfu-history-mode))
1256
1257(use-package corfu-popupinfo
1258 :after corfu
1259 :init
1260 (declare-function corfu-popupinfo-mode "corfu-popupinfo")
1261 :config
1262 (corfu-popupinfo-mode 1))
1263
1264(use-package corfu-terminal
1265 :unless (or (display-graphic-p) (>= emacs-major-version 31))
1266 :ensure t
1267 :hook
1268 (after-init . corfu-terminal-mode))
1269
1270(use-package envrc
1271 :defer 2
1272 :if (executable-find "direnv")
1273 :init
1274 (defvar envrc-mode-map)
1275 (declare-function envrc-global-mode "envrc")
1276 :bind (:map envrc-mode-map
1277 ("C-c e" . envrc-command-map))
1278 :custom
1279 (envrc-remote t)
1280 :config (envrc-global-mode))
1281
1282(use-package cape
1283 :init
1284 (declare-function cape-dabbrev "cape")
1285 (declare-function cape-file "cape")
1286 (declare-function cape-elisp-block "cape")
1287 (add-hook 'completion-at-point-functions #'cape-dabbrev)
1288 (add-hook 'completion-at-point-functions #'cape-file)
1289 (add-hook 'completion-at-point-functions #'cape-elisp-block))
1290
1291(use-package winner
1292 :unless noninteractive
1293 :hook
1294 (after-init . winner-mode))
1295
1296(use-package windmove
1297 :bind
1298 ("S-<up>" . windmove-up)
1299 ("S-<left>" . windmove-left)
1300 ("S-<right>" . windmove-right)
1301 ("S-<down>" . windmove-down)
1302 ("M-S-<up>" . windmove-swap-states-up)
1303 ("M-S-<left>" . windmove-swap-states-left)
1304 ("M-S-<right>" . windmove-swap-states-right)
1305 ("M-S-<down>" . windmove-swap-states-down))
1306
1307(use-package window
1308 :unless noninteractive
1309 :commands (shrink-window-horizontally shrink-window enlarge-window-horizontally enlarge-window)
1310 :bind (("S-C-<left>" . shrink-window-horizontally)
1311 ("S-C-<right>" . enlarge-window-horizontally)
1312 ("S-C-<down>" . shrink-window)
1313 ("S-C-<up>" . enlarge-window)))
1314
1315;; Prefer ripgrep (rg) if present (instead of grep)
1316(setq xref-search-program
1317 (cond
1318 ((or (executable-find "ripgrep")
1319 (executable-find "rg"))
1320 'ripgrep)
1321 ((executable-find "ugrep")
1322 'ugrep)
1323 (t
1324 'grep)))
1325
1326(use-package rg
1327 :if (executable-find "rg")
1328 :commands (rg rg-project rg-dwim)
1329 :bind (("M-s r r" . rg)
1330 ("M-s r p" . rg-project)
1331 ("M-s r s" . rg-dwim))
1332 :custom
1333 (rg-group-result t)
1334 (rg-hide-command t)
1335 (rg-show-columns nil)
1336 (rg-show-header t)
1337 (rg-default-alias-fallback "all")
1338 :config
1339 (defvar rg-custom-type-aliases)
1340 (defvar rg-buffer-name)
1341 (cl-pushnew '("tmpl" . "*.tmpl") rg-custom-type-aliases :test #'equal)
1342 (cl-pushnew '("gotest" . "*_test.go") rg-custom-type-aliases :test #'equal)
1343 (defun vde/rg-buffer-name ()
1344 "Generate a rg buffer name from project if in one"
1345 (let ((p (project-root (project-current))))
1346 (if p
1347 (format "rg: %s" (abbreviate-file-name p))
1348 "rg")))
1349 (declare-function vde/rg-buffer-name "init")
1350 (setq rg-buffer-name #'vde/rg-buffer-name))
1351
1352(use-package wgrep
1353 :unless noninteractive
1354 :commands (wgrep-change-to-wgrep-mode)
1355 :init
1356 (defvar grep-mode-map)
1357 :custom
1358 (wgrep-auto-save-buffer t)
1359 (wgrep-change-readonly-file t)
1360 :bind (:map grep-mode-map
1361 ("e" . wgrep-change-to-wgrep-mode)
1362 ("C-x C-q" . wgrep-change-to-wgrep-mode)))
1363
1364(use-package abbrev
1365 :ensure nil
1366 :custom
1367 (save-abbrevs nil)
1368 :config
1369 (define-abbrev-table 'global-abbrev-table
1370 '(;; Arrows
1371 ("ra" "→")
1372 ("la" "←")
1373 ("ua" "↑")
1374 ("da" "↓")
1375
1376 ;; Emojis for context markers
1377 ;; ("todo" "👷 TODO:")
1378 ;; ("fixme" "🔥 FIXME:")
1379 ;; ("note" "📎 NOTE:")
1380 ;; ("hack" "👾 HACK:")
1381 ("smile" "😄")
1382 ("party" "🎉")
1383 ("up" "☝️")
1384 ("applause" "👏")
1385 ("manyapplauses" "👏👏👏👏👏👏👏👏")
1386 ("heart" "❤️")
1387
1388 ;; NerdFonts
1389 ("nerdfolder" " ")
1390 ("nerdgit" "")
1391 ("nerdemacs" "")
1392
1393 ;; Markdown
1394 ("cb" "```@\n\n```"
1395 (lambda () (search-backward "@") (delete-char 1)))
1396
1397 ;; ORG
1398 ("ocb" "#+BEGIN_SRC @\n\n#+END_SRC"
1399 (lambda () (search-backward "@") (delete-char 1))))))
1400
1401(use-package tempel
1402 :custom (tempel-path (expand-file-name "templates" user-emacs-directory))
1403 :bind (("M-+" . tempel-complete) ;; Alternative tempel-expand
1404 ("M-*" . tempel-insert)))
1405
1406(use-package consult
1407 :bind
1408 ("M-g M-g" . consult-goto-line)
1409 ("M-K" . consult-keep-lines)
1410 ("M-s M-b" . consult-buffer)
1411 ("M-s M-f" . consult-find)
1412 ("M-s M-g" . consult-grep)
1413 ("M-s M-r" . consult-ripgrep)
1414 ("M-s M-h" . consult-history)
1415 ("M-s M-l" . consult-line)
1416 ("M-s M-m" . consult-mark)
1417 ("M-s M-y" . consult-yank-pop)
1418 ("M-s M-s" . consult-outline))
1419
1420(use-package consult-vc-modified-files
1421 :bind
1422 ("C-c v ." . consult-vc-log-select-files)
1423 ("C-c v m" . consult-vc-modified-files))
1424
1425(use-package surround
1426 :bind-keymap ("M-'" . surround-keymap))
1427
1428(use-package visual-regexp
1429 :bind (([remap query-replace-regexp] . vr/query-replace)))
1430
1431(use-package dape
1432 :commands (dape dape-breakpoint-toggle))
1433
1434(use-package editorconfig
1435 :config
1436 (editorconfig-mode 1))
1437
1438(use-package eldoc-box
1439 :commands (eldoc-box-help-at-point)
1440 :bind ("C-c h" . eldoc-box-help-at-point))
1441
1442(use-package dired-narrow
1443 :after dired
1444 :bind (:map dired-mode-map
1445 ("/" . dired-narrow)))
1446
1447(use-package dwim-shell-command
1448 :bind (([remap shell-command] . dwim-shell-command)
1449 ([remap async-shell-command] . dwim-shell-command)))
1450
1451(use-package org-rich-yank
1452 :after org
1453 :bind (:map org-mode-map
1454 ("C-M-y" . org-rich-yank)))
1455
1456(use-package org-tree-slide
1457 :after org
1458 :commands (org-tree-slide-mode)
1459 :bind (:map org-mode-map
1460 ("<f8>" . org-tree-slide-mode))
1461 :custom
1462 (org-tree-slide-slide-in-effect nil))
1463
1464(use-package avy
1465 :unless noninteractive
1466 :commands (avy-goto-char avy-goto-line avy-goto-word-1 avy-pop-mark avy-goto-char-timer)
1467 :bind (("M-j" . avy-goto-char-timer)
1468 ("C-c j w" . avy-goto-word-1)
1469 ("C-c j b" . avy-pop-mark)
1470 ("C-c j l" . avy-goto-line))
1471 :custom
1472 (avy-dispatch-alist
1473 '((?k . avy-action-kill-move)
1474 (?K . avy-action-kill-stay)
1475 (?y . avy-action-copy)
1476 (?m . avy-action-mark)
1477 (?o . avy-action-embark)
1478 (?z . avy-action-zap-to-char)))
1479 :config
1480 (defun avy-action-embark (pt)
1481 (unwind-protect
1482 (save-excursion
1483 (goto-char pt)
1484 (embark-act))
1485 (select-window
1486 (cdr (ring-ref avy-ring 0))))
1487 t))
1488
1489(use-package embark
1490 :unless noninteractive
1491 :commands (embark-act embark-dwim embark-prefix-help-command)
1492 :bind
1493 ("C-," . embark-act)
1494 ("M-," . embark-dwim)
1495 ("C-h b" . embark-bindings)
1496 ("C-h B" . embark-bindings-at-point)
1497 ("C-h M" . embark-bindings-in-keymap)
1498 (:map completion-list-mode-map
1499 ("," . embark-act))
1500 :custom
1501 (prefix-help-command #'embark-prefix-help-command)
1502 (embark-cycle-key ".")
1503 (embark-help-key "?")
1504 :config
1505 (defvar embark-action-indicator)
1506 (defvar embark-become-indicator)
1507 (defvar embark-symbol-map)
1508 (defvar embark-file-map)
1509 (defvar embark-buffer-map)
1510 (defvar embark-general-map)
1511 (defvar embark-keymap-alist)
1512 (defvar embark-target-finders)
1513 ;; which-key integration for better discoverability
1514 (declare-function which-key--show-keymap "which-key")
1515 (declare-function which-key--hide-popup-ignore-command "which-key")
1516 (setq embark-action-indicator
1517 (lambda (map _target)
1518 (which-key--show-keymap "Embark" map nil nil 'no-paging)
1519 #'which-key--hide-popup-ignore-command)
1520 embark-become-indicator embark-action-indicator)
1521
1522 ;; Symbol overlay actions
1523 (keymap-set embark-symbol-map "S i" #'symbol-overlay-put)
1524 (keymap-set embark-symbol-map "S c" #'symbol-overlay-remove-all)
1525 (keymap-set embark-symbol-map "S r" #'symbol-overlay-rename)
1526
1527 ;; File management actions
1528 (define-key embark-file-map "c" #'copy-file)
1529 (define-key embark-file-map "m" #'rename-file)
1530 (define-key embark-file-map "D" #'delete-file)
1531 (define-key embark-file-map "=" #'ediff-files)
1532
1533 ;; Open file as root
1534 (defun my/sudo-find-file (file)
1535 "Open FILE as root."
1536 (interactive "fFile: ")
1537 (find-file (concat "/sudo::" (expand-file-name file))))
1538 (declare-function my/sudo-find-file "init")
1539 (define-key embark-file-map "S" #'my/sudo-find-file)
1540
1541 ;; Buffer actions
1542 (defun my/kill-buffer-and-window ()
1543 "Kill current buffer and close its window."
1544 (interactive)
1545 (kill-buffer-and-window))
1546 (declare-function my/kill-buffer-and-window "init")
1547 (define-key embark-buffer-map "K" #'my/kill-buffer-and-window)
1548 (define-key embark-buffer-map "r" #'revert-buffer)
1549
1550 ;; Project/Git actions
1551 (define-key embark-file-map "p" #'project-find-file)
1552 (define-key embark-file-map "g" #'magit-file-dispatch)
1553
1554 (declare-function vc-root-dir "vc")
1555 (declare-function magit-status "magit")
1556 (defun my/magit-status-from-file ()
1557 "Open magit-status for the file's repository."
1558 (interactive)
1559 (magit-status (vc-root-dir)))
1560 (declare-function my/magit-status-from-file "init")
1561 (define-key embark-file-map "G" #'my/magit-status-from-file)
1562
1563 ;; Org-mode heading actions
1564 (declare-function org-refile "org")
1565 (declare-function org-todo "org")
1566 (declare-function org-schedule "org")
1567 (declare-function org-deadline "org")
1568 (declare-function org-archive-subtree "org")
1569 (declare-function org-narrow-to-subtree "org")
1570 (defvar-keymap embark-org-heading-map
1571 :doc "Actions for org headings"
1572 :parent embark-general-map
1573 "r" #'org-refile
1574 "t" #'org-todo
1575 "s" #'org-schedule
1576 "d" #'org-deadline
1577 "a" #'org-archive-subtree
1578 "n" #'org-narrow-to-subtree)
1579
1580 (add-to-list 'embark-keymap-alist '(org-heading . embark-org-heading-map))
1581
1582 ;; Custom target finders
1583 (declare-function thing-at-point-looking-at "thingatpt")
1584 (defun embark-target-github-issue ()
1585 "Target GitHub issue numbers like #123 or owner/repo#456."
1586 (when (thing-at-point-looking-at
1587 "\\(?:\\([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+\\)?#\\([0-9]+\\)\\)")
1588 (let ((_repo (match-string 1))
1589 (_number (match-string 2)))
1590 `(github-issue
1591 ,(match-string 0)
1592 ,(match-beginning 0)
1593 . ,(match-end 0)))))
1594
1595 (defun embark-target-jira-ticket ()
1596 "Target Jira tickets like PROJ-123."
1597 (when (thing-at-point-looking-at "\\([A-Z]+-[0-9]+\\)")
1598 `(jira-ticket
1599 ,(match-string 1)
1600 ,(match-beginning 0)
1601 . ,(match-end 0))))
1602
1603 (add-to-list 'embark-target-finders 'embark-target-github-issue)
1604 (add-to-list 'embark-target-finders 'embark-target-jira-ticket)
1605
1606 ;; GitHub issue actions
1607 (declare-function my/open-github-issue "init")
1608 (declare-function my/copy-github-issue-url "init")
1609 (defvar-keymap embark-github-issue-map
1610 :doc "Actions for GitHub issues/PRs"
1611 :parent embark-general-map
1612 "o" #'my/open-github-issue
1613 "c" #'my/copy-github-issue-url
1614 "b" #'browse-url)
1615
1616 (declare-function my/open-jira-ticket "init")
1617 (declare-function my/copy-jira-ticket-url "init")
1618 (defvar-keymap embark-jira-ticket-map
1619 :doc "Actions for Jira tickets"
1620 :parent embark-general-map
1621 "o" #'my/open-jira-ticket
1622 "c" #'my/copy-jira-ticket-url
1623 "b" #'browse-url)
1624
1625 (add-to-list 'embark-keymap-alist '(github-issue . embark-github-issue-map))
1626 (add-to-list 'embark-keymap-alist '(jira-ticket . embark-jira-ticket-map))
1627
1628 ;; Helper functions
1629 (defun my/open-github-issue (issue)
1630 "Open GitHub ISSUE in browser."
1631 (let* ((parts (split-string issue "#"))
1632 (repo (or (car parts) (vde/gh-get-current-repo)))
1633 (number (cadr parts))
1634 (url (format "https://github.com/%s/issues/%s" repo number)))
1635 (browse-url url)))
1636
1637 (defun my/copy-github-issue-url (issue)
1638 "Copy GitHub ISSUE URL to clipboard."
1639 (let* ((parts (split-string issue "#"))
1640 (repo (or (car parts) (vde/gh-get-current-repo)))
1641 (number (cadr parts))
1642 (url (format "https://github.com/%s/issues/%s" repo number)))
1643 (kill-new url)
1644 (message "Copied: %s" url)))
1645
1646 (defun my/open-jira-ticket (ticket)
1647 "Open Jira TICKET in browser."
1648 (let ((url (format "https://issues.redhat.com/browse/%s" ticket)))
1649 (browse-url url)))
1650
1651 (defun my/copy-jira-ticket-url (ticket)
1652 "Copy Jira TICKET URL to clipboard."
1653 (let ((url (format "https://issues.redhat.com/browse/%s" ticket)))
1654 (kill-new url)
1655 (message "Copied: %s" url)))
1656
1657 ;; Schedule actions (daily-plan integration)
1658 (declare-function my/schedule-jira-ticket "init")
1659 (declare-function my/schedule-github-issue "init")
1660
1661 (defun my/schedule-jira-ticket (ticket)
1662 "Schedule Jira TICKET via daily-plan."
1663 (let ((date (org-read-date nil nil nil (format "Schedule %s for: " ticket))))
1664 (message "%s" (string-trim (shell-command-to-string
1665 (format "daily-plan schedule %s %s" ticket date))))))
1666
1667 (defun my/schedule-github-issue (issue)
1668 "Schedule GitHub ISSUE via daily-plan."
1669 (let ((date (org-read-date nil nil nil (format "Schedule %s for: " issue))))
1670 (message "%s" (string-trim (shell-command-to-string
1671 (format "daily-plan schedule %s %s" issue date))))))
1672
1673 (define-key embark-jira-ticket-map "s" #'my/schedule-jira-ticket)
1674 (define-key embark-github-issue-map "s" #'my/schedule-github-issue)
1675 )
1676
1677(use-package embark-consult
1678 :after (embark consult)
1679 :unless noninteractive
1680 :hook
1681 (embark-collect-mode . consult-preview-at-point-mode))
1682
1683(use-package consult-gh
1684 :after (consult)
1685 :custom
1686 (consult-gh-show-preview t)
1687 (consult-gh-preview-key "C-o")
1688 (consult-gh-large-file-warning-threshold 2500000)
1689 (consult-gh-default-interactive-command #'consult-gh-transient)
1690 (consult-gh-prioritize-local-folder nil)
1691 (consult-gh-group-dashboard-by :reason)
1692 ;;;; Optional
1693 (consult-gh-repo-preview-major-mode nil) ; show readmes in their original format
1694 (consult-gh-preview-major-mode 'org-mode) ; use 'org-mode for editing comments, commit messages, ...
1695 :config
1696 ;; Remember visited orgs and repos across sessions
1697 (add-to-list 'savehist-additional-variables 'consult-gh--known-orgs-list)
1698 (add-to-list 'savehist-additional-variables 'consult-gh--known-repos-list))
1699
1700;; (consult-gh-issue-list "tektoncd/pipeline -- --assignee \"@me\"" t)
1701;; (consult-gh-pr-list "tektoncd/pipeline -- --assignee \"@me\"" t)
1702
1703(use-package consult-gh-embark
1704 :after (embark consult)
1705 :init
1706 (declare-function consult-gh-embark-mode "consult-gh-embark")
1707 :config
1708 (consult-gh-embark-mode +1))
1709
1710(use-package pr-review
1711 :commands (pr-review pr-review-open pr-review-submit-review)
1712 :custom
1713 (pr-review-ghub-host "api.github.com")
1714 (pr-review-notification-include-read nil)
1715 (pr-review-notification-include-unsubscribed nil))
1716
1717(use-package pr-review-search
1718 :commands (pr-review-search pr-review-search-open pr-review-current-repository pr-review-current-repository-search)
1719 :config
1720 (defun pr-review-current-repository-search (query)
1721 "Run pr-review-search on the current repository."
1722 (interactive "sSearch query: ")
1723 (pr-review-search (format "is:pr archived:false is:open repo:%s %s" (vde/gh-get-current-repo) query)))
1724
1725 (defun pr-review-current-repository ()
1726 "Run pr-review-search on the current repository."
1727 (interactive)
1728 (pr-review-search (format "is:pr archived:false is:open repo:%s" (vde/gh-get-current-repo)))))
1729
1730(use-package shipit
1731 :commands (shipit)
1732 :after magit
1733 :custom
1734 (shipit-notifications-enabled nil) ; start with polling disabled, enable manually
1735 :config
1736 (shipit-init))
1737
1738(use-package jinx
1739 :hook (emacs-startup . global-jinx-mode)
1740 :bind (([remap ispell-word] . jinx-correct) ;; ("M-$" . jinx-correct)
1741 ("C-M-$" . jinx-languages)))
1742
1743(use-package eljira
1744 :commands (eljira)
1745 :ensure nil
1746 :load-path "~/src/github.com/sawwheet/eljira/"
1747 :custom
1748 (eljira-token (passage-get "redhat/issues/token/myji"))
1749 (eljira-username "vdemeest@redhat.com")
1750 (eljira-url "https://issues.redhat.com"))
1751
1752
1753;; TODO window management
1754;; TODO ORG mode configuration (BIG one)
1755(defvar org-link-any-re)
1756(declare-function dom-by-tag "dom")
1757(declare-function dom-text "dom")
1758(declare-function org-in-regexp "org")
1759(declare-function org-make-link-string "org")
1760(defun ar/org-insert-link-dwim ()
1761 "Like `org-insert-link' but with personal dwim preferences."
1762 (interactive)
1763 (let* ((point-in-link (org-in-regexp org-link-any-re 1))
1764 (clipboard-url (when (string-match-p "^http" (current-kill 0))
1765 (current-kill 0)))
1766 (region-content (when (region-active-p)
1767 (buffer-substring-no-properties (region-beginning)
1768 (region-end)))))
1769 (cond ((and region-content clipboard-url (not point-in-link))
1770 (delete-region (region-beginning) (region-end))
1771 (insert (org-make-link-string clipboard-url region-content)))
1772 ((and clipboard-url (not point-in-link))
1773 (insert (org-make-link-string
1774 clipboard-url
1775 (read-string "title: "
1776 (with-current-buffer (url-retrieve-synchronously clipboard-url)
1777 (dom-text (car
1778 (dom-by-tag (libxml-parse-html-region
1779 (point-min)
1780 (point-max))
1781 'title))))))))
1782 (t
1783 (call-interactively 'org-insert-link)))))
1784
1785(use-package org
1786 :if (file-exists-p org-directory)
1787 :init
1788 (declare-function bind-key--remove "bind-key")
1789 :mode (("\\.org$" . org-mode)
1790 ("\\.org.draft$" . org-mode))
1791 :commands (org-agenda org-capture)
1792 :bind (("C-c o l" . org-store-link)
1793 ("C-c o r r" . org-refile)
1794 ;; ("C-c o r R" . vde/reload-org-refile-targets)
1795 ("C-c o a a" . org-agenda)
1796 ;; ("C-c o a r" . vde/reload-org-agenda-files)
1797 ;; ("C-c C-x i" . vde/org-clock-in-any-heading)
1798 ("C-c o s" . org-sort)
1799 ("C-c O" . org-open-at-point-global)
1800 ("<f12>" . org-agenda)
1801 :map org-mode-map
1802 ("C-c C-l" . ar/org-insert-link-dwim))
1803 :custom
1804 (org-use-speed-commands t)
1805 (org-special-ctrl-a/e t)
1806 (org-special-ctrl-k t)
1807 (org-hide-emphasis-markers t)
1808 (org-pretty-entities t)
1809 (org-ellipsis "…")
1810 (org-return-follows-link t)
1811 (org-todo-keywords '((sequence "STRT(s)" "NEXT(n)" "TODO(t)" "WAIT(w)" "|" "DONE(d!)" "CANX(c@/!)")))
1812 (org-use-fast-todo-selection 'expert) ; Always show selection buffer with hints
1813 (org-todo-state-tags-triggers '(("CANX" ("CANX" . t))
1814 ("WAIT" ("WAIT" . t))
1815 (done ("WAIT"))
1816 ("TODO" ("WAIT") ("CANX"))
1817 ("NEXT" ("WAIT") ("CANX"))
1818 ("DONE" ("WAIT") ("CANX"))))
1819 (org-tag-alist
1820 '((:startgroup)
1821 ("Handson" . ?o)
1822 (:grouptags)
1823 ("Write" . ?w) ("Code" . ?c)
1824 (:endgroup)
1825
1826 (:startgroup)
1827 ("Handsoff" . ?f)
1828 (:grouptags)
1829 ("Read" . ?r) ("Watch" . ?W) ("Listen" . ?l)
1830 (:endgroup)))
1831 (org-log-done 'time)
1832 (org-log-redeadline 'time)
1833 (org-log-reschedule 'time)
1834 (org-log-into-drawer t)
1835 ;; https://jeffbradberry.com/posts/2025/05/orgmode-priority-cookies/
1836 ;; 1 2 and 3 are high, 4 is default, 5 is "hide / whenever or maybe never"
1837 (org-priority-highest 1)
1838 (org-priority-lowest 5)
1839 (org-priority-default 4)
1840 (org-list-demote-modify-bullet '(("+" . "-") ("-" . "+")))
1841 (org-agenda-file-regexp "^[a-zA-Z0-9-_]+.org$")
1842 (org-agenda-files `(,org-inbox-file ,org-todos-file ,org-habits-file ,org-reading-list-file ,org-calendar-file))
1843 ;; (org-refile-targets '((org-agenda-files :maxlevel . 3)))
1844 (org-refile-targets (vde/org-refile-targets))
1845 (org-refile-use-outline-path 'file)
1846 (org-refile-allow-creating-parent-nodes 'confirm)
1847 (org-agenda-remove-tags t)
1848 (org-agenda-span 'day)
1849 (org-agenda-start-on-weekday 1)
1850 (org-agenda-window-setup 'other-window)
1851 (org-agenda-sticky t)
1852 (org-agenda-sorting-strategy
1853 '((agenda deadline-up scheduled-up time-up todo-state-up priority-down)
1854 (todo todo-state-up priority-down deadline-up)
1855 (tags todo-state-up priority-down deadline-up)
1856 (search todo-state-up priority-down deadline-up)))
1857 (org-agenda-custom-commands
1858 '(
1859 ;; Archive tasks
1860 ("#" "To archive" todo "DONE|CANX")
1861 ;; TODO take inspiration from those
1862 ;; ("$" "Appointments" agenda* "Appointments")
1863 ;; ("b" "Week tasks" agenda "Scheduled tasks for this week"
1864 ;; ((org-agenda-category-filter-preset '("-RDV")) ; RDV for Rendez-vous
1865 ;; (org-agenda-use-time-grid nil)))
1866 ;;
1867 ;; ;; Review started and next tasks
1868 ;; ("j" "STRT/NEXT" tags-todo "TODO={STRT\\|NEXT}")
1869 ;;
1870 ;; ;; Review other non-scheduled/deadlined to-do tasks
1871 ;; ("k" "TODO" tags-todo "TODO={TODO}+DEADLINE=\"\"+SCHEDULED=\"\"")
1872 ;;
1873 ;; ;; Review other non-scheduled/deadlined pending tasks
1874 ;; ("l" "WAIT" tags-todo "TODO={WAIT}+DEADLINE=\"\"+SCHEDULED=\"\"")
1875 ;;
1876 ;; ;; Review upcoming deadlines for the next 60 days
1877 ;; ("!" "Deadlines all" agenda "Past/upcoming deadlines"
1878 ;; ((org-agenda-span 1)
1879 ;; (org-deadline-warning-days 60)
1880 ;; (org-agenda-entry-types '(:deadline))))
1881
1882 ("d" "Daily Agenda"
1883 ((tags-todo "+PRIORITY=\"1\""
1884 ((org-agenda-overriding-header "🔴 High Priority")))
1885 (agenda ""
1886 ((org-agenda-span 'day)
1887 (org-deadline-warning-days 30)
1888 (org-agenda-overriding-header "📅 Schedule & Deadlines")))
1889 (todo "NEXT"
1890 ((org-agenda-overriding-header "➡️ Focus (NEXT)")))))
1891 ("i" "Inbox (triage)"
1892 ((tags-todo ".*"
1893 ((org-agenda-files `(,org-inbox-file)) ;; FIXME use constant here
1894 (org-agenda-overriding-header "Unprocessed Inbox Item")))))
1895 ("u" "Untagged Tasks"
1896 ((tags-todo "-{.*}"
1897 ((org-agenda-overriding-header "Untagged tasks")))))
1898 ))
1899 ;; TODO cleanup this list a bit
1900 (org-agenda-category-icon-alist `(("personal" ,(list (propertize "🏡")))
1901 ("work" ,(list (propertize "🏢")))
1902 ("appointments" ,(list (propertize "📅")))
1903 ("health" ,(list (propertize "⚕️")))
1904 ("systems" ,(list (propertize "🖥️")))
1905 ("journal" ,(list (propertize "📝")))
1906 ("project--" ,(list (propertize "💼" )))
1907 ("tekton", (list (propertize "😼")))
1908 ("openshift-pipelines", (list (propertize "🎩")))
1909 ("redhat", (list (propertize "🎩")))
1910 ("area--" ,(list (propertize"🏢" )))
1911 ("area--home" ,(list (propertize "🏡")))
1912 ("home" ,(list (propertize "🏡")))
1913 ("home-services" ,(list (propertize "☕ ")))
1914 ("email" ,(list (propertize"📨" )))
1915 ("people" ,(list (propertize"👤" )))
1916 ("machine" ,(list (propertize "🖥️")))
1917 ("website" ,(list (propertize "🌍")))
1918 ("bike" ,(list (propertize "🚴♂️")))
1919 ("security" ,(list (propertize "🛡️")))
1920 ("i*" ,(list (propertize "📒")))))
1921 (org-agenda-prefix-format '((agenda . " %i %?-12t%s%(my/org-agenda-repeater)")
1922 (todo . " %i")
1923 (tags . " %i")
1924 (search . " %i")))
1925 ;; Shorten scheduled leaders to free space for the repeater; give
1926 ;; deadlines their own ⏳ glyph (🔴 stays reserved for priority).
1927 (org-agenda-scheduled-leaders '("Sched " "S.%2dx "))
1928 (org-agenda-deadline-leaders '("⏳ 0d " "⏳%2dd " "⏳-%1dd "))
1929 (org-insert-heading-respect-content t)
1930 (org-M-RET-may-split-line '((default . nil)))
1931 (org-goto-interface 'outline-path-completion)
1932 (org-outline-path-complete-in-steps nil)
1933 (org-goto-max-level 2)
1934 :hook
1935 (org-mode . auto-fill-mode)
1936 (org-mode . auto-revert-mode)
1937 (org-mode . visual-line-mode)
1938 (org-mode . visual-wrap-prefix-mode)
1939 :bind
1940 (:map org-mode-map
1941 ("C-<left>" . org-shiftleft)
1942 ("C-<right>" . org-shiftright)
1943 ("C-<up>" . org-shiftup)
1944 ("C-<down>" . org-shiftdown)
1945 ("C-c C-S-o" . vde/org-open-at-point-in-new-tab))
1946 :config
1947 ;; Org Agenda display rules:
1948 ;; - Agenda opens as a dedicated left side-window
1949 ;; - Org files from agenda reuse the other (non-side) window
1950 (defun vde/display-buffer-org-agenda-managed-p (buffer-name _action)
1951 "Determine whether BUFFER-NAME is an org-agenda managed buffer."
1952 (with-current-buffer buffer-name
1953 (and (derived-mode-p 'org-mode)
1954 (buffer-file-name)
1955 (member (buffer-file-name) (org-agenda-files)))))
1956
1957 (add-to-list 'display-buffer-alist
1958 '("\\*Org Agenda"
1959 (display-buffer-in-side-window)
1960 (side . left)
1961 (window-width . 0.4)
1962 (dedicated . side)
1963 (inhibit-same-window . t)))
1964 (add-to-list 'display-buffer-alist
1965 '(vde/display-buffer-org-agenda-managed-p
1966 (display-buffer-reuse-mode-window
1967 display-buffer-use-some-window)
1968 (inhibit-same-window . t)))
1969
1970 ;; Unbind C-, to allow embark-act to work
1971 (unbind-key "C-," org-mode-map)
1972
1973 (unbind-key "S-<left>" org-mode-map)
1974 (unbind-key "S-<right>" org-mode-map)
1975 (unbind-key "S-<up>" org-mode-map)
1976 (unbind-key "S-<down>" org-mode-map)
1977 (unbind-key "M-S-<left>" org-mode-map)
1978 (unbind-key "M-S-<right>" org-mode-map)
1979 (unbind-key "M-S-<up>" org-mode-map)
1980 (unbind-key "M-S-<down>" org-mode-map)
1981 (unbind-key "C-S-<left>" org-mode-map)
1982 (unbind-key "C-S-<right>" org-mode-map)
1983 (unbind-key "C-S-<up>" org-mode-map)
1984 (unbind-key "C-S-<down>" org-mode-map)
1985
1986 (defun vde/org-open-at-point-in-new-tab ()
1987 "Open org link at point in a new tab-bar tab.
1988Works with any org link type. Creates a new tab, then opens the link."
1989 (interactive)
1990 (let ((link-info (org-element-context)))
1991 (when (eq (org-element-type link-info) 'link)
1992 (tab-bar-new-tab)
1993 (org-open-at-point))))
1994
1995 (declare-function org-get-repeat "org")
1996 (defun my/org-agenda-repeater ()
1997 "Return the repeat cookie (e.g. \"++1w\") for the current agenda entry.
1998Evaluated by `org-agenda-format-item': for real items point sits on the
1999heading in the source Org buffer; for grid lines it runs in the agenda
2000buffer. Show the repeater (so recurring tasks are visually distinct)
2001only when present, and never call `org-element-at-point' outside an Org
2002buffer."
2003 (if (derived-mode-p 'org-mode)
2004 (let ((rep (org-get-repeat)))
2005 (if rep (format "%-5s " rep) ""))
2006 ""))
2007
2008 (require 'dash)
2009 (require 's)
2010 (declare-function --remove "dash")
2011 (declare-function --map "dash")
2012 (declare-function ->> "dash")
2013 (declare-function s-starts-with? "s")
2014 (declare-function s-contains? "s")
2015 (defun vde/org-refile-targets ()
2016 (append '((org-inbox-file :level . 0)
2017 (org-todos-file :maxlevel . 3)
2018 (org-habits-file :maxlevel . 3))
2019 (->>
2020 (directory-files org-notes-directory nil ".org$")
2021 (--remove (s-starts-with? "." it))
2022 (--remove (s-contains? "==readwise=" it))
2023 (--map (format "%s/%s" org-notes-directory it))
2024 (--map `(,it :maxlevel . 3)))
2025 )))
2026
2027(defun vde/org-todo-subtree (state)
2028 "Set TODO entries in the current subtree to STATE.
2029By default, only changes entries with an active TODO keyword,
2030skipping plain headings and already-done items.
2031With prefix argument (C-u), force ALL entries to STATE regardless
2032of their current state."
2033 (interactive
2034 (list (completing-read "State: " org-todo-keywords-1 nil t)))
2035 (let ((match (if current-prefix-arg nil "/!")))
2036 (org-map-entries (lambda () (org-todo state)) match 'tree)))
2037
2038(use-package org-appear
2039 :hook (org-mode . org-appear-mode))
2040
2041(use-package org-id
2042 :after org
2043 :custom
2044 (org-id-method 'ts))
2045
2046(use-package org-attach
2047 :after org
2048 :custom
2049 (org-attach-id-to-path-function-list
2050 '(org-attach-id-ts-folder-format
2051 org-attach-id-uuid-folder-format))
2052 (org-attach-store-link-p 'attached))
2053
2054(use-package org-agenda
2055 :after org
2056 :commands (org-agenda)
2057 :config
2058 (unbind-key "S-<left>" org-agenda-mode-map)
2059 (unbind-key "S-<right>" org-agenda-mode-map)
2060 (unbind-key "S-<up>" org-agenda-mode-map)
2061 (unbind-key "S-<down>" org-agenda-mode-map)
2062 (unbind-key "C-S-<left>" org-agenda-mode-map)
2063 (unbind-key "C-S-<right>" org-agenda-mode-map))
2064
2065;; Make sure we load org-protocol
2066(use-package org-protocol
2067 :after org)
2068
2069(use-package org-tempo
2070 :after (org)
2071 :custom
2072 (org-structure-template-alist '(("a" . "aside")
2073 ("c" . "center")
2074 ("C" . "comment")
2075 ("e" . "example")
2076 ("E" . "export")
2077 ("Ea" . "export ascii")
2078 ("Eh" . "export html")
2079 ("El" . "export latex")
2080 ("q" . "quote")
2081 ("s" . "src")
2082 ("se" . "src emacs-lisp")
2083 ("sE" . "src emacs-lisp :results value code :lexical t")
2084 ("sg" . "src go")
2085 ("sr" . "src rust")
2086 ("sp" . "src python")
2087 ("v" . "verse"))))
2088
2089;;; Web capture helpers — used in org-capture templates
2090;; These functions fetch web content for org-capture %(sexp) expansion.
2091;; They require: org-web-tools (eww-readable + pandoc), monolith (single-file HTML archiver).
2092
2093(defconst vde/web-archive-dir (expand-file-name "web-archive" org-directory)
2094 "Directory for monolith web page archives.")
2095
2096(defun vde/web-archive--ensure-dir ()
2097 "Ensure `vde/web-archive-dir' exists."
2098 (unless (file-directory-p vde/web-archive-dir)
2099 (make-directory vde/web-archive-dir t)))
2100
2101(defun vde/web-archive--monolith (url)
2102 "Archive URL with monolith, return path to saved file or nil on failure.
2103Saves to `vde/web-archive-dir' with a timestamped filename."
2104 (vde/web-archive--ensure-dir)
2105 (let* ((timestamp (format-time-string "%Y%m%dT%H%M%S"))
2106 (safe-name (replace-regexp-in-string
2107 "[^a-zA-Z0-9._-]" "_"
2108 (url-host (url-generic-parse-url url))))
2109 (filename (format "%s--%s.html" timestamp safe-name))
2110 (filepath (expand-file-name filename vde/web-archive-dir)))
2111 (message "Archiving %s with monolith..." url)
2112 (if (zerop (call-process "monolith" nil nil nil
2113 url "-o" filepath
2114 "-I" "-j" "-t" "30"))
2115 (progn
2116 (message "Archived to %s" filepath)
2117 filepath)
2118 (message "monolith failed for %s" url)
2119 nil)))
2120
2121(defun vde/web-capture--read-url ()
2122 "Read URL for web capture, prompting with clipboard URL as default.
2123When called from org-protocol, use the protocol-provided URL via
2124`org-store-link-plist'."
2125 (require 'org-web-tools)
2126 (let ((proto-url (and (boundp 'org-store-link-plist)
2127 (plist-get org-store-link-plist :link)))
2128 (clip-url (org-web-tools--get-first-url)))
2129 (or proto-url
2130 (read-string "URL: " clip-url))))
2131
2132(defun vde/org-capture-web-page-readable ()
2133 "Return Org entry with readable content of URL.
2134Prompts for URL with clipboard as default. Suitable for %(sexp) in capture templates."
2135 (require 'org-web-tools)
2136 (let ((url (vde/web-capture--read-url)))
2137 (or (ignore-errors (org-web-tools--url-as-readable-org url))
2138 (format "* [[%s][%s]] :website:\n\n%s\n\n(Failed to extract readable content)"
2139 url url (format-time-string (org-time-stamp-format 'with-time 'inactive))))))
2140
2141(defun vde/org-capture-web-page-archived ()
2142 "Return Org entry for URL with monolith archive.
2143Prompts for URL with clipboard as default. Suitable for %(sexp) in capture templates."
2144 (require 'org-web-tools)
2145 (let* ((url (vde/web-capture--read-url))
2146 (title (or (ignore-errors
2147 (let* ((dom (plz 'get url :as #'org-web-tools--sanitized-dom))
2148 (result (org-web-tools--eww-readable dom)))
2149 (org-web-tools--cleanup-title (or (car result) ""))))
2150 url))
2151 (link (org-link-make-string url title))
2152 (timestamp (format-time-string (org-time-stamp-format 'with-time 'inactive)))
2153 (archive-path (vde/web-archive--monolith url))
2154 (archive-link (if archive-path
2155 (format "[[file:%s][Local archive]]" archive-path)
2156 "(archive failed)")))
2157 (format "* %s :website:archive:\n\n%s\n\nArchive: %s\n" link timestamp archive-link)))
2158
2159(defun vde/org-capture-web-page-both ()
2160 "Return Org entry with readable content AND monolith archive.
2161Prompts for URL with clipboard as default. Suitable for %(sexp) in capture templates."
2162 (require 'org-web-tools)
2163 (let* ((url (vde/web-capture--read-url))
2164 (dom (ignore-errors (plz 'get url :as #'org-web-tools--sanitized-dom)))
2165 (readable-result (when dom (ignore-errors (org-web-tools--eww-readable dom))))
2166 (title (org-web-tools--cleanup-title (or (car readable-result) "")))
2167 (readable-html (cdr readable-result))
2168 (converted (when readable-html
2169 (ignore-errors (org-web-tools--html-to-org-with-pandoc readable-html))))
2170 (link (org-link-make-string url (if (string-empty-p title) url title)))
2171 (timestamp (format-time-string (org-time-stamp-format 'with-time 'inactive)))
2172 (archive-path (vde/web-archive--monolith url))
2173 (archive-link (if archive-path
2174 (format "[[file:%s][Local archive]]" archive-path)
2175 "(archive failed)")))
2176 (with-temp-buffer
2177 (org-mode)
2178 (if converted
2179 (progn
2180 (insert converted)
2181 (org-web-tools--demote-headings-below 2)
2182 (goto-char (point-min))
2183 (insert "* " link " :website:archive:" "\n\n"
2184 timestamp "\n\n"
2185 "Archive: " archive-link "\n\n"
2186 "** Article" "\n\n"))
2187 ;; Fallback if readable extraction failed
2188 (insert "* " link " :website:archive:" "\n\n"
2189 timestamp "\n\n"
2190 "Archive: " archive-link "\n\n"
2191 "(Failed to extract readable content)\n"))
2192 (buffer-string))))
2193
2194(use-package org-capture
2195 :demand t ;; Load eagerly so org-capture-templates is available for raffi/emacsclient queries
2196 :bind (("C-c o c" . org-capture))
2197 :config
2198 (add-to-list 'org-capture-templates
2199 `("t" "📥 Tasks")
2200 t)
2201 (add-to-list 'org-capture-templates
2202 `("tt" " New task" entry
2203 (file ,org-inbox-file)
2204 "* TODO %?\n:PROPERTIES:\n:CREATED:\t%U\n:END:\n\n%i\n\nFrom: %a"
2205 :empty-lines 1)
2206 t)
2207 (add-to-list 'org-capture-templates
2208 `("tl" " New task (from capture)" entry
2209 (file ,org-inbox-file)
2210 "* TODO %a\n:PROPERTIES:\n:CREATED:\t%U\n:END:\n\n%i\n\nFrom: %a"
2211 :empty-lines 1)
2212 t)
2213 (add-to-list 'org-capture-templates
2214 `("td" "✅ Done (log)")
2215 t)
2216 (add-to-list 'org-capture-templates
2217 `("tdw" "✅ Work" entry
2218 (file+headline ,org-todos-file "Work")
2219 "* DONE %?\nCLOSED: %U\n:PROPERTIES:\n:CREATED:\t%U\n:END:"
2220 :empty-lines 1)
2221 t)
2222 (add-to-list 'org-capture-templates
2223 `("tds" "✅ Systems" entry
2224 (file+headline ,org-todos-file "Systems")
2225 "* DONE %?\nCLOSED: %U\n:PROPERTIES:\n:CREATED:\t%U\n:END:"
2226 :empty-lines 1)
2227 t)
2228 (add-to-list 'org-capture-templates
2229 `("tdp" "✅ Personal" entry
2230 (file+headline ,org-todos-file "Personal")
2231 "* DONE %?\nCLOSED: %U\n:PROPERTIES:\n:CREATED:\t%U\n:END:"
2232 :empty-lines 1)
2233 t)
2234 ;; Refine this
2235 (add-to-list 'org-capture-templates
2236 `("tr" " PR Review" entry
2237 (file ,org-inbox-file)
2238 "* TODO review gh:%^{issue} :review:\n:PROPERTIES:\n:CREATED:%U\n:END:\n\n%i\n%?\nFrom: %a"
2239 :empty-lines 1)
2240 t)
2241 (add-to-list 'org-capture-templates
2242 `("l" "🔗 Links")
2243 t)
2244 (add-to-list 'org-capture-templates
2245 `("ll" "🔗 Link" entry
2246 (file ,org-inbox-file)
2247 "* %a\n%U\n%?\n%i"
2248 :empty-lines 1)
2249 t)
2250 (add-to-list 'org-capture-templates
2251 `("lw" "🌐 Web page (readable)" entry
2252 (file ,org-inbox-file)
2253 "%(vde/org-capture-web-page-readable)")
2254 t)
2255 (add-to-list 'org-capture-templates
2256 `("la" "📦 Web page (archived)" entry
2257 (file ,org-inbox-file)
2258 "%(vde/org-capture-web-page-archived)")
2259 t)
2260 (add-to-list 'org-capture-templates
2261 `("lb" "📦🌐 Web page (both)" entry
2262 (file ,org-inbox-file)
2263 "%(vde/org-capture-web-page-both)")
2264 t)
2265 (add-to-list 'org-capture-templates
2266 `("lk" "🔖 Bookmark (flux)" entry
2267 (file+function ,(expand-file-name "bookmarks.org" org-directory)
2268 (lambda () (goto-char (point-min))
2269 (re-search-forward "^\\*" nil t)
2270 (beginning-of-line)))
2271 "* %a%^g\n\n<%<%Y-%m-%d %a>>\n\n%?\n"
2272 :empty-lines 1)
2273 t)
2274 (add-to-list 'org-capture-templates
2275 `("i" "💡 TIL")
2276 t)
2277 (add-to-list 'org-capture-templates
2278 `("ii" "💡 Today I Learned" entry
2279 (file+function ,(expand-file-name "til.org" org-directory)
2280 (lambda () (goto-char (point-min))
2281 (re-search-forward "^\\*" nil t)
2282 (beginning-of-line)))
2283 "* %^{Title}%^g\n\n<%<%Y-%m-%d %a>>\n\n%?\n"
2284 :empty-lines 1)
2285 t)
2286 (add-to-list 'org-capture-templates
2287 `("m" "✉ Email Workflow")
2288 t)
2289 ;; Forward declaration - defined in mu4e config
2290 (declare-function vde-mu4e--body-summary-for-capture "init")
2291 (add-to-list 'org-capture-templates
2292 `("mf" "Follow Up" entry
2293 (file ,org-inbox-file)
2294 "* TODO Follow up: %:subject :email:
2295:PROPERTIES:
2296:CREATED:\t%U
2297:FROM:\t%:from
2298:DATE:\t%:date
2299:END:
2300SCHEDULED:%t
2301DEADLINE: %(org-insert-time-stamp (org-read-date nil t \"+2d\"))
2302%(vde-mu4e--body-summary-for-capture)
2303%i
2304Link: %a"
2305 :immediate-finish t)
2306 t)
2307 (add-to-list 'org-capture-templates
2308 `("mr" "Read Later" entry
2309 (file ,org-inbox-file)
2310 "* TODO Read: %:subject :email:
2311:PROPERTIES:
2312:CREATED:\t%U
2313:FROM:\t%:from
2314:DATE:\t%:date
2315:END:
2316SCHEDULED:%t
2317DEADLINE: %(org-insert-time-stamp (org-read-date nil t \"+2d\"))
2318%(vde-mu4e--body-summary-for-capture)
2319%i
2320Link: %a"
2321 :immediate-finish t)
2322 t)
2323 (add-to-list 'org-capture-templates
2324 `("mt" "Task from email" entry
2325 (file ,org-inbox-file)
2326 "* TODO %:subject :email:
2327:PROPERTIES:
2328:CREATED:\t%U
2329:FROM:\t%:from
2330:DATE:\t%:date
2331:END:
2332%(vde-mu4e--body-summary-for-capture)
2333%?
2334Link: %a"
2335 :empty-lines 1)
2336 t)
2337
2338 (defvar vde/org-capture-refile-templates '("mt")
2339 "List of capture template keys that should trigger refile after capture.")
2340
2341 (defun vde/org-capture-refile-if-needed ()
2342 "Refile the captured entry if the template key is in `vde/org-capture-refile-templates'.
2343After a successful refile, delete the popup frame if applicable."
2344 (when (and (not org-note-abort)
2345 (member (plist-get org-capture-plist :key) vde/org-capture-refile-templates))
2346 (let ((is-popup (frame-parameter nil 'vde/window-popup-frame)))
2347 (org-capture-goto-last-stored)
2348 (call-interactively 'org-refile)
2349 (when is-popup
2350 (delete-frame)))))
2351
2352 (add-hook 'org-capture-after-finalize-hook #'vde/org-capture-refile-if-needed)
2353 (add-to-list 'org-capture-templates
2354 `("J" "🗞 Journal (antidated) entry" item
2355 (file+datetree+prompt ,org-journal-file)
2356 "%U %?\n%i")
2357 t)
2358 ;; TODO: refine this, create a function that reset this
2359 ;; emails
2360 ;; (add-to-list 'org-capture-templates
2361 ;; `("m" "Meeting notes" entry
2362 ;; (file+datetree ,org-meeting-notes-file)
2363 ;; (file ,(concat user-emacs-directory "/etc/orgmode/meeting-notes.org"))))
2364
2365 ;; Make *Org Select* use the full window instead of splitting
2366 (add-to-list 'display-buffer-alist
2367 '("\\*Org Select\\*"
2368 (display-buffer-same-window)))
2369
2370 (defun vde/window-delete-popup-frame (&rest _)
2371 "Kill selected selected frame if it has parameter `vde/window-popup-frame'.
2372Use this function via a hook.
2373Skip deletion when:
2374- `org-capture-refile' (C-c C-w) is running (it handles cleanup via advice)
2375- the capture template auto-refiles via `vde/org-capture-refile-templates'"
2376 (when (and (frame-parameter nil 'vde/window-popup-frame)
2377 (not (eq this-command 'org-capture-refile))
2378 (not (and (bound-and-true-p org-capture-plist)
2379 (member (plist-get org-capture-plist :key)
2380 vde/org-capture-refile-templates)
2381 (not org-note-abort))))
2382 (delete-frame)))
2383
2384 (advice-add 'org-capture-refile :around
2385 (lambda (orig-fn &rest args)
2386 "Delete the popup frame after org-capture-refile completes."
2387 (let ((is-popup (frame-parameter nil 'vde/window-popup-frame)))
2388 (apply orig-fn args)
2389 (when is-popup
2390 (delete-frame)))))
2391
2392 ;; (add-to-list 'org-capture-templates
2393 ;; `("w" "Writing"))
2394 (declare-function vde/window-delete-popup-frame "init")
2395 (add-hook 'org-capture-after-finalize-hook #'vde/window-delete-popup-frame)
2396
2397 (defun vde/org-capture-full-frame ()
2398 "Make org-capture use the full frame in popup frames.
2399When capture opens in a dedicated popup frame (with `vde/window-popup-frame'
2400parameter), remove all other windows so the capture buffer fills the frame."
2401 (when (frame-parameter nil 'vde/window-popup-frame)
2402 (delete-other-windows)))
2403
2404 (add-hook 'org-capture-mode-hook #'vde/org-capture-full-frame))
2405
2406
2407;; Usage Metrics - dumps loaded features and command frequency for usage-metrics collector
2408(use-package usage-metrics
2409 :demand t)
2410
2411;; Daily Plan - Jira/GitHub → org-mode scheduling
2412(use-package daily-plan
2413 :commands (daily-plan-show daily-plan-inbox daily-plan-weekly
2414 daily-plan-review daily-plan-schedule
2415 daily-plan-schedule-at-point daily-plan-yank-markdown)
2416 :bind-keymap ("C-c d" . daily-plan-prefix-map)
2417 :config
2418 (defvar daily-plan-prefix-map
2419 (let ((map (make-sparse-keymap)))
2420 (define-key map "d" #'daily-plan-show)
2421 (define-key map "i" #'daily-plan-inbox)
2422 (define-key map "w" #'daily-plan-weekly)
2423 (define-key map "r" #'daily-plan-review)
2424 (define-key map "s" #'daily-plan-schedule)
2425 map)
2426 "Keymap for daily-plan commands under C-c d."))
2427
2428
2429(use-package org-habit
2430 :after org
2431 :custom
2432 (org-habit-show-habits-only-for-today nil)
2433 (org-habit-graph-column 80))
2434
2435;; org-ql is used by org-batch-functions / pi-org-todos
2436(use-package org-ql
2437 :commands (org-ql-search org-ql-query))
2438
2439;; org-batch-functions and pi-org-todos are site-lisp packages called
2440;; externally via emacsclient — their require chains pull in org/org-ql.
2441(use-package org-batch-functions
2442 :commands (org-batch-list-todos
2443 org-batch-scheduled-today
2444 org-batch-by-section
2445 org-batch-count-by-state
2446 org-batch-search
2447 org-batch-get-sections
2448 org-batch-get-children
2449 org-batch-get-todo-content
2450 org-batch-get-overdue
2451 org-batch-get-upcoming
2452 org-batch-get-statistics
2453 org-batch-append-content
2454 org-batch-update-state
2455 org-batch-add-todo
2456 org-batch-schedule-task
2457 org-batch-set-deadline
2458 org-batch-set-priority
2459 org-batch-archive-done
2460 org-batch-get-all-entries
2461 org-batch-get-refile-targets
2462 org-batch-refile-entry))
2463(use-package pi-org-todos
2464 :commands (pi/org-todo-list
2465 pi/org-todo-list-all
2466 pi/org-todo-scheduled
2467 pi/org-todo-upcoming
2468 pi/org-todo-overdue
2469 pi/org-todo-search
2470 pi/org-todo-get
2471 pi/org-todo-sections
2472 pi/org-todo-by-section
2473 pi/org-todo-statistics
2474 pi/org-todo-done
2475 pi/org-todo-state
2476 pi/org-todo-schedule
2477 pi/org-todo-deadline
2478 pi/org-todo-priority
2479 pi/org-todo-add
2480 pi/org-todo-append
2481 pi/org-todo-archive-done
2482 pi/org-todo-add-tags
2483 pi/org-todo-remove-tags
2484 pi/org-todo-all-tags
2485 pi/org-todo-get-property
2486 pi/org-todo-set-property
2487 pi/org-todo-inbox-all
2488 pi/org-todo-get-refile-targets
2489 pi/org-todo-refile))
2490
2491(use-package ox-tufte
2492 :after org
2493 :ensure t
2494 :commands (org-tufte-export-to-html
2495 org-tufte-export-as-html
2496 vde/org-add-tufte-headers
2497 vde/org-add-tufte-headers-to-file
2498 vde/org-export-notes-with-export-tag)
2499 :custom
2500 ;; Path to tufte.css relative to website root
2501 (org-tufte-htmlize-output-type 'css)
2502 :config
2503 ;; Default HTML head includes for Tufte CSS
2504 (setq org-html-head-include-default-style nil)
2505 (setq org-html-head-include-scripts nil)
2506
2507 (defun vde/org-add-tufte-headers ()
2508 "Add Tufte CSS export headers to current org-mode buffer.
2509Inserts the headers after the frontmatter (#+title, #+date, etc.)
2510if they don't already exist."
2511 (interactive)
2512 (unless (derived-mode-p 'org-mode)
2513 (user-error "Not in an org-mode buffer"))
2514 (save-excursion
2515 ;; Check if headers already exist
2516 (goto-char (point-min))
2517 (if (re-search-forward "^#\\+HTML_HEAD:.*tufte\\.css" nil t)
2518 (message "Tufte headers already present")
2519 ;; Find insertion point after frontmatter
2520 (goto-char (point-min))
2521 ;; Skip past all the #+KEYWORD: lines at the top
2522 (while (and (not (eobp))
2523 (looking-at "^#\\+\\(title\\|date\\|filetags\\|identifier\\|category\\|author\\|email\\|description\\):"))
2524 (forward-line 1))
2525 ;; Insert headers
2526 (insert "#+HTML_HEAD: <link rel=\"stylesheet\" href=\"tufte.css\" type=\"text/css\" />\n")
2527 (insert "#+HTML_HEAD: <link rel=\"stylesheet\" href=\"ox-tufte.css\" type=\"text/css\" />\n")
2528 (insert "#+OPTIONS: toc:nil num:nil html-style:nil html-scripts:nil\n")
2529 (message "Tufte headers added"))))
2530
2531 (defun vde/org-add-tufte-headers-to-file (file)
2532 "Add Tufte CSS export headers to an org-mode FILE."
2533 (interactive "fOrg file: ")
2534 (with-current-buffer (find-file-noselect file)
2535 (vde/org-add-tufte-headers)
2536 (save-buffer)))
2537
2538 (defun vde/org-export-notes-with-export-tag (notes-dir output-dir)
2539 "Export all org files in NOTES-DIR with _export tag to OUTPUT-DIR using ox-tufte.
2540Creates OUTPUT-DIR if it doesn't exist."
2541 (interactive "DNotes directory: \nDOutput directory: ")
2542 (unless (file-directory-p output-dir)
2543 (make-directory output-dir t))
2544 (let ((files (directory-files notes-dir t "\\.org$"))
2545 (exported 0))
2546 (dolist (file files)
2547 (with-temp-buffer
2548 (insert-file-contents file)
2549 (when (re-search-forward ":_export:" nil t)
2550 (let* ((basename (file-name-base file))
2551 (output-file (expand-file-name
2552 (concat basename ".html")
2553 output-dir)))
2554 (with-current-buffer (find-file-noselect file)
2555 (org-tufte-export-to-html)
2556 (when (file-exists-p (concat (file-name-sans-extension file) ".html"))
2557 (rename-file (concat (file-name-sans-extension file) ".html")
2558 output-file t)
2559 (message "Exported: %s -> %s" (file-name-nondirectory file) output-file)
2560 (setq exported (1+ exported))))))))
2561 (message "Exported %d file(s) with _export tag" exported))))
2562
2563(use-package ob-ditaa
2564 :after org
2565 :commands (org-babel-execute:ditaa)
2566 :config
2567 (setq org-ditaa-jar-path "/home/vincent/local/state/nix/profiles/home-manager/home-path/lib/ditaa.jar"))
2568
2569(use-package ob-dot
2570 :after org
2571 :commands (org-babel-execute:dot))
2572
2573(use-package xeft
2574 :commands (xeft)
2575 :custom
2576 (xeft-directory org-notes-directory)
2577 (xeft-recursive 'follow-symlinks)
2578 (xeft-extensions '("md" "org")))
2579
2580(use-package denote
2581 :commands (denote)
2582 :init
2583 (declare-function denote-rename-buffer-mode "denote")
2584 :bind (("C-c n c" . denote-region)
2585 ("C-c n i" . denote-link-or-create)
2586 ("C-c n b" . denote-backlinks)
2587 ("C-c n F f" . denote-find-link)
2588 ("C-c n F b" . denote-find-backlink)
2589 ("C-c n r" . vde/org-refile-to-denote))
2590 :custom
2591 (denote-directory org-notes-directory)
2592 (denote-rename-buffer-format "📝 %t")
2593 (denote-date-prompt-denote-date-prompt-use-org-read-date t)
2594 (denote-prompts '(title keywords))
2595 (denote-backlinks-display-buffer-action
2596 '((display-buffer-reuse-window
2597 display-buffer-in-side-window)
2598 (side . bottom)
2599 (slot . 99)
2600 (window-width . 0.3)
2601 (dedicated . t)
2602 (preserve-size . (t . t))))
2603 :hook (dired-mode . denote-dired-mode)
2604 :config
2605 (denote-rename-buffer-mode 1)
2606 (declare-function denote-file-is-note-p "denote")
2607 (declare-function denote-rename-file-using-front-matter "denote")
2608 (defun my-denote-always-rename-on-save-based-on-front-matter ()
2609 "Rename the current Denote file, if needed, upon saving the file.
2610Rename the file based on its front matter, checking for changes in the
2611title or keywords fields.
2612
2613Add this function to the `after-save-hook'."
2614 (let ((_denote-rename-confirmations nil)
2615 (_denote-save-buffers t)) ; to save again post-rename
2616 (when (and buffer-file-name (denote-file-is-note-p buffer-file-name))
2617 (ignore-errors (denote-rename-file-using-front-matter buffer-file-name))
2618 (message "Buffer saved; Denote file renamed"))))
2619
2620 (declare-function my-denote-always-rename-on-save-based-on-front-matter "init")
2621 (add-hook 'after-save-hook #'my-denote-always-rename-on-save-based-on-front-matter)
2622
2623 (declare-function denote-sluggify "denote")
2624 (declare-function denote--retrieve-title-or-filename "denote")
2625 (defun vde/org-category-from-buffer ()
2626 "Get the org category (#+category:) value from the buffer"
2627 (cond
2628 ((string-match "__journal.org$" (buffer-file-name))
2629 "journal")
2630 (t
2631 (denote-sluggify (denote--retrieve-title-or-filename (buffer-file-name) 'org)))))
2632
2633 (declare-function org-refile-get-targets "org")
2634 (declare-function org-refile-cache-clear "org")
2635 (declare-function denote-retrieve-title-value "denote")
2636 (declare-function denote-directory-files "denote")
2637 (declare-function denote-title-prompt "denote")
2638 (defun vde/org-refile-to-denote ()
2639 "Refile to a denote note (existing or new)."
2640 (interactive)
2641 (let* ((source-buffer (current-buffer))
2642 (files (denote-directory-files))
2643 (choices (mapcar (lambda (f)
2644 (cons (denote-retrieve-title-value f 'org) f))
2645 files))
2646 (selection (completing-read "Refile to note: " (mapcar #'car choices)))
2647 (target (if-let ((match (cdr (assoc selection choices))))
2648 match
2649 (cl-letf (((symbol-function 'denote-title-prompt)
2650 (lambda (&rest _) selection)))
2651 (call-interactively #'denote))))
2652 (target-buffer (find-file-noselect target)))
2653 (with-current-buffer target-buffer
2654 (org-mode)
2655 (save-excursion
2656 (goto-char (point-min))
2657 (unless (re-search-forward "^\\*+ " nil t)
2658 (goto-char (point-max))
2659 (insert "\n* Notes\n")))
2660 (save-buffer))
2661 (with-current-buffer source-buffer
2662 (let ((org-refile-targets `((,target :maxlevel . 3)))
2663 (org-refile-use-outline-path 'file)
2664 (org-refile-target-verify-function nil))
2665 (org-refile-cache-clear)
2666 ;; Force org to rebuild targets
2667 (org-refile-get-targets)
2668 (call-interactively #'org-refile))))))
2669
2670(use-package denote-org
2671 :after (denote org)
2672 :defer 2)
2673
2674(use-package popper
2675 :commands (popper-mode)
2676 :bind (("C-#" . popper-toggle)
2677 ;; ("C-~" . popper-kill-latest-popup)
2678 ("M-#" . popper-cycle)
2679 ;; alt keybind for mac mode, possibly disposable? TODO: should bind only on mac?
2680 ("C-`" . popper-toggle-latest)
2681 ("C-M-#" . popper-toggle-type))
2682 :custom
2683 (popper-reference-buffers
2684 '("\\*Messages\\*"
2685 "\\*Warnings\\*"
2686 "Output\\*$"
2687 "\\*Async Shell Command\\*"
2688 help-mode
2689 compilation-mode))
2690 (popper-window-height 0.3)
2691 :config
2692 (popper-mode))
2693
2694(use-package popper-echo
2695 :commands (popper-echo-mode popper-tab-line-mode)
2696 :init
2697 (popper-tab-line-mode))
2698
2699(use-package mu4e
2700 :commands (mu4e)
2701 :init
2702 (declare-function make-mu4e-context "mu4e")
2703 (declare-function mu4e~draft-message-filename-construct "mu4e")
2704 (declare-function mu4e~proc-add "mu4e")
2705 (declare-function mu4e~mark-check-target "mu4e")
2706 (declare-function mu4e-flags-to-string "mu4e")
2707 (declare-function mu4e-message-field "mu4e")
2708 (declare-function mu4e-message-at-point "mu4e")
2709 (declare-function mu4e-root-maildir "mu4e")
2710 (declare-function mu4e-join-paths "mu4e")
2711 (declare-function mu4e-create-maildir-maybe "mu4e")
2712 (declare-function mu4e-ask-maildir "mu4e")
2713 (declare-function mu4e-file-is-note-p "mu4e")
2714 :custom
2715 (mu4e-mu-home (expand-file-name "mu" (or (getenv "XDG_DATA_HOME")
2716 (expand-file-name ".local/share" (getenv "HOME")))))
2717 (mu4e-context-policy 'pick-first)
2718 (mu4e-change-filenames-when-moving t)
2719 (mu4e-attachment-dir "~/desktop/downloads")
2720 :config
2721 ;; No need for get-mail-command - goimapnotify handles syncing via IMAP IDLE
2722 ;; Disable automatic polling - mail arrives instantly via goimapnotify
2723 (setq mu4e-update-interval nil)
2724
2725 ;; Friendlier buffer names for frame titles
2726 (setq mu4e-headers-buffer-name "*mail - headers*"
2727 mu4e-view-buffer-name "*mail - article*")
2728
2729 ;; Configure msmtp for sending mail
2730 (setq sendmail-program "msmtp"
2731 send-mail-function 'smtpmail-send-it
2732 message-sendmail-f-is-evil t
2733 message-sendmail-extra-arguments '("--read-envelope-from")
2734 message-send-mail-function 'message-send-mail-with-sendmail)
2735
2736 (defun vde-mu4e--extract-body-summary (&optional max-lines)
2737 "Extract a summary from the current mu4e message body.
2738Returns the first MAX-LINES lines (default 10) of the plain text body,
2739with quoted lines and signatures removed."
2740 (let ((max-lines (or max-lines 10))
2741 (msg (mu4e-message-at-point 'noerror)))
2742 (if (not msg)
2743 ""
2744 (let* ((path (mu4e-message-field msg :path))
2745 (body-txt (when (and path (file-exists-p path))
2746 (with-temp-buffer
2747 (insert-file-contents path)
2748 (goto-char (point-min))
2749 ;; Skip headers - find empty line
2750 (when (re-search-forward "^$" nil t)
2751 (forward-line 1)
2752 (buffer-substring-no-properties (point) (point-max)))))))
2753 (if (not body-txt)
2754 ""
2755 ;; Clean up the body
2756 (with-temp-buffer
2757 (insert body-txt)
2758 ;; Try to decode if it looks like quoted-printable
2759 (goto-char (point-min))
2760 (when (re-search-forward "Content-Transfer-Encoding: quoted-printable" nil t)
2761 (goto-char (point-min))
2762 (when (re-search-forward "^$" nil t)
2763 (forward-line 1)
2764 (ignore-errors
2765 (quoted-printable-decode-region (point) (point-max)))))
2766 ;; Remove MIME boundaries and headers
2767 (goto-char (point-min))
2768 (while (re-search-forward "^--.*$\\|^Content-Type:.*$\\|^Content-Transfer-Encoding:.*$" nil t)
2769 (replace-match ""))
2770 ;; Remove HTML tags if present
2771 (goto-char (point-min))
2772 (while (re-search-forward "<[^>]+>" nil t)
2773 (replace-match ""))
2774 ;; Remove quoted lines (starting with >)
2775 (goto-char (point-min))
2776 (while (re-search-forward "^>.*$" nil t)
2777 (replace-match ""))
2778 ;; Remove signature (everything after -- )
2779 (goto-char (point-min))
2780 (when (re-search-forward "^-- $" nil t)
2781 (delete-region (match-beginning 0) (point-max)))
2782 ;; Remove excessive whitespace
2783 (goto-char (point-min))
2784 (while (re-search-forward "^[ \t]*\n\\([ \t]*\n\\)+" nil t)
2785 (replace-match "\n"))
2786 ;; Get first N non-empty lines
2787 (goto-char (point-min))
2788 (let ((lines nil)
2789 (count 0))
2790 (while (and (< count max-lines) (not (eobp)))
2791 (let ((line (string-trim (buffer-substring-no-properties
2792 (line-beginning-position)
2793 (line-end-position)))))
2794 (when (and (> (length line) 0)
2795 (not (string-match-p "^\\s-*$" line)))
2796 (push line lines)
2797 (setq count (1+ count))))
2798 (forward-line 1))
2799 (string-join (nreverse lines) "\n"))))))))
2800
2801 (defun vde-mu4e--body-summary-for-capture ()
2802 "Return email body summary for org-capture template."
2803 (let ((summary (vde-mu4e--extract-body-summary 8)))
2804 (if (string-empty-p summary)
2805 ""
2806 (concat "\n#+begin_quote\n" summary "\n#+end_quote"))))
2807
2808 (defun vde-mu4e--mark-get-copy-target ()
2809 "Ask for a copy target, and propose to create it if it does not exist."
2810 (let* ((target (mu4e-ask-maildir "Copy message to: "))
2811 (target (if (string= (substring target 0 1) "/")
2812 target
2813 (concat "/" target)))
2814 (fulltarget (mu4e-join-paths (mu4e-root-maildir) target)))
2815 (when (mu4e-create-maildir-maybe fulltarget)
2816 target)))
2817
2818 (defun copy-message-to-target(_docid msg target)
2819 (let (
2820 (new_msg_path nil) ;; local variable
2821 (msg_flags (mu4e-message-field msg :flags))
2822 )
2823 ;; 1. target is already determined interactively when executing the mark (:ask-target)
2824
2825 ;; 2. Determine the path for the new file: we use mu4e~draft-message-filename-construct from
2826 ;; mu4e-draft.el to create a new random filename, and append the original's msg_flags
2827 (setq new_msg_path (format "%s/%s/cur/%s" mu4e-maildir target (mu4e~draft-message-filename-construct
2828 (mu4e-flags-to-string msg_flags))))
2829
2830 ;; 3. Copy the message using file system call (copy-file) to new_msg_path:
2831 ;; (See e.g. mu4e-draft.el > mu4e-draft-open > resend)
2832 (copy-file (mu4e-message-field msg :path) new_msg_path)
2833
2834 ;; 4. Add the information to the database (may need to update current search query with 'g' if duplicating to current box. Try also 'V' to toggle the display of duplicates)
2835 (mu4e~proc-add new_msg_path (mu4e~mark-check-target target))
2836 )
2837 )
2838
2839 (defun vde-mu4e--refile (msg)
2840 "Refile function to smartly move `MSG' to a given folder."
2841 (cond
2842 ;; FIXME
2843 ((string= (plist-get (car-safe (mu4e-message-field msg :cc)) :email) "ci_activity@noreply.github.com")
2844 "/icloud/Deleted Messages")
2845 (t
2846 (let ((year (format-time-string "%Y" (mu4e-message-field msg :date))))
2847 (format "/icloud/Archives/%s" year)))))
2848
2849 (setq
2850 mu4e-headers-draft-mark '("D" . "💈")
2851 mu4e-headers-flagged-mark '("F" . "📍")
2852 mu4e-headers-new-mark '("N" . "🔥")
2853 mu4e-headers-passed-mark '("P" . "❯")
2854 mu4e-headers-replied-mark '("R" . "❮")
2855 mu4e-headers-seen-mark '("S" . "☑")
2856 mu4e-headers-trashed-mark '("T" . "💀")
2857 mu4e-headers-attach-mark '("a" . "📎")
2858 mu4e-headers-encrypted-mark '("x" . "🔒")
2859 mu4e-headers-signed-mark '("s" . "🔑")
2860 mu4e-headers-unread-mark '("u" . "⎕")
2861 mu4e-headers-list-mark '("l" . "🔈")
2862 mu4e-headers-personal-mark '("p" . "👨")
2863 mu4e-headers-calendar-mark '("c" . "📅"))
2864
2865 (setopt mu4e-completing-read-function completing-read-function)
2866 (setq mu4e-refile-folder 'vde-mu4e--refile)
2867
2868 ;; === Compose & Reply Settings ===
2869 ;; Don't include own addresses in reply-all
2870 (setq mu4e-compose-reply-ignore-address
2871 '("no-?reply" "vincent@demeester.fr" "vdemeest@redhat.com"))
2872
2873 ;; Better citation format for replies
2874 (setq message-citation-line-format "On %a %d %b %Y at %R, %f wrote:\n"
2875 message-citation-line-function 'message-insert-formatted-citation-line)
2876
2877 ;; Fix improperly formatted sender addresses in replies
2878 (setq rfc2047-quote-decoded-words-containing-tspecials t)
2879
2880 ;; Enable format=flowed for better plain text display on mobile/other clients
2881 (setq mu4e-compose-format-flowed t)
2882
2883 ;; Open compose in a new frame
2884 (setq mu4e-compose-switch 'frame)
2885
2886 ;; Build contexts only for accounts whose maildir actually exists on this
2887 ;; host, so a machine missing icloud (or redhat) doesn't error out.
2888 (setq mu4e-contexts
2889 (let ((maildir-root (expand-file-name "~/desktop/mails")))
2890 (delq nil
2891 (list
2892 (when (file-directory-p (expand-file-name "icloud" maildir-root))
2893 (make-mu4e-context
2894 :name "icloud"
2895 :match-func (lambda (msg) (when msg
2896 (string-prefix-p "/icloud" (mu4e-message-field msg :maildir))))
2897 :vars '(
2898 (user-mail-address . "vincent@demeester.fr")
2899 (mu4e-trash-folder . "/icloud/Deleted Messages")
2900 (mu4e-sent-folder . "/icloud/Sent Messages")
2901 (mu4e-draft-folder . "/icloud/Drafts")
2902 ;; (mu4e-get-mail-command . "mbsync icloud")
2903 )))
2904 (when (file-directory-p (expand-file-name "redhat" maildir-root))
2905 (make-mu4e-context
2906 :name "redhat"
2907 :match-func (lambda (msg) (when msg
2908 (string-prefix-p "/redhat" (mu4e-message-field msg :maildir))))
2909 :vars '(
2910 (user-mail-address . "vdemeest@redhat.com")
2911 (mu4e-drafts-folder . "/redhat/[Gmail]/Drafts")
2912 (mu4e-sent-folder . "/redhat/[Gmail]/Sent Mail")
2913 ;; (mu4e-refile-folder . "/redhat/[Gmail]/All Mail")
2914 (mu4e-trash-folder . "/redhat/[Gmail]/Trash")
2915 ;; (mu4e-get-mail-command . "mbsync redhat")
2916 )))))))
2917 ;; === Priority & Unread ===
2918 (add-to-list 'mu4e-bookmarks
2919 '(:name "All Inboxes"
2920 :query "maildir:/icloud/Inbox OR maildir:/redhat/Inbox"
2921 :key ?b))
2922
2923 (add-to-list 'mu4e-bookmarks
2924 '(:name "Unread (All)"
2925 :query "flag:unread AND NOT flag:trashed"
2926 :key ?u))
2927
2928 (add-to-list 'mu4e-bookmarks
2929 '(:name "Unread Inboxes"
2930 :query "flag:unread AND (maildir:/icloud/Inbox OR maildir:/redhat/Inbox)"
2931 :key ?U))
2932
2933 (add-to-list 'mu4e-bookmarks
2934 '(:name "Flagged"
2935 :query "flag:flagged AND NOT flag:trashed"
2936 :key ?f))
2937
2938 (add-to-list 'mu4e-bookmarks
2939 '(:name "Today"
2940 :query "date:today..now AND NOT flag:trashed"
2941 :key ?t))
2942
2943 (add-to-list 'mu4e-bookmarks
2944 '(:name "This Week"
2945 :query "date:7d..now AND NOT flag:trashed"
2946 :key ?w))
2947
2948 ;; === Work - RedHat Projects ===
2949 (add-to-list 'mu4e-bookmarks
2950 '(:name "Pipelines/Tekton"
2951 :query "maildir:/redhat/pipelines/* OR maildir:/redhat/tekton/*"
2952 :key ?p))
2953
2954 (add-to-list 'mu4e-bookmarks
2955 '(:name "Konflux"
2956 :query "maildir:/redhat/konflux/*"
2957 :key ?k))
2958
2959 (add-to-list 'mu4e-bookmarks
2960 '(:name "Serverless (Knative)"
2961 :query "maildir:/redhat/serverless/* OR maildir:/redhat/knative/*"
2962 :key ?s))
2963
2964 (add-to-list 'mu4e-bookmarks
2965 '(:name "DevTools Team"
2966 :query "maildir:/redhat/devtools/*"
2967 :key ?d))
2968
2969 (add-to-list 'mu4e-bookmarks
2970 '(:name "Cloud & Insights"
2971 :query "maildir:/redhat/cloud/* OR maildir:/redhat/insights/*"
2972 :key ?c))
2973
2974 ;; === Work - Management & Communication ===
2975 (add-to-list 'mu4e-bookmarks
2976 '(:name "Managers"
2977 :query "maildir:/redhat/managers/*"
2978 :key ?m))
2979
2980 (add-to-list 'mu4e-bookmarks
2981 '(:name "Announcements"
2982 :query "maildir:/redhat/announce/* OR maildir:/redhat/area/*"
2983 :key ?a))
2984
2985 (add-to-list 'mu4e-bookmarks
2986 '(:name "Local (France/Remote)"
2987 :query "maildir:/redhat/local/*"
2988 :key ?l))
2989
2990 ;; === Work - Trackers & Automation ===
2991 (add-to-list 'mu4e-bookmarks
2992 '(:name "Jira Tickets"
2993 :query "maildir:/redhat/_tracker/jira/*"
2994 :key ?j))
2995
2996 (add-to-list 'mu4e-bookmarks
2997 '(:name "All Trackers"
2998 :query "maildir:/redhat/_tracker/*"
2999 :key ?T))
3000
3001 (add-to-list 'mu4e-bookmarks
3002 '(:name "Build Notifications"
3003 :query "maildir:/redhat/_build/*"
3004 :key ?B))
3005
3006 (add-to-list 'mu4e-bookmarks
3007 '(:name "Receipts"
3008 :query "maildir:/icloud/Receipts/*"
3009 :key ?r))
3010
3011 (add-to-list 'mu4e-bookmarks
3012 '(:name "Newsletters"
3013 :query "maildir:/icloud/Newsletters/*"
3014 :key ?n))
3015
3016 (add-to-list 'mu4e-bookmarks
3017 '(:name "House Hunting"
3018 :query "maildir:\"/icloud/house hunting/*\""
3019 :key ?h))
3020
3021 ;; === Sent & Drafts ===
3022 (add-to-list 'mu4e-bookmarks
3023 '(:name "Sent (All)"
3024 :query "maildir:\"/icloud/Sent Messages\" OR maildir:\"/redhat/[Gmail]/Sent Mail\""
3025 :key ?S))
3026
3027 (add-to-list 'mu4e-bookmarks
3028 '(:name "Drafts (All)"
3029 :query "maildir:/icloud/Drafts OR maildir:\"/redhat/[Gmail]/Drafts\""
3030 :key ?D))
3031
3032 ;; === Advanced Queries ===
3033 (add-to-list 'mu4e-bookmarks
3034 '(:name "With Attachments"
3035 :query "flag:attach AND NOT flag:trashed"
3036 :key ?A))
3037
3038 (add-to-list 'mu4e-bookmarks
3039 '(:name "Large Emails (>1MB)"
3040 :query "size:1M..500M AND NOT flag:trashed"
3041 :key ?L))
3042
3043 (add-to-list 'mu4e-bookmarks
3044 '(:name "To Me Directly"
3045 :query "(to:vincent@demeester.fr OR to:vdemeest@redhat.com) AND NOT flag:trashed AND NOT maildir:/_tracker/*"
3046 :key ?M))
3047
3048 ;; === Recent Activity ===
3049 (add-to-list 'mu4e-bookmarks
3050 '(:name "Unread This Week (Work)"
3051 :query "flag:unread AND date:7d..now AND maildir:/redhat/*"
3052 :key ?W))
3053
3054 (add-to-list 'mu4e-bookmarks
3055 '(:name "Unread This Week (Personal)"
3056 :query "flag:unread AND date:7d..now AND maildir:/icloud/*"
3057 :key ?P))
3058
3059 ;; Custom sorting: oldest-first for active folders, newest-first for archives
3060 (declare-function mu4e-search-change-sorting "mu4e")
3061 (declare-function vde/mu4e-set-sort-order-by-bookmark nil)
3062
3063 (defcustom vde/mu4e-oldest-first-bookmarks '(
3064 "maildir:/icloud/Inbox"
3065 "maildir:/redhat/Inbox"
3066 "maildir:/icloud/Inbox OR maildir:/redhat/Inbox" ; All Inboxes
3067 "maildir:/icloud/Drafts"
3068 "maildir:/redhat/[Gmail]/Drafts"
3069 "flag:unread AND NOT flag:trashed" ; Unread (All)
3070 "flag:unread AND (maildir:/icloud/Inbox OR maildir:/redhat/Inbox)" ; Unread Inboxes
3071 "flag:flagged AND NOT flag:trashed" ; Flagged
3072 "(to:vincent@demeester.fr OR to:vdemeest@redhat.com) AND NOT flag:trashed AND NOT maildir:/_tracker/*" ; To Me Directly
3073 )
3074 "Bookmarks/folders to sort oldest-first (ascending).
3075Active folders like INBOX and Drafts show oldest messages first to prioritize
3076pending items by age. All other folders (Sent, Archives) will sort newest-first."
3077 :type '(repeat string)
3078 :group 'mu4e)
3079
3080 (defun vde/mu4e-set-sort-order-by-bookmark (search)
3081 "Set sort order based on bookmark type.
3082Active folders (INBOX, Drafts) sort ascending (oldest first) to prioritize
3083pending items. Archives and Sent folders sort descending (newest first) to
3084show recent activity."
3085 (if (member search vde/mu4e-oldest-first-bookmarks)
3086 (mu4e-search-change-sorting :date 'ascending)
3087 (mu4e-search-change-sorting :date 'descending)))
3088
3089 (add-hook 'mu4e-search-bookmark-hook #'vde/mu4e-set-sort-order-by-bookmark)
3090
3091 ;; Open mu4e links in a new tab
3092 ;; This overrides the default mu4e link handler to open in tab-bar
3093 (defun vde/mu4e-org-open-in-new-tab (link)
3094 "Open mu4e LINK in a new tab-bar tab.
3095LINK is the part after 'mu4e:' in the org link."
3096 (tab-bar-new-tab)
3097 (require 'mu4e-org)
3098 (mu4e-org-open link))
3099
3100 (with-eval-after-load 'ol
3101 (org-link-set-parameters "mu4e"
3102 :follow #'vde/mu4e-org-open-in-new-tab))
3103
3104 ;; imapfilter integration
3105 (defun vde/mu4e-imapfilter-add-rule ()
3106 "Add an imapfilter rule from the current email message.
3107Prompts for rule type (from, domain, subject, header) and category
3108(delete, receipts, newsletters, archive), then appends the rule to
3109the appropriate file in ~/.local/share/imapfilter-rules/"
3110 (interactive)
3111 (unless (mu4e-message-at-point)
3112 (user-error "No message at point"))
3113 (let* ((msg (mu4e-message-at-point))
3114 (from (mu4e-message-field msg :from))
3115 (from-email (when from (plist-get (car from) :email)))
3116 (subject (mu4e-message-field msg :subject))
3117 (rules-dir (expand-file-name "~/.local/share/imapfilter-rules"))
3118
3119 ;; Prompt for rule type
3120 (rule-type (completing-read "Rule type: "
3121 '("from" "domain" "subject" "header")
3122 nil t))
3123
3124 ;; Generate rule pattern based on type
3125 (pattern
3126 (pcase rule-type
3127 ("from" from-email)
3128 ("domain" (when from-email
3129 (concat "@" (replace-regexp-in-string "^.*@" "" from-email))))
3130 ("subject" (read-string "Subject pattern: " subject))
3131 ("header" (let ((header-name (read-string "Header name: "))
3132 (header-value (read-string "Header value: ")))
3133 (format "%s:%s" header-name header-value)))))
3134
3135 ;; Prompt for category
3136 (category (completing-read "Category: "
3137 '("delete" "receipts" "newsletters" "archive")
3138 nil t))
3139
3140 ;; Build rule line
3141 (rule-line (format "%s:%s" rule-type pattern))
3142 (rule-file (expand-file-name (format "%s.txt" category) rules-dir)))
3143
3144 ;; Validate
3145 (unless (file-directory-p rules-dir)
3146 (user-error "Rules directory does not exist: %s" rules-dir))
3147 (unless pattern
3148 (user-error "Could not extract pattern for rule type: %s" rule-type))
3149
3150 ;; Show preview and confirm
3151 (when (y-or-n-p (format "Add rule '%s' to %s.txt? " rule-line category))
3152 ;; Append to file
3153 (with-temp-buffer
3154 (insert rule-line)
3155 (insert "\n")
3156 (append-to-file (point-min) (point-max) rule-file))
3157
3158 (message "Added rule: %s → %s.txt" rule-line category)
3159
3160 ;; Offer to commit and push
3161 (when (y-or-n-p "Commit and push this rule? ")
3162 (let ((default-directory rules-dir))
3163 (shell-command (format "git add %s.txt" category))
3164 (shell-command (format "git commit -m 'Add %s rule: %s'" category rule-line))
3165 (shell-command "git push")
3166 (message "Rule committed and pushed"))))))
3167
3168 (with-eval-after-load "mm-decode"
3169 (add-to-list 'mm-discouraged-alternatives "text/html")
3170 (add-to-list 'mm-discouraged-alternatives "text/richtext")))
3171
3172;; TODO gptel configuration (and *maybe* copilot)
3173
3174
3175(use-package mcp
3176 :commands (mcp-hub-start-all-server)
3177 :after gptel
3178 :custom (mcp-hub-servers
3179 `(("jira"
3180 :command "/home/vincent/src/github.com/chmouel/jayrah/.venv/bin/jayrah"
3181 :args ("mcp"))
3182 ("github"
3183 :command "github-mcp-server"
3184 :args ("stdio")
3185 :env (:GITHUB_PERSONAL_ACCESS_TOKEN ,(passage-get "github/vdemeester/github-mcp-server")))
3186 ("playwright"
3187 :command "npx @playwright/mcp@latest"
3188 :args ("--executable-path", "/run/current-system/sw/bin/chromium"))))
3189 :config (require 'mcp-hub))
3190
3191(use-package gptel
3192 :commands (gptel gptel-mode)
3193 :init
3194 (declare-function gptel-make-ollama "gptel")
3195 (declare-function gptel-make-openai "gptel")
3196 (declare-function gptel-make-gemini "gptel")
3197 (declare-function gptel-mcp-connect "gptel")
3198 (defvar gptel-mode-map)
3199 :bind (("C-c a g" . gptel))
3200 :hook
3201 (gptel-mode . visual-line-mode)
3202 (gptel-mode . visual-wrap-prefix-mode)
3203 :bind
3204 (:map gptel-mode-map
3205 ("C-c C-k" . gptel-abort)
3206 ("C-c C-m" . gptel-menu)
3207 ("C-c C-c" . gptel-send))
3208 :custom
3209 (gptel-default-mode #'markdown-mode)
3210 :config
3211 ;; (require 'gptel-curl)
3212 (require 'gptel-gemini)
3213 (require 'gptel-transient)
3214 (require 'gptel-integrations)
3215 (require 'gptel-rewrite)
3216 (require 'gptel-org)
3217 (require 'gptel-openai)
3218 (require 'gptel-openai-extras)
3219 (require 'gptel-autoloads)
3220 (gptel-mcp-connect)
3221
3222 (setq gptel-model 'gemini-2.5-flash
3223 gptel-backend (gptel-make-gemini "Gemini"
3224 :key (passage-get "ai/gemini/api_key"))
3225 )
3226
3227 (gptel-make-gemini "Gemini Red Hat"
3228 :key (passage-get "redhat/google/osp/vdeemest-api-key"))
3229
3230 )
3231
3232(use-package acp
3233 :defer t)
3234
3235(use-package agent-shell
3236 :commands (agent-shell agent-shell-toggle agent-shell-new-shell)
3237 :bind (("C-c a a" . vde/agent-shell-dwim)
3238 ("C-c a n" . agent-shell-new-shell)
3239 ("C-c a A" . agent-shell-toggle)
3240 ("C-c a ?" . agent-shell-help-menu))
3241 :init
3242 (declare-function agent-shell-google-make-authentication "agent-shell")
3243 (declare-function agent-shell-make-environment-variables "agent-shell")
3244 (declare-function agent-shell-project-buffers "agent-shell")
3245 (declare-function agent-shell--read-shell-buffer "agent-shell")
3246 (declare-function agent-shell--display-buffer "agent-shell")
3247 (defun vde/agent-shell-dwim (&optional arg)
3248 "Project-aware agent-shell launcher.
3249
3250No prefix ARG:
3251- 0 shells in this project -> start a new one
3252- 1 shell -> toggle its window (show/hide)
3253- N shells -> `completing-read' to pick one, then display.
3254
3255With \\[universal-argument]: always force a brand-new shell.
3256With \\[universal-argument] \\[universal-argument]: always prompt with completion."
3257 (interactive "P")
3258 (cond
3259 ((equal arg '(16)) (agent-shell '(16)))
3260 (arg (agent-shell '(4)))
3261 (t
3262 (let ((buffers (agent-shell-project-buffers)))
3263 (pcase (length buffers)
3264 (0 (agent-shell))
3265 (1 (let* ((buf (car buffers))
3266 (win (get-buffer-window buf)))
3267 (if win
3268 (quit-restore-window win 'bury)
3269 (agent-shell--display-buffer buf))))
3270 (_ (agent-shell--display-buffer
3271 (agent-shell--read-shell-buffer
3272 :prompt "Project agent shell: "
3273 :buffers buffers))))))))
3274 :config
3275 (setq agent-shell-google-authentication
3276 (agent-shell-google-make-authentication :api-key (passage-get "redhat/google/osp/vdeemest-api-key")))
3277 (setq agent-shell-anthropic-claude-environment
3278 (agent-shell-make-environment-variables
3279 "CLAUDE_CODE_USE_VERTEX" "1"
3280 "CLOUD_ML_REGION" "global"
3281 "ANTHROPIC_VERTEX_PROJECT_ID" "itpc-gcp-pnd-pe-eng-claude")))
3282
3283(use-package agent-shell-pi
3284 :after agent-shell
3285 :defer t
3286 :config
3287 (setq agent-shell-pi-environment
3288 (agent-shell-make-environment-variables
3289 "GOOGLE_CLOUD_PROJECT" "itpc-gcp-pnd-pe-eng-claude"
3290 "GOOGLE_CLOUD_LOCATION" "global"
3291 "GEMINI_API_KEY" (passage-get "redhat/google/osp/vdeemest-api-key"))))
3292
3293(use-package phscroll
3294 :defer t)
3295
3296(use-package pi-from-github
3297 :after pi-coding-agent
3298 :commands (pi-from-github-pr pi-from-github-issue)
3299 :bind (("C-c a r" . pi-from-github-pr)
3300 ("C-c a i" . pi-from-github-issue)))
3301
3302(use-package pi-coding-agent
3303 :commands (pi-coding-agent pi-coding-agent-toggle)
3304 :bind (("C-c a p" . vde/pi-coding-agent-dwim)
3305 ("C-c a P" . pi-coding-agent))
3306 :init
3307 (defalias 'pi #'pi-coding-agent)
3308 (declare-function pi-coding-agent-project-buffers "pi-coding-agent-ui")
3309 (declare-function pi-coding-agent-toggle "pi-coding-agent")
3310 (declare-function pi-coding-agent--show-session-buffers "pi-coding-agent")
3311 (defun vde/pi-coding-agent-dwim (&optional arg)
3312 "Project-aware pi-coding-agent launcher.
3313
3314No prefix ARG:
3315- 0 sessions in this project -> start the default one
3316- 1 session -> toggle its windows (show/hide)
3317- N sessions -> `completing-read' to pick one, then show.
3318
3319With \\[universal-argument]: prompt for a session name (new named session).
3320With \\[universal-argument] \\[universal-argument]: always prompt with completion."
3321 (interactive "P")
3322 (require 'pi-coding-agent-ui nil t)
3323 (cond
3324 ((equal arg '(16))
3325 (let* ((buffers (or (pi-coding-agent-project-buffers)
3326 (user-error "No pi session for this project")))
3327 (alist (mapcar (lambda (b) (cons (buffer-name b) b)) buffers))
3328 (choice (completing-read "Pi session: " alist nil t))
3329 (chat-buf (cdr (assoc choice alist)))
3330 (input-buf (buffer-local-value 'pi-coding-agent--input-buffer
3331 chat-buf)))
3332 (pi-coding-agent--show-session-buffers chat-buf input-buf)))
3333 (arg
3334 (let ((current-prefix-arg '(4)))
3335 (call-interactively #'pi-coding-agent)))
3336 (t
3337 (let ((buffers (pi-coding-agent-project-buffers)))
3338 (pcase (length buffers)
3339 (0 (pi-coding-agent))
3340 (1 (pi-coding-agent-toggle))
3341 (_ (let* ((alist (mapcar (lambda (b) (cons (buffer-name b) b)) buffers))
3342 (choice (completing-read "Pi session: " alist nil t))
3343 (chat-buf (cdr (assoc choice alist)))
3344 (input-buf (buffer-local-value 'pi-coding-agent--input-buffer
3345 chat-buf)))
3346 (pi-coding-agent--show-session-buffers chat-buf input-buf))))))))
3347 :custom
3348 (pi-coding-agent-input-window-height 10)
3349 (pi-coding-agent-tool-preview-lines 10)
3350 (pi-coding-agent-bash-preview-lines 5)
3351 (pi-coding-agent-executable '("pir"))
3352 (pi-coding-agent-context-warning-threshold 70)
3353 (pi-coding-agent-context-error-threshold 90)
3354 ;; Trust project-local .pi resources (prompts, skills, settings, themes,
3355 ;; extensions) by default — frontend equivalent of --approve (v2.5.0).
3356 (pi-coding-agent-project-trust-policy 'approve)
3357 :config
3358 ;; Shorten buffer names (and thus the Emacs frame/window title) from
3359 ;; "*pi-coding-agent-input:~/src/home*" to "*pi:input:~/src/home*".
3360 (declare-function pi-coding-agent--buffer-name "pi-coding-agent-ui")
3361 (defun vde/pi-coding-agent-buffer-name (type dir &optional session)
3362 "Shorter buffer name for pi-coding-agent TYPE in DIR with SESSION."
3363 (let ((type-str (pcase type (:chat "chat") (:input "input")))
3364 (abbrev-dir (abbreviate-file-name dir)))
3365 (if (and session (not (string-empty-p session)))
3366 (format "*pi:%s:%s<%s>*" type-str abbrev-dir session)
3367 (format "*pi:%s:%s*" type-str abbrev-dir))))
3368 (advice-add 'pi-coding-agent--buffer-name :override
3369 #'vde/pi-coding-agent-buffer-name)
3370
3371 ;; NOTE: thinking blocks are visible by default since v2.5.0, which also
3372 ;; batches history replay and avoids the O(n²) re-render that previously
3373 ;; forced us to hide thinking. Re-enable the advice below only if long
3374 ;; chats become sluggish again.
3375 ;; Notify when a Pi session goes idle (task finished) so background work
3376 ;; surfaces without watching the buffer (v2.5.0 activity phase hooks).
3377 (add-hook 'pi-coding-agent-activity-phase-functions
3378 (lambda (chat-buffer _input-buffer old-phase new-phase reason)
3379 (when (and (eq reason 'phase-change)
3380 (equal new-phase "idle")
3381 (not (equal old-phase "idle"))
3382 (buffer-live-p chat-buffer)
3383 (not (eq chat-buffer (window-buffer (selected-window)))))
3384 (alert "Pi session idle" :title "pi-coding-agent"
3385 :buffer chat-buffer))))
3386 ;; Remove ispell from completion in pi input buffer (conflicts with corfu)
3387 (add-hook 'pi-coding-agent-input-mode-hook
3388 (lambda ()
3389 (remove-hook 'completion-at-point-functions #'ispell-completion-at-point t)))
3390 ;; Auto-resync status when pi reports idle but Emacs thinks it's streaming
3391 ;; Workaround for OSC escape sequences in RPC output breaking event parsing
3392 (add-hook 'pi-coding-agent-input-mode-hook
3393 (lambda ()
3394 (add-hook 'post-command-hook
3395 (lambda ()
3396 (when-let ((chat-buf (pi-coding-agent--get-chat-buffer)))
3397 (with-current-buffer chat-buf
3398 (when (and (eq pi-coding-agent--status 'streaming)
3399 (pi-coding-agent--state-needs-verification-p))
3400 (pi-coding-agent--rpc-async
3401 pi-coding-agent--process
3402 '(:type "get_state")
3403 (lambda (r)
3404 (when (buffer-live-p chat-buf)
3405 (with-current-buffer chat-buf
3406 (pi-coding-agent--update-state-from-response r)))))))))
3407 nil t)))
3408 ;; Refresh header after extension slash commands (e.g. /mode) so the model
3409 ;; name stays in sync. Extension-driven pi.setModel() calls emit no RPC
3410 ;; model-change event, so we re-query get_state after the prompt response.
3411 (define-advice pi-coding-agent--send-prompt (:around (orig text &rest args) refresh-after-ext-cmd)
3412 "After non-builtin slash commands, refresh state so the header updates."
3413 (if (and (string-prefix-p "/" text)
3414 (not (assoc (car (split-string (substring text 1)))
3415 pi-coding-agent--builtin-commands)))
3416 (let ((proc (pi-coding-agent--get-process))
3417 (chat-buf (pi-coding-agent--get-chat-buffer)))
3418 (apply orig text args)
3419 (when (and proc (process-live-p proc) (buffer-live-p chat-buf))
3420 ;; Delay: let the extension handler finish its async work
3421 (run-at-time 2.0 nil
3422 (lambda ()
3423 (when (and (process-live-p proc) (buffer-live-p chat-buf))
3424 (pi-coding-agent--rpc-async proc '(:type "get_state")
3425 (lambda (r)
3426 (when (and (plist-get r :success) (buffer-live-p chat-buf))
3427 (with-current-buffer chat-buf
3428 (pi-coding-agent--update-state-from-response r)
3429 (pi-coding-agent--refresh-header))))))))))
3430 (apply orig text args))))
3431
3432;; Make markdown links [text](url) clickable in pi chat buffers.
3433;; Tree-sitter markdown hides the URL portion; this jit-lock function
3434;; finds inline links and adds button properties so RET/click opens them.
3435(with-eval-after-load 'pi-coding-agent
3436 (defvar vde/pi-link-keymap
3437 (let ((map (make-sparse-keymap)))
3438 (define-key map [mouse-1]
3439 (lambda (e)
3440 (interactive "e")
3441 (when-let ((url (get-text-property
3442 (posn-point (event-start e))
3443 'pi-link-url)))
3444 (browse-url url))))
3445 (define-key map (kbd "RET")
3446 (lambda ()
3447 (interactive)
3448 (when-let ((url (get-text-property (point) 'pi-link-url)))
3449 (browse-url url))))
3450 map)
3451 "Keymap for clickable markdown links in pi chat buffers.")
3452
3453 (defun vde/pi-buttonize-markdown-links (beg end)
3454 "Buttonize markdown inline links in BEG..END for pi chat buffers."
3455 (save-excursion
3456 (goto-char beg)
3457 (while (re-search-forward "\\[\\([^]]+\\)\\](\\([^)]+\\))" end t)
3458 (let ((text-beg (match-beginning 1))
3459 (text-end (match-end 1))
3460 (url (match-string-no-properties 2)))
3461 (unless (get-text-property text-beg 'pi-link-url)
3462 (let ((inhibit-read-only t))
3463 (put-text-property text-beg text-end 'pi-link-url url)
3464 (put-text-property text-beg text-end 'help-echo url)
3465 (put-text-property text-beg text-end 'mouse-face 'highlight)
3466 (put-text-property text-beg text-end 'keymap vde/pi-link-keymap)
3467 (add-face-text-property text-beg text-end
3468 '(:underline t) 'append)))))))
3469
3470 (add-hook 'pi-coding-agent-chat-mode-hook
3471 (lambda ()
3472 (jit-lock-register #'vde/pi-buttonize-markdown-links))))
3473
3474(use-package devdocs
3475 :commands (devdocs-lookup devdocs-install vde/install-devdocs)
3476 :bind (("C-h D" . devdocs-lookup))
3477 :config
3478 (defun vde/install-devdocs ()
3479 "Install the devdocs I am using the most."
3480 (interactive)
3481 (dolist (docset '("bash"
3482 "c"
3483 "click"
3484 "cpp"
3485 "css"
3486 "elisp"
3487 "flask"
3488 "git"
3489 "gnu_make"
3490 "go"
3491 "html"
3492 "htmx"
3493 "http"
3494 "javascript"
3495 "jq"
3496 "jquery"
3497 "kubectl"
3498 "kubernetes"
3499 "lua~5.4"
3500 "nix"
3501 "python~3.13"
3502 "python~3.12"
3503 "requests"
3504 "sqlite"
3505 "terraform"
3506 "werkzeug"
3507 "zig"))
3508 (devdocs-install docset))))
3509
3510(use-package proced
3511 :ensure nil
3512 :defer t
3513 :custom
3514 (proced-enable-color-flag t)
3515 (proced-tree-flag t)
3516 (proced-auto-update-flag 'visible)
3517 (proced-auto-update-interval 1)
3518 (proced-descend t)
3519 (proced-format 'medium) ;; can be changed interactively with `F'
3520 (proced-filter 'user)) ;; can be changed interactively with `f'
3521
3522(use-package ytr
3523 :commands (ytr)
3524 :bind ("C-c m y" . ytr))
3525
3526(use-package empv
3527 :commands (empv-subsonic-search
3528 empv-subsonic-artists
3529 empv-subsonic-albums
3530 empv-subsonic-songs)
3531 :bind (("C-c M s" . empv-subsonic-search)
3532 ("C-c M a" . empv-subsonic-albums)
3533 ("C-c M P" . empv-subsonic-playlists)
3534 ("C-c M R" . empv-subsonic-radios)
3535 ("C-c M SPC" . empv-toggle)
3536 ("C-c M i" . empv-display-current)
3537 ("C-c M n" . empv-playlist-next)
3538 ("C-c M p" . empv-playlist-prev)
3539 :prefix "C-c M q"
3540 :prefix-map empv-queue-map
3541 ("s" . empv-playlist-select)
3542 ("c" . empv-playlist-clear)
3543 ("r" . empv-playlist-shuffle)
3544 ("l" . empv-playlist-loop-on)
3545 ("L" . empv-playlist-loop-off))
3546 :config
3547 (setq empv-subsonic-url "https://music.sbr.pm"
3548 empv-subsonic-username (passage-get "home/music.sbr.pm/username")
3549 empv-subsonic-password (passage-get "home/music.sbr.pm/password"))
3550
3551 ;; Subsonic playlist support (not yet upstream in empv.el)
3552 ;; Adds formatting, act-on-candidate handling, and interactive command
3553 ;; for getPlaylists/getPlaylist Subsonic API endpoints.
3554
3555 (defun empv--subsonic-format-playlist (cand)
3556 "Format a Subsonic playlist candidate for display."
3557 (format "%s %s"
3558 (propertize (alist-get 'name cand) 'face 'bold)
3559 (propertize (format "[%s songs]" (or (alist-get 'songCount cand) "?")) 'face 'italic)))
3560
3561 ;; Extend the formatter to handle playlist kind
3562 (advice-add 'empv--subsonic-format-candidate :around
3563 (lambda (orig cand)
3564 (if (eq (alist-get 'kind cand) 'playlist)
3565 (empv--with-text-properties
3566 (empv--subsonic-format-playlist cand)
3567 :item cand)
3568 (funcall orig cand))))
3569
3570 ;; Extend act-on-candidate to handle playlist kind
3571 ;; Fetches playlist tracks via getPlaylist and enqueues all songs
3572 (advice-add 'empv--subsonic-act-on-candidate :around
3573 (lambda (orig selected)
3574 (if (eq (alist-get 'kind selected) 'playlist)
3575 (let ((id (alist-get 'id selected))
3576 (name (alist-get 'name selected)))
3577 (empv--subsonic-request
3578 "getPlaylist.view"
3579 :id id
3580 (lambda (response)
3581 ;; getPlaylist returns entries under 'entry key
3582 ;; but empv's processor puts them under 'results
3583 ;; if they match result-fields. Since 'entry is
3584 ;; not in result-fields, we extract manually.
3585 (let* ((raw (alist-get 'entry response))
3586 (songs (or (alist-get 'results response) raw)))
3587 (if songs
3588 (empv-play-or-enqueue
3589 (mapcar #'empv--subsonic-item-extract-url
3590 (if (alist-get 'results response)
3591 songs
3592 ;; raw entries need kind annotation
3593 (mapcar (lambda (s)
3594 (thread-first s
3595 (map-insert 'kind 'song)
3596 (map-insert 'type 'subsonic)))
3597 songs))))
3598 (message "Playlist '%s' is empty" name))))))
3599 (funcall orig selected))))
3600
3601 (defun empv-subsonic-playlists ()
3602 "List Subsonic playlists and act on selection."
3603 (interactive)
3604 (empv--subsonic-request
3605 "getPlaylists.view"
3606 (empv--subsonic-result-handler "Select playlist:")))
3607
3608 ;; Subsonic internet radio support
3609 ;; Uses getInternetRadioStations Subsonic API endpoint.
3610
3611 (defun empv--subsonic-format-radio (cand)
3612 "Format a Subsonic radio candidate for display."
3613 (format "%s %s"
3614 (propertize (alist-get 'name cand) 'face 'bold)
3615 (propertize (or (alist-get 'homePageUrl cand) "") 'face 'italic)))
3616
3617 ;; Extend the formatter to handle radio kind
3618 (advice-add 'empv--subsonic-format-candidate :around
3619 (lambda (orig cand)
3620 (if (eq (alist-get 'kind cand) 'radio)
3621 (empv--with-text-properties
3622 (empv--subsonic-format-radio cand)
3623 :item cand)
3624 (funcall orig cand))))
3625
3626 ;; Extend act-on-candidate to handle radio kind
3627 ;; Radio stations have a direct streamUrl, no need to fetch sub-items
3628 (advice-add 'empv--subsonic-act-on-candidate :around
3629 (lambda (orig selected)
3630 (if (eq (alist-get 'kind selected) 'radio)
3631 (let ((url (alist-get 'streamUrl selected)))
3632 (if url
3633 (empv-play url)
3634 (message "Radio '%s' has no stream URL" (alist-get 'name selected))))
3635 (funcall orig selected))))
3636
3637 (defun empv-subsonic-radios ()
3638 "List Subsonic internet radio stations and play selection."
3639 (interactive)
3640 (empv--subsonic-request
3641 "getInternetRadioStations.view"
3642 (lambda (response)
3643 ;; empv's result-fields doesn't include internetRadioStation,
3644 ;; so we extract manually from the raw response info.
3645 (let* ((raw (alist-get 'internetRadioStation response))
3646 (stations (mapcar (lambda (s)
3647 (thread-first s
3648 (map-insert 'kind 'radio)
3649 (map-insert 'type 'subsonic)))
3650 raw)))
3651 (if stations
3652 (empv--subsonic-act-on-candidate
3653 (empv--completing-read-object
3654 "Select radio:"
3655 stations
3656 :formatter #'empv--subsonic-format-candidate
3657 :category 'empv-subsonic-item))
3658 (message "No internet radio stations found")))))))
3659
3660(use-package ready-player
3661 :init
3662 (declare-function ready-player-mode "ready-player")
3663 :config
3664 (ready-player-mode +1))
3665
3666(defvar highlight-codetags-keywords
3667 '(("\\<\\(TODO\\|FIXME\\|BUG\\|XXX\\)\\>" 1 font-lock-warning-face prepend)
3668 ("\\<\\(NOTE\\|HACK\\)\\>" 1 font-lock-doc-face prepend)))
3669
3670(define-minor-mode highlight-codetags-local-mode
3671 "Highlight codetags like TODO, FIXME..."
3672 :global nil
3673 (if highlight-codetags-local-mode
3674 (font-lock-add-keywords nil highlight-codetags-keywords)
3675 (font-lock-remove-keywords nil highlight-codetags-keywords))
3676
3677 ;; Fontify the current buffer
3678 (when (bound-and-true-p font-lock-mode)
3679 (if (fboundp 'font-lock-flush)
3680 (font-lock-flush)
3681 (with-no-warnings (font-lock-fontify-buffer)))))
3682
3683(add-hook 'prog-mode-hook #'highlight-codetags-local-mode)
3684
3685(defun vde/wtype-text (text)
3686 "Process TEXT for wtype, handling newlines properly."
3687 (let* ((has-final-newline (string-match-p "\n$" text))
3688 (lines (split-string text "\n"))
3689 (last-idx (1- (length lines))))
3690 (string-join
3691 (cl-loop for line in lines
3692 for i from 0
3693 collect (cond
3694 ;; Last line without final newline
3695 ((and (= i last-idx) (not has-final-newline))
3696 (format "wtype -s 50 \"%s\""
3697 (replace-regexp-in-string "\"" "\\\\\"" line)))
3698 ;; Any other line
3699 (t
3700 (format "wtype -s 50 \"%s\" && wtype -k Return"
3701 (replace-regexp-in-string "\"" "\\\\\"" line)))))
3702 " && ")))
3703
3704(define-minor-mode vde/type-mode
3705 "Minor mode for inserting text via wtype."
3706 :keymap `((,(kbd "C-c C-c") . ,(lambda () (interactive)
3707 (call-process-shell-command
3708 (vde/wtype-text (buffer-string))
3709 nil 0)
3710 (delete-frame)))
3711 (,(kbd "C-c C-k") . ,(lambda () (interactive)
3712 (kill-buffer (current-buffer))))))
3713
3714(defun vde/type ()
3715 "Launch a temporary frame with a clean buffer for typing."
3716 (interactive)
3717 (let ((frame (make-frame '((name . "emacs-float")
3718 (fullscreen . 0)
3719 (undecorated . t)
3720 (width . 70)
3721 (height . 20))))
3722 (buf (get-buffer-create "emacs-float")))
3723 (select-frame frame)
3724 (switch-to-buffer buf)
3725 (with-current-buffer buf
3726 (erase-buffer)
3727 ;; (org-mode)
3728 (markdown-mode) ;; more common ?
3729 (flyspell-mode)
3730 (vde/type-mode)
3731 (setq-local header-line-format
3732 (format " %s to insert text or %s to cancel."
3733 (propertize "C-c C-c" 'face 'help-key-binding)
3734 (propertize "C-c C-k" 'face 'help-key-binding)))
3735 ;; Make the frame more temporary-like
3736 (set-frame-parameter frame 'delete-before-kill-buffer t)
3737 (set-window-dedicated-p (selected-window) t))))
3738
3739(defun vde/agenda ()
3740 "Launch a frame with the org-agenda and the `org-todos-file' buffer."
3741 (interactive)
3742 (let ((frame (make-frame '((name . "emacs-org-agenda")))))
3743 (select-frame frame)
3744 (org-agenda nil "d")
3745 (split-window-horizontally)
3746 (other-window 1)
3747 (find-file org-todos-file)))
3748
3749(defun memoize-remote (key cache orig-fn &rest args)
3750 "Memoize a value if the key is a remote path."
3751 (if (and key
3752 (file-remote-p key))
3753 (if-let ((current (assoc key (symbol-value cache))))
3754 (cdr current)
3755 (let ((current (apply orig-fn args)))
3756 (set cache (cons (cons key current) (symbol-value cache)))
3757 current))
3758 (apply orig-fn args)))
3759;; Memoize current project
3760(defvar project-current-cache nil)
3761(defun memoize-project-current (orig &optional prompt directory)
3762 (memoize-remote (or directory
3763 project-current-directory-override
3764 default-directory)
3765 'project-current-cache orig prompt directory))
3766
3767(advice-add 'project-current :around #'memoize-project-current)
3768
3769;; Memoize magit top level
3770(defvar magit-toplevel-cache nil)
3771(defun memoize-magit-toplevel (orig &optional directory)
3772 (memoize-remote (or directory default-directory)
3773 'magit-toplevel-cache orig directory))
3774(advice-add 'magit-toplevel :around #'memoize-magit-toplevel)
3775
3776;; memoize vc-git-root
3777(defvar vc-git-root-cache nil)
3778(defun memoize-vc-git-root (orig file)
3779 (let ((value (memoize-remote (file-name-directory file) 'vc-git-root-cache orig file)))
3780 ;; sometimes vc-git-root returns nil even when there is a root there
3781 (when (null (cdr (car vc-git-root-cache)))
3782 (setq vc-git-root-cache (cdr vc-git-root-cache)))
3783 value))
3784(advice-add 'vc-git-root :around #'memoize-vc-git-root)
3785
3786;; BIND this
3787(defun mu-date-at-point (date)
3788 "Insert current DATE at point via `completing-read'."
3789 (interactive
3790 (let* ((formats '("%Y%m%d" "%F" "%Y%m%d%H%M" "%Y-%m-%dT%T"))
3791 (vals (mapcar #'format-time-string formats))
3792 (opts
3793 (lambda (string pred action)
3794 (if (eq action 'metadata)
3795 '(metadata (display-sort-function . identity))
3796 (complete-with-action action vals string pred)))))
3797 (list (completing-read "Insert date: " opts nil t))))
3798 (insert date))
3799
3800(provide 'init)
3801
3802;; Local Variables:
3803;; byte-compile-warnings: (not free-vars)
3804;; End:
3805
3806;;; init.el ends here