diff --git a/config.el b/config.el index 906c757..d8f39fa 100644 --- a/config.el +++ b/config.el @@ -1,75 +1,54 @@ ;;; $DOOMDIR/config.el -*- lexical-binding: t; -*- ;;; ============================================================ -;;; USER SETTINGS +;;; USER IDENTITY ;;; ============================================================ -;; User identity -(setq user-full-name "Martin Sukany" +(setq user-full-name "Martin Sukany" user-mail-address "martin@sukany.cz") -;; Theme and font + +;;; ============================================================ +;;; THEME & 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 +;;; 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 ;;; ============================================================ -;; 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) -;; 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")))) +;; PATH: add MacTeX binaries +(setenv "PATH" (concat "/Library/TeX/texbin:" (getenv "PATH"))) +(add-to-list 'exec-path "/Library/TeX/texbin") ;; macOS clipboard integration via pbcopy/pbpaste (works in terminal Emacs too) (defun my/pbcopy (text &optional _push) @@ -82,28 +61,50 @@ (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")))) -;; Emacs → system clipboard -(setq interprogram-cut-function #'my/pbcopy) -;; System clipboard → Emacs -(setq interprogram-paste-function #'my/pbpaste) +(setq interprogram-cut-function #'my/pbcopy + interprogram-paste-function #'my/pbpaste) -;; Let Evil use the clipboard (y/d/c operations go to system clipboard) +;; Let Evil use the system clipboard (y/d/c go to system) (after! evil (setq evil-want-clipboard t)) -;; PATH fix for MacTeX -(setenv "PATH" (concat "/Library/TeX/texbin:" (getenv "PATH"))) -(add-to-list 'exec-path "/Library/TeX/texbin") +;; 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")))) -;; Disable TLS verification — WARNING: insecure, use only in trusted environments -(setq gnutls-verify-error nil) -(setq gnutls-algorithm-priority "NORMAL:-VERS-TLS1.3") -;; Org clock idle time -(setq org-idle-time 1.0) +;;; ============================================================ +;;; 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") ;;; ============================================================ @@ -111,44 +112,38 @@ ;;; ============================================================ (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)) - ;; Helper: return path to project.org in current Projectile project (if it exists) + (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 (defun my/project-org-file () - "Return path to ./project.org in the current Projectile project, if it exists." + "Return path to ./project.org in 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)))) + (lambda (_backend) (org-update-all-dblocks))) + + ;; Restore window layout after capture quit + (setq org-capture-restore-window-after-quit t)) ;;; ============================================================ @@ -157,54 +152,44 @@ (after! org (setq org-capture-templates - `( - ;; Inbox task - ("i" "Inbox task" entry + `(("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)))) ;;; ============================================================ @@ -212,52 +197,42 @@ ;;; ============================================================ (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))) - - ;; Elfeed feed list (kept here since it depends on org-directory) - (setq rmh-elfeed-org-files - (list (expand-file-name "elfeed.org" org-directory)))) + (expand-file-name "roam" org-directory) + (expand-file-name "notes" org-directory)))) ;;; ============================================================ -;;; ORG MODE — EXPORT / LATEX +;;; ORG MODE — LATEX EXPORT ;;; ============================================================ -;; Count the number of data columns in an Org table line +;; Count 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 LaTeX column spec for tabularx: first column 'l', rest 'Y' (centered X) +;; Generate tabularx column spec: first column left-aligned, rest Y (auto-width) (defun my/org-table-attr-latex-spec (ncols) - "Generate tabularx column spec for NCOLS columns: first l, rest Y." + "Return 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 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'. +;; Automatically insert #+ATTR_LATEX tabularx before tables on LaTeX export (defun my/org-auto-tabularx (backend) - "Automatically add #+ATTR_LATEX tabularx before every table during LaTeX export." + "Insert #+ATTR_LATEX tabularx before each table when exporting to LaTeX." (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))) @@ -266,40 +241,33 @@ (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: 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. +;; 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. (defun my/org-agenda-goto-task-name (&rest _) - "Move cursor to the task name — past the TODO keyword and priority [#A]." + "Move cursor to the task name on the current org-agenda line. +Skips past the TODO keyword and optional priority indicator [#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 the end of the TODO keyword by its face (org-todo or org-agenda-done) + ;; Find end of TODO keyword by 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))) @@ -310,7 +278,7 @@ (cl-intersection face '(org-todo org-agenda-done))))) (setq todo-end next)) (setq pos next))) - ;; Move past the TODO keyword and optional priority marker [#X] + ;; Move past TODO keyword and optional priority [#X] (when todo-end (goto-char todo-end) (skip-chars-forward " \t") @@ -322,28 +290,217 @@ ;;; ============================================================ -;;; OTHER PACKAGES +;;; GPTEL — AI INTEGRATION (OpenWebUI / OpenRouter) ;;; ============================================================ -;; --- Dired --- -(after! dired - (put 'dired-find-alternate-file 'disabled nil) - (map! :map dired-mode-map - "RET" #'dired-find-alternate-file - "^" #'dired-up-directory)) +(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"))) -;; --- PlantUML --- -(add-to-list 'auto-mode-alist '("\\.puml\\'" . plantuml-mode)) + ;; 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)) (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 @@ -351,7 +508,7 @@ (append utf8 nil)))))) (defun my/plantuml-fix-png-header (file) - "Remove any bytes before the PNG signature in FILE." + "Strip 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) @@ -365,14 +522,14 @@ (write-region (point-min) (point-max) file nil 'silent))))))) (defun my/plantuml-render-server (type) - "Render current .puml buffer via PlantUML server to PNG or SVG." + "Render current .puml buffer via PlantUML server to TYPE (png or svg)." (interactive (list (completing-read "Type: " '("png" "svg") nil t "png"))) - (unless buffer-file-name (user-error "Open a .puml file first")) - (let* ((text (buffer-substring-no-properties (point-min) (point-max))) + (unless buffer-file-name (user-error "Open .puml as a 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)) @@ -385,141 +542,72 @@ (define-key plantuml-mode-map (kbd "C-c C-s") (lambda () (interactive) (my/plantuml-render-server "svg")))) -;; --- GPTel + OpenWebUI (OpenRouter backend) --- -(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"))) +;;; ============================================================ +;;; TRAMP & REMOTE +;;; ============================================================ - ;; 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)))) +(after! tramp + (setq projectile-git-command "git ls-files -zco --exclude-standard" + projectile-indexing-method 'alien)) - (defvar my/openwebui-models-cache nil) +;; 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)) - (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")))))) +(defadvice projectile-project-root (around ignore-remote first activate) + (unless (file-remote-p default-directory) ad-do-it)) - (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)))) +(setq remote-file-name-inhibit-cache nil + tramp-verbose 1) - ;; 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))) - ;; 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)))) +;;; ============================================================ +;;; DIRED +;;; ============================================================ - ;; 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) +(after! dired + (put 'dired-find-alternate-file 'disabled nil) + (map! :map dired-mode-map + "RET" #'dired-find-alternate-file + "^" #'dired-up-directory)) - (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) - (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) +;;; ============================================================ +;;; PROJECTILE +;;; ============================================================ - ;; Debug logging (uncomment if needed): - ;; (setq gptel-log-level 'debug) - ) +(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"))) -;; 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))) -;; 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))) +;;; ============================================================ +;;; 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 +(setq python-shell-interpreter "python3") +(after! org + (setq org-babel-python-command "python3") + (require 'ob-python)) -(defconst my/emacspeak-dir (expand-file-name "~/.emacspeak")) + +;;; ============================================================ +;;; 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-wrapper (expand-file-name "~/.local/bin/emacspeak-mac")) -;; Path to the speech server binary (setq dtk-program my/emacspeak-wrapper) -;; State flags -(defvar my/emacspeak-loaded nil) +(defvar my/emacspeak-loaded nil) (defvar my/emacspeak-enabled nil) - -;; Hard inhibit: when non-nil, Emacspeak is NOT allowed to start/restart the server +;; Hard inhibit: when non-nil, Emacspeak server will not start/restart (defvar my/emacspeak-inhibit-server t) (defun my/emacspeak--ensure-loaded () @@ -528,7 +616,6 @@ (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) @@ -536,28 +623,25 @@ fn :around (lambda (orig &rest args) (if my/emacspeak-inhibit-server - nil ;; OFF mode: do nothing, do not restart the speaker + nil ; OFF: do nothing, don't restart (apply orig args))))))))) (defun my/emacspeak-on () - "Enable speech and allow the server to start." + "Enable speech and allow TTS 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 robustly: stop output and prevent auto-restart." + "Disable speech and prevent auto-restart." (interactive) - (setq my/emacspeak-enabled nil) - (setq my/emacspeak-inhibit-server t) - (when (fboundp 'dtk-stop) - (ignore-errors (dtk-stop))) + (setq my/emacspeak-enabled nil + 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) @@ -566,133 +650,34 @@ (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 - ;; 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)) + (setq-default emacspeak-character-echo nil + emacspeak-word-echo t + 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 +;; 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 the global default speech rate after TTS init or restart." + "Apply global default speech rate after TTS init/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) --- + +;;; ============================================================ +;;; KEYBINDINGS +;;; ============================================================ + (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)) + (:prefix ("h" . "help") + :desc "Describe bindings (buffer-local)" "B" #'describe-bindings))