diff --git a/config.el b/config.el index d8f39fa..906c757 100644 --- a/config.el +++ b/config.el @@ -1,54 +1,75 @@ ;;; $DOOMDIR/config.el -*- lexical-binding: t; -*- ;;; ============================================================ -;;; USER IDENTITY +;;; USER SETTINGS ;;; ============================================================ -(setq user-full-name "Martin Sukany" +;; User identity +(setq user-full-name "Martin Sukany" user-mail-address "martin@sukany.cz") - -;;; ============================================================ -;;; THEME & FONT -;;; ============================================================ - +;; Theme and font (setq doom-theme 'modus-vivendi-deuteranopia doom-font (font-spec :family "JetBrains Mono" :size 14) doom-variable-pitch-font nil) +;; Line numbers (setq display-line-numbers-type t) ;;; ============================================================ -;;; UI & DISPLAY -;;; ============================================================ - -(setq doom-modeline-refresh-rate 1.0) - -(setq which-key-idle-delay 0.8 - which-key-idle-secondary-delay 0.05) - -;; Centered cursor mode — disabled: conflicts with macOS Zoom focus follower -(use-package! centered-cursor-mode - :config - (setq ccm-vpos-init 0.5) - ;; (global-centered-cursor-mode +1) ; uncomment to enable - ) - - -;;; ============================================================ -;;; MACOS / PLATFORM +;;; UI / DISPLAY ;;; ============================================================ +;; Mouse and focus behavior (setq mouse-autoselect-window t focus-follows-mouse t select-enable-clipboard t select-enable-primary t inhibit-splash-screen t) -;; PATH: add MacTeX binaries -(setenv "PATH" (concat "/Library/TeX/texbin:" (getenv "PATH"))) -(add-to-list 'exec-path "/Library/TeX/texbin") +;; Modeline refresh rate +(setq doom-modeline-refresh-rate 1.0) + +;; Which-key delays +(setq which-key-idle-delay 0.8 + which-key-idle-secondary-delay 0.05) + +;; Auto-save: disable the #file# noise; save all buffers on idle instead +(setq auto-save-default nil) +(defun my/save-all-buffers () + (save-some-buffers t)) +(run-with-idle-timer 10 t #'my/save-all-buffers) + +;; Centered cursor mode +;; Disabled — conflicts with macOS Zoom focus follower (causes screen jumping) +(use-package! centered-cursor-mode + :config + (setq ccm-vpos-init 0.5) ;; 0.5 = center of window + ;; (global-centered-cursor-mode +1) + ) + +;; Performance: GC tuning +(setq gc-cons-threshold (* 100 1024 1024) ;; 100 MB + gc-cons-percentage 0.6) + +;; GCMH — Doom's GC manager +;; Increased idle delay to reduce GC-triggered redraws (avoids Zoom focus jumping) +(after! gcmh + (setq gcmh-idle-delay 'auto + gcmh-auto-idle-delay-factor 20 ;; default 10, raised for less frequent GC + gcmh-high-cons-threshold (* 200 1024 1024))) ;; 200 MB threshold + +(add-hook 'focus-out-hook #'garbage-collect) + +;; macOS Zoom accessibility: cancel the persp-mode 2.5s cache timer to minimize redraws +(run-with-timer 3 nil + (lambda () + (when (and (boundp 'persp-frame-buffer-predicate-buffer-list-cache--timer) + (timerp persp-frame-buffer-predicate-buffer-list-cache--timer)) + (cancel-timer persp-frame-buffer-predicate-buffer-list-cache--timer) + (setq persp-frame-buffer-predicate-buffer-list-cache--timer nil) + (message "persp-mode 2.5s cache timer cancelled for Zoom accessibility")))) ;; macOS clipboard integration via pbcopy/pbpaste (works in terminal Emacs too) (defun my/pbcopy (text &optional _push) @@ -61,50 +82,28 @@ (defun my/pbpaste () "Return text from the macOS clipboard using pbpaste." (when (executable-find "pbpaste") - (string-trim-right (shell-command-to-string "pbpaste")))) + (string-trim-right + (shell-command-to-string "pbpaste")))) -(setq interprogram-cut-function #'my/pbcopy - interprogram-paste-function #'my/pbpaste) +;; Emacs → system clipboard +(setq interprogram-cut-function #'my/pbcopy) +;; System clipboard → Emacs +(setq interprogram-paste-function #'my/pbpaste) -;; Let Evil use the system clipboard (y/d/c go to system) +;; Let Evil use the clipboard (y/d/c operations go to system clipboard) (after! evil (setq evil-want-clipboard t)) -;; macOS Zoom accessibility — cancel persp-mode's 2.5s cache timer after startup -;; (reduces unnecessary redraws that cause Zoom to jump) -(run-with-timer 3 nil - (lambda () - (when (and (boundp 'persp-frame-buffer-predicate-buffer-list-cache--timer) - (timerp persp-frame-buffer-predicate-buffer-list-cache--timer)) - (cancel-timer persp-frame-buffer-predicate-buffer-list-cache--timer) - (setq persp-frame-buffer-predicate-buffer-list-cache--timer nil) - (message "persp-mode 2.5s cache timer cancelled for Zoom accessibility")))) +;; PATH fix for MacTeX +(setenv "PATH" (concat "/Library/TeX/texbin:" (getenv "PATH"))) +(add-to-list 'exec-path "/Library/TeX/texbin") +;; Disable TLS verification — WARNING: insecure, use only in trusted environments +(setq gnutls-verify-error nil) +(setq gnutls-algorithm-priority "NORMAL:-VERS-TLS1.3") -;;; ============================================================ -;;; PERFORMANCE & GC -;;; ============================================================ - -(setq gc-cons-threshold (* 100 1024 1024) ; 100 MB - gc-cons-percentage 0.6) - -;; GCMH — Doom's GC manager; increase idle delay to reduce redraws -(after! gcmh - (setq gcmh-idle-delay 'auto - gcmh-auto-idle-delay-factor 20 - gcmh-high-cons-threshold (* 200 1024 1024))) ; 200 MB - -(add-hook 'focus-out-hook #'garbage-collect) - -;; Auto-save all buffers on idle (replaces noisy #file# autosave) -(setq auto-save-default nil) -(defun my/save-all-buffers () (save-some-buffers t)) -(run-with-idle-timer 10 t #'my/save-all-buffers) - -;; !!! WARNING: TLS verification disabled globally !!! -;; Required for self-signed certs on local services (ai.apps.sukany.cz etc.) -(setq gnutls-verify-error nil - gnutls-algorithm-priority "NORMAL:-VERS-TLS1.3") +;; Org clock idle time +(setq org-idle-time 1.0) ;;; ============================================================ @@ -112,38 +111,44 @@ ;;; ============================================================ (after! org + ;; Required packages (require 'ox-hugo) + ;; Org directory and default notes file (setq org-directory "~/org/") (setq org-default-notes-file (expand-file-name "inbox.org" org-directory)) + ;; TODO keyword states + (setq org-todo-keywords + '((sequence "TODO(t)" "NEXT(n)" "WAIT(w@/!)" "|" "DONE(d!)" "CANCELLED(c@)"))) + + ;; Log completion time when a task is marked DONE + (setq org-log-done 'time) + + ;; Refile targets: all agenda files up to 5 levels deep + (setq org-refile-targets '((org-agenda-files :maxlevel . 5)) + org-outline-path-complete-in-steps nil + org-refile-use-outline-path 'file) + ;; Helper: return absolute path to a file inside org-directory (defun ms/org-file (name) "Return absolute path to NAME inside `org-directory`." (expand-file-name name org-directory)) - (setq org-todo-keywords - '((sequence "TODO(t)" "NEXT(n)" "WAIT(w@/!)" "|" "DONE(d!)" "CANCELLED(c@)"))) - - (setq org-log-done 'time) - - (setq org-refile-targets '((org-agenda-files :maxlevel . 5)) - org-outline-path-complete-in-steps nil - org-refile-use-outline-path 'file) - - ;; Return path to project.org in current Projectile project, if it exists + ;; Helper: return path to project.org in current Projectile project (if it exists) (defun my/project-org-file () - "Return path to ./project.org in current Projectile project, if it exists." + "Return path to ./project.org in the current Projectile project, if it exists." (when-let ((root (projectile-project-root))) (let ((f (expand-file-name "project.org" root))) (when (file-exists-p f) f)))) + ;; Restore window layout after closing a capture buffer + (setq org-capture-restore-window-after-quit t) + ;; Update all dynamic blocks before export (add-hook 'org-export-before-processing-hook - (lambda (_backend) (org-update-all-dblocks))) - - ;; Restore window layout after capture quit - (setq org-capture-restore-window-after-quit t)) + (lambda (_backend) + (org-update-all-dblocks)))) ;;; ============================================================ @@ -152,44 +157,54 @@ (after! org (setq org-capture-templates - `(("i" "Inbox task" entry + `( + ;; Inbox task + ("i" "Inbox task" entry (file ,(ms/org-file "inbox.org")) "* TODO %?\n%U\n%a\n") + ;; Note under inbox ("n" "Note" entry (file+headline ,(ms/org-file "inbox.org") "Notes") "* %?\n%U\n%a\n") + ;; Project task ("p" "Project task" entry (file ,(ms/org-file "inbox.org")) "* TODO %? :project:\n%U\n%a\n") + ;; Subtask under the currently clocked item ("s" "Clocked subtask" entry (clock) "* TODO %?\n%U\n%a\n%i" :empty-lines 1) + ;; Journal entry in journal.org (datetree), with time tracking ("j" "Journal" entry (file+olp+datetree ,(ms/org-file "journal.org")) "\n* %<%I:%M %p> - Journal :journal:\n\n%?\n\n" :clock-in :clock-resume :empty-lines 1) + ;; Meeting in journal.org (datetree), with time tracking ("m" "Meeting" entry (file+olp+datetree ,(ms/org-file "journal.org")) "* %<%I:%M %p> - %^{Meeting title} :meetings:\nContext: %a\n\n%?\n\n" :clock-in :clock-resume :empty-lines 1) + ;; Email check entry in journal.org (datetree), with time tracking ("e" "Checking Email" entry (file+olp+datetree ,(ms/org-file "journal.org")) "* Checking Email :email:\n\n%?" :clock-in :clock-resume :empty-lines 1) + ;; Weight metric logged as a table row in metrics.org ("w" "Weight" table-line (file+headline ,(ms/org-file "metrics.org") "Weight") "| %U | %^{Weight} | %^{Notes} |" - :kill-buffer t)))) + :kill-buffer t) + ))) ;;; ============================================================ @@ -197,42 +212,52 @@ ;;; ============================================================ (after! org + ;; Files scanned by the agenda (setq org-agenda-files (list org-directory (expand-file-name "projects" org-directory) - (expand-file-name "roam" org-directory) - (expand-file-name "notes" org-directory)))) + (expand-file-name "roam" org-directory) + (expand-file-name "notes" org-directory))) + + ;; Elfeed feed list (kept here since it depends on org-directory) + (setq rmh-elfeed-org-files + (list (expand-file-name "elfeed.org" org-directory)))) ;;; ============================================================ -;;; ORG MODE — LATEX EXPORT +;;; ORG MODE — EXPORT / LATEX ;;; ============================================================ -;; Count data columns in an Org table line +;; Count the number of data columns in an Org table line (defun my/org-count-table-columns (line) "Count the number of data columns in Org table LINE." (length (cl-remove-if (lambda (s) (string-match-p "^-*$" (string-trim s))) (cdr (butlast (split-string line "|")))))) -;; Generate tabularx column spec: first column left-aligned, rest Y (auto-width) +;; Generate LaTeX column spec for tabularx: first column 'l', rest 'Y' (centered X) (defun my/org-table-attr-latex-spec (ncols) - "Return tabularx column spec for NCOLS columns: first l, rest Y." + "Generate tabularx column spec for NCOLS columns: first l, rest Y." (concat "l" (make-string (max 0 (1- ncols)) ?Y))) -;; Automatically insert #+ATTR_LATEX tabularx before tables on LaTeX export +;; Automatically insert #+ATTR_LATEX tabularx before each table on LaTeX export. +;; Usage: export with SPC m e l p — tables are wrapped automatically, no manual steps needed. +;; To customize the column spec, edit `my/org-table-attr-latex-spec'. (defun my/org-auto-tabularx (backend) - "Insert #+ATTR_LATEX tabularx before each table when exporting to LaTeX." + "Automatically add #+ATTR_LATEX tabularx before every table during LaTeX export." (when (org-export-derived-backend-p backend 'latex) (save-excursion (goto-char (point-min)) (while (not (eobp)) (cond + ;; Line starts with | — might be the beginning of a table ((looking-at "^|") (let ((prev-line (save-excursion (forward-line -1) (buffer-substring-no-properties (line-beginning-position) (line-end-position))))) + ;; Only on the FIRST row of the table (previous line does NOT start with |) (when (not (string-match-p "^|" prev-line)) + ;; Insert only if #+ATTR_LATEX is not already present (when (not (string-match-p "^#\\+ATTR_LATEX" prev-line)) (let* ((table-line (buffer-substring-no-properties (line-beginning-position) (line-end-position))) @@ -241,33 +266,40 @@ (attr (format "#+ATTR_LATEX: :environment tabularx :width \\textwidth :align %s\n" spec))) (when (> ncols 0) - (insert attr))))) + (insert attr)))))) (forward-line)) (t (forward-line))))))) +;; Register the hook — fires before each export (add-hook 'org-export-before-processing-hook #'my/org-auto-tabularx) ;; Optional: enable booktabs style (horizontal rules in tables) ;; (setq org-latex-tables-booktabs t) +;; Python for Org Babel +(setq python-shell-interpreter "python3") +(after! org + (setq org-babel-python-command "python3") + (require 'ob-python)) + ;;; ============================================================ ;;; ORG MODE — CUSTOM BEHAVIOR ;;; ============================================================ -;; Org agenda: position cursor at task name (after TODO keyword and priority) -;; Works with n/p (or j/k in evil mode) — skips TODO keyword and [#A] priority. +;; Org Agenda: move cursor to the task name after each navigation step. +;; After each n/p (or j/k in Evil mode), the cursor skips the TODO keyword +;; and optional priority marker [#A], landing directly on the task title. (defun my/org-agenda-goto-task-name (&rest _) - "Move cursor to the task name on the current org-agenda line. -Skips past the TODO keyword and optional priority indicator [#A]." + "Move cursor to the task name — past the TODO keyword and priority [#A]." (when (get-text-property (line-beginning-position) 'org-hd-marker) (beginning-of-line) (let* ((bol (point)) (eol (line-end-position)) (todo-end nil) (pos bol)) - ;; Find end of TODO keyword by face (org-todo or org-agenda-done) + ;; Find the end of the TODO keyword by its face (org-todo or org-agenda-done) (while (< pos eol) (let* ((face (get-text-property pos 'face)) (next (or (next-single-property-change pos 'face nil eol) eol))) @@ -278,7 +310,7 @@ Skips past the TODO keyword and optional priority indicator [#A]." (cl-intersection face '(org-todo org-agenda-done))))) (setq todo-end next)) (setq pos next))) - ;; Move past TODO keyword and optional priority [#X] + ;; Move past the TODO keyword and optional priority marker [#X] (when todo-end (goto-char todo-end) (skip-chars-forward " \t") @@ -290,217 +322,28 @@ Skips past the TODO keyword and optional priority indicator [#A]." ;;; ============================================================ -;;; GPTEL — AI INTEGRATION (OpenWebUI / OpenRouter) +;;; OTHER PACKAGES ;;; ============================================================ -(use-package! gptel - :config - ;; API key from environment variable (no secrets in config) - (defun my/openwebui-key () - (or (getenv "OPENWEBUI_API_KEY") - (user-error "Missing OPENWEBUI_API_KEY env var"))) +;; --- Dired --- +(after! dired + (put 'dired-find-alternate-file 'disabled nil) + (map! :map dired-mode-map + "RET" #'dired-find-alternate-file + "^" #'dired-up-directory)) - ;; Fetch available models from OpenWebUI /api/models - (defun my/openwebui-fetch-model-ids () - "Return list of model ids from OpenWebUI /api/models." - (require 'url) - (require 'json) - (let* ((url-request-method "GET") - (url-request-extra-headers - `(("Authorization" . ,(concat "Bearer " (funcall #'my/openwebui-key)))))) - (with-current-buffer (url-retrieve-synchronously - "https://ai.apps.sukany.cz/api/models" t t 15) - (goto-char (point-min)) - (re-search-forward "\n\n" nil 'move) - (let* ((json-object-type 'alist) - (json-array-type 'list) - (json-key-type 'symbol) - (obj (json-read)) - (data (alist-get 'data obj)) - (ids (delq nil (mapcar (lambda (it) (alist-get 'id it)) data)))) - (kill-buffer (current-buffer)) - ids)))) - - (defvar my/openwebui-models-cache nil) - - (defun my/openwebui-models () - "Return cached list of model ids; falls back to a minimal list on failure." - (or my/openwebui-models-cache - (setq my/openwebui-models-cache - (condition-case err - (my/openwebui-fetch-model-ids) - (error - (message "OpenWebUI models fetch failed: %s" err) - '("openai/gpt-4o-mini" "openai/gpt-4.1-mini")))))) - - (defun my/openwebui-refresh-models () - "Clear model cache and refetch from OpenWebUI." - (interactive) - (setq my/openwebui-models-cache nil) - (message "OpenWebUI models refreshed: %d" (length (my/openwebui-models)))) - - ;; Register OpenWebUI as an OpenAI-compatible backend - (setq gptel-backend - (gptel-make-openai "OpenWebUI" - :host "ai.apps.sukany.cz" - :protocol "https" - :key #'my/openwebui-key - :endpoint "/api/chat/completions" - :stream t - :curl-args '("--http1.1") - :models (my/openwebui-models))) - - ;; Default model: prefer gpt-5-mini, fall back to first available - (let* ((models (my/openwebui-models)) - (preferred "openai/gpt-5-mini")) - (setq gptel-model (if (member preferred models) preferred (car models)))) - - ;; Presets for quick task-specific model switching - (gptel-make-preset 'fast - :description "Default (fast/cheap) — everyday work" - :backend "OpenWebUI" - :model "openai/gpt-4o-mini" - :system "Reply in Czech. Be specific and step-by-step. No fluff." - :temperature 0.2) - - (gptel-make-preset 'coding - :description "Code / refactor / review" - :backend "OpenWebUI" - :model "openai/gpt-4.1-mini" - :system "You are a strict code reviewer. Propose concrete changes and flag risks." - :temperature 0.1) - - (gptel-make-preset 'deep - :description "Complex analysis / architecture" - :backend "OpenWebUI" - :model "openai/gpt-4.1" - :system "Work systematically. Provide alternatives, tradeoffs, and a recommendation." - :temperature 0.2)) - -;; CLI helper: call gptel from emacs --batch -(defun my/gptel-cli (prompt &optional model system) - "Send PROMPT via gptel and print response to stdout." - (require 'gptel) - (let* ((done nil) - (result nil) - (gptel-model (or model gptel-model)) - (gptel--system-message (or system gptel--system-message))) - (gptel-request prompt - :callback (lambda (response _info) - (setq result response) - (setq done t))) - (while (not done) (accept-process-output nil 0.05)) - (princ result))) - -;; GPTel keybindings under SPC o g -(after! gptel - (map! :leader - (:prefix ("o g" . "GPTel") - :desc "Send (region or buffer)" "s" #'gptel-send - :desc "Menu (model/scope/preset)" "m" #'gptel-menu - :desc "Chat buffer" "c" #'gptel - :desc "Abort request" "x" #'gptel-abort - :desc "Refresh OpenWebUI models" "R" #'my/openwebui-refresh-models))) - - -;;; ============================================================ -;;; COMPLETION — CORFU + CAPE -;;; ============================================================ - -(after! corfu - (setq corfu-auto t - corfu-auto-delay 0.15 - corfu-auto-prefix 2 - corfu-cycle t - corfu-preselect 'prompt - corfu-quit-no-match 'separator - corfu-preview-current nil) - (global-corfu-mode)) - -;; Cape: additional completion-at-point sources -(use-package! cape - :after corfu - :config - (defun martin/cape-capf-setup () - "Set up cape completion sources for prog-mode and text-mode." - (add-to-list 'completion-at-point-functions #'cape-dabbrev 0) ; words from buffers - (add-to-list 'completion-at-point-functions #'cape-file 0) ; file paths - (add-to-list 'completion-at-point-functions #'cape-keyword 0) ; language keywords - (add-to-list 'completion-at-point-functions #'cape-elisp-symbol 0)) - (add-hook 'prog-mode-hook #'martin/cape-capf-setup) - (add-hook 'text-mode-hook #'martin/cape-capf-setup)) - -;; Corfu popup in terminal (iTerm2 / SSH / tmux) -(use-package! corfu-terminal - :when (not (display-graphic-p)) - :after corfu - :config - (corfu-terminal-mode +1)) - - -;;; ============================================================ -;;; EMAIL — MU4E -;;; ============================================================ - -(add-to-list 'load-path - (expand-file-name "/opt/homebrew/opt/mu/share/emacs/site-lisp/mu/mu4e")) - -(after! mu4e - (setq mu4e-maildir "~/.mail" - mu4e-get-mail-command "mbsync personal" - mu4e-update-interval 300 - mu4e-change-filenames-when-moving t - mu4e-view-show-images t - mu4e-sent-folder "/personal/Sent" - mu4e-drafts-folder "/personal/Drafts" - mu4e-trash-folder "/personal/Trash" - mu4e-refile-folder "/personal/Archive" - mu4e-headers-show-threads t - mu4e-headers-include-related t - mu4e-use-fancy-chars t - mu4e-headers-mark-for-thread t - mu4e-headers-fields '((:human-date . 12) - (:flags . 6) - (:from . 22) - (:subject)))) - -(after! mu4e - (setq sendmail-program "msmtp" - message-send-mail-function #'message-send-mail-with-sendmail - mail-specify-envelope-from t - message-sendmail-envelope-from 'header)) - - -;;; ============================================================ -;;; RSS — ELFEED -;;; ============================================================ - -(map! :leader :desc "Elfeed" "o r" #'elfeed) - -(after! org - (setq rmh-elfeed-org-files - (list (expand-file-name "elfeed.org" org-directory)))) - -(after! elfeed - (require 'elfeed-org) - (elfeed-org)) - - -;;; ============================================================ -;;; PLANTUML -;;; ============================================================ - -(add-to-list 'auto-mode-alist '("\\.puml\\'" . plantuml-mode)) +;; --- PlantUML --- +(add-to-list 'auto-mode-alist '("\\.puml\\'" . plantuml-mode)) (add-to-list 'auto-mode-alist '("\\.plantuml\\'" . plantuml-mode)) (after! plantuml-mode (setq plantuml-default-exec-mode 'server - plantuml-server-url "https://www.plantuml.com/plantuml" - plantuml-output-type "svg" - plantuml-verbose t)) + plantuml-server-url "https://www.plantuml.com/plantuml" + plantuml-output-type "svg" + plantuml-verbose t)) (defun my/plantuml-encode-hex (text) - "Encode TEXT using PlantUML HEX encoding (~h + hex(UTF-8 bytes))." + "Encode TEXT using PlantUML HEX encoding: ~h + hex(UTF-8 bytes)." (let* ((utf8 (encode-coding-string text 'utf-8 t))) (concat "~h" (apply #'concat @@ -508,7 +351,7 @@ Skips past the TODO keyword and optional priority indicator [#A]." (append utf8 nil)))))) (defun my/plantuml-fix-png-header (file) - "Strip any bytes before the PNG signature in FILE." + "Remove any bytes before the PNG signature in FILE." (let ((sig (unibyte-string #x89 ?P ?N ?G ?\r ?\n #x1a ?\n))) (with-temp-buffer (set-buffer-multibyte nil) @@ -522,14 +365,14 @@ Skips past the TODO keyword and optional priority indicator [#A]." (write-region (point-min) (point-max) file nil 'silent))))))) (defun my/plantuml-render-server (type) - "Render current .puml buffer via PlantUML server to TYPE (png or svg)." + "Render current .puml buffer via PlantUML server to PNG or SVG." (interactive (list (completing-read "Type: " '("png" "svg") nil t "png"))) - (unless buffer-file-name (user-error "Open .puml as a file first")) - (let* ((text (buffer-substring-no-properties (point-min) (point-max))) + (unless buffer-file-name (user-error "Open a .puml file first")) + (let* ((text (buffer-substring-no-properties (point-min) (point-max))) (encoded (my/plantuml-encode-hex text)) - (server (string-remove-suffix "/" plantuml-server-url)) - (url (format "%s/%s/%s" server type encoded)) - (out (concat (file-name-sans-extension buffer-file-name) "." type))) + (server (string-remove-suffix "/" plantuml-server-url)) + (url (format "%s/%s/%s" server type encoded)) + (out (concat (file-name-sans-extension buffer-file-name) "." type))) (url-copy-file url out t) (when (string-equal type "png") (my/plantuml-fix-png-header out)) @@ -542,72 +385,141 @@ Skips past the TODO keyword and optional priority indicator [#A]." (define-key plantuml-mode-map (kbd "C-c C-s") (lambda () (interactive) (my/plantuml-render-server "svg")))) +;; --- GPTel + OpenWebUI (OpenRouter backend) --- -;;; ============================================================ -;;; TRAMP & REMOTE -;;; ============================================================ +(use-package! gptel + :config + ;; API key from environment variable — no keys in config + (defun my/openwebui-key () + (or (getenv "OPENWEBUI_API_KEY") + (user-error "Missing OPENWEBUI_API_KEY env var"))) -(after! tramp - (setq projectile-git-command "git ls-files -zco --exclude-standard" - projectile-indexing-method 'alien)) + ;; Fetch model list from OpenWebUI /api/models + (defun my/openwebui-fetch-model-ids () + "Return list of model ids from OpenWebUI /api/models (field data[].id)." + (require 'url) + (require 'json) + (let* ((url-request-method "GET") + (url-request-extra-headers + `(("Authorization" . ,(concat "Bearer " (funcall #'my/openwebui-key)))))) + (with-current-buffer (url-retrieve-synchronously + "https://ai.apps.sukany.cz/api/models" t t 15) + (goto-char (point-min)) + (re-search-forward "\n\n" nil 'move) ;; skip HTTP headers + (let* ((json-object-type 'alist) + (json-array-type 'list) + (json-key-type 'symbol) + (obj (json-read)) + (data (alist-get 'data obj)) + (ids (delq nil (mapcar (lambda (it) (alist-get 'id it)) data)))) + (kill-buffer (current-buffer)) + ids)))) -;; Disable VC and Projectile over TRAMP — main cause of hangs -(setq vc-ignore-dir-regexp - (format "%s\\|%s" vc-ignore-dir-regexp tramp-file-name-regexp)) + (defvar my/openwebui-models-cache nil) -(defadvice projectile-project-root (around ignore-remote first activate) - (unless (file-remote-p default-directory) ad-do-it)) + (defun my/openwebui-models () + "Cached list of model ids. Falls back to a minimal list if the API fails." + (or my/openwebui-models-cache + (setq my/openwebui-models-cache + (condition-case err + (my/openwebui-fetch-model-ids) + (error + (message "OpenWebUI models fetch failed: %s" err) + ;; Fallback model list + '("openai/gpt-4o-mini" "openai/gpt-4.1-mini")))))) -(setq remote-file-name-inhibit-cache nil - tramp-verbose 1) + (defun my/openwebui-refresh-models () + "Clear cache and refetch the OpenWebUI model list." + (interactive) + (setq my/openwebui-models-cache nil) + (message "OpenWebUI models refreshed: %d" (length (my/openwebui-models)))) + ;; Backend: OpenWebUI (OpenAI-compatible chat/completions endpoint) + (setq gptel-backend + (gptel-make-openai "OpenWebUI" + :host "ai.apps.sukany.cz" + :protocol "https" + :key #'my/openwebui-key + :endpoint "/api/chat/completions" + :stream t + :curl-args '("--http1.1") ;; more stable via reverse proxy/ingress + :models (my/openwebui-models))) -;;; ============================================================ -;;; DIRED -;;; ============================================================ + ;; Default model: lightweight and practical + (let* ((models (my/openwebui-models)) + (preferred "openai/gpt-5-mini")) + (setq gptel-model (if (member preferred models) + preferred + (car models)))) -(after! dired - (put 'dired-find-alternate-file 'disabled nil) - (map! :map dired-mode-map - "RET" #'dired-find-alternate-file - "^" #'dired-up-directory)) + ;; Presets for quick task-based model switching + (gptel-make-preset 'fast + :description "Default (fast/cheap) — everyday use" + :backend "OpenWebUI" + :model "openai/gpt-4o-mini" + :system "Reply in Czech. Be specific and step-by-step. No padding." + :temperature 0.2) + (gptel-make-preset 'coding + :description "Code / refactor / review (stronger model when needed)" + :backend "OpenWebUI" + :model "openai/gpt-4.1-mini" + :system "You are a strict code reviewer. Suggest concrete changes and flag risks." + :temperature 0.1) -;;; ============================================================ -;;; PROJECTILE -;;; ============================================================ + (gptel-make-preset 'deep + :description "Heavy analysis / architecture" + :backend "OpenWebUI" + :model "openai/gpt-4.1" + :system "Proceed systematically. Give variants, tradeoffs and recommendations." + :temperature 0.2) -(after! projectile - (setq projectile-enable-caching nil - projectile-indexing-method 'alien) - (when (executable-find "fd") - (setq projectile-generic-command - "fd . -0 --type f --hidden --follow --exclude .git --color=never"))) + ;; Debug logging (uncomment if needed): + ;; (setq gptel-log-level 'debug) + ) +;; CLI helper: invoke gptel from the command line via `emacs --batch' +(defun my/gptel-cli (prompt &optional model system) + "Send PROMPT via gptel and print the response to stdout." + (require 'gptel) + (let* ((done nil) + (result nil) + (gptel-model (or model gptel-model)) + (gptel--system-message (or system gptel--system-message))) + (gptel-request prompt + :callback (lambda (response _info) + (setq result response) + (setq done t))) + (while (not done) + (accept-process-output nil 0.05)) + (princ result))) -;;; ============================================================ -;;; PYTHON -;;; ============================================================ +;; GPTel keybindings: SPC o g ... +(after! gptel + (map! :leader + (:prefix ("o g" . "GPTel") + :desc "GPTel send (region or buffer)" "s" #'gptel-send + :desc "GPTel menu (model/scope/preset)" "m" #'gptel-menu + :desc "GPTel chat buffer" "c" #'gptel + :desc "GPTel abort request" "x" #'gptel-abort + :desc "Refresh OpenWebUI models" "R" #'my/openwebui-refresh-models))) -(setq python-shell-interpreter "python3") -(after! org - (setq org-babel-python-command "python3") - (require 'ob-python)) +;; --- Emacspeak: robust ON/OFF for Doom --- +;; Default: OFF (will not auto-restart on its own) +;; SPC t s → Speech ON +;; SPC t S → Speech OFF - -;;; ============================================================ -;;; ACCESSIBILITY — EMACSPEAK -;;; ============================================================ -;;; Default: OFF. Toggle with SPC t s (on) / SPC t S (off). - -(defconst my/emacspeak-dir (expand-file-name "~/.emacspeak")) +(defconst my/emacspeak-dir (expand-file-name "~/.emacspeak")) (defconst my/emacspeak-wrapper (expand-file-name "~/.local/bin/emacspeak-mac")) +;; Path to the speech server binary (setq dtk-program my/emacspeak-wrapper) -(defvar my/emacspeak-loaded nil) +;; State flags +(defvar my/emacspeak-loaded nil) (defvar my/emacspeak-enabled nil) -;; Hard inhibit: when non-nil, Emacspeak server will not start/restart + +;; Hard inhibit: when non-nil, Emacspeak is NOT allowed to start/restart the server (defvar my/emacspeak-inhibit-server t) (defun my/emacspeak--ensure-loaded () @@ -616,6 +528,7 @@ Skips past the TODO keyword and optional priority indicator [#A]." (setq my/emacspeak-loaded t) (setq emacspeak-directory my/emacspeak-dir) (load-file (expand-file-name "lisp/emacspeak-setup.el" emacspeak-directory)) + ;; Install inhibition advices to prevent auto-restart when speech is OFF (with-eval-after-load 'dtk-speak (dolist (fn '(dtk-initialize dtk-start-process dtk-speak)) (when (fboundp fn) @@ -623,25 +536,28 @@ Skips past the TODO keyword and optional priority indicator [#A]." fn :around (lambda (orig &rest args) (if my/emacspeak-inhibit-server - nil ; OFF: do nothing, don't restart + nil ;; OFF mode: do nothing, do not restart the speaker (apply orig args))))))))) (defun my/emacspeak-on () - "Enable speech and allow TTS server to start." + "Enable speech and allow the server to start." (interactive) (setq my/emacspeak-inhibit-server nil) (my/emacspeak--ensure-loaded) (setq my/emacspeak-enabled t) - (when (fboundp 'dtk-restart) (ignore-errors (dtk-restart))) - (when (fboundp 'dtk-speak) (ignore-errors (dtk-speak "Emacspeak on."))) + (when (fboundp 'dtk-restart) + (ignore-errors (dtk-restart))) + (when (fboundp 'dtk-speak) + (ignore-errors (dtk-speak "Emacspeak on."))) (message "Emacspeak ON")) (defun my/emacspeak-off () - "Disable speech and prevent auto-restart." + "Disable speech robustly: stop output and prevent auto-restart." (interactive) - (setq my/emacspeak-enabled nil - my/emacspeak-inhibit-server t) - (when (fboundp 'dtk-stop) (ignore-errors (dtk-stop))) + (setq my/emacspeak-enabled nil) + (setq my/emacspeak-inhibit-server t) + (when (fboundp 'dtk-stop) + (ignore-errors (dtk-stop))) (when (boundp 'dtk-speaker-process) (let ((p dtk-speaker-process)) (when (processp p) @@ -650,34 +566,133 @@ Skips past the TODO keyword and optional priority indicator [#A]." (setq dtk-speaker-process nil)) (message "Emacspeak OFF (server restart inhibited)")) +;; Doom leader keys for Emacspeak toggle (map! :leader (:prefix ("t" . "toggle") :desc "Speech ON" "s" #'my/emacspeak-on :desc "Speech OFF" "S" #'my/emacspeak-off)) +;; Emacspeak defaults: punctuation mode and typing feedback (with-eval-after-load 'dtk-speak (setq dtk-speech-rate-base 300) (setq-default dtk-punctuation-mode 'none)) (with-eval-after-load 'emacspeak - (setq-default emacspeak-character-echo nil - emacspeak-word-echo t - emacspeak-line-echo t)) - -;; Apply global default speech rate after TTS init/restart -(setq dtk-default-speech-rate 400) -(with-eval-after-load 'dtk-speak - (defun my/dtk-apply-global-default-rate (&rest _) - "Apply global default speech rate after TTS init/restart." - (when (fboundp 'dtk-set-rate) - (ignore-errors (dtk-set-rate dtk-default-speech-rate t)))) - (advice-add 'dtk-initialize :after #'my/dtk-apply-global-default-rate)) - - -;;; ============================================================ -;;; KEYBINDINGS -;;; ============================================================ + ;; Echo words and lines while typing, not individual characters + (setq-default emacspeak-character-echo nil) + (setq-default emacspeak-word-echo t) + (setq-default emacspeak-line-echo t)) +;; Describe buffer-local bindings shortcut (map! :leader (:prefix ("h" . "help") :desc "Describe bindings (buffer-local)" "B" #'describe-bindings)) + +;; Global default speech rate; applied after TTS is initialized/restarted +(setq dtk-default-speech-rate 400) + +(with-eval-after-load 'dtk-speak + (defun my/dtk-apply-global-default-rate (&rest _) + "Apply the global default speech rate after TTS init or restart." + (when (fboundp 'dtk-set-rate) + ;; Non-nil prefix arg sets the GLOBAL default (per Emacspeak manual) + (ignore-errors (dtk-set-rate dtk-default-speech-rate t)))) + (advice-add 'dtk-initialize :after #'my/dtk-apply-global-default-rate)) + +;; --- Elfeed (RSS reader) --- +(map! :leader + :desc "Elfeed" "o r" #'elfeed) + +(after! elfeed + (require 'elfeed-org) + (elfeed-org)) + +;; --- Corfu (modern completion UI) --- +(after! corfu + (setq corfu-auto t + corfu-auto-delay 0.15 + corfu-auto-prefix 2 + corfu-cycle t + corfu-preselect 'prompt + corfu-quit-no-match 'separator + corfu-preview-current nil) + (global-corfu-mode)) + +;; --- Cape (completion-at-point sources) --- +(use-package! cape + :after corfu + :config + (defun martin/cape-capf-setup () + "Register completion sources usable almost everywhere (without LSP)." + (add-to-list 'completion-at-point-functions #'cape-dabbrev 0) ;; words from open buffers + (add-to-list 'completion-at-point-functions #'cape-file 0) ;; file paths + (add-to-list 'completion-at-point-functions #'cape-keyword 0) ;; keywords (mainly prog-mode) + (add-to-list 'completion-at-point-functions #'cape-elisp-symbol 0)) ;; Elisp symbols + (add-hook 'prog-mode-hook #'martin/cape-capf-setup) + (add-hook 'text-mode-hook #'martin/cape-capf-setup)) + +;; Corfu popup in terminal (iTerm2 / ssh / tmux) +(use-package! corfu-terminal + :when (not (display-graphic-p)) + :after corfu + :config + (corfu-terminal-mode +1)) + +;; --- TRAMP --- +(after! tramp + (setq projectile-git-command "git ls-files -zco --exclude-standard" + projectile-indexing-method 'alien)) + +;; Disable VC and Projectile over TRAMP — primary cause of hangs +(setq vc-ignore-dir-regexp + (format "%s\\|%s" vc-ignore-dir-regexp tramp-file-name-regexp)) + +;; Disable Projectile entirely for remote paths +(defadvice projectile-project-root (around ignore-remote first activate) + (unless (file-remote-p default-directory) ad-do-it)) + +;; TRAMP cache: avoid repeated expensive remote queries +(setq remote-file-name-inhibit-cache nil + tramp-verbose 1) + +;; --- Projectile --- +(after! projectile + (setq projectile-enable-caching nil + projectile-indexing-method 'alien) + (when (executable-find "fd") + (setq projectile-generic-command + "fd . -0 --type f --hidden --follow --exclude .git --color=never"))) + +;; --- mu4e --- +(add-to-list 'load-path (expand-file-name "/opt/homebrew/opt/mu/share/emacs/site-lisp/mu/mu4e")) + +(after! mu4e + (setq mu4e-maildir (expand-file-name "~/.mail") + mu4e-get-mail-command "mbsync personal" + mu4e-update-interval 300 + mu4e-change-filenames-when-moving t + mu4e-view-show-images t + mu4e-sent-folder "/personal/Sent" + mu4e-drafts-folder "/personal/Drafts" + mu4e-trash-folder "/personal/Trash" + mu4e-refile-folder "/personal/Archive")) + +(after! mu4e + (setq sendmail-program "msmtp" + message-send-mail-function #'message-send-mail-with-sendmail + mail-specify-envelope-from t + message-sendmail-envelope-from 'header)) + +(after! mu4e + ;; Thread view + (setq mu4e-headers-show-threads t + mu4e-headers-include-related t) + ;; Header column layout + (setq mu4e-headers-fields + '((:human-date . 12) + (:flags . 6) + (:from . 22) + (:subject))) + ;; Fancy thread tree characters and mark-for-thread support + (setq mu4e-use-fancy-chars t) + (setq mu4e-headers-mark-for-thread t))