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