├── LICENSE ├── README.md └── comint-histories.el /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2025 Nicholas Hubbard 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # comint-histories 2 | 3 | ## Installation 4 | 5 | Available on MELPA. 6 | 7 | ## Overview 8 | 9 | comint-histories allows you to to create separate histories for different comint inputs. This is useful if you use comint-mode to run different programs and you want each program to have its own history. comint-histories also allows you to save your histories across sessions and create custom filters to keep junk out of the histories. 10 | 11 | Say, for example, you use `M-x shell` as your shell, but you also use `M-x shell` to run python repls and use gdb. You could have three histories, one for shell inputs, one for python inputs, and another for gdb inputs. Each of these histories could have different lengths and policies for what input is allowed in the history. You may also sometimes run your python repl from its inferior-mode (`M-x run-python`), and you want those inputs added to the same python history as when you run python through `M-x shell`. This is all possible with comint-histories. 12 | 13 | For the most part comint-histories leverages the existing components of comint-mode, including the `comint-input-ring`. 14 | 15 | To use comint-histories you must turn on the global minor mode `comint-histories-mode`: 16 | 17 | ``` 18 | (comint-histories-mode 1) 19 | ``` 20 | 21 | The two primary components of comint-histories are `comint-histories-add-history` and `comint-histories-search-history`. The next sections will explain these in detail. 22 | 23 | ## Functions 24 | 25 | To define a new history you must use `comint-histories-add-history`. Here is an example usage: 26 | 27 | ``` 28 | (comint-histories-add-history ielm 29 | :predicates '((lambda () (derived-mode-p 'inferior-emacs-lisp-mode))) 30 | :filters '((lambda (input) (<= (length input) 3))) 31 | :persist t 32 | :length 2000 33 | :rtrim t 34 | :ltrim t) 35 | ``` 36 | 37 | This creates a history for `ielm` inputs with a max length of 2000 items, saves across sessions, rejects inputs with length less or equal to 3, and trims whitespace off the front and end of the input before processing. 38 | 39 | Here is the docstring for `comint-histories-add-history`: 40 | 41 | ``` 42 | (defmacro comint-histories-add-history (name &rest props) 43 | "Declare a comint-histories history named NAME with properties PROPS. 44 | 45 | Usage: (comint-histories-add-history history-name 46 | [:keyword [option]]...) 47 | 48 | :predicates List of functions that take zero args who's conjunction 49 | determines the selection of this history. 50 | 51 | :filters List of regexp strings and functions that take one arg. If the 52 | input matches any of the regexp's, or any of the functions return 53 | non-nil when applied to the input, then the input is not added 54 | to the history. 55 | 56 | :persist If non-nil, then save and load the history to/from a file. 57 | Defaults to T. 58 | 59 | :length Maximum length of the history ring. Defaults to 100. 60 | 61 | :no-dups Do not allow duplicate entries from entering the history. When 62 | adding a duplicate item to the history, the older entry is 63 | removed first. Defaults to NIL. 64 | 65 | :rtrim If non-nil, then trim beginning whitespace from the input before 66 | adding attempting to add it to the history. Defaults to T. 67 | 68 | :ltrim If non-nil, then trim ending whitespace from the input before 69 | attempting to add it to the history. Defaults to T. 70 | 71 | If a history with name NAME does not already exist in 72 | `comint-histories--histories', then the new one will be added to the end of 73 | `comint-histories--histories' (giving it lowest selection precedence), and it's 74 | history file will be loaded if :persist is non-nil. Otherwise, if a history 75 | with name NAME does already exist in `comint-histories--histories', then it's 76 | settings will be updated to the new definition, but it's existing history ring 77 | will not be updated other than resizing it to the new :length. 78 | 79 | If a history with name NAME already exists in `comint-histories--histories', 80 | then update the props of the existing history to reflect PROPS. Note that in 81 | this case the order of `comint-histories--histories' is preserved, and the 82 | actual saved history for this history is not modified outside changing its 83 | length if :length was changed in PROPS." 84 | ``` 85 | 86 | #### comint-histories-search-history 87 | 88 | This is the only interactive function provided by comint-histories and allows you to browse a history with `completing-read` to select and insert a history item. If called with prefix arg, then the user is prompted to select a history with `completing-read`, otherwise automatic selection is made. 89 | 90 | Though comint-histories leverages the `comint-input-ring`, which means functions like `comint-history-isearch-backward` work seamlessly, a `completing-read` interface is preferred by the author of comint-histories. 91 | 92 | Many packages will sort the candidates for `completing-read`, however, you almost certainly do not want your histories sorted as they are already in order of newest entries to oldest. For this reason, a binding similar to the following is recommended for using `comint-histories-search-history`. 93 | 94 | ``` 95 | (define-key comint-mode-map (kbd "C-r") #'(lambda () (interactive) 96 | (let ((ivy-sort-functions-alist nil) 97 | (ivy-prescient-enable-sorting nil) 98 | (vertico-sort-function nil) 99 | (vertico-sort-override-function nil) 100 | (vertico-prescient-enable-sorting nil) 101 | (selectrum-should-sort nil) 102 | (selectrum-prescient-enable-sorting nil)) 103 | (call-interactively #'comint-histories-search-history)))) 104 | ``` 105 | 106 | #### comint-histories-get-prompt 107 | 108 | A helper function that returns the prompt of the comint buffer. This function exists because it is very likely that users will want to define history `:predicates` based on the comint prompt. 109 | 110 | #### comint-histories-get-input 111 | 112 | A helper function that returns the contents of the comint input buffer. This function exists because it is likely that users will want to define history `:predicates` based on the contents of the comint input buffer. Here is an example of a history that saves your `cd` shell commands that use full paths. 113 | 114 | ``` 115 | (comint-histories-add-history shell-cds 116 | :predicates '((lambda () (derived-mode-p 'shell-mode)) 117 | (lambda () (string-match-p "^cd [/~]" (comint-histories-get-input))))) 118 | ``` 119 | 120 | #### comint-histories-index-move 121 | 122 | Move a history in the history list by index (starting at 0). This function is useful for when you add a new history mid Emacs session, and you wish to place it in a certain position in the history list. Remember that the order of the history list is relevant for history selection, and `comint-histories-add-history` always adds to the end of the history list. This function takes two arguments, HIST-IDX and MOVE-IDX, and moves the history at index HIST-IDX to index MOVE-IDX within the history list. If HIST-IDX is nil, then it is set to the maximum index in the history list. 123 | 124 | ## Variables 125 | 126 | #### comint-histories-global-filters 127 | 128 | A list of global filters to be used as a filter for every history. Here is an example usage that prevents inputs of length less or equal to 3 from entering any history: 129 | 130 | ``` 131 | (add-to-list 'comint-histories-global-filters #'(lambda (input) (<= (length input) 3))) 132 | ``` 133 | 134 | #### comint-histories-persist-dir 135 | 136 | Directory to place history files for persistent histories. 137 | 138 | ## Example configuration 139 | 140 | Here is a modified version of the authors current configuration for comint-histories: 141 | 142 | ``` 143 | (use-package comint-histories 144 | :demand t 145 | :bind 146 | (:map comint-mode-map 147 | ("C-r" . (lambda () (interactive) 148 | (let ((ivy-sort-functions-alist nil) 149 | (ivy-prescient-enable-sorting nil) 150 | (vertico-sort-function nil) 151 | (vertico-sort-override-function nil) 152 | (vertico-prescient-enable-sorting nil) 153 | (selectrum-should-sort nil) 154 | (selectrum-prescient-enable-sorting nil)) 155 | (call-interactively #'comint-histories-search-history))))) 156 | :custom 157 | (comint-histories-global-filters '((lambda (x) (<= (length x) 3)) string-blank-p)) 158 | :config 159 | (comint-histories-mode 1) 160 | 161 | (comint-histories-add-history gdb 162 | :predicates '((lambda () (string-match-p "^(gdb)" (comint-histories-get-prompt)))) 163 | :length 2000 164 | :no-dups t) 165 | 166 | (comint-histories-add-history python 167 | :predicates '((lambda () (or (derived-mode-p 'inferior-python-mode) 168 | (string-match-p "^>>>" (comint-histories-get-prompt))))) 169 | :length 2000) 170 | 171 | (comint-histories-add-history ielm 172 | :predicates '((lambda () (derived-mode-p 'inferior-emacs-lisp-mode))) 173 | :length 2000 174 | :no-dups t) 175 | 176 | (comint-histories-add-history shell 177 | :predicates '((lambda () (derived-mode-p 'shell-mode))) 178 | :filters '("^ls" "^cd") 179 | :length 2000 180 | :no-dups t)) 181 | ``` 182 | -------------------------------------------------------------------------------- /comint-histories.el: -------------------------------------------------------------------------------- 1 | ;;; comint-histories.el --- Many comint histories -*- lexical-binding: t; -*- 2 | 3 | ;; Copyright (C) 2025 Nicholas Hubbard 4 | ;; 5 | ;; Licensed under the same terms as Emacs and under the MIT license. 6 | 7 | ;; SPDX-License-Identifier: MIT 8 | 9 | ;; Author: Nicholas Hubbard 10 | ;; URL: https://github.com/NicholasBHubbard/comint-histories 11 | ;; Package-Requires: ((emacs "25.1") (f "0.21.0")) 12 | ;; Version: 2.0 13 | ;; Created: 2025-01-02 14 | ;; By: Nicholas Hubbard 15 | ;; Keywords: convenience, processes, terminals 16 | 17 | ;;; Commentary: 18 | 19 | ;; This package provides functionality for defining multiple histories for 20 | ;; comint inputs. This is useful for dividing up histories for different 21 | ;; programs run through comint buffers. Users can define custom histories via 22 | ;; the comint-histories-add-history macro. Histories can be customized in 23 | ;; various ways including their length, if they should persist across sessions, 24 | ;; filters to prevent inputs from being added to the history, and more. 25 | 26 | ;; Please see https://github.com/NicholasBHubbard/comint-histories for more 27 | ;; information. 28 | 29 | ;;; Code: 30 | 31 | (require 'comint) 32 | (require 'cl-lib) 33 | (require 'seq) 34 | (require 'f) 35 | 36 | (defcustom comint-histories-persist-dir 37 | (f-join user-emacs-directory "comint-histories") 38 | "Directory for storing saved histories." 39 | :type 'string 40 | :group 'comint-histories) 41 | 42 | (defcustom comint-histories-global-filters nil 43 | "Filters to be implicitly added to all history :filters." 44 | :type '(repeat sexp) 45 | :group 'comint-histories) 46 | 47 | (defvar-local comint-histories--last-selected-history nil 48 | "Internal variable to keep track of the buffers selected history.") 49 | 50 | (defvar comint-histories--histories nil 51 | "Internal alist of plists containing all defined histories.") 52 | 53 | (defmacro comint-histories-add-history (name &rest props) 54 | "Declare a comint-histories history named NAME with properties PROPS. 55 | 56 | Usage: (comint-histories-add-history history-name 57 | [:keyword [option]]...) 58 | 59 | :predicates List of functions that take zero args who's conjunction 60 | determines the selection of this history. 61 | 62 | :filters List of regexp strings and functions that take one arg. If the 63 | input matches any of the regexp's, or any of the functions return 64 | non-nil when applied to the input, then the input is not added 65 | to the history. 66 | 67 | :persist If non-nil, then save and load the history to/from a file. 68 | Defaults to T. 69 | 70 | :length Maximum length of the history ring. Defaults to 100. 71 | 72 | :no-dups Do not allow duplicate entries from entering the history. When 73 | adding a duplicate item to the history, the older entry is 74 | removed first. Defaults to NIL. 75 | 76 | :rtrim If non-nil, then trim beginning whitespace from the input before 77 | adding attempting to add it to the history. Defaults to T. 78 | 79 | :ltrim If non-nil, then trim ending whitespace from the input before 80 | attempting to add it to the history. Defaults to T. 81 | 82 | If a history with name NAME does not already exist in 83 | `comint-histories--histories', then the new one will be added to the end of 84 | `comint-histories--histories' (giving it lowest selection precedence), and it's 85 | history file will be loaded if :persist is non-nil. Otherwise, if a history 86 | with name NAME does already exist in `comint-histories--histories', then it's 87 | settings will be updated to the new definition, but it's existing history ring 88 | will not be updated other than resizing it to the new :length. 89 | 90 | If a history with name NAME already exists in `comint-histories--histories', 91 | then update the props of the existing history to reflect PROPS. Note that in 92 | this case the order of `comint-histories--histories' is preserved, and the 93 | actual saved history for this history is not modified outside changing its 94 | length if :length was changed in PROPS." 95 | (declare (indent defun)) 96 | (let ((name (symbol-name name)) 97 | (history (list :history nil 98 | :predicates nil 99 | :filters nil 100 | :persist t 101 | :length 100 102 | :no-dups nil 103 | :rtrim t 104 | :ltrim t)) 105 | (valid-props 106 | '(:predicates :filters :persist :length :no-dups :rtrim :ltrim))) 107 | (while props 108 | (let ((prop (car props)) 109 | (val (cadr props))) 110 | (if (not (memq prop valid-props)) 111 | (user-error "Invalid history property: %s" prop) 112 | (setq history (plist-put history prop (eval val))) 113 | (setq props (cddr props))))) 114 | (when (null (plist-get history :predicates)) 115 | (user-error ":predicates cannot be NIL")) 116 | (let ((history- (cons name history))) 117 | `(let ((history (quote ,history-))) 118 | (if-let ((existing-history (assoc (car history) 119 | comint-histories--histories))) 120 | (let ((existing-ring (plist-get (cdr existing-history) :history)) 121 | (new-length (plist-get (cdr history) :length))) 122 | (ring-resize existing-ring new-length) 123 | (setq history (cons (car history) 124 | (plist-put (cdr history) 125 | :history existing-ring))) 126 | (setf (cdr (assoc (car history) comint-histories--histories)) 127 | (cdr history))) 128 | (setf (plist-get (cdr history) :history) 129 | (make-ring (plist-get (cdr history) :length))) 130 | (add-to-list 'comint-histories--histories history t) 131 | (when (and (plist-get (cdr history) :persist) 132 | (f-file? (comint-histories--history-file history t))) 133 | (comint-histories--load-history-from-disk history t))))))) 134 | 135 | (defun comint-histories-search-history (arg &optional history) 136 | "Search the HISTORY with `completing-read' and insert the selection. 137 | 138 | If HISTORY is NIL then if ARG (or prefix arg) prompt for a history else 139 | automatically select the history." 140 | (interactive "P") 141 | (let ((history (or history 142 | (if arg 143 | (let ((history-name 144 | (completing-read 145 | "histories: " 146 | (mapcar #'car comint-histories--histories) 147 | nil t))) 148 | (assoc history-name comint-histories--histories)) 149 | (comint-histories--select-history))))) 150 | (if (not history) 151 | (user-error "No history could be selected") 152 | (let ((history-val (completing-read 153 | (format "history (%s): " (car history)) 154 | (ring-elements (plist-get (cdr history) :history)) 155 | nil t))) 156 | (goto-char (point-max)) 157 | (delete-region (comint-line-beginning-position) (point)) 158 | (insert history-val))))) 159 | 160 | (defun comint-histories-get-prompt () 161 | "Return the latest comint prompt in the current buffer as a string." 162 | (when (derived-mode-p 'comint-mode) 163 | (save-excursion 164 | (goto-char (point-max)) 165 | (let ((prompt-end (comint-line-beginning-position)) 166 | (inhibit-field-text-motion t)) 167 | (beginning-of-line) 168 | (buffer-substring-no-properties (point) prompt-end))))) 169 | 170 | (defun comint-histories--history-filter-function (history) 171 | "Return function to be the `comint-input-filter' based on HISTORYs :filters." 172 | (lambda (input) 173 | (cl-every 174 | (lambda (filter) 175 | (not (if (functionp filter) 176 | (funcall filter input) 177 | (string-match-p filter input)))) 178 | (append comint-histories-global-filters 179 | (plist-get (cdr history) :filters))))) 180 | 181 | (defun comint-histories--history-file (history &optional dont-create) 182 | "Return the history-file for HISTORY, maybe creating it if it doesn't exist." 183 | (let* ((dir (or (bound-and-true-p comint-histories-persist-dir) 184 | (f-join user-emacs-directory "comint-histories"))) 185 | (file (f-join dir (car history)))) 186 | (when (and (not dont-create) (not (f-directory? dir))) 187 | (f-mkdir dir)) 188 | (when (and (not dont-create) (not (f-file? file))) 189 | (f-touch file)) 190 | file)) 191 | 192 | (defun comint-histories--load-history-from-disk (history &optional insert) 193 | "Load the history-ring from HISTORY's persistent file returning it as a list. 194 | 195 | If INSERT is non-nil then insert the history into HISTORY's history ring." 196 | (let* ((history-file (comint-histories--history-file history)) 197 | (history-text (f-read-text history-file 'utf-8)) 198 | (length (plist-get (cdr history) :length)) 199 | (lines (seq-take 200 | (split-string history-text (format "%c" #x1F) t) 201 | length))) 202 | (when insert 203 | (let ((comint-input-ring (plist-get (cdr history) :history)) 204 | (comint-input-filter 205 | (comint-histories--history-filter-function history)) 206 | (comint-input-ring-size (plist-get (cdr history) :length))) 207 | (dolist (x (reverse lines)) 208 | (comint-add-to-input-history x)))) 209 | lines)) 210 | 211 | (defun comint-histories--save-history-to-disk (history) 212 | "Save HISTORY's history-ring to it's persistent file." 213 | (let* ((history-file (comint-histories--history-file history)) 214 | (existing-history (ring-elements (plist-get (cdr history) :history))) 215 | (loaded-history (comint-histories--load-history-from-disk history)) 216 | (all-history (append existing-history loaded-history)) 217 | (text "")) 218 | (when (plist-get (cdr history) :no-dups) 219 | (setq all-history (delete-dups all-history))) 220 | (dolist (x (seq-take all-history (plist-get (cdr history) :length))) 221 | (setq text (concat text (format "%s%c" x #x1F)))) 222 | (f-write-text text 'utf-8 history-file))) 223 | 224 | (defun comint-histories--select-history (&rest _args) 225 | "Select a history from `comint-histories--histories'. 226 | 227 | A history is selected if all of it's :predicates return non-nil when invoked 228 | with zero arguments. If a new history is selected then set `comint-input-ring' 229 | to that histories history ring." 230 | (let ((selected-history)) 231 | (catch 'loop 232 | (dolist (history comint-histories--histories) 233 | (when (cl-every (lambda (fn) (funcall fn)) 234 | (plist-get (cdr history) :predicates)) 235 | (setq selected-history history) 236 | (throw 'loop t)))) 237 | (when (and selected-history 238 | (not (equal (car selected-history) 239 | (car comint-histories--last-selected-history)))) 240 | (setq-local comint-histories--last-selected-history selected-history) 241 | (setq-local comint-input-ring (plist-get (cdr selected-history) :history)) 242 | (setq-local comint-input-ring-size 243 | (plist-get (cdr selected-history) :length)) 244 | (setq-local comint-input-filter 245 | (comint-histories--history-filter-function selected-history))) 246 | selected-history)) 247 | 248 | (defun comint-histories-get-input () 249 | "Return the content of the comint input buffer." 250 | (let ((proc (get-buffer-process (current-buffer)))) 251 | (if (not proc) 252 | (user-error "Current buffer has no process") 253 | (save-excursion 254 | (goto-char (point-max)) 255 | (let ((beg-point (comint-line-beginning-position)) 256 | (end-point (point))) 257 | (buffer-substring-no-properties beg-point end-point)))))) 258 | 259 | (defun comint-histories-index-move (hist-idx move-idx) 260 | "Move the history at index HIST-IDX to index MOVE-IDX in the history list. 261 | 262 | If HIST-IDX is NIL then it is assumed to be the maximum index of 263 | `comint-histories--histories'. 264 | 265 | Note that indices start at 0." 266 | (let* ((histories-length (length comint-histories--histories)) 267 | (max-index (- histories-length 1)) 268 | (hist-idx (or hist-idx max-index))) 269 | (if (= 0 histories-length) 270 | (user-error "History list is empty") 271 | (if (= 1 histories-length) 272 | (user-error "History list only has 1 history") 273 | (if (or (> 0 hist-idx) (> hist-idx max-index)) 274 | (user-error "HIST-IDX not in range") 275 | (if (or (> 0 move-idx) (> move-idx max-index)) 276 | (user-error "MOVE-IDX not in range") 277 | (let* ((history (nth hist-idx comint-histories--histories)) 278 | (history-list 279 | (cl-remove 280 | (car history) 281 | comint-histories--histories 282 | :test #'equal 283 | :key #'car)) 284 | (padded (cons nil history-list)) 285 | (c (nthcdr move-idx padded))) 286 | (setcdr c (cons history (cdr c))) 287 | (setq comint-histories--histories (cdr padded)) 288 | (mapcar #'car comint-histories--histories)))))))) 289 | 290 | (defun comint-histories--save-histories-to-disk () 291 | "Save persistent histories in `comint-histories--histories' to disk." 292 | (dolist (history comint-histories--histories) 293 | (when (plist-get (cdr history) :persist) 294 | (comint-histories--save-history-to-disk history)))) 295 | 296 | (defun comint-histories--before-add-to-comint-input-ring (args) 297 | "Advise function to run before `comint-add-to-input-history'. 298 | 299 | Uses the most recently selected history as the history. Trims the input if the 300 | :ltrim or :rtrim history options are set. If the :no-dups option is set then 301 | removes duplicate items from `comint-input-ring'." 302 | (if-let ((history comint-histories--last-selected-history) 303 | (cmd (car args))) 304 | (let ((ltrim (plist-get (cdr history) :ltrim)) 305 | (rtrim (plist-get (cdr history) :rtrim)) 306 | (no-dups (plist-get (cdr history) :no-dups))) 307 | (when ltrim 308 | (setq cmd (replace-regexp-in-string "^[\n\r ]+" "" cmd))) 309 | (when rtrim 310 | (setq cmd (replace-regexp-in-string "[\n\r ]+$" "" cmd))) 311 | (when no-dups 312 | (while-let ((idx (ring-member comint-input-ring cmd))) 313 | (ring-remove comint-input-ring idx))) 314 | (list cmd)) 315 | args)) 316 | 317 | (define-minor-mode comint-histories-mode 318 | "Toggle `comint-histories-mode'." 319 | :global t 320 | :group 'comint-histories 321 | :require 'comint-histories 322 | (if comint-histories-mode 323 | (progn 324 | (add-hook 'comint-mode-hook #'comint-histories--select-history) 325 | (advice-add 'comint-send-input :before 326 | #'comint-histories--select-history) 327 | (advice-add 'comint-add-to-input-history :filter-args 328 | #'comint-histories--before-add-to-comint-input-ring) 329 | (add-hook 'kill-emacs-hook #'comint-histories--save-histories-to-disk)) 330 | (remove-hook 'comint-mode-hook #'comint-histories--select-history) 331 | (advice-remove 'comint-send-input #'comint-histories--select-history) 332 | (advice-remove 'comint-add-to-input-history 333 | #'comint-histories--before-add-to-comint-input-ring) 334 | (remove-hook 'kill-emacs-hook #'comint-histories--save-histories-to-disk))) 335 | 336 | (provide 'comint-histories) 337 | 338 | ;; Local Variables: 339 | ;; indent-tabs-mode: nil 340 | ;; End: 341 | 342 | ;;; comint-histories.el ends here 343 | --------------------------------------------------------------------------------