From c74c3526fe719ff8127d17f44b67a33772adc334 Mon Sep 17 00:00:00 2001 From: cashmere Date: Sat, 11 Jul 2026 08:50:14 +0200 Subject: 2026-07-11T08:50:16+02:00 --- .gitignore | 11 + config.el | 1323 ++++++++++++++++++++-------------------------- config.org | 401 ++++++++------ lisp/agent-shell-diff.el | 268 ++++++++++ lisp/agent-shell-kimi.el | 612 +++++++++++++++++++++ 5 files changed, 1700 insertions(+), 915 deletions(-) create mode 100644 lisp/agent-shell-diff.el create mode 100644 lisp/agent-shell-kimi.el diff --git a/.gitignore b/.gitignore index 766f23c..2adefd4 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,17 @@ /auto-save-list /bbdb /bookmarks +/bookmarks.eld +/cursory-latest-state.eld +/nov-places +/org-workbench-db.el +/places.eld +/projects.eld +/recentf.eld +/config.org.tangled +/denote-solo-last-directory +/image.jpg +/tldr/ /eln-cache /elpa /emms diff --git a/config.el b/config.el index 08ac520..32f561e 100644 --- a/config.el +++ b/config.el @@ -26,18 +26,19 @@ (defvar my/local-packages nil "Alist of (PACKAGE . DIRECTORY) loaded from a local checkout. Set per-host in the gitignored `local.el'.") - - (load (locate-user-emacs-file "local.el") 'noerror 'nomessage) - - (defun my/package-recipe (package remote) - "Elpaca `:ensure' recipe for PACKAGE. -Return nil when PACKAGE is registered in `my/local-packages', so -use-package loads it from `load-path'; otherwise return REMOTE." - (if (assq package my/local-packages) nil remote))) + (load (locate-user-emacs-file "local.el") 'noerror 'nomessage)) (dolist (entry my/local-packages) (add-to-list 'load-path (expand-file-name (cdr entry)))) +(add-to-list 'load-path (expand-file-name "lisp" user-emacs-directory)) + +(defun my/elpaca-skip-local (orig name _keyword args) + (if (assq name my/local-packages) + nil + (funcall orig name _keyword args))) +(advice-add 'use-package-normalize/:ensure :around #'my/elpaca-skip-local) + (use-package emacs :ensure nil :custom @@ -66,7 +67,6 @@ use-package loads it from `load-path'; otherwise return REMOTE." (use-dialog-box nil) ;; Disable dialog boxes in favor of minibuffer prompts. (use-short-answers t) ;; Use short answers in prompts for quicker responses (y instead of yes) (warning-minimum-level :emergency) ;; Set the minimum level of warnings to display. - :hook ;; Add hooks to enable specific features in certain modes. (prog-mode . display-line-numbers-mode) (org-mode . display-line-numbers-mode) @@ -114,10 +114,11 @@ use-package loads it from `load-path'; otherwise return REMOTE." (scroll-bar-mode -1) (add-to-list 'default-frame-alist '(vertical-scroll-bars . nil)) (add-to-list 'default-frame-alist '(horizontal-scroll-bars . nil)) - ;; (set-frame-parameter (selected-frame) 'alpha-background 80) - ;; (add-to-list 'default-frame-alist '(alpha-background . 80)) + (set-frame-parameter (selected-frame) 'alpha-background 80) + (add-to-list 'default-frame-alist '(alpha-background . 80)) (global-hl-line-mode 1) ;; Highlight the current line (add-hook 'org-mode-hook (lambda () (setq-local global-hl-line-mode nil))) ;; hl-line repaints wipe kitty-graphics scaled headings + (add-hook 'markdown-mode-hook (lambda () (setq-local global-hl-line-mode nil))) ;; same conflict for kitty-graphics markdown headings (global-auto-revert-mode 1) ;; Enable global auto-revert mode to keep buffers up to date with their corresponding files. (setq-default indent-tabs-mode nil) (when (daemonp) @@ -160,11 +161,15 @@ use-package loads it from `load-path'; otherwise return REMOTE." (derived-mode-p 'compilation-mode))) (defun my/compile-or-recompile () - "Recompile with the last command if a compilation ran before, else prompt." + "Recompile with the last command if a ghostel compilation ran before, else prompt. +Selects the compilation window so the cursor lands in it." (interactive) - (if (get-buffer "*compilation*") - (recompile) - (call-interactively #'compile))) + (if (get-buffer ghostel-compile-buffer-name) + (ghostel-recompile) + (call-interactively #'ghostel-compile)) + (let ((buffer (get-buffer ghostel-compile-buffer-name))) + (when buffer + (pop-to-buffer buffer)))) (defun my/focus-async-shell-window (window) "Focus the async-shell window and make q bury it without killing the process." @@ -252,8 +257,6 @@ use-package loads it from `load-path'; otherwise return REMOTE." (use-package async :ensure t) -;; Always-available IRC entry points so `M-x run-irc` works from any daemon -;; (hub, work, standalone). Calling `erc-tls` autoloads ERC on demand. (defun my/soju-password () "Read the Soju bouncer password from the sops-nix secret." (string-trim @@ -262,212 +265,84 @@ use-package loads it from `load-path'; otherwise return REMOTE." "/home/cashmere/.config/sops-nix/secrets/senpai_password") (buffer-string)))) -(defun run-irc () - "Connect to all IRC networks via Soju bouncer." - (interactive) - (let ((pw (my/soju-password))) - (erc-tls :server "bouncer.cashmere.rs" - :port 6699 - :nick "cashmere1337" - :user "cashmere/irc.libera.chat@emacs" - :password pw) - (erc-tls :server "bouncer.cashmere.rs" - :port 6699 - :nick "cashmere" - :user "cashmere/ergo@emacs" - :password pw) - (erc-tls :server "bouncer.cashmere.rs" - :port 6699 - :nick "cashmere" - :user "cashmere/mansion@emacs" - :password pw))) - -(use-package erc - :ensure nil - :defer t +(use-package clatter + :ensure (:host github :repo "parenworks/clatter.el" :files ("*.el")) + :commands (clatter clatter-connect) :custom - ;; connection - (erc-nick "cashmere1337") - (erc-user-full-name "cashmere") - - ;; behaviour - (erc-join-buffer 'buffer) - (erc-kill-buffer-on-part nil) - (erc-kill-queries-on-quit t) - (erc-kill-server-buffer-on-quit t) - - ;; visuals - (erc-fill-function 'erc-fill-wrap) - (erc-timestamp-format "[%H:%M]") - (erc-timestamp-format-left "[%H:%M]") - (erc-timestamp-format-right "[%H:%M]") - (erc-hide-list '("JOIN" "PART" "QUIT" "NICK" "MODE")) - (erc-track-exclude-types '("JOIN" "PART" "QUIT" "NICK" "MODE" "324" "329" "332" "333" "353" "477")) - (erc-track-shorten-start 4) - (erc-track-visibility 'visible) - - ;; prompt - (erc-prompt (lambda () (concat (buffer-name) ">"))) - - ;; modules - (erc-modules '(autojoin - button - completion - fill - irccontrols - list - match - move-to-prompt - netsplit - networks - nicks - noncommands - notifications - readonly - ring - scrolltobottom - stamp - track)) + (clatter-networks + '(("libera" + :server "bouncer.cashmere.rs" :port 6699 :tls t + :nick "cashmere1337" + :username "cashmere/irc.libera.chat@emacs") + ("ergo" + :server "bouncer.cashmere.rs" :port 6699 :tls t + :nick "cashmere" + :username "cashmere/ergo@emacs") + ("mansion" + :server "bouncer.cashmere.rs" :port 6699 :tls t + :nick "cashmere" + :username "cashmere/mansion@emacs") + ("bitlbee" + :server "bouncer.cashmere.rs" :port 6699 :tls t + :nick "cashmere" + :username "cashmere/bitlbee@emacs"))) + (clatter-flyspell-enable nil) + (clatter-format-strip-only t) + (clatter-message-order 'oldest-first) + (clatter-read-marker-enabled nil) + (clatter-chathistory-limit 1000) :config - ;; highlight own nick - (setq erc-current-nick-highlight-type 'all) - (setq erc-keywords '("cashmere")) - (setq erc-pals '("cashmere")) - - ;; nicks module (built-in nick coloring in Emacs 30) - (setq erc-nicks-contrast-range '(40 . 90)) - - ;; scrolltobottom -- keep input always at bottom - (setq erc-scrolltobottom-all t) - (erc-scrolltobottom-mode 1) - - ;; fill-wrap -- modern chat-like wrapping - (setq erc-fill-wrap-align-prompt nil) - (setq erc-fill-static-center 14) - - ;; track -- activity in modeline - (setq erc-track-position-in-mode-line t) - - ;; keep large buffer for soju history replay - (setq erc-max-buffer-size 100000) - - (erc-update-modules)) - -(defvar my/consult-source-erc - (list :name "IRC" - :narrow ?i - :category 'buffer - :face 'erc-default-face - :state #'consult--buffer-state - :items (lambda () - (mapcar #'buffer-name - (cl-remove-if-not - (lambda (buf) - (with-current-buffer buf - (derived-mode-p 'erc-mode))) - (buffer-list)))))) - -(defun my/erc-switch-channel () - "Switch to an ERC channel buffer via consult." - (interactive) - (consult-buffer (list my/consult-source-erc))) - -(defun my/erc-fetch-history (&optional count) - "Fetch COUNT previous messages from Soju via CHATHISTORY. -Defaults to 1000 (soju max). With prefix arg, prompts for count. -Temporarily disables notifications during the fetch." - (interactive "P") - (let ((target (erc-default-target))) - (if (not target) - (message "Not in a channel buffer.") - (let ((n (min (if count - (read-number "Messages to fetch: " 1000) - 1000) - 1000))) - (erc-notifications-disable) - (erc-server-send (format "CHATHISTORY LATEST %s * %d" target n)) - (run-at-time 5 nil #'erc-notifications-enable))))) - -(defun my/erc-quit-all () - "Disconnect from IRC and kill all ERC buffers." + (require 'gnutls) + (clatter-setup) + (setq clatter-hl-keywords '("cashmere")) + (advice-add 'clatter-buffer-name :override #'my/clatter-buffer-name) + (add-hook 'clatter-mode-hook (lambda () (setq-local corfu-auto nil))) + (general-def 'normal clatter-mode-map + "gb" #'my/clatter-switch-buffer + "gH" #'clatter-chathistory-request + "gh" #'clatter-chathistory-more)) + +(defun my/clatter-buffer-name (network target) + "Name clatter buffers by bare channel/query TARGET, server by NETWORK." + (if (string= target "*server*") + (format "*%s*" network) + target)) + +(defun my/clatter-switch-buffer () + "Switch to a clatter IRC buffer." (interactive) - (erc-cmd-QUIT "bye") - (run-at-time 1 nil - (lambda () - (dolist (buf (buffer-list)) - (when (with-current-buffer buf (derived-mode-p 'erc-mode)) - (kill-buffer buf)))))) - -(defun my/erc-reconnect () - "Reconnect to the current ERC server." - (interactive) - (erc-cmd-RECONNECT)) - -(defun my/erc-list-channels () - "Request channel list from the current ERC server." - (interactive) - (erc-cmd-LIST)) + (let ((bufs (cl-remove-if-not + (lambda (b) + (with-current-buffer b (derived-mode-p 'clatter-mode))) + (buffer-list)))) + (if bufs + (switch-to-buffer + (completing-read "IRC buffer: " (mapcar #'buffer-name bufs) nil t)) + (message "No clatter buffers.")))) + +(defvar my/clatter-inhibit-popup nil + "When non-nil, clatter buffers are not auto-displayed by `display-buffer'.") + +(defun my/clatter-popup-suppressed-p (buf &rest _) + "Non-nil when BUF is a clatter buffer and popups are inhibited." + (and my/clatter-inhibit-popup + (get-buffer buf) + (with-current-buffer (get-buffer buf) + (derived-mode-p 'clatter-mode)))) + +(add-to-list 'display-buffer-alist + '(my/clatter-popup-suppressed-p + display-buffer-no-window + (allow-no-window . t))) -(defun persp-irc () - "Switch to (or create) the IRC frame and show ERC buffers." +(defun run-irc () + "Connect to all IRC networks via the Soju bouncer, in the background." (interactive) - (let* ((erc-bufs (cl-remove-if-not - (lambda (buf) - (with-current-buffer buf - (derived-mode-p 'erc-mode))) - (buffer-list))) - (irc-frame (cl-find-if - (lambda (f) - (string= (frame-parameter f 'name) "irc")) - (frame-list)))) - (if irc-frame - (select-frame-set-input-focus irc-frame) - (select-frame-set-input-focus (make-frame '((name . "irc"))))) - (if erc-bufs - (switch-to-buffer (car erc-bufs)) - (letrec ((hook-fn (lambda () - (switch-to-buffer (current-buffer)) - (remove-hook 'erc-join-hook hook-fn)))) - (add-hook 'erc-join-hook hook-fn)) - (run-irc)))) - -;; ERC keybindings and hooks — register from any daemon, since `run-irc` -;; autoloads ERC even outside the hub daemon. -(with-eval-after-load 'erc - (add-hook 'erc-mode-hook - (lambda () - (setq-local scroll-margin 0) - (setq-local maximum-scroll-margin 0.0) - (setq-local corfu-auto nil))) - - ;; Prevent accidental ERC buffer kills from parting channels - (add-hook 'kill-buffer-query-functions - (lambda () - (if (and (derived-mode-p 'erc-mode) - (erc-server-process-alive) - (not (eq this-command 'my/erc-quit-all))) - (yes-or-no-p - (format "ERC buffer %s is connected. Really kill (will part channel)? " - (buffer-name))) - t))) - - (with-eval-after-load 'general - (general-def 'normal erc-mode-map - "q" 'quit-window - "Q" 'my/erc-quit-all - "gb" 'my/erc-switch-channel - "gn" 'erc-track-switch-buffer - "gH" 'my/erc-fetch-history - "go" 'erc-channel-names - "gj" 'erc-join-channel - "gl" 'my/erc-list-channels - "gr" 'my/erc-reconnect - "RET" 'erc-send-current-line) - - (general-def '(normal insert) erc-mode-map - "M-n" 'erc-track-switch-buffer - "C-k" 'erc-previous-command - "C-j" 'erc-next-command))) + (let ((pw (my/soju-password))) + (setq my/clatter-inhibit-popup t) + (dolist (net '("libera" "ergo" "mansion" "bitlbee")) + (clatter-connect net :password pw)) + (run-at-time 8 nil (lambda () (setq my/clatter-inhibit-popup nil))))) (defvar my/centered-cursor-enabled nil) @@ -489,65 +364,6 @@ Temporarily disables notifications during the fetch." (setq my/centered-cursor-enabled t) (message "centered-cursor on")))) -(use-package cursory - :ensure t - :demand t - :if (display-graphic-p) - :config - (setq cursory-presets - '((box - :cursor-color success ; will typically be green - :blink-cursor-interval 1.2) - (box-no-blink - :inherit box - :blink-cursor-mode -1) - (bar - :cursor-type (bar . 2) - :cursor-color error ; will typically be red - :blink-cursor-interval 0.8) - (bar-no-other-window - :inherit bar - :cursor-in-non-selected-windows nil) - (bar-no-blink - :inherit bar - :blink-cursor-mode -1) - (underscore - :cursor-color warning ; will typically be yellow - :cursor-type (hbar . 3) - :blink-cursor-interval 0.3 - :blink-cursor-blinks 50) - (underscore-no-other-window - :inherit underscore - :cursor-in-non-selected-windows nil) - (underscore-thick - :inherit underscore - :cursor-type (hbar . 8) - :cursor-in-non-selected-windows (hbar . 3)) - (t ; the default values - :cursor-color unspecified ; use the theme's original - :cursor-type box - :cursor-in-non-selected-windows hollow - :blink-cursor-mode 1 - :blink-cursor-blinks 10 - :blink-cursor-interval 0.2 - :blink-cursor-delay 0.2))) - - ;; I am using the default value of `cursory-latest-state-file'. - - ;; Set last preset or fall back to desired style from - ;; `cursory-presets'. Alternatively, use the function - ;; `cursory-set-last-or-fallback' (can be added to the - ;; `after-init-hook'. - (cursory-set-preset (or (cursory-restore-latest-preset) 'box)) - - ;; Persist configurations between Emacs sessions. Also apply the - ;; :cursor-color again when swithcing to another theme. - (cursory-mode 1) - :bind - ;; We have to use the "point" mnemonic, because C-c c is often the - ;; suggested binding for `org-capture' and is the one I use as well. - ("C-c p" . cursory-set-preset)) - (use-package clipetty :ensure t :after evil @@ -690,7 +506,7 @@ Temporarily disables notifications during the fetch." (or (mode . eca-chat-mode))) - ("ERC" (or (mode . erc-mode))) + ("IRC" (or (mode . clatter-mode))) ("Emacs" (or (mode . emacs-lisp-mode) @@ -730,9 +546,10 @@ Temporarily disables notifications during the fetch." (general-evil-setup) (general-create-definer my-leader - :states '(normal visual) + :states '(normal visual insert) :keymaps 'override - :prefix "SPC") + :prefix "SPC" + :non-normal-prefix "M-SPC") (general-create-definer my-local-leader :states '(normal visual) @@ -810,7 +627,7 @@ Temporarily disables notifications during the fetch." (claude-code-ide-cli-path "claude") (claude-code-ide-cli-debug nil) ;; Terminal backend - (claude-code-ide-terminal-backend 'eat) + (claude-code-ide-terminal-backend 'ghostel) ;; Window placement — mirror eca on the right side so the two AI assistants ;; don't fight over the same slot @@ -863,27 +680,13 @@ Temporarily disables notifications during the fetch." ;; Leader-key bindings (SPC a …) — defined pre-load so :commands autoloads fire. (with-eval-after-load 'general + ;; 2026-06-23: replaced claude-code-ide bindings with pi-coding-agent under `a'. + ;; pi-coding-agent only exposes 3 user-facing commands, so the prefix is intentionally sparse. (my-leader - "a" '(:ignore t :wk "claude-code") - "aa" '(claude-code-ide :wk "start / toggle") - "am" '(claude-code-ide-menu :wk "transient menu") - "at" '(claude-code-ide-toggle :wk "toggle window") - "aT" '(claude-code-ide-toggle-recent :wk "toggle recent") - "ap" '(claude-code-ide-send-prompt :wk "send prompt") - "a@" '(claude-code-ide-insert-at-mentioned :wk "@mention region") - "ac" '(claude-code-ide-continue :wk "continue last") - "ar" '(claude-code-ide-resume :wk "resume session") - "as" '(claude-code-ide-switch-to-buffer :wk "switch buffer") - "al" '(claude-code-ide-list-sessions :wk "list sessions") - "aq" '(claude-code-ide-stop :wk "stop") - "a?" '(claude-code-ide-check-status :wk "check status") - "ad" '(claude-code-ide-show-debug :wk "show debug") - "aD" '(claude-code-ide-clear-debug :wk "clear debug")) - - (my-leader 'visual - "a@" '(claude-code-ide-insert-at-mentioned :wk "@mention region"))) - - (global-set-key (kbd "C-c C-'") #'claude-code-ide-menu) + "aa" '(agent-shell :wk "start") + "at" '(agent-shell-toggle :wk "toggle") + "am" '(agent-shell-help-menu :wk "open session") + "ar" '(agent-shell-send-region :wk "send region"))) :config ;; Runs only on first invocation — not at Emacs startup. @@ -894,6 +697,85 @@ Temporarily disables notifications during the fetch." :config (setopt evil-collection-eca-maps 't)) +(use-package pi-coding-agent + :ensure t + :commands (pi-coding-agent + pi-coding-agent-toggle + pi-coding-agent-open-session-file)) + +(defun my/agent-shell-autoscroll (&rest _) + (dolist (win (get-buffer-window-list (current-buffer) 'visible)) + (when (>= (window-point win) + (save-excursion (goto-char (point-max)) (point))) + (set-window-point win (point-max))))) + +(defun my/agent-shell-display-buffer-rules () + '(("\\*Kimi\\(?:\\*\\|:[^*]*\\*\\)" + (display-buffer-in-side-window) + (side . bottom) + (slot . 0) + (window-height . 0.33) + (dedicated . t) + (window-parameters . ((no-other-window . nil) + (no-delete-other-windows . nil)))) + ("\\*agent-shell-diff\\*" + (display-buffer-in-side-window) + (side . right) + (slot . 1) + (window-width . 0.5) + (dedicated . t) + (window-parameters . ((no-other-window . nil) + (no-delete-other-windows . nil)))))) + +(use-package agent-shell + :ensure t + :bind (:map agent-shell-mode-map + ("RET" . newline) + ("M-RET" . shell-maker-submit)) + :config + (advice-add 'shell-maker-submit :after #'my/agent-shell-autoscroll) + (dolist (rule (my/agent-shell-display-buffer-rules)) + (add-to-list 'display-buffer-alist rule)) + (setq agent-shell-tool-use-expand-by-default nil) + (setq agent-shell-thought-process-expand-by-default nil) + (setq agent-shell-user-message-expand-by-default nil) + (setq agent-shell-diff-delta-command + (when (executable-find "delta") + '("delta" "--no-gitconfig" "--color-only"))) + + (defun my/agent-shell-send-clipboard-image-in-terminal (orig-fn &optional pick-shell) + "Call ORIG-FN, but allow `agent-shell-send-clipboard-image' in terminal Emacs." + (if (window-system) + (funcall orig-fn pick-shell) + (let* ((screenshots-dir (agent-shell--dot-subdir "screenshots")) + (image-path (agent-shell--save-clipboard-image + :destination-dir screenshots-dir)) + (shell-buffer (when pick-shell + (agent-shell--read-shell-buffer + :prompt "Send image to shell: ")))) + (agent-shell-insert + :text (agent-shell--get-files-context :files (list image-path)) + :shell-buffer shell-buffer)))) + (advice-add 'agent-shell-send-clipboard-image :around + #'my/agent-shell-send-clipboard-image-in-terminal) + + ;; Also let C-y / `agent-shell-yank-dwim' try the clipboard image first + ;; in terminal Emacs before falling back to plain text yank. + (defun my/agent-shell-yank-dwim-in-terminal (orig-fn &optional arg) + "Call ORIG-FN, but try clipboard image first in terminal Emacs." + (if (window-system) + (funcall orig-fn arg) + (if-let* ((screenshots-dir (agent-shell--dot-subdir "screenshots")) + (image-path (agent-shell--save-clipboard-image + :destination-dir screenshots-dir + :no-error t))) + (agent-shell-insert + :text (agent-shell--get-files-context :files (list image-path)) + :shell-buffer (agent-shell--shell-buffer)) + (funcall orig-fn arg)))) + (advice-add 'agent-shell-yank-dwim :around + #'my/agent-shell-yank-dwim-in-terminal)) + (use-package eldoc :ensure nil :config @@ -903,14 +785,6 @@ Temporarily disables notifications during the fetch." :init (global-eldoc-mode)) -(use-package eldoc-box - :ensure t - :defer t - :config - (setq eldoc-box-max-pixel-width 800) - (setq eldoc-box-max-pixel-height 600)) - - (use-package flymake :ensure nil ;; This is built-in, no need to fetch it. :defer t @@ -927,7 +801,6 @@ Temporarily disables notifications during the fetch." (use-package flycheck :ensure t :config - (setq flycheck-global-modes '(not org-mode)) (setq flycheck-display-errors-function nil) (add-hook 'after-init-hook #'global-flycheck-mode)) @@ -960,6 +833,13 @@ Temporarily disables notifications during the fetch." :ensure t :hook (flycheck-mode . sideline-flycheck-setup)) +;; (use-package flycheck-vale +;; :ensure t +;; :after flycheck +;; :demand t +;; :config +;; (flycheck-vale-setup)) + (use-package xref :ensure nil) @@ -998,6 +878,7 @@ Temporarily disables notifications during the fetch." (setq org-src-fontify-natively t org-src-tab-acts-natively t org-edit-src-content-indentation 0) + (add-to-list 'org-src-lang-modes '("nix" . nix-ts)) (setq org-log-done t org-auto-align-tags t org-tags-column -80 @@ -1005,7 +886,6 @@ Temporarily disables notifications during the fetch." org-special-ctrl-a/e t org-insert-heading-respect-content t) - (add-hook 'org-mode-hook 'variable-pitch-mode) ;; (add-hook 'org-mode-hook 'org-indent-mode) (add-hook 'org-mode-hook 'visual-line-mode) (add-hook 'org-mode-hook (lambda () (electric-indent-local-mode -1))) @@ -1061,39 +941,11 @@ Temporarily disables notifications during the fetch." (kitty-gfx-shr-fit-height 20) :hook (dired-mode . kitty-gfx-dired-auto-preview-mode) :config - (kitty-graphics-setup)) + (setq kitty-gfx-enable-browser t + kitty-gfx-casty-program "~/projects/casty/bin/casty.js" + kitty-gfx-casty-chrome "helium-browser") -(use-package org-fancy-priorities - :ensure t - :hook - (org-mode . org-fancy-priorities-mode) - :config - (setq org-fancy-priorities-list '("⚡" "⬆" "⬇" "☕"))) - -(setq org-priority-faces - '((?A . (:background "#e91e63" :foreground "#ffffff" :weight bold)) - (?B . (:background "#ff9800" :foreground "#1a1a1a" :weight bold)) - (?C . (:background "#2196f3" :foreground "#ffffff" :weight bold)) - (?D . (:background "#795548" :foreground "#ffffff" :weight bold)))) - -;; (setq org-agenda-start-on-weekday nil -;; org-agenda-block-separator nil -;; org-agenda-remove-tags t) - -;; (setq org-agenda-skip-scheduled-if-done t -;; org-agenda-skip-deadline-if-done t -;; org-agenda-include-deadlines t -;; org-agenda-block-separator nil -;; org-agenda-compact-blocks t -;; org-agenda-start-day nil -;; org-agenda-span 1 -;; org-agenda-start-on-weekday nil -;; org-agenda-hide-tags-regexp "task" -;; org-agenda-prefix-format -;; '((agenda . " %i %?-12t% s") -;; (todo . " %i ") -;; (tags . " %i ") -;; (search . " %i "))) + (kitty-graphics-setup)) (with-eval-after-load 'org (setq org-confirm-babel-evaluate nil) @@ -1130,6 +982,38 @@ Temporarily disables notifications during the fetch." (screen . t) (eshell . t)))) +(with-eval-after-load 'org + (defun my/org-babel-tangle-targets-missing-p (&optional buffer) + "Return non-nil if any :tangle target of BUFFER is missing. +BUFFER defaults to the current buffer." + (with-current-buffer (or buffer (current-buffer)) + (when (and (buffer-file-name) (derived-mode-p 'org-mode)) + (require 'ob-tangle) + (let* ((default-directory (file-name-directory (buffer-file-name))) + (targets (mapcar #'car (org-babel-tangle-collect-blocks))) + missing) + (dolist (file targets) + (unless (or missing (file-exists-p file)) + (setq missing t))) + missing)))) + + (defun my/org-babel-tangle-if-missing (&optional org-file target-file lang) + "Tangle only if a target file is missing. +When called interactively with no arguments, check all :tangle +targets in the current buffer and run `org-babel-tangle' only if +at least one target is missing. + +When called non-interactively, tangle ORG-FILE to TARGET-FILE +only if TARGET-FILE does not exist. Optional LANG restricts +tangling to source blocks of that language." + (interactive) + (if (or org-file target-file lang) + (unless (file-exists-p target-file) + (org-babel-tangle-file org-file target-file lang)) + (if (my/org-babel-tangle-targets-missing-p) + (org-babel-tangle) + (message "All tangle targets already exist; skipping"))))) + (use-package ob-async :ensure t :after org) @@ -1217,7 +1101,6 @@ and convert it to Org using the pandoc utility." (kill-region (point-min) (point-max))) (yank))) - (with-eval-after-load 'org ;; Log state changes with timestamps (setq org-log-done 'time @@ -1408,102 +1291,61 @@ Skips capture tasks and projects." (goto-char (point-min)) (search-forward "- Goal :: " nil t))) +(defun my/org-capture-to-journal (heading) + (require 'denote-journal) + (denote-journal-new-or-existing-entry) + (goto-char (point-max)) + (insert heading) + (current-buffer)) (defun my/org-capture-timeblock-to-journal () - "Capture function: jump to today's journal for a timeblock." - (require 'denote) - (require 'denote-journal) - (let* ((today (format-time-string "%Y-%m-%d")) - (journal-files (denote-directory-files "journal")) - (today-journal (seq-find - (lambda (file) - (string-match-p today (file-name-nondirectory file))) - journal-files))) - (if today-journal - (find-file today-journal) - (denote-journal-new-or-existing-entry)) - (goto-char (point-max)) - (insert "\n* Timeblock\n"))) + (my/org-capture-to-journal "\n* Timeblock\n")) (defun my/org-capture-recurring-to-journal () - "Capture function: jump to today's journal for a recurring task." - (require 'denote) - (require 'denote-journal) - (let* ((today (format-time-string "%Y-%m-%d")) - (journal-files (denote-directory-files "journal")) - (today-journal (seq-find - (lambda (file) - (string-match-p today (file-name-nondirectory file))) - journal-files))) - (if today-journal - (find-file today-journal) - (denote-journal-new-or-existing-entry)) - (goto-char (point-max)) - (insert "\n* Recurring\n"))) + (my/org-capture-to-journal "\n* Recurring\n")) (defun my/org-capture-meeting-to-journal () - "Capture function: jump to today's journal for a meeting note." - (require 'denote) - (require 'denote-journal) - (let* ((today (format-time-string "%Y-%m-%d")) - (journal-files (denote-directory-files "journal")) - (today-journal (seq-find - (lambda (file) - (string-match-p today (file-name-nondirectory file))) - journal-files))) - (if today-journal - (find-file today-journal) - (denote-journal-new-or-existing-entry)) - (goto-char (point-max)) - (insert "\n* Meeting: "))) - -;; capture templates are merged in the org-capture section below + (my/org-capture-to-journal "\n* Meeting: ")) (with-eval-after-load 'eglot (add-to-list 'eglot-server-programs '(org-mode . ("harper-ls" "--stdio")))) -(defun my/org-capture-denote-deadline () +(defun my/org-capture-task-to-journal () + (let* ((context (read-string "Task: ")) + (todo-state (completing-read "TODO state: " + '("ACTIVE" "NEXT" "TODO" "WAIT")))) + (my/org-capture-to-journal (format "\n* %s %s\n\n" todo-state context)))) + +(defun my/org-capture-deadline-to-journal () (let* ((context (read-string "Task with deadline: ")) - (deadline (org-read-date t nil nil "Deadline: ")) - (file-name (denote nil '("agenda")))) - (find-file file-name) - (goto-char (point-min)) - (forward-line 4) - (insert (format "\n* %s\nDEADLINE: <%s>\n\n" context deadline)) - (current-buffer))) + (deadline (org-read-date t nil nil "Deadline: "))) + (my/org-capture-to-journal (format "\n* %s\nDEADLINE: <%s>\n\n" context deadline)))) -(defun my/org-capture-denote-scheduled () +(defun my/org-capture-scheduled-to-journal () + "Capture a scheduled task into today's denote-journal file." (let* ((context (read-string "Scheduled task: ")) - (schedule (org-read-date t nil nil "Schedule: ")) - (file-name (denote nil '("agenda")))) - (find-file file-name) - (goto-char (point-min)) - (forward-line 4) - (insert (format "\n* %s\nSCHEDULED: <%s>\n\n" context schedule)) - (current-buffer))) + (schedule (org-read-date t nil nil "Schedule: "))) + (my/org-capture-to-journal (format "\n* %s\nSCHEDULED: <%s>\n\n" context schedule)))) -(defun my/org-capture-denote-task () - (let* ((context (read-string "Task: ")) - (todo-state (completing-read "TODO state: " - '("ACTIVE" "NEXT" "TODO" "WAIT"))) - (file-name (denote nil '("agenda")))) - (find-file file-name) - (goto-char (point-min)) - (forward-line 4) - (insert (format "\n* %s %s\n\n" todo-state context)) - (point))) +(defun my/org-capture-float () + (interactive) + (select-frame + (make-frame '((title . "org-capture") + (width . 160) + (height . 14)))) + (org-capture)) (with-eval-after-load 'org (setq org-capture-templates '(("t" "Task" plain - (function my/org-capture-denote-task) + (function my/org-capture-task-to-journal) "" :immediate-finish t :jump-to-captured t) ("d" "Deadline" plain - (function my/org-capture-denote-deadline) + (function my/org-capture-deadline-to-journal) "" :immediate-finish t :jump-to-captured t) ("s" "Scheduled" plain - (function my/org-capture-denote-scheduled) + (function my/org-capture-scheduled-to-journal) "" :immediate-finish t :jump-to-captured t) ("b" "Timeblock" plain (function my/org-capture-timeblock-to-journal) @@ -1812,27 +1654,6 @@ Skips capture tasks and projects." (add-hook 'org-agenda-finalize-hook #'my/org-agenda-fix-block-spacing) -(defun my/agenda-app () - "Launch Emacs as a dedicated agenda kiosk. -Opens the 'd' (Today) agenda view in a clean, distraction-free frame. -Usage: emacs --eval '(my/agenda-app)' --no-splash" - (interactive) - (set-frame-name "Agenda") - (delete-other-windows) - (org-agenda nil "d") - (delete-other-windows) - (setq mode-line-format nil) - (menu-bar-mode -1) - (tool-bar-mode -1) - (scroll-bar-mode -1) - (when (bound-and-true-p tab-bar-mode) - (tab-bar-mode -1)) - (when (find-font (font-spec :family "Lato")) - (buffer-face-set '(:family "Lato"))) - (text-scale-increase 2) - (hide-mode-line-mode 1) - (winner-mode 1)) - (use-package org-contrib :ensure (:wait t)) @@ -1856,7 +1677,6 @@ Usage: emacs --eval '(my/agenda-app)' --no-splash" (setq org-download-timestamp "%Y%m%d-%H%M%S_") (setq org-download-heading-lvl nil)) - ;; (setq org-publish-project-alist ;; '(("wiki" ;; :author "cashmere" @@ -1912,7 +1732,7 @@ Usage: emacs --eval '(my/agenda-app)' --no-splash" (setq denote-rename-buffer-format "%t" denote-buffer-name-prefix "" denote-directory (expand-file-name "~/org/")) - (denote-rename-buffer-mode 1)) + (denote-rename-buffer-mode 1)) (use-package denote-agenda :ensure t @@ -2091,11 +1911,6 @@ for `denote-directory' and silently drop the file-list filter." :ensure t ) - - -(use-package denote-explore - :ensure t) - (use-package ox-gfm :ensure t) @@ -2182,7 +1997,6 @@ Timers that expired while Emacs was closed fire immediately." (add-hook 'kill-emacs-hook #'my/tmr-save-timers) (add-hook 'emacs-startup-hook #'my/tmr-restore-timers)) - (use-package denote-merge :ensure (:host github :repo "protesilaos/denote-merge") ; not in any package archive ;; You can bind these to keys. They are here so you can learn about them. @@ -2197,6 +2011,27 @@ Timers that expired while Emacs was closed fire immediately." denote-merge-region-markdown-quote denote-merge-region-markdown-fenced-block)) +(use-package denote-solo + :ensure (:host github :repo "pavlo/denote-solo") + :after denote + :config + (setq denote-solo-directories + '(("org" . "~/org") + ("garden" . "~/garden")) + denote-solo-display-modeline nil) + (denote-solo-mode 1) + + (defun my/denote-solo-sync-directory (&rest _) + (when-let* ((solo denote-solo--current-solo) + (path (denote-solo--directory-for-name solo))) + (setq-default denote-directory path) + (dolist (buf (buffer-list)) + (with-current-buffer buf + (when (local-variable-p 'denote-directory) + (setq denote-directory path)))))) + + (advice-add 'denote-solo-switch :after #'my/denote-solo-sync-directory)) + (use-package which-key :ensure t :defer t @@ -2231,7 +2066,7 @@ Timers that expired while Emacs was closed fire immediately." :custom (corfu-cycle t) (corfu-auto t) - (corfu-auto-delay 0.001) + (corfu-auto-delay 0.2) (corfu-auto-prefix 2) :init (global-corfu-mode) @@ -2294,6 +2129,7 @@ Timers that expired while Emacs was closed fire immediately." ("M-." . embark-dwim) ("C-h B" . embark-bindings) :map minibuffer-local-map + ("C-c C-a" . embark-act-all) ("C-c C-e" . embark-export) ("C-c C-o" . embark-collect)) :init @@ -2332,16 +2168,16 @@ completion-category-overrides '((file (styles partial-completion))))) ;; Customi (setq eglot-connect-timeout 30) (setq eglot-events-buffer-size 0) (setq eglot-report-progress nil) - (setq jsonrpc-default-request-timeout 10) + (setq jsonrpc-default-request-timeout 10)) - (add-hook 'eglot-managed-mode-hook - (lambda () - (eglot-inlay-hints-mode -1))) + ;; (add-hook 'eglot-managed-mode-hook + ;; (lambda () + ;; (eglot-inlay-hints-mode -1))) - (setq eglot-code-action-indications '(eldoc-hint)) + ;; (setq eglot-code-action-indications '(eldoc-hint)) - (setq eglot-ignored-server-capabilities - '(:inlayHintProvider :documentHighlightProvider))) + ;; (setq eglot-ignored-server-capabilities + ;; '(:inlayHintProvider :documentHighlightProvider))) (use-package typst-ts-mode :ensure t @@ -2372,6 +2208,42 @@ completion-category-overrides '((file (styles partial-completion))))) ;; Customi (put 'eglot-workspace-configuration 'safe-local-variable #'listp) +(use-package c-ts-mode + :ensure nil + :mode (("\\.c\\'" . c-ts-mode) + ("\\.h\\'" . c-ts-mode)) + :hook (c-ts-mode . eglot-ensure) + :custom + (c-ts-mode-indent-offset 2) + (c-ts-mode-indent-style 'k&r) + :config + (with-eval-after-load 'eglot + (add-to-list 'eglot-server-programs + '((c-ts-mode c-mode) . + ("clangd" + "--background-index" + "--clang-tidy" + "--header-insertion=never"))))) + +(use-package c3-ts-mode + :ensure (:host github :repo "c3lang/c3-ts-mode") + :mode (("\\.c3\\'" . c3-ts-mode) + ("\\.c3i\\'" . c3-ts-mode) + ("\\.c3t\\'" . c3-ts-mode)) + :hook (c3-ts-mode . eglot-ensure) + :custom + (c3-ts-mode-indent-offset 2) + (setq treesit-font-lock-level 4) + :init + (add-to-list 'treesit-language-source-alist + '(c3 "https://github.com/c3lang/tree-sitter-c3")) + :config + (unless (treesit-language-available-p 'c3) + (treesit-install-language-grammar 'c3)) + (with-eval-after-load 'eglot + (add-to-list 'eglot-server-programs + '(c3-ts-mode . ("c3-lsp"))))) + (use-package python :ensure nil :mode ("\\.py\\'" . python-ts-mode) @@ -2427,20 +2299,6 @@ completion-category-overrides '((file (styles partial-completion))))) ;; Customi (add-to-list 'eglot-server-programs '((typescript-ts-mode tsx-ts-mode) . ("rass" "--" "typescript-language-server" "--stdio" "--" "vscode-eslint-language-server" "--stdio"))))) -(use-package web-mode - :ensure t - :mode "\\.vue\\'" - :hook (web-mode . eglot-ensure) - :config - (setq web-mode-markup-indent-offset 2) - (setq web-mode-css-indent-offset 2) - (setq web-mode-code-indent-offset 2) - (setq web-mode-style-padding 2) - (setq web-mode-script-padding 2) - (with-eval-after-load 'eglot - (add-to-list 'eglot-server-programs - '(web-mode . ("rass" "vuetail"))))) - (use-package xonsh-mode :ensure (:host github :repo "seanfarley/xonsh-mode") :mode ("\\.xsh\\'" "\\.xonshrc\\'")) @@ -2514,28 +2372,6 @@ BODY is the xonsh script. PARAMS may include :dir and :cmdline." (with-eval-after-load 'ob-async (add-hook 'ob-async-pre-execute-src-block-hook my/ob-xonsh-installer)) -(use-package go-mode - :ensure t - :mode "\\.go\\'" - :hook (go-mode . eglot-ensure) - :config - (with-eval-after-load 'eglot - (add-to-list 'eglot-server-programs - '(go-mode . ("gopls" :initializationOptions - (:staticcheck t - :matcher "CaseSensitive" - :usePlaceholders t))))) - - (defun go-mode-setup () - (add-hook 'before-save-hook #'eglot-format-buffer -10 t) - (add-hook 'before-save-hook - (lambda () - (when (eglot-managed-p) - (eglot-code-action-organize-imports nil t))) - nil t)) - - (add-hook 'go-mode-hook #'go-mode-setup)) - (use-package json-ts-mode :ensure nil :mode "\\.json\\'" @@ -2580,20 +2416,9 @@ BODY is the xonsh script. PARAMS may include :dir and :cmdline." ;; :ensure t ;; :after just-mode) -(use-package eat - :ensure t - :custom - (eat-shell "xonsh") - (eat-kill-buffer-on-exit t) - (eat-enable-shell-prompt-annotation t) - (eat-term-name "xterm-256color") - :hook - (eshell-load . eat-eshell-mode) - (eshell-load . eat-eshell-visual-command-mode)) - (use-package olivetti :ensure t - :hook (org-mode . olivetti-mode) + ;; :hook (org-mode . olivetti-mode) ; no auto-center in org buffers :custom (olivetti-style nil) ; Use window margins (fringes disabled in early-init) (olivetti-margin-width 10) ; No side margins @@ -2615,6 +2440,11 @@ BODY is the xonsh script. PARAMS may include :dir and :cmdline." (dolist (win (get-buffer-window-list nil nil 'visible)) (when (buffer-local-value 'olivetti-mode (window-buffer win)) (olivetti-set-window win)))) + (defun my/olivetti-hide-continuation-glyph () + (let ((dt (or buffer-display-table (make-display-table)))) + (set-display-table-slot dt 'wrap (if olivetti-mode ?\s nil)) + (setq buffer-display-table dt))) + (add-hook 'olivetti-mode-hook #'my/olivetti-hide-continuation-glyph) (with-eval-after-load 'diff-hl-margin (advice-add 'diff-hl-margin-local-mode :after #'my/olivetti-rebalance) (advice-add 'diff-hl-margin-ensure-visible :after #'my/olivetti-rebalance))) @@ -2631,27 +2461,6 @@ BODY is the xonsh script. PARAMS may include :dir and :cmdline." (unless (display-graphic-p) (diff-hl-margin-mode 1))) -(defun my-org-sidecar-left () - "Pick an org-mode buffer via consult and display it as a left sidecar." - (interactive) - (let* ((bufs (cl-remove-if-not - (lambda (b) - (with-current-buffer b - (derived-mode-p 'org-mode))) - (buffer-list))) - (names (mapcar #'buffer-name bufs)) - (choice (consult--read names - :prompt "Org sidecar: " - :sort nil - :require-match t - :category 'buffer))) - (when-let* ((buf (get-buffer choice))) - (display-buffer-in-side-window - buf - '((side . left) - (window-width . 0.25) - (slot . 0)))))) - (use-package hl-todo :ensure (:host github :repo "tarsius/hl-todo") :config @@ -2715,13 +2524,23 @@ so the working-tree diff stays visible until the user explicitly stages." (add-hook 'after-save-hook #'my/auto-git-add-new-file) +(defun my/magit-uncommit () + "Undo the last commit, leaving its changes in the working tree, unstaged." + (interactive) + (magit-reset-mixed "HEAD^") + (message "Uncommitted HEAD^ — changes preserved, unstaged")) + (use-package indent-guide :defer t :ensure t :hook (prog-mode . indent-guide-mode) ;; Activate indent-guide in programming modes. :config - (setq indent-guide-char "│")) ;; Set the character used for the indent guide. + (setq indent-guide-char "│") ;; Set the character used for the indent guide. + (define-advice indent-guide-show (:around (orig) my/guard-stale-window) + (when (and (eq (window-buffer) (current-buffer)) + (<= (window-start) (point-max))) + (funcall orig)))) (use-package evil :ensure (:wait t) @@ -2740,16 +2559,18 @@ so the working-tree diff stays visible until the user explicitly stages." (evil-set-initial-state 'messages-buffer-mode 'normal) (evil-set-initial-state 'dired-mode 'normal) (evil-set-initial-state 'ibuffer-mode 'normal) - (evil-set-initial-state 'erc-mode 'normal)) + (evil-set-initial-state 'clatter-mode 'normal) + (define-key evil-insert-state-map (kbd "C-w") 'evil-window-map)) (modify-syntax-entry ?_ "w") (defun my/eldoc-and-jump () "Show documentation at point. Works in both GUI and terminal." (interactive) - (if (display-graphic-p) - (eldoc-box-help-at-point) - (eldoc-doc-buffer t))) + ;; (if (display-graphic-p) + ;; (eldoc-box-help-at-point) + ;; (eldoc-doc-buffer t)) + (eldoc-doc-buffer t)) (define-key evil-normal-state-map (kbd "K") #'my/eldoc-and-jump) @@ -2959,35 +2780,51 @@ so the working-tree diff stays visible until the user explicitly stages." (read-key) ;; Wait for the user to press any key. (kill-emacs)) ;; Close Emacs after installation is complete. -(use-package hide-mode-line - :ensure t) - -(use-package svg-line - :ensure (:host nil :repo "ssh://git.cashmere.rs/svg-line.git" - :branch "feat/tty-kitty-modeline") +(display-time-mode 1) + +(defvar-local my/doom-modeline--buffer-title nil) + +(defun my/doom-modeline--update-title-cache () + "Update the cached #+title for the current buffer." + (setq-local my/doom-modeline--buffer-title + (cadar (org-collect-keywords '("TITLE"))))) + +(defun my/doom-modeline--buffer-title () + "Return the cached #+title, computing it if necessary." + (unless (local-variable-p 'my/doom-modeline--buffer-title) + (my/doom-modeline--update-title-cache)) + my/doom-modeline--buffer-title) + +(defun my/doom-modeline-set-buffer-title (&rest _) + "Override doom-modeline's cached file name with the Org #+title. +If no TITLE keyword is found, leave doom-modeline's default name." + (when-let* ((title (my/doom-modeline--buffer-title))) + (setq doom-modeline--buffer-file-name + (propertize title + 'face 'doom-modeline-buffer-file + 'mouse-face 'mode-line-highlight + 'help-echo (concat (or buffer-file-truename (buffer-name)) + "\nmouse-1: Previous buffer\nmouse-3: Next buffer") + 'local-map mode-line-buffer-identification-keymap)))) + +(defun my/doom-modeline--invalidate-title-cache () + "Invalidate the cached title so it gets recomputed on next update." + (kill-local-variable 'my/doom-modeline--buffer-title)) + +(use-package doom-modeline + :ensure t + :init (doom-modeline-mode 1) :config - (require 'svg-line-segments) - (setq svg-line-weather-latitude "53.25" - svg-line-weather-longitude "10.41") - (svg-line-weather-mode 1) - - (svg-line-define 'cashmere-mode-line - :target 'mode-line - :layout 'lines - :active #'mode-line-window-selected-p - :background (lambda () (face-background 'mode-line nil t)) - :foreground (lambda () (face-foreground 'mode-line nil t)) - :inactive-background (lambda () (face-background 'mode-line-inactive nil t)) - :inactive-foreground (lambda () (face-foreground 'mode-line-inactive nil t)) - :content - (lambda () - (list (cons (list #'svg-line-segment-evil " " - #'svg-line-segment-buffer #'svg-line-segment-vc) - (list #'svg-line-segment-weather #'svg-line-segment-battery - #'svg-line-segment-position " " - #'svg-line-segment-major-mode))))) - - (svg-line-activate 'cashmere-mode-line)) + (setq doom-modeline-hud t + doom-modeline-irc t + doom-modeline-irc-buffers nil + doom-modeline-mu4e t + doom-modeline-buffer-file-name-style 'relative-to-project + doom-modeline-buffer-encoding nil + doom-modeline-time t + doom-modeline-time-live-icon t) + (advice-add #'doom-modeline-update-buffer-file-name :after #'my/doom-modeline-set-buffer-title) + (add-hook 'before-save-hook #'my/doom-modeline--invalidate-title-cache)) (use-package adaptive-wrap :ensure t @@ -3011,42 +2848,36 @@ Runs per-frame so a TTY-only daemon never touches fonts while GUI frames created later still get them." (when (display-graphic-p frame) (set-face-attribute 'default frame - :family "MapleMono NF" + :family "Maple Mono NF" :height 180 :font (font-spec - :family "MapleMono NF" - :features '(cv04 ss05 zero))) - (set-face-attribute 'fixed-pitch frame :family "MapleMono NF" :weight 'regular) - (set-face-attribute 'variable-pitch frame :family "MapleMono NF" :weight 'regular :height 1.1))) + :family "Maple Mono NF" + :features '(cv04 ss05 zero) + )) + (set-face-attribute 'fixed-pitch frame :family "Maple Mono NF" :weight 'regular) + (set-face-attribute 'variable-pitch frame :family "Maple Mono NF" :weight 'regular :height 1.1))) (add-hook 'server-after-make-frame-hook #'cashmere/set-fonts) (cashmere/set-fonts) -(use-package mixed-pitch - :ensure t - :defer t - :hook ((org-mode . mixed-pitch-mode) - (LaTeX-mode . mixed-pitch-mode))) - (use-package grip-mode :ensure t :config (setq grip-command 'auto)) ;; auto, grip, go-grip or mdopen (add-to-list 'custom-theme-load-path "~/.emacs.d/themes/") -(load-theme 'noctalia t) +(load-theme 'tinty t) (use-package dirvish - :ensure t + :ensure (:host github :repo "latiagertrutis/dirvish" :branch "main") :init (dirvish-override-dired-mode) :custom (dirvish-quick-access-entries - '(("h" "~/" "Home") - ("d" "~/Downloads/" "Downloads") - ;; ("p" "~/projects/" "Projects") - )) + '(("h" "~/" "home") + ("d" "~/Downloads/" "downloads") + ("c" "~/.config/" "config"))) (dirvish-reuse-session 'open) (dirvish-attributes '(file-size)) @@ -3085,7 +2916,7 @@ created later still get them." dirvish-preview-dispatchers))))) (use-package zoxide - :ensure t) + :ensure t) (use-package dired-rsync :ensure t) @@ -3183,6 +3014,14 @@ activates automatically. Edit, then `C-c C-c' commits, `C-c C-k' aborts." (with-eval-after-load 'embark (advice-add 'embark-export :after #'my/embark-export-auto-wgrep)) +(defun my/consult-fd-in (dir) + (interactive "DFind files in: ") + (consult-fd dir)) + +(defun my/consult-ripgrep-in (dir) + (interactive "DGrep in: ") + (consult-ripgrep dir)) + (use-package persp-mode :ensure t :demand t @@ -3204,8 +3043,8 @@ activates automatically. Edit, then `C-c C-c' commits, `C-c C-k' aborts." persp-add-buffer-on-after-change-major-mode t persp-kill-foreign-buffer-behaviour 'kill persp-remove-buffers-from-nil-persp-behaviour nil - persp-auto-resume-time 1.0 - persp-auto-save-opt 1 + persp-auto-resume-time -1 + persp-auto-save-opt 0 uniquify-buffer-name-style nil) (add-to-list 'persp-filter-save-buffers-functions @@ -3379,7 +3218,6 @@ activates automatically. Edit, then `C-c C-c' commits, `C-c C-k' aborts." (defun my/workspace-switch-to-project (&optional dir) (let* ((dir (or dir default-directory)) - (default-directory dir) (name (projectile-project-name dir))) (when persp-mode (if (or (eq my/workspaces-on-switch-project t) @@ -3389,7 +3227,8 @@ activates automatically. Edit, then `C-c C-c' commits, `C-c C-k' aborts." (my/workspace-switch name t) (switch-to-buffer (my/workspace-fallback-buffer))) (ignore-errors (persp-rename name (get-current-persp)))) - (funcall my/workspace-switch-project-function)))) + (let ((default-directory dir)) + (funcall my/workspace-switch-project-function))))) (with-eval-after-load 'projectile (setq projectile-switch-project-action #'my/workspace-switch-to-project)) @@ -3399,30 +3238,17 @@ activates automatically. Edit, then `C-c C-c' commits, `C-c C-k' aborts." (define-key persp-mode-map [remap evil-window-delete] #'my/workspace-close-window-or-workspace)) - (defun my/workspace-restore-services (&rest _) - (run-with-idle-timer - 1 nil - (lambda () - (unless (and (fboundp 'mu4e-running-p) (mu4e-running-p)) - (mu4e t))))) - (add-hook 'persp-after-load-state-functions #'my/workspace-restore-services) - (add-hook 'persp-filter-save-buffers-functions (lambda (buf) - (with-current-buffer buf (derived-mode-p 'erc-mode)))) + (with-current-buffer buf (derived-mode-p 'clatter-mode)))) - (run-with-timer (* 10 60) (* 10 60) - (lambda () (when persp-mode (persp-save-state-to-file)))) - - (defun my/workspace--inhibit-erc-part (orig &rest args) - (let ((erc-kill-channel-hook nil) - (erc-kill-server-hook nil) - (erc-kill-buffer-hook nil)) + (defun my/workspace--inhibit-irc-part (orig &rest args) + (cl-letf (((symbol-function 'clatter--on-kill-buffer) #'ignore)) (apply orig args))) - (advice-add 'my/workspace-kill :around #'my/workspace--inhibit-erc-part) - (advice-add 'my/workspace-kill-session :around #'my/workspace--inhibit-erc-part) + (advice-add 'my/workspace-kill :around #'my/workspace--inhibit-irc-part) + (advice-add 'my/workspace-kill-session :around #'my/workspace--inhibit-irc-part) - (when (daemonp) (persp-mode 1))) + (persp-mode 1)) (with-eval-after-load 'consult (defvar my/workspace-consult-source @@ -3450,8 +3276,11 @@ activates automatically. Edit, then `C-c C-c' commits, `C-c C-k' aborts." (defun my/ibuffer-workspace () (interactive) - (ibuffer nil (format "*ibuffer: %s*" (my/workspace-current-name)) - (list (cons 'persp t)))) + (let ((name (my/workspace-current-name))) + (if (equal name my/workspaces-master) + (ibuffer nil "*ibuffer: master*") + (ibuffer nil (format "*ibuffer: %s*" name) + (list (cons 'persp t)))))) (my-leader "TAB" '(:ignore t :wk "workspace") @@ -3551,7 +3380,8 @@ activates automatically. Edit, then `C-c C-c' commits, `C-c C-k' aborts." :config ;; (setq golden-ratio-auto-scale t) ;; (golden-ratio-mode 1) -) + (setq golden-ratio-exclude-buffer-names '(" *which-key*")) + (setq golden-ratio-exclude-buffer-regexp '("\\`\\*eldoc"))) (use-package pdf-tools :ensure nil @@ -3588,10 +3418,13 @@ activates automatically. Edit, then `C-c C-c' commits, `C-c C-k' aborts." :wk "find file/switch project") "sp" '(consult-projectile :wk "search project") "ss" '(consult-line :wk "search project") + "sd" '(my/consult-ripgrep-in :wk "grep in dir") + "sf" '(my/consult-fd-in :wk "find file in dir") "/" '(consult-ripgrep :wk "search project") "." '(find-file :wk "find file") "," '(consult-buffer :wk "switch buffer") ":" (lambda () (interactive) (execute-extended-command nil)) + "u" '(universal-argument :wk "universal argument") "d" '(:ignore t :wk "denote") "dj" '(denote-journal-new-or-existing-entry :wk "journal") @@ -3603,6 +3436,7 @@ activates automatically. Edit, then `C-c C-c' commits, `C-c C-k' aborts." "dl" '(denote-link-or-create t :wk "Link Note") "dn" '(denote t :wk "Create a new note") "dr" '(denote-rename-file t :wk "Rename Note") + "ds" '(denote-solo-switch :wk "switch silo") "dtl" '(tmr-list-timers :wk "list timer") "dtt" '(tmr :wk "set timer") @@ -3649,6 +3483,7 @@ activates automatically. Edit, then `C-c C-c' commits, `C-c C-k' aborts." "gt" '(eglot-find-typeDefinition :wk "go to type definition") "gr" '(xref-find-references :wk "find references") "gs" '(magit-file-stage :wk "stage file") + "gu" '(my/magit-uncommit :wk "uncommit (keep & unstage)") "gb" '(vc-annotate :wk "blame") "gT" '(my/code-todos-harvest :wk "harvest code TODOs") @@ -3656,6 +3491,7 @@ activates automatically. Edit, then `C-c C-c' commits, `C-c C-k' aborts." "os" '(my/snip-upload :wk "snip buffer/region") "oS" '(my/snip-upload-file :wk "snip file") "op" '(pass :wk "pass") + "ot" '(ghostel :wk "ghostel") "h" '(:ignore t :wk "help") "hm" '(describe-mode :wk "mode") @@ -3681,8 +3517,8 @@ activates automatically. Edit, then `C-c C-c' commits, `C-c C-k' aborts." "w L" '(buf-move-right :wk "Buffer move right") "c" '(:ignore t :wk "code") - "cc" '(my/compile-or-recompile :wk "compile / recompile") - "cC" '(compile :wk "compile (new command)") + "cc" '(my/compile-or-recompile :wk "compile") + "cC" '(ghostel-compile :wk "recompile") "ca" '(eglot-code-actions :wk "code actions") "cr" '(eglot-rename :wk "lsp rename") "cf" '(eglot-format :wk "format buffer") @@ -3728,17 +3564,19 @@ activates automatically. Edit, then `C-c C-c' commits, `C-c C-k' aborts." (call-interactively 'flash-evil-jump))))) (general-def '(normal visual operator) 'override + :predicate '(not (derived-mode-p 'mu4e-main-mode 'mu4e-headers-mode + 'mu4e-view-mode 'mu4e-compose-mode)) "s" 'my/s-key-dispatch) (general-def 'normal 'override "K" 'my/eldoc-and-jump - "'d" 'flycheck-next-error - ";d" 'flycheck-previous-error + "]d" 'flycheck-next-error + "[d" 'flycheck-previous-error "]c" 'diff-hl-next-hunk "[c" 'diff-hl-previous-hunk - "'b" 'switch-to-next-buffer - ";b" 'switch-to-prev-buffer + "]b" 'switch-to-next-buffer + "[b" 'switch-to-prev-buffer "]t" 'tab-next "[t" 'tab-previous "P" 'consult-yank-from-kill-ring @@ -3767,6 +3605,7 @@ activates automatically. Edit, then `C-c C-c' commits, `C-c C-k' aborts." "mC" '(org-capture :wk "capture") "mc" '(:wk "set" :ignore) + "mk" '(kitty-gfx-org-heading-sizes :wk "kgfx headlines") "mcd" '(org-deadline :wk "deadline") "mcs" '(org-schedule :wk "schedule") "mce" '(org-set-effort :wk "effort") @@ -3929,13 +3768,19 @@ date) and the dired header are never skipped." ((string> a-feed b-feed) nil) (t (string< a-title b-title))))))) -(use-package elfeed-org - :ensure t +(use-package elfeed-protocol + :ensure (:host github :repo "fasheng/elfeed-protocol") :after elfeed :custom - (rmh-elfeed-org-files (list "~/org/rss.org")) + (elfeed-protocol-work-with-others t) + (elfeed-protocol-enabled-protocols '(ttrss)) + :init + (add-to-list 'elfeed-feeds + '("ttrss+https://cashmere@rss.cashmere.rs" + :password (password-store-get "rss.cashmere.rs")) + :append) :config - (elfeed-org)) + (elfeed-protocol-enable)) (use-package elfeed-goodies :ensure t @@ -3955,29 +3800,36 @@ date) and the dired header are never skipped." line-spacing 0.2) (shrface-mode 1)))) -(defun elfeed-export-from-index-to-csv (output-file) - (interactive "FExport CSV nach: ") - (require 'elfeed) - (require 'elfeed-db) - - (elfeed-db-load) - - (let ((entries (hash-table-values elfeed-db-entries))) - (with-temp-file output-file - (insert "id,title,link,date,feed,tags\n") - (dolist (entry entries) - (insert (format "\"%s\",\"%s\",\"%s\",%s,\"%s\",\"%s\"\n" - (elfeed-entry-id entry) - (replace-regexp-in-string "\"" "\"\"" (elfeed-entry-title entry)) - (elfeed-entry-link entry) - (elfeed-entry-date entry) - (elfeed-feed-title (elfeed-entry-feed entry)) - (mapconcat #'symbol-name (elfeed-entry-tags entry) " ")))) - (message "Export abgeschlossen: %d Einträge" (length entries))))) - -(setq browse-url-browser-function 'browse-url-generic +(setq browse-url-browser-function 'eww-browse-url + browse-url-secondary-browser-function 'browse-url-generic browse-url-generic-program "qutebrowser") +(defun my/browse-url-no-frame-guard (orig url &rest args) + "Around-advice for `browse-url' that resolves and calls the handler +directly, bypassing the upstream `browse-url' body. + +Emacs 30+ unconditionally calls `(frame-parameter nil \\='display)` +inside `browse-url' to set DISPLAY from the selected frame. That raises +\"Frames are not in use or not initialized\" in daemon contexts, and +in TUI-only setups the native-compiled frame access inside the subr +can throw even when `(frame-live-p (selected-frame))' returns non-nil +in the caller (the two checks diverge). + +For the handlers registered here (`eww-browse-url' is in-frame, +`browse-url-generic' inherits DISPLAY from the daemon env) the DISPLAY +reset is unnecessary, so do the handler resolution ourselves and skip +`browse-url' entirely." + (interactive (browse-url-interactive-arg "URL: ")) + (unless (called-interactively-p 'interactive) + (setq args (or args (list browse-url-new-window-flag)))) + (let ((function (or (browse-url-select-handler url) + browse-url-browser-function))) + (if (functionp function) + (apply function url args) + (error "No suitable browser for URL %s" url)))) + +(advice-add 'browse-url :around #'my/browse-url-no-frame-guard) + (use-package eww :ensure nil :commands (eww eww-search-words) @@ -4061,6 +3913,12 @@ date) and the dired header are never skipped." :ensure t :commands (link-hint-open-link link-hint-copy-link)) +(with-eval-after-load 'general + (general-def :states '(normal visual) + :keymaps '(eww-mode-map elfeed-show-mode-map elfeed-search-mode-map) + "f" #'link-hint-open-link + "F" #'link-hint-copy-link)) + (use-package engine-mode :ensure t :commands (engine/search-duckduckgo @@ -4184,18 +4042,6 @@ date) and the dired header are never skipped." (setq global-mode-string (delete '(:eval mu4e-alert-mode-line) global-mode-string))) -(use-package denote-project-notes - :ensure t - :after org - :config - (setq denote-project-notes-identifier '(project stayem))) - -(use-package arrow - :ensure t - :elpaca (arrow :host github :repo "vmargb/arrow.el") - :config - (setq arrow-mode 1)) - (use-package colorful-mode ;; :diminish :ensure t ; Optional @@ -4276,12 +4122,6 @@ opening another file in same project does not re-notify." (setq eshell-command-aliases-list '(("nh-switch" "nh os switch --quiet $*")))) -(use-package eshell-atuin - :ensure t - :after eshell - :config - (eshell-atuin-mode)) - (use-package el-fetch :ensure t) @@ -4291,6 +4131,7 @@ opening another file in same project does not re-notify." (ghostel-shell "xonsh") (ghostel-ssh-install-terminfo t) :config + (require 'ghostel-compile) (setq-default window-adjust-process-window-size-function #'window-adjust-process-window-size-largest) (add-to-list 'ghostel-tramp-shells '("rpc" login-shell)) @@ -4317,8 +4158,17 @@ opening another file in same project does not re-notify." :ensure (:host github :repo "dakra/ghostel" :files ("extensions/evil-ghostel/*.el")) :after (ghostel evil) + :custom + (evil-ghostel-escape 'evil) :hook (ghostel-mode . evil-ghostel-mode)) +(use-package inheritenv + :ensure t + :after ghostel + :config + (inheritenv-add-advice 'ghostel-compile) + (inheritenv-add-advice 'ghostel-recompile)) + (when-let* ((garden-dir (expand-file-name (or (alist-get 'garden my/local-packages) "~/garden"))) ((file-directory-p garden-dir))) @@ -4331,17 +4181,24 @@ opening another file in same project does not re-notify." (garden-auto-index-mode 1) (autoload 'garden "garden-dashboard" nil t) (autoload 'garden-sidecar "garden-dashboard" nil t) + (autoload 'garden-search "garden-dashboard" nil t) + (autoload 'garden-fleet "garden-fleet" nil t) (autoload 'garden-connect "garden-connect" nil t) - (autoload 'garden-connect-pick "garden-connect" nil t)) -;; (autoload 'garden-player "garden-player" nil t) - -(autoload 'denote-capf-setup "denote-capf" nil t) -(add-hook 'org-mode-hook #'denote-capf-setup) -(autoload 'garden-publish "garden-publish" nil t) -(autoload 'garden-publish-all "garden-publish" nil t) -(autoload 'garden-publish-wiki "garden-publish" nil t) -(autoload 'garden-publish-blog "garden-publish" nil t) -(autoload 'garden-publish-deploy "garden-publish" nil t) + (autoload 'garden-connect-pick "garden-connect" nil t) + (autoload 'garden-publish "garden-publish" nil t) + (autoload 'garden-publish-all "garden-publish" nil t) + (autoload 'garden-publish-wiki "garden-publish" nil t) + (autoload 'garden-publish-blog "garden-publish" nil t) + (autoload 'garden-publish-deploy "garden-publish" nil t) + (autoload 'denote-capf-setup "denote-capf" nil t) + (with-eval-after-load 'denote-capf + (setq denote-capf-directories '("~/org/"))) + (add-hook 'org-mode-hook #'denote-capf-setup) + + (defun my/denote-capf-tab-in-insert () + (when (featurep 'evil) + (evil-local-set-key 'insert (kbd "TAB") #'indent-for-tab-command))) + (add-hook 'org-mode-hook #'my/denote-capf-tab-in-insert)) (use-package sops :ensure (:type git :host github :repo "djgoku/sops") @@ -4359,12 +4216,6 @@ opening another file in same project does not re-notify." remote-file-name-inhibit-locks t tramp-verbose 1)) -(use-package difftastic - :ensure t - :defer t - :init - (difftastic-bindings-mode)) - (use-package magit-delta :ensure t :after magit @@ -4373,99 +4224,57 @@ opening another file in same project does not re-notify." (add-hook 'magit-mode-hook #'magit-delta-mode))) (use-package pretty-sha-path - :ensure t) - -(use-package grid - :ensure (:host github :repo "ichernyshovvv/grid.el") - :demand t) - -(use-package enlight - :ensure (:host github :repo "ichernyshovvv/enlight") - :demand t - :after grid + :ensure t :config - (require 'enlight-menu) - (defface my/enlight-header - '((t (:inherit font-lock-keyword-face :weight bold))) - "Enlight banner face.") - - (defvar my/enlight-banner - (propertize - "███████ ███ ███ █████ ██████ ███████ -██ ████ ████ ██ ██ ██ ██ -█████ ██ ████ ██ ███████ ██ ███████ -██ ██ ██ ██ ██ ██ ██ ██ -███████ ██ ██ ██ ██ ██████ ███████" - 'face 'my/enlight-header)) - - (defun my/enlight-stats () - (concat - (format "%d packages" (length (elpaca--queued))) - (when (bound-and-true-p after-init-time) - (format " · %s" (emacs-init-time "%.2fs"))) - (format " · %s" (format-time-string "%a %d %b %H:%M")))) - - (defun my/enlight-recent () - (if (bound-and-true-p recentf-list) - (mapconcat - (lambda (f) - (let ((s (abbreviate-file-name f))) - (if (> (length s) 32) - (concat "…" (substring s (- (length s) 31))) - s))) - (seq-take recentf-list 8) - "\n") - (propertize "no recent files" 'face 'shadow))) - - (defun my/enlight-content () - (concat - (grid-make-box `(:align center :width 80 :content ,my/enlight-banner)) - "\n" - (grid-make-box `(:align center :width 80 - :content ,(propertize (my/enlight-stats) 'face 'shadow))) - "\n\n" - (grid-make-row - (list - (grid-make-box - `(:width 40 :border t :padding 1 - :content ,(enlight-menu - '(("Quick actions" - ("Agenda" (org-agenda nil "d") "a") - ("Recent file" consult-recent-file "r") - ("Switch project" projectile-switch-project "p") - ("Find note" denote-open-or-create "n") - ("Journal entry" denote-journal-new-or-existing-entry "j") - ("Magit" magit-status "g") - ("Files" dirvish "f") - ("Mail" mu4e "m") - ("IRC" run-irc "i") - ("Switch workspace" my/workspace-switch-to "w") - ("Restore session" my/workspace-load-session "S")))))) - " " - (grid-make-box - `(:width 38 :border t :padding 1 - :content ,(concat (propertize "Recent files\n\n" 'face 'enlight-menu-section) - (my/enlight-recent))))))))) - -(defun my/enlight-refresh (&rest _) - (enlight--update 'enlight-content (my/enlight-content))) -(advice-add 'enlight :before #'my/enlight-refresh) - -(defun my/session-exists-p () - (file-exists-p (file-name-concat persp-save-dir persp-auto-save-fname))) - -(setq initial-buffer-choice - (lambda () - (if (and (fboundp 'enlight) (not (my/session-exists-p))) - (enlight) - (get-buffer-create "*scratch*")))) - -(add-hook 'elpaca-after-init-hook - (lambda () - (when (and (fboundp 'enlight-open) (not (my/session-exists-p))) - (enlight-open)))) + (setopt global-pretty-sha-path-mode 't)) -(with-eval-after-load 'evil - (evil-set-initial-state 'enlight-mode 'emacs)) +(use-package pulsar + :ensure t + :config + (setq pulsar-pulse t) + (setq pulsar-delay 0.025) + (setq pulsar-iterations 10) + (setq pulsar-face 'evil-ex-lazy-highlight) + (setq pulsar-tty-color "white") + + (let ((orig-face-background (symbol-function 'face-background))) + (defun my/pulsar--create-pulse (locus face) + "Like `pulsar--create-pulse' but with a smarter TTY colour fallback." + (let ((common-fn (lambda (locus face) + (let ((pulse-flag t) + (pulse-delay pulsar-delay) + (pulse-iterations pulsar-iterations) + (overlay (make-overlay (car locus) (cdr locus)))) + (overlay-put overlay 'pulse-delete t) + (overlay-put overlay 'window (frame-selected-window)) + (pulse-momentary-highlight-overlay overlay face))))) + (if (display-graphic-p) + (funcall common-fn locus face) + (cl-letf (((symbol-function 'face-background) + (lambda (f &optional frame inherit) + (let ((bg (funcall orig-face-background f frame inherit))) + (cond + ((and (eq f 'default) (null bg)) + (or (frame-parameter frame 'background-color) + (if (eq (frame-parameter frame 'background-mode) 'light) + "white" + "black"))) + ((null bg) pulsar-tty-color) + (t bg)))))) + (funcall common-fn locus face)))))) + (advice-add 'pulsar--create-pulse :override #'my/pulsar--create-pulse) + + (add-to-list 'pulsar-pulse-functions 'evil-scroll-down) + (add-to-list 'pulsar-pulse-functions 'flymake-goto-next-error) + (add-to-list 'pulsar-pulse-functions 'flymake-goto-prev-error) + (add-to-list 'pulsar-pulse-functions 'evil-yank) + (add-to-list 'pulsar-pulse-functions 'evil-yank-line) + (add-to-list 'pulsar-pulse-functions 'evil-delete) + (add-to-list 'pulsar-pulse-functions 'evil-delete-line) + (add-to-list 'pulsar-pulse-functions 'evil-jump-item) + (add-to-list 'pulsar-pulse-functions 'diff-hl-next-hunk) + (add-to-list 'pulsar-pulse-functions 'diff-hl-previous-hunk) + + (pulsar-global-mode)) (provide 'init) diff --git a/config.org b/config.org index 8a717ab..eeb4d82 100644 --- a/config.org +++ b/config.org @@ -33,10 +33,6 @@ * local packages -Per-host development overrides. Any package registered in the gitignored =local.el= is loaded from a local checkout on =load-path= instead of its remote source, so I can hack on it in place (for example =kitty-graphics.el= on its =testing= branch) while =config.org= stays identical across every machine. No per-package recipe wrapper is needed: an =:around= advice on the elpaca =:ensure= normalizer rewrites the recipe to =nil= for anything in =my/local-packages=, so the =use-package= forms downstream use the plain =:ensure (:host github :repo "...")= they always have and =local.el= overrides. - -=local.el= is loaded inside =eval-and-compile= so the advice sees the same =my/local-packages= whether =config.el= runs as source or native-compiled. After editing =local.el=, re-evaluate the file (or restart) so the new override takes effect. A minimal =local.el= looks like: - #+begin_example ;;; -*- lexical-binding: t -*- (setq my/local-packages @@ -53,6 +49,8 @@ Set per-host in the gitignored `local.el'.") (dolist (entry my/local-packages) (add-to-list 'load-path (expand-file-name (cdr entry)))) +(add-to-list 'load-path (expand-file-name "lisp" user-emacs-directory)) + (defun my/elpaca-skip-local (orig name _keyword args) (if (assq name my/local-packages) nil @@ -138,8 +136,8 @@ Set per-host in the gitignored `local.el'.") (scroll-bar-mode -1) (add-to-list 'default-frame-alist '(vertical-scroll-bars . nil)) (add-to-list 'default-frame-alist '(horizontal-scroll-bars . nil)) - ;; (set-frame-parameter (selected-frame) 'alpha-background 80) - ;; (add-to-list 'default-frame-alist '(alpha-background . 80)) + (set-frame-parameter (selected-frame) 'alpha-background 80) + (add-to-list 'default-frame-alist '(alpha-background . 80)) (global-hl-line-mode 1) ;; Highlight the current line (add-hook 'org-mode-hook (lambda () (setq-local global-hl-line-mode nil))) ;; hl-line repaints wipe kitty-graphics scaled headings (add-hook 'markdown-mode-hook (lambda () (setq-local global-hl-line-mode nil))) ;; same conflict for kitty-graphics markdown headings @@ -288,6 +286,8 @@ Selects the compilation window so the cursor lands in it." (setq insert-directory-program gls))))) #+end_src +* async + #+begin_src emacs-lisp (use-package async :ensure t) #+end_src @@ -781,7 +781,8 @@ Defined early so later =with-eval-after-load 'general= callbacks find =my-leader (my-leader "aa" '(agent-shell :wk "start") "at" '(agent-shell-toggle :wk "toggle") - "am" '(agent-shell-help-menu :wk "open session"))) + "am" '(agent-shell-help-menu :wk "open session") + "ar" '(agent-shell-send-region :wk "send region"))) :config ;; Runs only on first invocation — not at Emacs startup. @@ -814,7 +815,27 @@ let's give agent-shell another try #+begin_src emacs-lisp (defun my/agent-shell-autoscroll (&rest _) (dolist (win (get-buffer-window-list (current-buffer) 'visible)) - (set-window-point win (point-max)))) + (when (>= (window-point win) + (save-excursion (goto-char (point-max)) (point))) + (set-window-point win (point-max))))) + +(defun my/agent-shell-display-buffer-rules () + '(("\\*Kimi\\(?:\\*\\|:[^*]*\\*\\)" + (display-buffer-in-side-window) + (side . bottom) + (slot . 0) + (window-height . 0.33) + (dedicated . t) + (window-parameters . ((no-other-window . nil) + (no-delete-other-windows . nil)))) + ("\\*agent-shell-diff\\*" + (display-buffer-in-side-window) + (side . right) + (slot . 1) + (window-width . 0.5) + (dedicated . t) + (window-parameters . ((no-other-window . nil) + (no-delete-other-windows . nil)))))) (use-package agent-shell :ensure t @@ -823,10 +844,51 @@ let's give agent-shell another try ("M-RET" . shell-maker-submit)) :config (advice-add 'shell-maker-submit :after #'my/agent-shell-autoscroll) - (setq agent-shell-anthropic-claude-environment - (agent-shell-make-environment-variables - "ANTHROPIC_BASE_URL" "https://api.minimax.io/anthropic" - "ANTHROPIC_AUTH_TOKEN" (password-store-get "minimax")))) + (dolist (rule (my/agent-shell-display-buffer-rules)) + (add-to-list 'display-buffer-alist rule)) + (setq agent-shell-tool-use-expand-by-default nil) + (setq agent-shell-thought-process-expand-by-default nil) + (setq agent-shell-user-message-expand-by-default nil) + (setq agent-shell-diff-delta-command + (when (executable-find "delta") + '("delta" "--no-gitconfig" "--color-only"))) + + ;; Work around agent-shell's `window-system' guard so clipboard images + ;; work in terminal Emacs (`emacs -nw'). The underlying save routine + ;; already shells out to wl-paste/xclip/pngpaste/powershell, none of + ;; which require a GUI frame. + (defun my/agent-shell-send-clipboard-image-in-terminal (orig-fn &optional pick-shell) + "Call ORIG-FN, but allow `agent-shell-send-clipboard-image' in terminal Emacs." + (if (window-system) + (funcall orig-fn pick-shell) + (let* ((screenshots-dir (agent-shell--dot-subdir "screenshots")) + (image-path (agent-shell--save-clipboard-image + :destination-dir screenshots-dir)) + (shell-buffer (when pick-shell + (agent-shell--read-shell-buffer + :prompt "Send image to shell: ")))) + (agent-shell-insert + :text (agent-shell--get-files-context :files (list image-path)) + :shell-buffer shell-buffer)))) + (advice-add 'agent-shell-send-clipboard-image :around + #'my/agent-shell-send-clipboard-image-in-terminal) + + ;; Also let C-y / `agent-shell-yank-dwim' try the clipboard image first + ;; in terminal Emacs before falling back to plain text yank. + (defun my/agent-shell-yank-dwim-in-terminal (orig-fn &optional arg) + "Call ORIG-FN, but try clipboard image first in terminal Emacs." + (if (window-system) + (funcall orig-fn arg) + (if-let* ((screenshots-dir (agent-shell--dot-subdir "screenshots")) + (image-path (agent-shell--save-clipboard-image + :destination-dir screenshots-dir + :no-error t))) + (agent-shell-insert + :text (agent-shell--get-files-context :files (list image-path)) + :shell-buffer (agent-shell--shell-buffer)) + (funcall orig-fn arg)))) + (advice-add 'agent-shell-yank-dwim :around + #'my/agent-shell-yank-dwim-in-terminal)) #+end_src * eldoc @@ -1063,6 +1125,10 @@ Only activates when running in a Kitty terminal (not in GUI Emacs). (kitty-gfx-shr-fit-height 20) :hook (dired-mode . kitty-gfx-dired-auto-preview-mode) :config + (setq kitty-gfx-enable-browser t + kitty-gfx-casty-program "~/projects/casty/bin/casty.js" + kitty-gfx-casty-chrome "helium-browser") + (kitty-graphics-setup)) #+end_src @@ -1477,55 +1543,22 @@ Skips capture tasks and projects." *** org-capture helpers #+begin_src emacs-lisp -(defun my/org-capture-timeblock-to-journal () - "Capture function: jump to today's journal for a timeblock." - (require 'denote) +(defun my/org-capture-to-journal (heading) (require 'denote-journal) - (let* ((today (format-time-string "%Y-%m-%d")) - (journal-files (denote-directory-files "journal")) - (today-journal (seq-find - (lambda (file) - (string-match-p today (file-name-nondirectory file))) - journal-files))) - (if today-journal - (find-file today-journal) - (denote-journal-new-or-existing-entry)) - (goto-char (point-max)) - (insert "\n* Timeblock\n"))) + (denote-journal-new-or-existing-entry) + (goto-char (point-max)) + (insert heading) + (current-buffer)) + +(defun my/org-capture-timeblock-to-journal () + (my/org-capture-to-journal "\n* Timeblock\n")) (defun my/org-capture-recurring-to-journal () - "Capture function: jump to today's journal for a recurring task." - (require 'denote) - (require 'denote-journal) - (let* ((today (format-time-string "%Y-%m-%d")) - (journal-files (denote-directory-files "journal")) - (today-journal (seq-find - (lambda (file) - (string-match-p today (file-name-nondirectory file))) - journal-files))) - (if today-journal - (find-file today-journal) - (denote-journal-new-or-existing-entry)) - (goto-char (point-max)) - (insert "\n* Recurring\n"))) + (my/org-capture-to-journal "\n* Recurring\n")) (defun my/org-capture-meeting-to-journal () - "Capture function: jump to today's journal for a meeting note." - (require 'denote) - (require 'denote-journal) - (let* ((today (format-time-string "%Y-%m-%d")) - (journal-files (denote-directory-files "journal")) - (today-journal (seq-find - (lambda (file) - (string-match-p today (file-name-nondirectory file))) - journal-files))) - (if today-journal - (find-file today-journal) - (denote-journal-new-or-existing-entry)) - (goto-char (point-max)) - (insert "\n* Meeting: "))) + (my/org-capture-to-journal "\n* Meeting: ")) -;; capture templates are merged in the org-capture section below #+end_src @@ -1541,48 +1574,42 @@ Skips capture tasks and projects." *** org-capture combined with denote -#+begin_src emacs-lisp -(defun my/org-capture-denote-deadline () +#+begin_src emacs-lisp +(defun my/org-capture-task-to-journal () + (let* ((context (read-string "Task: ")) + (todo-state (completing-read "TODO state: " + '("ACTIVE" "NEXT" "TODO" "WAIT")))) + (my/org-capture-to-journal (format "\n* %s %s\n\n" todo-state context)))) + +(defun my/org-capture-deadline-to-journal () (let* ((context (read-string "Task with deadline: ")) - (deadline (org-read-date t nil nil "Deadline: ")) - (file-name (denote nil '("agenda")))) - (find-file file-name) - (goto-char (point-min)) - (forward-line 4) - (insert (format "\n* %s\nDEADLINE: <%s>\n\n" context deadline)) - (current-buffer))) + (deadline (org-read-date t nil nil "Deadline: "))) + (my/org-capture-to-journal (format "\n* %s\nDEADLINE: <%s>\n\n" context deadline)))) -(defun my/org-capture-denote-scheduled () +(defun my/org-capture-scheduled-to-journal () + "Capture a scheduled task into today's denote-journal file." (let* ((context (read-string "Scheduled task: ")) - (schedule (org-read-date t nil nil "Schedule: ")) - (file-name (denote nil '("agenda")))) - (find-file file-name) - (goto-char (point-min)) - (forward-line 4) - (insert (format "\n* %s\nSCHEDULED: <%s>\n\n" context schedule)) - (current-buffer))) + (schedule (org-read-date t nil nil "Schedule: "))) + (my/org-capture-to-journal (format "\n* %s\nSCHEDULED: <%s>\n\n" context schedule)))) -(defun my/org-capture-denote-task () - (let* ((context (read-string "Task: ")) - (todo-state (completing-read "TODO state: " - '("ACTIVE" "NEXT" "TODO" "WAIT"))) - (file-name (denote nil '("agenda")))) - (find-file file-name) - (goto-char (point-min)) - (forward-line 4) - (insert (format "\n* %s %s\n\n" todo-state context)) - (point))) +(defun my/org-capture-float () + (interactive) + (select-frame + (make-frame '((title . "org-capture") + (width . 160) + (height . 14)))) + (org-capture)) (with-eval-after-load 'org (setq org-capture-templates '(("t" "Task" plain - (function my/org-capture-denote-task) + (function my/org-capture-task-to-journal) "" :immediate-finish t :jump-to-captured t) ("d" "Deadline" plain - (function my/org-capture-denote-deadline) + (function my/org-capture-deadline-to-journal) "" :immediate-finish t :jump-to-captured t) ("s" "Scheduled" plain - (function my/org-capture-denote-scheduled) + (function my/org-capture-scheduled-to-journal) "" :immediate-finish t :jump-to-captured t) ("b" "Timeblock" plain (function my/org-capture-timeblock-to-journal) @@ -2350,6 +2377,34 @@ Timers that expired while Emacs was closed fire immediately." denote-merge-region-markdown-fenced-block)) #+end_src +** denote-solo + +Auto switch silos (different workspaces/folders) based on preconfigured directories. + +#+begin_src emacs-lisp +(use-package denote-solo + :ensure (:host github :repo "pavlo/denote-solo") + :after denote + :config + (setq denote-solo-directories + '(("org" . "~/org") + ("garden" . "~/garden")) + denote-solo-display-modeline nil) + (denote-solo-mode 1) + + (defun my/denote-solo-sync-directory (&rest _) + (when-let* ((solo denote-solo--current-solo) + (path (denote-solo--directory-for-name solo))) + (setq-default denote-directory path) + (dolist (buf (buffer-list)) + (with-current-buffer buf + (when (local-variable-p 'denote-directory) + (setq denote-directory path)))))) + + (advice-add 'denote-solo-switch :after #'my/denote-solo-sync-directory)) +#+end_src + + * which-key #+begin_src emacs-lisp @@ -3434,9 +3489,44 @@ Native =undo-redo= (via =evil-undo-system='undo-redo=); just raise the undo limi * modelines +** display-time + +#+begin_src emacs-lisp +(display-time-mode 1) +#+end_src + ** doom-modeline #+begin_src emacs-lisp +(defvar-local my/doom-modeline--buffer-title nil) + +(defun my/doom-modeline--update-title-cache () + "Update the cached #+title for the current buffer." + (setq-local my/doom-modeline--buffer-title + (cadar (org-collect-keywords '("TITLE"))))) + +(defun my/doom-modeline--buffer-title () + "Return the cached #+title, computing it if necessary." + (unless (local-variable-p 'my/doom-modeline--buffer-title) + (my/doom-modeline--update-title-cache)) + my/doom-modeline--buffer-title) + +(defun my/doom-modeline-set-buffer-title (&rest _) + "Override doom-modeline's cached file name with the Org #+title. +If no TITLE keyword is found, leave doom-modeline's default name." + (when-let* ((title (my/doom-modeline--buffer-title))) + (setq doom-modeline--buffer-file-name + (propertize title + 'face 'doom-modeline-buffer-file + 'mouse-face 'mode-line-highlight + 'help-echo (concat (or buffer-file-truename (buffer-name)) + "\nmouse-1: Previous buffer\nmouse-3: Next buffer") + 'local-map mode-line-buffer-identification-keymap)))) + +(defun my/doom-modeline--invalidate-title-cache () + "Invalidate the cached title so it gets recomputed on next update." + (kill-local-variable 'my/doom-modeline--buffer-title)) + (use-package doom-modeline :ensure t :init (doom-modeline-mode 1) @@ -3448,7 +3538,9 @@ Native =undo-redo= (via =evil-undo-system='undo-redo=); just raise the undo limi doom-modeline-buffer-file-name-style 'relative-to-project doom-modeline-buffer-encoding nil doom-modeline-time t - doom-modeline-time-live-icon t)) + doom-modeline-time-live-icon t) + (advice-add #'doom-modeline-update-buffer-file-name :after #'my/doom-modeline-set-buffer-title) + (add-hook 'before-save-hook #'my/doom-modeline--invalidate-title-cache)) #+end_src * soph @@ -4153,6 +4245,7 @@ activates automatically. Edit, then `C-c C-c' commits, `C-c C-k' aborts." "dl" '(denote-link-or-create t :wk "Link Note") "dn" '(denote t :wk "Create a new note") "dr" '(denote-rename-file t :wk "Rename Note") + "ds" '(denote-solo-switch :wk "switch silo") "dtl" '(tmr-list-timers :wk "list timer") "dtt" '(tmr :wk "set timer") @@ -4207,6 +4300,7 @@ activates automatically. Edit, then `C-c C-c' commits, `C-c C-k' aborts." "os" '(my/snip-upload :wk "snip buffer/region") "oS" '(my/snip-upload-file :wk "snip file") "op" '(pass :wk "pass") + "ot" '(ghostel :wk "ghostel") "h" '(:ignore t :wk "help") "hm" '(describe-mode :wk "mode") @@ -4279,17 +4373,19 @@ activates automatically. Edit, then `C-c C-c' commits, `C-c C-k' aborts." (call-interactively 'flash-evil-jump))))) (general-def '(normal visual operator) 'override + :predicate '(not (derived-mode-p 'mu4e-main-mode 'mu4e-headers-mode + 'mu4e-view-mode 'mu4e-compose-mode)) "s" 'my/s-key-dispatch) (general-def 'normal 'override "K" 'my/eldoc-and-jump - "'d" 'flycheck-next-error - ";d" 'flycheck-previous-error + "]d" 'flycheck-next-error + "[d" 'flycheck-previous-error "]c" 'diff-hl-next-hunk "[c" 'diff-hl-previous-hunk - "'b" 'switch-to-next-buffer - ";b" 'switch-to-prev-buffer + "]b" 'switch-to-next-buffer + "[b" 'switch-to-prev-buffer "]t" 'tab-next "[t" 'tab-previous "P" 'consult-yank-from-kill-ring @@ -4325,6 +4421,7 @@ activates automatically. Edit, then `C-c C-c' commits, `C-c C-k' aborts." "mC" '(org-capture :wk "capture") "mc" '(:wk "set" :ignore) + "mk" '(kitty-gfx-org-heading-sizes :wk "kgfx headlines") "mcd" '(org-deadline :wk "deadline") "mcs" '(org-schedule :wk "schedule") "mce" '(org-set-effort :wk "effort") @@ -5027,7 +5124,6 @@ opening another file in same project does not re-notify." (autoload 'garden-fleet "garden-fleet" nil t) (autoload 'garden-connect "garden-connect" nil t) (autoload 'garden-connect-pick "garden-connect" nil t) - ;; (autoload 'garden-player "garden-player" nil t) (autoload 'garden-publish "garden-publish" nil t) (autoload 'garden-publish-all "garden-publish" nil t) (autoload 'garden-publish-wiki "garden-publish" nil t) @@ -5036,34 +5132,13 @@ opening another file in same project does not re-notify." (autoload 'denote-capf-setup "denote-capf" nil t) (with-eval-after-load 'denote-capf (setq denote-capf-directories '("~/org/"))) - (add-hook 'org-mode-hook #'denote-capf-setup)) -#+end_src - -** garden-player + (add-hook 'org-mode-hook #'denote-capf-setup) -=garden-player= (=M-x garden-player=, buffer =*navi*=) is a terminal Navidrome client: it streams through a background =mpv= over a unix socket, renders a TUI with =vui=, and shows album art inline through the kitty graphics protocol. It opens on your starred library; press =u= to flip the song list to the live up-next queue and =u= again to flip back. - -The buffer installs =garden-player-map= as a vim-like keymap (bindings live in =garden-player.el=, this is just the reference): - -| key | action | -|-----+--------------------------------------------------------------| -| =SPC= | play / pause | -| =h= | previous track | -| =l= | next track | -| =j= | restart the current song from the beginning | -| =k= | cycle repeat mode: off → repeat-all → repeat-one | -| =s= | reshuffle the queue | -| =n= | search the library and play the pick next (no playlist made) | -| =N= | search the library and append the pick to the end | -| =/= | same as =N= | -| =u= | toggle between the starred library and the up-next queue | -| =f= | favorite the current song | -| =r= | radio: queue songs similar to the current one | -| =g= | reload the starred library | -| =q= | quit | -| =RET= | play the song on the current row | - -Interactive commands: =garden-player= opens the player; =garden-player-play-next= and =garden-player-enqueue= search and queue without touching server playlists; =garden-player-toggle-view= flips the list; =garden-player-repeat=, =garden-player-shuffle=, =garden-player-radio=, =garden-player-like=, =garden-player-refresh= and =garden-player-quit= back the matching keys. + (defun my/denote-capf-tab-in-insert () + (when (featurep 'evil) + (evil-local-set-key 'insert (kbd "TAB") #'indent-for-tab-command))) + (add-hook 'org-mode-hook #'my/denote-capf-tab-in-insert)) +#+end_src * sops @@ -5104,52 +5179,62 @@ Interactive commands: =garden-player= opens the player; =garden-player-play-next #+begin_src emacs-lisp (use-package pretty-sha-path - :ensure t) + :ensure t + :config + (setopt global-pretty-sha-path-mode 't)) #+end_src - -* COMMENT empv +* pulsar-mode #+begin_src emacs-lisp -(use-package empv +(use-package pulsar :ensure t - :defer t :config - (setq empv-subsonic-url (password-store-get-field "navidrome" "url")) - (setq empv-subsonic-username (password-store-get-field "navidrome" "username")) - (setq empv-subsonic-password (password-store-get "navidrome")) - - (defun my/empv-subsonic-starred-songs () - "List every starred song in Navidrome and play the selected one." - (interactive) - (empv--subsonic-request - "getStarred2.view" - (empv--subsonic-result-handler "Starred song:"))) - - (defun my/empv-subsonic-starred-songs-urls () - "Return a list of playable URLs for every starred song." - (thread-last - (empv--subsonic-request "getStarred2.view") - (alist-get 'results) - (seq-filter (lambda (item) (eq (alist-get 'kind item) 'song))) - (mapcar #'empv--subsonic-item-extract-url))) - - (defun my/empv-subsonic-play-all-starred-songs () - "Clear the playlist and play every starred song." - (interactive) - (if-let ((urls (my/empv-subsonic-starred-songs-urls))) - (empv-play urls) - (message "No starred songs found"))) - - (defun my/empv-subsonic-enqueue-all-starred-songs () - "Append every starred song to the current playlist." - (interactive) - (if-let ((urls (my/empv-subsonic-starred-songs-urls))) - (empv-enqueue urls) - (message "No starred songs found"))) - - (with-eval-after-load 'embark - (empv-embark-initialize-extra-actions))) + (setq pulsar-pulse t) + (setq pulsar-delay 0.025) + (setq pulsar-iterations 10) + (setq pulsar-face 'evil-ex-lazy-highlight) + (setq pulsar-tty-color "white") + + (let ((orig-face-background (symbol-function 'face-background))) + (defun my/pulsar--create-pulse (locus face) + "Like `pulsar--create-pulse' but with a smarter TTY colour fallback." + (let ((common-fn (lambda (locus face) + (let ((pulse-flag t) + (pulse-delay pulsar-delay) + (pulse-iterations pulsar-iterations) + (overlay (make-overlay (car locus) (cdr locus)))) + (overlay-put overlay 'pulse-delete t) + (overlay-put overlay 'window (frame-selected-window)) + (pulse-momentary-highlight-overlay overlay face))))) + (if (display-graphic-p) + (funcall common-fn locus face) + (cl-letf (((symbol-function 'face-background) + (lambda (f &optional frame inherit) + (let ((bg (funcall orig-face-background f frame inherit))) + (cond + ((and (eq f 'default) (null bg)) + (or (frame-parameter frame 'background-color) + (if (eq (frame-parameter frame 'background-mode) 'light) + "white" + "black"))) + ((null bg) pulsar-tty-color) + (t bg)))))) + (funcall common-fn locus face)))))) + (advice-add 'pulsar--create-pulse :override #'my/pulsar--create-pulse) + + (add-to-list 'pulsar-pulse-functions 'evil-scroll-down) + (add-to-list 'pulsar-pulse-functions 'flymake-goto-next-error) + (add-to-list 'pulsar-pulse-functions 'flymake-goto-prev-error) + (add-to-list 'pulsar-pulse-functions 'evil-yank) + (add-to-list 'pulsar-pulse-functions 'evil-yank-line) + (add-to-list 'pulsar-pulse-functions 'evil-delete) + (add-to-list 'pulsar-pulse-functions 'evil-delete-line) + (add-to-list 'pulsar-pulse-functions 'evil-jump-item) + (add-to-list 'pulsar-pulse-functions 'diff-hl-next-hunk) + (add-to-list 'pulsar-pulse-functions 'diff-hl-previous-hunk) + + (pulsar-global-mode)) #+end_src * provide init diff --git a/lisp/agent-shell-diff.el b/lisp/agent-shell-diff.el new file mode 100644 index 0000000..b78bdf1 --- /dev/null +++ b/lisp/agent-shell-diff.el @@ -0,0 +1,268 @@ +;;; agent-shell-diff.el --- A quick way to query/display a diff. -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Alvaro Ramirez + +;; Author: Alvaro Ramirez https://xenodium.com +;; URL: https://github.com/xenodium/agent-shell + +;; This package is free software; you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation; either version 3, or (at your option) +;; any later version. + +;; This package is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with GNU Emacs. If not, see . + +;;; Commentary: +;; +;; Report issues at https://github.com/xenodium/agent-shell/issues +;; +;; ✨ Please support this work https://github.com/sponsors/xenodium ✨ + +;;; Code: + +(eval-when-compile + (require 'cl-lib)) +(require 'ansi-color) +(require 'diff) +(require 'diff-mode) + +(defcustom agent-shell-diff-delta-command + '("delta" "--no-gitconfig" "--color-only") + "Command and arguments used to colorize diffs with delta. + +Set to nil to disable delta and fall back to the standard `diff-no-select' +output. delta must be installed and on `exec-path'." + :type '(choice (const :tag "Disable delta" nil) + (repeat string)) + :group 'agent-shell) + +(defvar-local agent-shell-diff--on-exit nil + "Function to call when the diff buffer is killed. + +This variable is automatically set by :on-exit from `agent-shell-diff' +and can be temporarily let-bound to nil to prevent the +on-exit callback from running when the buffer is killed.") + +(defvar-local agent-shell-diff--file nil + "Buffer-local file path associated with the diff.") + +(defvar-local agent-shell-diff--accept-all-command nil + "Buffer-local command to accept all changes in the diff.") + +(defvar-local agent-shell-diff--reject-all-command nil + "Buffer-local command to reject all changes in the diff.") + +(defvar-local agent-shell-diff--used-delta nil + "Non-nil when the current buffer's diff was generated using delta.") + +(defvar agent-shell-diff-mode-map + (let ((map (make-sparse-keymap))) + (define-key map (kbd "n") #'diff-hunk-next) + (define-key map (kbd "p") #'diff-hunk-prev) + (define-key map (kbd "y") #'agent-shell-diff-accept-all) + (define-key map (kbd "C-c C-c") #'agent-shell-diff-reject-all) + (define-key map (kbd "f") #'agent-shell-diff-open-file) + (define-key map (kbd "q") #'kill-current-buffer) + map) + "Keymap for `agent-shell-diff-mode'.") + +(define-derived-mode agent-shell-diff-mode diff-mode "Agent-Shell-Diff" + "Major mode for `agent-shell' diff buffers. +Derives from `diff-mode'. Provides `agent-shell-diff-accept-all' +and `agent-shell-diff-reject-all' commands that can be rebound +via `agent-shell-diff-mode-map'." + :group 'agent-shell + ;; Don't inherit diff-mode-map (some bindings can be destructive). + (set-keymap-parent agent-shell-diff-mode-map nil) + (setq buffer-read-only t)) + +(defun agent-shell-diff-kill-buffer (buffer) + "Kill diff BUFFER, suppressing any `agent-shell-diff--on-exit' callback. +If BUFFER is not live, do nothing." + (when (buffer-live-p buffer) + (with-current-buffer buffer + (setq agent-shell-diff--on-exit nil)) + (kill-buffer buffer))) + +(defun agent-shell-diff-accept-all () + "Accept all changes in the current diff buffer." + (interactive) + (if agent-shell-diff--accept-all-command + (let ((buf (current-buffer))) + (funcall agent-shell-diff--accept-all-command) + (when (buffer-live-p buf) + (let ((agent-shell-diff--on-exit nil)) + (kill-buffer buf)))) + (user-error "No accept command available in this buffer"))) + +(defun agent-shell-diff-reject-all () + "Reject all changes in the current diff buffer." + (interactive) + (if agent-shell-diff--reject-all-command + (let ((buf (current-buffer))) + (when (funcall agent-shell-diff--reject-all-command) + (when (buffer-live-p buf) + (let ((agent-shell-diff--on-exit nil)) + (kill-buffer buf))))) + (user-error "No reject command available in this buffer"))) + +(cl-defun agent-shell-diff (&key old new on-exit on-accept on-reject title file) + "Display a diff between OLD and NEW strings in a buffer. + +Creates a new buffer showing the differences between OLD and NEW +using `agent-shell-diff-mode'. The buffer is read-only. + +When the buffer is killed, calls ON-EXIT with no arguments. + +Returns the newly created diff buffer. + +Arguments: + :OLD - Original string content + :NEW - Modified string content + :ON-EXIT - Function called with no arguments when buffer is killed + :ON-ACCEPT - Command to accept all changes + :ON-REJECT - Command to reject all changes + :TITLE - Optional title to display in header line + :FILE - File path" + (let* ((diff-buffer (generate-new-buffer "*agent-shell-diff*")) + (calling-window (selected-window)) + (calling-buffer (current-buffer)) + (interrupt-key (where-is-internal 'agent-shell-interrupt + (current-local-map) t))) + (unwind-protect + (progn + (with-current-buffer diff-buffer + (let ((inhibit-read-only t) + (diff-mode-read-only nil)) + (erase-buffer) + ;; Set mode before inserting diff so diff-no-select + ;; doesn't reset font-lock (see #316). + (agent-shell-diff-mode) + (agent-shell-diff--insert-diff old new file diff-buffer) + ;; Add overlays to hide scary text. + (save-excursion + (goto-char (point-min)) + ;; `diff-no-select' adds a command line at the top and + ;; "Diff finished." at the bottom. delta output does not, + ;; so only strip those when delta was not used. + (unless agent-shell-diff--used-delta + (delete-region (point) (progn (forward-line 1) (point))) + (delete-region (progn (goto-char (point-max)) (forward-line -1) (forward-line 0) (point)) + (point-max)) + (goto-char (point-min))) + (goto-char (point-min)) + ;; Hide --- and +++ lines + (while (re-search-forward "^\\(---\\|\\+\\+\\+\\).*\n" nil t) + (let ((overlay (make-overlay (match-beginning 0) (match-end 0)))) + (overlay-put overlay 'category 'diff-header) + (overlay-put overlay 'display "") + (overlay-put overlay 'evaporate t))) + ;; Replace @@ lines with "Changes" + (goto-char (point-min)) + (while (re-search-forward "^@@.*@@.*\n" nil t) + (let ((overlay (make-overlay (match-beginning 0) (match-end 0))) + (face 'diff-hunk-header)) ; or any face you prefer + (overlay-put overlay 'category 'diff-header) + ;; Intended display is: + ;; ╭─────────╮ + ;; │ changes │ + ;; ╰─────────╯ + ;; Using before-string so diff-hunk-next + ;; lands on "│" instead of "╭". + (overlay-put overlay 'before-string + (propertize "\n╭─────────╮\n" 'face face)) + (overlay-put overlay 'display + (propertize "│ changes │\n╰─────────╯\n\n" 'face face)) + (overlay-put overlay 'evaporate t))))) + (goto-char (point-min)) + (ignore-errors (diff-hunk-next)) + (setq agent-shell-diff--file file + agent-shell-diff--accept-all-command on-accept + agent-shell-diff--reject-all-command on-reject) + (when on-exit + (setq agent-shell-diff--on-exit on-exit) + (add-hook 'kill-buffer-hook + (lambda () + (when (and agent-shell-diff--on-exit + (buffer-live-p calling-buffer)) + (with-current-buffer calling-buffer + (funcall on-exit))) + ;; Give focus back to calling buffer. + (when (buffer-live-p calling-buffer) + (ignore-errors + (when (window-live-p calling-window) + (unless (eq (window-buffer calling-window) calling-buffer) + (set-window-buffer calling-window calling-buffer)) + (select-window calling-window))))) + nil t)) + (let ((map (copy-keymap agent-shell-diff-mode-map))) + (when (and interrupt-key + (not (lookup-key map interrupt-key))) + (define-key map interrupt-key #'agent-shell-diff-reject-all)) + (use-local-map map)) + (setq header-line-format + (substitute-command-keys + (concat + " " + (when title + (concat (propertize title 'face 'mode-line-emphasis) " ")) + "\\[diff-hunk-next] next hunk " + "\\[diff-hunk-prev] previous hunk " + "\\[agent-shell-diff-accept-all] accept " + "\\[agent-shell-diff-reject-all] reject " + "\\[agent-shell-diff-open-file] open " + "\\[kill-current-buffer] quit")))) + diff-buffer) + (pop-to-buffer diff-buffer '((display-buffer-use-some-window + display-buffer-same-window)))))) + +(defun agent-shell-diff-open-file () + "Open the file associated with the current diff buffer." + (interactive) + (if agent-shell-diff--file + (find-file agent-shell-diff--file) + (user-error "No file associated with this diff buffer"))) + +(defun agent-shell-diff--insert-diff (old new file buf) + "Insert diff from FILE between OLD and NEW strings in buffer BUF. + +When `agent-shell-diff-delta-command' is non-nil and delta is available +on `exec-path', pipe the unified diff through delta for syntax +highlighting; otherwise fall back to `diff-no-select'." + (let* ((suffix (format ".%s" (file-name-extension file))) + (old-file (make-temp-file "old" nil suffix)) + (new-file (make-temp-file "new" nil suffix)) + (use-delta (and agent-shell-diff-delta-command + (executable-find (car agent-shell-diff-delta-command))))) + (with-temp-file old-file (insert old)) + (with-temp-file new-file (insert new)) + (with-current-buffer buf + (setq agent-shell-diff--used-delta use-delta) + (if use-delta + (let ((coding-system-for-read 'utf-8) + (coding-system-for-write 'utf-8) + (command (concat "diff -U3 " + (shell-quote-argument old-file) + " " + (shell-quote-argument new-file) + " | " + (mapconcat #'shell-quote-argument + agent-shell-diff-delta-command + " ")))) + (call-process-shell-command command nil t nil) + ;; `ansi-color-apply-on-region' can drop properties in some + ;; Emacs builds, so apply to the string and re-insert. + (let ((colored (ansi-color-apply (buffer-string)))) + (erase-buffer) + (insert colored))) + (diff-no-select old-file new-file "-U3" t buf))))) + +(provide 'agent-shell-diff) + +;;; agent-shell-diff.el ends here diff --git a/lisp/agent-shell-kimi.el b/lisp/agent-shell-kimi.el new file mode 100644 index 0000000..3a9d948 --- /dev/null +++ b/lisp/agent-shell-kimi.el @@ -0,0 +1,612 @@ +;;; agent-shell-kimi.el --- Kimi Code agent configurations -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Nicolai Singh + +;; This package is free software; you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation; either version 3, or (at your option) +;; any later version. + +;; This package is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with GNU Emacs. If not, see . + +;;; Commentary: +;; +;; This file includes Kimi Code configuration using `kimi acp'. +;; +;; It exposes the Kimi-specific flags that are available when starting an +;; ACP server (`--yolo', `--auto', `--plan', `--model', `--add-dir', +;; `--skills-dir') and integrates with Kimi's session index so recent +;; sessions can be resumed from Emacs. + +;;; Code: + +(eval-when-compile + (require 'cl-lib)) +(require 'json) +(require 'shell-maker) +(require 'acp) + +(declare-function agent-shell--indent-string "agent-shell") +(declare-function agent-shell--make-acp-client "agent-shell") +(declare-function agent-shell-make-agent-config "agent-shell") +(autoload 'agent-shell-make-agent-config "agent-shell") +(declare-function agent-shell--dwim "agent-shell") +(declare-function agent-shell-start "agent-shell") +(declare-function shell-maker-welcome-message "shell-maker") +(defvar shell-maker-prompt) +(defvar shell-maker-prompt-regexp) + +(defcustom agent-shell-kimi-acp-command + '("kimi" "acp") + "Command and parameters for the Kimi Code CLI client. + +The first element is the command name, and the rest are command parameters. +This is the base command; flags controlled by the other custom variables +in this file are prepended before the `acp' subcommand." + :type '(repeat string) + :group 'agent-shell) + +(defcustom agent-shell-kimi-default-model-id + nil + "Default Kimi model ID. + +Must be one of the model ID's displayed under \"Available models\" +when starting a new shell. + +Can be set to either a string or a function that returns a string." + :type '(choice (const nil) string function) + :group 'agent-shell) + +(defcustom agent-shell-kimi-default-session-mode-id + nil + "Default Kimi Code session mode ID. + +Must be one of the mode ID's displayed under \"Available modes\" +when starting a new shell. + +Can be set to either a string or a function that returns a string." + :type '(choice (const nil) string function) + :group 'agent-shell) + +(defcustom agent-shell-kimi-environment + nil + "Environment variables for the Kimi Code CLI client. + +This should be a list of environment variables to be used when +starting the Kimi client process." + :type '(repeat string) + :group 'agent-shell) + +(defcustom agent-shell-kimi-yolo + nil + "When non-nil, pass `--yolo' to `kimi' so all actions are auto-approved. + +See `kimi --help' for the exact semantics." + :type 'boolean + :group 'agent-shell) + +(defcustom agent-shell-kimi-auto + nil + "When non-nil, pass `--auto' to `kimi' to start in auto permission mode." + :type 'boolean + :group 'agent-shell) + +(defcustom agent-shell-kimi-plan + nil + "When non-nil, pass `--plan' to `kimi' to start in plan mode." + :type 'boolean + :group 'agent-shell) + +(defcustom agent-shell-kimi-model + nil + "Kimi model alias passed to `--model'. + +Can be either a string or a function that returns a string. When nil, +no `--model' flag is added and Kimi uses its configured default." + :type '(choice (const nil) string function) + :group 'agent-shell) + +(defcustom agent-shell-kimi-additional-dirs + nil + "List of additional workspace directories passed via `--add-dir'. + +Each entry should be an expanded directory path." + :type '(repeat string) + :group 'agent-shell) + +(defcustom agent-shell-kimi-skills-dirs + nil + "List of skills directories passed via `--skills-dir'. + +Each entry should be an expanded directory path." + :type '(repeat string) + :group 'agent-shell) + +(defcustom agent-shell-kimi-extra-command-params + nil + "Extra command-line parameters passed to `kimi' before the `acp' subcommand. + +Use this for flags that do not yet have a dedicated custom variable." + :type '(repeat string) + :group 'agent-shell) + +(defcustom agent-shell-kimi-session-index + (expand-file-name "~/.kimi-code/session_index.jsonl") + "Path to Kimi's session index file. + +Used by `agent-shell-kimi-resume-session' to list recent sessions." + :type 'file + :group 'agent-shell) + +(cl-defun agent-shell-kimi-make-authentication (&key api-key login none) + "Create a Kimi authentication configuration. + +API-KEY is a Kimi API key string or a function that returns one. +LOGIN when non-nil indicates login-based (device-code) authentication. +NONE when non-nil indicates no authentication method is requested. + +Only one of API-KEY, LOGIN, or NONE should be provided." + (when (> (seq-count #'identity (list api-key login none)) 1) + (error "Cannot specify multiple authentication methods - choose one")) + (unless (> (seq-count #'identity (list api-key login none)) 0) + (error "Must specify one of :api-key, :login, or :none")) + (cond + (api-key `((:api-key . ,api-key))) + (login `((:login . t))) + (none `((:none . t))))) + +(defcustom agent-shell-kimi-authentication + (agent-shell-kimi-make-authentication :none t) + "Configuration for Kimi authentication. + +For no explicit authentication (default): + + (setq agent-shell-kimi-authentication + (agent-shell-kimi-make-authentication :none t)) + +For login-based authentication: + + (setq agent-shell-kimi-authentication + (agent-shell-kimi-make-authentication :login t)) + +For API key (string): + + (setq agent-shell-kimi-authentication + (agent-shell-kimi-make-authentication :api-key \"your-key\")) + +For API key (function): + + (setq agent-shell-kimi-authentication + (agent-shell-kimi-make-authentication :api-key (lambda () ...)))" + :type 'alist + :group 'agent-shell) + +(defcustom agent-shell-kimi-login-command + '("kimi" "login") + "Command used by `agent-shell-kimi-login' to run the device-code flow." + :type '(repeat string) + :group 'agent-shell) + +(defcustom agent-shell-kimi-show-model-in-prompt + t + "When non-nil, include the active model/mode in the Kimi shell prompt." + :type 'boolean + :group 'agent-shell) + +;;;; Faces + +(defface agent-shell-kimi-prompt-face + '((t (:foreground "#81c8be" :weight bold :inherit fixed-pitch))) + "Face for the Kimi shell prompt." + :group 'agent-shell) + +(defface agent-shell-kimi-user-face + '((t (:inherit default))) + "Face for user messages in the Kimi shell." + :group 'agent-shell) + +(defface agent-shell-kimi-assistant-face + '((t (:foreground "#8caaee"))) + "Face for assistant messages in the Kimi shell." + :group 'agent-shell) + +(defface agent-shell-kimi-tool-face + '((t (:foreground "#ca9ee6"))) + "Face for tool-use sections in the Kimi shell." + :group 'agent-shell) + +(defface agent-shell-kimi-thought-face + '((t (:foreground "#a5adce" :slant italic))) + "Face for thought-process sections in the Kimi shell." + :group 'agent-shell) + +(defface agent-shell-kimi-error-face + '((t (:foreground "#e78284" :weight bold))) + "Face for errors in the Kimi shell." + :group 'agent-shell) + +;;;; Authentication & helpers + +(defun agent-shell-kimi-key () + "Get the Kimi API key from `agent-shell-kimi-authentication'." + (cond ((stringp (map-elt agent-shell-kimi-authentication :api-key)) + (map-elt agent-shell-kimi-authentication :api-key)) + ((functionp (map-elt agent-shell-kimi-authentication :api-key)) + (condition-case _err + (funcall (map-elt agent-shell-kimi-authentication :api-key)) + (error + "API key not found. Check out `agent-shell-kimi-authentication'"))) + (t + nil))) + +(defun agent-shell-kimi-login () + "Run `kimi login' to authenticate via the device-code flow." + (interactive) + (unless agent-shell-kimi-login-command + (user-error "`agent-shell-kimi-login-command' is not set")) + (let ((command (mapconcat #'shell-quote-argument agent-shell-kimi-login-command " "))) + (async-shell-command command "*Kimi login*"))) + +(defun agent-shell-kimi--resolve-string-or-function (value) + "Resolve VALUE if it is a function, otherwise return it as-is." + (if (functionp value) + (funcall value) + value)) + +(defun agent-shell-kimi--command-params () + "Return the complete list of parameters passed to `kimi' before `acp'." + (let ((params nil)) + (when agent-shell-kimi-yolo + (push "--yolo" params)) + (when agent-shell-kimi-auto + (push "--auto" params)) + (when agent-shell-kimi-plan + (push "--plan" params)) + (when-let* ((model (agent-shell-kimi--resolve-string-or-function agent-shell-kimi-model)) + ((not (string-empty-p model)))) + (push "--model" params) + (push model params)) + (dolist (dir (reverse agent-shell-kimi-additional-dirs)) + (push "--add-dir" params) + (push dir params)) + (dolist (dir (reverse agent-shell-kimi-skills-dirs)) + (push "--skills-dir" params) + (push dir params)) + (dolist (arg (reverse agent-shell-kimi-extra-command-params)) + (push arg params)) + (nreverse params))) + +(defun agent-shell-kimi--active-model-string () + "Return the currently configured Kimi model alias, or nil if none." + (when-let* ((model (agent-shell-kimi--resolve-string-or-function + agent-shell-kimi-model)) + ((not (string-empty-p model)))) + model)) + +(defun agent-shell-kimi--active-mode-string () + "Return the currently configured Kimi session mode ID, or nil if none." + (when-let* ((mode (agent-shell-kimi--resolve-string-or-function + agent-shell-kimi-default-session-mode-id)) + ((not (string-empty-p mode)))) + mode)) + +(defun agent-shell-kimi--prompt-string () + "Return the propertized Kimi shell prompt string." + (let ((extra nil)) + (when agent-shell-kimi-show-model-in-prompt + (let ((model (agent-shell-kimi--active-model-string)) + (mode (agent-shell-kimi--active-mode-string))) + (when (or model mode) + (setq extra (concat "(" + (or model "default") + (when mode (concat "|" mode)) + ")"))))) + (propertize (concat "Kimi" extra "> ") + 'font-lock-face 'agent-shell-kimi-prompt-face))) + +(defun agent-shell-kimi-update-prompt () + "Update the prompt in the current Kimi shell buffer." + (interactive) + (when (eq major-mode 'agent-shell-mode) + (setq-local shell-maker-prompt (agent-shell-kimi--prompt-string)) + (setq-local shell-maker-prompt-regexp + (concat "^" (regexp-quote (substring-no-properties shell-maker-prompt)))))) + +(defun agent-shell-kimi-make-config () + "Create a Kimi Code agent configuration. + +Returns an agent configuration alist using `agent-shell-make-agent-config'." + (agent-shell-make-agent-config + :identifier 'kimi + :mode-line-name "Kimi" + :buffer-name "Kimi" + :shell-prompt (agent-shell-kimi--prompt-string) + :shell-prompt-regexp "Kimi[^>]*> " + :welcome-function #'agent-shell-kimi--welcome-message + :client-maker (lambda (buffer) + (agent-shell-kimi-make-client :buffer buffer)) + :default-model-id (lambda () (agent-shell-kimi--resolve-string-or-function + agent-shell-kimi-default-model-id)) + :default-session-mode-id (lambda () (agent-shell-kimi--resolve-string-or-function + agent-shell-kimi-default-session-mode-id)) + :install-instructions "See https://www.kimi.com/code for installation.")) + +(defun agent-shell-kimi-start-agent () + "Start an interactive Kimi Code agent shell." + (interactive) + (agent-shell--dwim :config (agent-shell-kimi-make-config) + :new-shell t)) + +(defun agent-shell-kimi-start-coding () + "Start a Kimi shell for normal coding. + +This is equivalent to `agent-shell-kimi-start-agent' but explicitly +resets `--plan' and `--yolo' so the shell starts in plain coding mode." + (interactive) + (let ((agent-shell-kimi-plan nil) + (agent-shell-kimi-yolo nil)) + (agent-shell-kimi-start-agent))) + +(defun agent-shell-kimi-start-plan () + "Start a Kimi shell in plan mode (`--plan')." + (interactive) + (let ((agent-shell-kimi-plan t) + (agent-shell-kimi-yolo nil)) + (agent-shell-kimi-start-agent))) + +(defun agent-shell-kimi-start-yolo () + "Start a Kimi shell in yolo/auto-approve mode (`--yolo')." + (interactive) + (let ((agent-shell-kimi-yolo t)) + (agent-shell-kimi-start-agent))) + +(defun agent-shell-kimi-start-review () + "Start a Kimi shell tuned for code review. + +Enables `--plan' and, if no model is set, falls back to +`kimi-for-coding'. Customize `agent-shell-kimi-model' to change the +review model." + (interactive) + (let ((agent-shell-kimi-plan t) + (agent-shell-kimi-yolo nil) + (agent-shell-kimi-model (or agent-shell-kimi-model "kimi-for-coding"))) + (agent-shell-kimi-start-agent))) + +(cl-defun agent-shell-kimi-make-client (&key buffer) + "Create a Kimi Code ACP client with BUFFER as context." + (unless buffer + (error "Missing required argument: :buffer")) + (let* ((command (car agent-shell-kimi-acp-command)) + (base-params (cdr agent-shell-kimi-acp-command)) + (kimi-params (agent-shell-kimi--command-params)) + ;; Global `kimi' flags go before the `acp' subcommand. + (command-params (if (string= (car base-params) "acp") + (append kimi-params base-params) + (append base-params kimi-params))) + (env-vars (append agent-shell-kimi-environment + (when-let* ((api-key (agent-shell-kimi-key)) + ((not (map-elt agent-shell-kimi-authentication :none)))) + (list (format "KIMI_API_KEY=%s" api-key)))))) + (agent-shell--make-acp-client :command command + :command-params command-params + :environment-variables env-vars + :context-buffer buffer))) + +(defun agent-shell-kimi--read-jsonl (path) + "Return a list of parsed JSON objects from PATH (one per line)." + (when (file-exists-p path) + (with-temp-buffer + (insert-file-contents path) + (let ((result nil)) + (goto-char (point-min)) + (while (not (eobp)) + (let ((line (string-trim (thing-at-point 'line t)))) + (unless (string-empty-p line) + (push (json-parse-string line :object-type 'alist) result))) + (forward-line 1)) + (nreverse result))))) + +(defun agent-shell-kimi--session-mtime (session) + "Return the modification time of SESSION's directory, or 0 if missing." + (let ((dir (cdr (assq 'sessionDir session)))) + (if (file-directory-p dir) + (time-to-seconds (file-attribute-modification-time (file-attributes dir))) + 0))) + +(defun agent-shell-kimi--relative-time (seconds) + "Return a human-readable string for SECONDS since the epoch." + (let ((delta (max 0 (- (float-time) seconds)))) + (cond ((< delta 60) "just now") + ((< delta 3600) (format "%d min ago" (floor delta 60))) + ((< delta 86400) (format "%d hr ago" (floor delta 3600))) + ((< delta 604800) (format "%d days ago" (floor delta 86400))) + (t (format "%d wks ago" (floor delta 604800)))))) + +(defun agent-shell-kimi--session-project-name (session) + "Return a project-like name for SESSION's working directory." + (let ((dir (abbreviate-file-name (or (cdr (assq 'workDir session)) "")))) + (if (string-empty-p dir) + "?" + (file-name-nondirectory (directory-file-name dir))))) + +(defun agent-shell-kimi--session-label (session) + "Return a display label for SESSION suitable for `completing-read'." + (let ((id (cdr (assq 'sessionId session))) + (dir (abbreviate-file-name (or (cdr (assq 'workDir session)) ""))) + (project (agent-shell-kimi--session-project-name session)) + (mtime (agent-shell-kimi--session-mtime session))) + (format "%s %s %s %s" + project + (agent-shell-kimi--relative-time mtime) + dir + (or id "")))) + +(defun agent-shell-kimi--session-current-p (session) + "Return non-nil if SESSION's workDir is under `default-directory'." + (let ((dir (cdr (assq 'workDir session))) + (cwd (expand-file-name default-directory))) + (and dir + (string-prefix-p cwd (expand-file-name dir))))) + +(defun agent-shell-kimi-clean-session-index () + "Remove session index entries whose working directory no longer exists. + +Returns the number of removed entries. The original index file is +renamed with a timestamp backup before rewriting." + (interactive) + (unless (file-exists-p agent-shell-kimi-session-index) + (user-error "Session index not found: %s" agent-shell-kimi-session-index)) + (let* ((sessions (agent-shell-kimi--read-jsonl agent-shell-kimi-session-index)) + (valid (seq-filter (lambda (s) + (let ((dir (cdr (assq 'workDir s)))) + (and dir (file-directory-p dir)))) + sessions)) + (removed (- (length sessions) (length valid)))) + (when (> removed 0) + (rename-file agent-shell-kimi-session-index + (format "%s.bak-%s" agent-shell-kimi-session-index + (format-time-string "%Y%m%d-%H%M%S")) + t) + (with-temp-file agent-shell-kimi-session-index + (dolist (s valid) + (insert (json-encode s) "\n")))) + (message "Removed %d stale Kimi session(s)" removed) + removed)) + +(defun agent-shell-kimi-resume-session (cleanup) + "Resume a recent Kimi session by selecting from the session index. + +With prefix arg CLEANUP, first remove index entries whose working +directory no longer exists." + (interactive "P") + (when cleanup + (agent-shell-kimi-clean-session-index)) + (let* ((sessions (agent-shell-kimi--read-jsonl agent-shell-kimi-session-index)) + (sessions (seq-filter (lambda (s) + (let ((dir (cdr (assq 'workDir s)))) + (and dir (file-directory-p dir)))) + sessions)) + (sessions (seq-sort-by #'agent-shell-kimi--session-mtime #'> sessions)) + ;; Surface sessions from the current project first. + (sessions (append (seq-filter #'agent-shell-kimi--session-current-p sessions) + (seq-remove #'agent-shell-kimi--session-current-p sessions))) + (choices (mapcar (lambda (s) + (cons (agent-shell-kimi--session-label s) s)) + sessions)) + (choice (completing-read "Resume Kimi session: " choices nil t)) + (session (cdr (assoc choice choices))) + (dir (cdr (assq 'workDir session))) + (id (cdr (assq 'sessionId session)))) + (unless session + (user-error "No session selected")) + (unless (and dir (file-directory-p dir)) + (user-error "Session working directory no longer exists: %s" dir)) + (let ((default-directory dir)) + (agent-shell-start :config (agent-shell-kimi-make-config) + :session-id id)))) + +(defun agent-shell-kimi--welcome-status-line () + "Return a short status line for the Kimi welcome message." + (let ((model (or (agent-shell-kimi--active-model-string) "default")) + (mode (or (agent-shell-kimi--active-mode-string) "default"))) + (concat + (propertize "model" 'font-lock-face 'agent-shell-kimi-tool-face) + ": " model " " + (propertize "mode" 'font-lock-face 'agent-shell-kimi-tool-face) + ": " mode " " + (propertize "send" 'font-lock-face 'agent-shell-kimi-thought-face) " M-RET " + (propertize "interrupt" 'font-lock-face 'agent-shell-kimi-thought-face) " C-c C-c " + (propertize "menu" 'font-lock-face 'agent-shell-kimi-thought-face) " SPC a k"))) + +(defun agent-shell-kimi--welcome-message (config) + "Return Kimi ASCII art using `shell-maker' CONFIG." + (let ((art (agent-shell--indent-string 4 (agent-shell-kimi--ascii-art))) + (message (string-trim-left (shell-maker-welcome-message config) "\n"))) + (concat "\n\n" + art + "\n\n" + " " (agent-shell-kimi--welcome-status-line) "\n\n" + message))) + +(defun agent-shell-kimi--ascii-art () + "Kimi ASCII art." + (let* ((is-dark (eq (frame-parameter nil 'background-mode) 'dark)) + (text (string-trim " + ██╗ ██╗ ██╗ ███╗ ███╗ ██╗ + ██║ ██╔╝ ██║ ████╗ ████║ ██║ + █████╔╝ ██║ ██╔████╔██║ ██║ + ██╔═██╗ ██║ ██║╚██╔╝██║ ██║ + ██║ ██╗ ██║ ██║ ╚═╝ ██║ ██║ + ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ +" "\n"))) + (propertize text 'font-lock-face (if is-dark + '(:foreground "#dddddd" :inherit fixed-pitch) + '(:foreground "#000000" :inherit fixed-pitch))))) + +(defcustom agent-shell-kimi-model-choices + '("kimi-for-coding" "kimi-k2" "kimi-k2-5") + "List of model aliases offered by `agent-shell-kimi-set-model'. + +Set this to the model aliases your Kimi CLI installation supports." + :type '(repeat string) + :group 'agent-shell) + +(defun agent-shell-kimi-set-model (model) + "Interactively set `agent-shell-kimi-model' to MODEL. + +Use `agent-shell-kimi-model-choices' for completion. An empty choice +clears the model and uses Kimi's configured default." + (interactive + (list (completing-read "Kimi model (empty for default): " + agent-shell-kimi-model-choices + nil nil + (or (agent-shell-kimi--active-model-string) "")))) + (setq agent-shell-kimi-model (if (string-empty-p model) nil model)) + (message "Kimi model set to %s" (or agent-shell-kimi-model "default"))) + +(defun agent-shell-kimi-set-directory (dir) + "Add DIR to `agent-shell-kimi-additional-dirs'. + +Existing entries are preserved; duplicates are removed." + (interactive "DAdditional workspace directory: ") + (setq agent-shell-kimi-additional-dirs + (seq-uniq (append agent-shell-kimi-additional-dirs + (list (expand-file-name dir))))) + (message "Kimi additional dirs: %s" agent-shell-kimi-additional-dirs)) + +(defcustom agent-shell-kimi-pulse-on-done + t + "When non-nil, pulse the mode-line when Kimi finishes responding. + +Only applies when the Kimi buffer is not the selected window; the +pulse is meant as a subtle TUI notification." + :type 'boolean + :group 'agent-shell) + +(declare-function shell-maker--announce-response "shell-maker") + +(defun agent-shell-kimi-response-done-pulse (buffer) + "Pulse the mode-line to notify that BUFFER has finished responding. + +Meant to be added as :after advice to `shell-maker--announce-response'." + (when (and agent-shell-kimi-pulse-on-done + (buffer-live-p buffer) + (not (eq buffer (window-buffer (selected-window))))) + (let ((face 'agent-shell-kimi-prompt-face) + (original-face (face-attribute 'mode-line :inherit))) + (set-face-attribute 'mode-line nil :inherit face) + (run-with-timer 0.3 nil + (lambda () + (set-face-attribute 'mode-line nil :inherit original-face)))))) + +(advice-add 'shell-maker--announce-response :after #'agent-shell-kimi-response-done-pulse) + +(provide 'agent-shell-kimi) + +;;; agent-shell-kimi.el ends here -- cgit v1.3.1