aboutsummaryrefslogtreecommitdiff
path: root/modules
Commit message (Collapse)AuthorAgeFilesLines
* refactor(org-agenda): extract main-agenda prefix format into a defvarCraig Jennings2026-05-131-4/+10
| | | | | | `org-agenda-custom-commands` inlined ` %i %-15:c%?-15t% s` four times across the "d" command's overdue / high-priority / schedule / priority-B blocks. New `cj/--main-agenda-prefix-format` defvar holds the literal once; every block now references the symbol so a format tweak lands in one place. Regression test walks the "d" command's blocks and asserts each `org-agenda-prefix-format` cell resolves to the shared symbol -- a block that silently diverges fails the check.
* feat(dashboard): add Telegram launcher to a new third rowCraig Jennings2026-05-131-1/+8
| | | | | | `dashboard-navigator-buttons' grows a Row 3 with a single Telegram entry (using the `nf-fa-telegram' icon, launching `telega'). The dashboard-mode-map gets a single-letter `g' shortcut to match the other launcher keys. Two follow-up TODOs filed under the parent telegram task: the TDLib docker setup script (so a fresh-clone install can boot telega without a system-wide TDLib build) and a dashboard-icon-balance pass (Row 3 with one entry is asymmetric; decide whether to leave it or reorganize).
* feat(telega): add telega.el module with docker-backed TDLibCraig Jennings2026-05-131-0/+41
| | | | | | | | New `modules/telega-config.el` configures telega.el as an in-Emacs Telegram client. `telega-use-docker' is on so TDLib runs in a container instead of needing a system-level build -- pairs with a follow-up `scripts/setup-telega.sh' for fresh-clone installs. First-run auth (phone + verification code) is interactive inside `M-x telega' and isn't scripted here. Launcher binding: `C-; G` (mnemonic: teleGram). `C-; t` and `C-; m t` were both taken (test-runner, music's "repeat track"), so the launcher landed on a free top-level letter. Two tests cover the wiring: module loads, launcher is bound.
* feat(vterm): show tmux scrollback history in placeCraig Jennings2026-05-131-2/+7
| | | | | | `cj/vterm-tmux-history' previously used `pop-to-buffer', which routed the history view through display-buffer-alist -- in an agent window that often meant a split or a hand-off to another window, costing the agent its frame slot. `switch-to-buffer' instead drops the history into the selected window directly; the existing quit handler already restores the origin in that same window via `set-window-buffer'. New test asserts the in-place behavior: starting single-window with a vterm origin, invoking the command leaves `(one-window-p)` t with the history buffer in the original slot. The existing render test no longer needs its `pop-to-buffer' stub since `switch-to-buffer' works in batch.
* fix(ai-vterm): preserve single-window layout across F9 toggleCraig Jennings2026-05-131-10/+41
| | | | | | | | When the agent buffer is the only window in the frame, F9 buries it (correct) but the next F9 redisplayed it as a side split instead of restoring the full-frame layout -- the display-saved path always called `display-buffer-in-direction`, which insists on a split. New `cj/--ai-vterm-last-was-bury` flag tracks which toggle-off path ran. `cj/ai-vterm` sets it to t in the bury branch (one-window-p) and nil in the delete-window branch. `cj/--ai-vterm-display-saved` checks the flag at toggle-on: if t and the frame is still single-window, it replaces the selected window's buffer in place rather than splitting. Either branch consumes the flag so it never stays stale. 5 tests in test-ai-vterm--single-window-toggle.el cover the flag's set/clear paths, the still-one-window guard, and the end-to-end roundtrip.
* feat(org-agenda): use project name as todo.org categoryCraig Jennings2026-05-131-0/+44
| | | | | | The %c column on agenda blocks rendered every project's todo.org as "todo:" -- org defaults the buffer category to the filename without extension, so every entry looked alike. An org-mode-hook now overrides org-category with the parent directory's basename (stripping a single leading dot, so ~/.emacs.d/todo.org reads as "emacs.d") whenever a todo.org file opens and its category is still the filename default. Explicit #+CATEGORY: keywords still win. 14 tests in test-org-agenda-config-category.el cover the helper's normal/boundary/error paths and the hook's override + explicit-category-preserved cases.
* fix(ui-navigation): clear dedicated before toggling window splitCraig Jennings2026-05-131-22/+32
| | | | | | | | toggle-window-split swapped buffers between two windows via two set-window-buffer calls. If either window was strongly dedicated (e.g. *Org Agenda* via the display-buffer-alist rule), the dedicated window rejected the second swap. Both panes ended up showing the dedicated buffer. The non-dedicated buffer never made it across. The fix clears dedicated on both windows before the swap. The user explicitly invoked a layout change, so preserving per-window dedicated through the operation would just re-trigger the same wedge on the next toggle. Tests in tests/test-ui-navigation--toggle-window-split.el cover the no-dedicated baseline, the bug-trigger (dedicated selected window), the post-toggle cleared state, and the one-window / three-window no-op boundaries.
* fix(org-agenda): skip CANCELLED entries from main agenda SCHEDULECraig Jennings2026-05-131-0/+4
| | | | | | | | The "d" command's (agenda ...) block had no org-agenda-skip-function. The global org-agenda-skip-scheduled-if-done is nil. CANCELLED tasks with a SCHEDULED date rendered in the forward-looking schedule unfiltered. The fix adds an org-agenda-skip-function to the SCHEDULE block: (org-agenda-skip-entry-if 'todo '("CANCELLED")). The scope is deliberate. Only CANCELLED is filtered, not DONE or FAILED. A scheduled DONE task is a record of when something happened and stays visible. Tests cover the configuration: the form must appear on the agenda block and must not appear on the overdue, hi-pri, or priority-B blocks.
* fix(calibredb-epub): point cj/nov--file-path at nov-file-nameCraig Jennings2026-05-131-4/+4
| | | | | | The fallback chain was checking `nov-epub-filename` and `nov-epub-file`, but neither symbol exists in nov.el — the real var is `nov-file-name`, set by `nov-mode` from the visited file. Both `boundp` arms always returned nil, so the fallbacks were dead code. The bug was dormant rather than active: `buffer-file-name` always holds the EPUB path for normal nov buffers and covered the first arm of the `or`. I replaced both wrong-named arms with a single live arm on `nov-file-name`, and added a Boundary test that exercises it.
* refactor(calibredb-epub): extract the nov image-padding helperCraig Jennings2026-05-131-4/+12
| | | | I pulled the image-centering math out of `cj/nov-center-images` into `cj/--nov-image-padding-cols`. The wrapper still loops over the image text properties and writes the line/wrap-prefix. The helper takes col-width, img-px, and font-width-px and returns the left padding. With the math in a pure helper I can unit-test the centering rule directly instead of building a buffer with image-display properties.
* fix(prog-webdev): toggle electric-pair-local-mode in the setup hookCraig Jennings2026-05-121-1/+1
| | | | `cj/webdev-setup` called `(electric-pair-mode t)`, which flips the global minor mode every time a TS/JS/TSX buffer opens. I switched it to `electric-pair-local-mode` so the pairing stays scoped to those buffers, like the other buffer-local settings in the hook. (Same fix I just made in `prog-python.el`.)
* refactor(prog-webdev): extract the prettier format-command builderCraig Jennings2026-05-121-4/+7
| | | | I pulled the prettier command-string construction out of `cj/webdev-format-buffer` into `cj/--webdev-format-command`. The wrapper stays thin: resolve `(or buffer-file-name "file.ts")`, hand the command to `shell-command-on-region`, clamp point. With the command shape in a pure helper I can unit-test it directly.
* refactor(org-drill): hoist the commands out of :config and clear the ↵Craig Jennings2026-05-121-88/+94
| | | | | | | | byte-compile warnings The `cj/drill-*` defuns and `cj/drill-map` lived inside the `use-package org-drill` `:config` block, so the byte-compiler never registered them — every cross-reference between them warned ("function `cj/drill-this-file' is not known", and so on). I moved all of that to module top level, where the compiler sees it. The ten `(setq org-drill-...)` lines became a `:custom` block (no more "assignment to free variable"). Added `(require 'user-constants)` and `(require 'keybindings)` for `drill-dir` and `cj/custom-keymap`, plus `declare-function` for `org-drill`, `org-drill-resume`, `org-capture`, and `org-refile`. The module byte-compiles clean now, and `C-; D` still mounts the drill submenu with the same leaf keys. I also gave `tests/test-org-drill-first-function.el` a `cj/custom-keymap` stub: its "loads without error" test does a bare `load` of the module, which now runs the keymap mount at load time instead of deferring it inside `:config`.
* fix(ai-vterm): make F9 toggle the agent from inside an agent bufferCraig Jennings2026-05-121-0/+12
| | | | | | vterm binds `<f1>`..`<f12>` to `vterm--self-insert`, so a plain `<f9>` typed while point is in an agent buffer goes to the terminal program instead of the global toggle. That's invisible most of the time — you press F9 from another window — but it bites when the agent buffer is the only window in the frame, because there's nowhere else to press it from. I re-bound the F9 family in `vterm-mode-map` (via `with-eval-after-load 'vterm`) so that `<f9>`, `C-<f9>`, and `M-<f9>` reach `cj/ai-vterm`, `cj/ai-vterm-pick-project`, and `cj/ai-vterm-pick-buffer` from there too. The C-/M- variants aren't actually in vterm's intercept set, but binding them keeps things uniform. New `tests/test-ai-vterm--f9-in-vterm.el`: 4 ERT tests over the `vterm-mode-map` and global bindings. F12's `cj/vterm-toggle` has the same shape of bug and isn't touched here.
* feat(dirvish): start org-drill on the .org file at point with SCraig Jennings2026-05-121-1/+17
| | | | | | `S` ("study") in `dirvish-mode-map` opens the `.org` file at point and runs `cj/drill-this-file` on it, so I can drill any deck straight from the file list. It `user-error`s on no file, on a directory, or on a non-`.org` file. `D` and `O` were already taken (duplicate-file, open-with-command), so I went with `S`. It shadows dired's `dired-do-symlink`, which I never use from dirvish and which stays on `M-x` anyway. New `tests/test-dirvish-config-drill.el`: 6 ERT tests with `dired-get-filename`, `find-file`, and `cj/drill-this-file` mocked. I also fixed the stale `P` line in the module commentary — `P` is the print command now, not copy-path.
* feat(org-drill): drill any Org file from anywhereCraig Jennings2026-05-121-15/+41
| | | | | | | | `org-drill` has no fixed home — with `org-drill-scope` left at its default it just drills the current buffer. So the only thing in my config tied to a location was `cj/drill-start`, which forced a pick from `drill-dir`. `C-; D f` (`cj/drill-this-file`) drills whatever Org buffer is current, so a drill file living anywhere works. It `user-error`s when the buffer isn't an Org buffer. `C-u C-; D s` (and `C-u C-; D e`) now prompts for the directory to pick from instead of always using `drill-dir`. Bare `C-; D s` is unchanged. I pulled the picking logic into `cj/--drill-files-in`, `cj/--drill-pick-dir`, and `cj/--drill-pick-file` so it's unit-testable. New `tests/test-org-drill-config.el`: 12 ERT tests over those helpers, `cj/drill-this-file`, `cj/drill-start`, and the keymap.
* fix(prog-python): toggle electric-pair-local-mode in the setup hookCraig Jennings2026-05-121-1/+1
| | | | `cj/python-setup` called `(electric-pair-mode t)`, which flips the global minor mode every time a Python buffer opens. I switched it to `electric-pair-local-mode` so the pairing stays scoped to Python buffers, like the other buffer-local settings in the hook.
* refactor(prog-python): extract the mypy and pdb command buildersCraig Jennings2026-05-121-3/+10
| | | | I pulled the command-string construction out of `cj/python-mypy` and `cj/python-debug` into `cj/--python-mypy-command` and `cj/--python-debug-command`. The wrappers stay thin — resolve the target, hand off to `compile` or `pdb`. With the command shape in a pure helper I can unit-test it directly instead of stubbing `compile` and `pdb`.
* fix(nov): center the EPUB text by setting window margins directlyCraig Jennings2026-05-121-37/+55
| | | | | | | | | | | | The 80% width from `4d9a206' wasn't actually narrowing the page: `cj/nov-apply-preferences' set `nov-text-width' to t (nov renders the text unfilled, one long line per paragraph) and counted on `visual-fill-column-mode' to set the window's display margins, but those margins never got applied in nov-mode buffers (even after manually re-running the layout), so the text wrapped at the full window width. The cause is still unknown. This drops `visual-fill-column' from nov entirely: - `nov-text-width' is a column count (~80% of the window's natural width), so nov's `shr' fills the text itself. - `cj/nov-update-layout' sets the window's left/right margins directly to `(natural - text-width) / 2' each, centering the block, and pushes the fringes out to the window edge so they don't show as thin lines beside the text. When the width changes it re-renders, restoring the reading position approximately. - `cj/nov-apply-preferences' adds a `kill-buffer-hook' that drops the margins and fringes when the EPUB buffer goes away, so a later buffer in that window isn't left indented. - `+'/`=' and `-'/`_' adjust `cj/nov-margin-percent' and re-flow + re-center. The text-width math moved into a `cj/nov--natural-window-width' helper alongside the existing `cj/nov--text-width'. Known nit: the centering is a touch left-of-center because shr wraps at word boundaries, so the rendered text is a bit narrower than `nov-text-width' and the right margin ends up slightly larger. Logged as a follow-up.
* chore(ui): disable cursor blinking (keep a solid block)Craig Jennings2026-05-121-0/+4
|
* feat(nov): default the EPUB text column to 80% of the windowCraig Jennings2026-05-121-3/+3
| | | | `1c5c8bd' had set `cj/nov-margin-percent' to 12 (~76% text); 10 gives a round 80%. Adjust per-buffer with the `+'/`-' keys; clamp is unchanged (0..25, i.e. 50%..100%).
* feat(org): add reveal.js present command and header removalCraig Jennings2026-05-121-5/+81
| | | | | | `C-; p SPC' (`cj/reveal-present') is the new fast path: it inserts reveal.js headers if the buffer doesn't have them (prompting for a title), saves the buffer, exports to self-contained HTML, and opens it in the browser. It's the export-and-open you'd otherwise reach by doing `C-; p h' then `C-; p e'. `C-; p H' (`cj/reveal-remove-headers') strips the header block this module inserts, and `C-; p h' (`cj/reveal-insert-header') now errors instead of duplicating headers when a reveal block is already present. The header logic moved into small helpers (`cj/--reveal-has-header-p', `cj/--reveal-remove-headers', `cj/--reveal-ensure-header', `cj/--reveal-keyword-regexp', and the `cj/--reveal-header-keywords' list), and `test-org-reveal-config-headers.el' drives them directly: detection on a reveal vs. a plain Org buffer, removal of the generated block (and only the leading block), body preservation, and the no-duplicate-headers error.
* fix(org): give the F8 agenda window 75% of the frameCraig Jennings2026-05-121-4/+16
| | | | | | The agenda buffer's `display-buffer-alist' rule used `(window-height . fit-window-to-buffer)', so a sparse agenda opened as a sliver a few lines tall. The rule now takes `(window-height . cj/org-agenda-window-height)', a defcustom defaulting to 0.75 (the fraction of the frame the agenda window gets), and the rule itself moved into `cj/--org-agenda-display-rule' so it's testable. New `test-org-agenda-config-display.el' checks that the configured fraction flows through, that it's no longer `fit-window-to-buffer', and (integration) that `display-buffer' produces a window near that size. `(use-package alert)' gained an `:if (or (not noninteractive) (require 'alert nil t))' guard: the batch test runner loads this module without `package-initialize', so the optional notification package may be installed but not yet on the load path, and the unconditional `:config' setq's would error.
* fix(nov): rework the EPUB reading-width layoutCraig Jennings2026-05-121-14/+61
| | | | | | | | | | | | | | `cj/nov--text-width-for-window' computed the target column as a percentage of `(window-body-width)'. But body width is the column count *after* the display margins. `cj/nov-update-layout' runs from `window-configuration-change-hook': it sets `visual-fill-column''s margins, which changes the body width, which fires the hook, which re-runs the layout against the now-narrower body, and so on. It's a shrinking feedback loop that bottoms out at `cj/nov-min-text-width' (40 columns) no matter what `cj/nov-margin-percent' is. That's why the column was a thin strip regardless of the margin setting. The width is now computed from the window's *natural* column count (body width plus any margins already set), so re-running the layout is idempotent. The margin math moved into a pure `cj/nov--text-width' helper, which is what the unit tests drive, and there's a regression test that the result is the same whether or not margins are already in place. Also: - `+'/`=' (`cj/nov-widen-text') and `-'/`_' (`cj/nov-narrow-text') step `cj/nov-margin-percent' by `cj/nov-margin-step' and re-lay-out, reporting the new percentage. `cj/nov-margin-percent' is now clamped to 0..25, so the text column runs from 50% (the floor) to 100% (the full window). - `cj/nov-margin-percent' default is 12 (≈76% text) for a comfortable starting width. - `cj/nov-apply-preferences' re-renders the document at the end again. `b3b537f' removed that on the theory `visual-fill-column' would re-trigger the render. The first page came up off-center until a manual resize, so it's back. - `cj/nov-update-layout' is now a command. The visible result (a ~75% centered column on first open, `+`/`-` to adjust) needs a restart to confirm. The tests cover the width math and clamping, idempotency, the adjust commands and their keybindings, the command status, and the re-render.
* feat(dirvish): print the file at point with PCraig Jennings2026-05-121-1/+47
| | | | | | `P' in dirvish/dired now runs `cj/dirvish-print-file': it sends the file at point to the default printer via CUPS (`lp', falling back to `lpr'), after a confirmation prompt. It refuses directories and file types outside `cj/dirvish-print-extensions' (pdf, txt, org, images, source files, ...). CUPS filters handle PDFs directly, so `lp <file>' covers everything in the list, and no separate print dialog is needed. `P' was the project/home-relative variant of `cj/dired-copy-path-as-kill', now dropped (the `p' absolute-path and `l' org-link bindings stay, and `M-x cj/dired-copy-path-as-kill' still works). Plain dired's built-in `P' was `dired-do-print', which `dirvish-mode-map' had already shadowed, so this just replaces it with a type-aware version. Tests cover the predicates and the command's confirm / decline / no-file / directory / non-printable / no-printer / print-failure paths.
* fix(mail): clear the marks after saving from the attachment selectorCraig Jennings2026-05-121-1/+7
| | | | `cj/mu4e-attachment-selection-save-marked' left the `[x]' marks set after a successful save, so a second `s' silently re-saved the same files. It now unmarks every row and re-renders once the save returns, so the buffer stays open for another batch (and `q'/RET still exit). The save-marked test asserts the marks are cleared.
* fix(slack): error when adding a reaction outside a Slack bufferCraig Jennings2026-05-121-7/+7
| | | | `cj/slack-message-add-reaction' wrapped its whole body in `when-let*' on `slack-current-buffer', so invoking C-; S ! outside a Slack message view did nothing at all, and with no message it looked like the key wasn't even bound. It now `user-error's "Not in a Slack buffer". A test covers the case.
* refactor(lsp): rename cj/lsp--disable-eldoc-hover for accuracyCraig Jennings2026-05-121-3/+4
| | | | The helper removes lsp-mode's entry from `eldoc-documentation-functions' in the current buffer. It never touched hover display, so the old name was misleading. It's now `cj/lsp--remove-eldoc-provider', the two tests rename to match, and the docstring drops the "hover" wording.
* refactor(mail): fail fast on an attachment part with no MIME handleCraig Jennings2026-05-121-1/+1
| | | | `cj/mu4e--save-attachment-part' called `cj/mu4e--ensure-attachment-save-functions' first, so a part with no MIME handle triggered the `mu4e-mime-parts' load before the handle check could fail. The handle check now runs first, so the malformed input is caught right away and the user-error fires the same way whether or not mu4e's MIME support is loadable. The test for that case drops the mu4e stubs it only needed because the load used to come first.
* refactor(mail): extract the mu4e attachment workflow into its own moduleCraig Jennings2026-05-122-229/+248
| | | | | | The attachment-save UI (the MIME-part filters, the three save commands, and the `special-mode'-derived selection buffer) was ~230 lines in `mail-config.el' and depended on nothing else there. It moves to `modules/mu4e-attachments.el', which `mail-config' now requires. `cj/email-map' and its C-; e bindings stay put. The keymap just points at commands that now live next door. The unit tests move with it: `test-mail-config-attachments.el' becomes `test-mu4e-attachments.el' and requires the new module directly instead of pulling in the whole mu4e and org-msg use-package stack. The two tests that check `cj/email-map' wiring move to a new `test-mail-config.el', since that map belongs to `mail-config'. One of the moved tests quietly relied on a real mu4e install (it loaded `mu4e-mime-parts' through a load-path entry that loading `mail-config' happens to add), so it now stubs that path itself.
* docs: clarify the coverage-exclude and token-seed commentsCraig Jennings2026-05-111-0/+2
| | | | The Makefile's `COVERAGE_EXCLUDE' comment said why `test-all-comp-errors.el' is excluded but not why `test-lorem-optimum-benchmark.el' is. It now notes that undercover's instrumentation slows execution enough to fail the benchmark's wall-clock assertions. And `cj/markov-generate' now has a comment explaining why `tokens' is seeded reversed (`(list w2 w1)'): the list is built with `push' and `nreverse'd at the end, so without the note the reversed seed reads like a bug at a glance.
* fix(org-capture): single-source the file+headline cache keyCraig Jennings2026-05-111-1/+1
| | | | `cj/org-capture--goto-file-headline' stored the marker under `(list (expand-file-name (buffer-file-name)) headline)' while the lookup helper `cj/org-capture--file-headline-cache-key' uses `(list (org-capture-expand-file path) headline)'. They agree for plain absolute paths, but a symlinked target (`expand-file-name' doesn't follow symlinks, `find-file' canonicalizes the buffer name) or a capture path that needs `org-capture-expand-file's special handling would make the put-key and get-key diverge, so the cache would silently never hit. Production now goes through the helper too, so the key is built one way.
* feat(mu4e): simpler attachment-save commands on C-; e S/s/mCraig Jennings2026-05-111-10/+237
| | | | Three project-owned commands that reuse mu4e's MIME metadata (`mu4e-view-mime-parts') and save primitives (`mm-save-part-to-file', `mu4e-uniquify-save-file-name-function') directly instead of driving mu4e's completion UI. `cj/mu4e-save-all-attachments' (`C-; e S') prompts once for a directory and saves every attachment-like part. `cj/mu4e-save-attachment-here' (`C-; e s') saves one attachment, picked by display label, with duplicate filenames shown as "name <part N>" so they don't collapse into one completion candidate. `cj/mu4e-save-some-attachments' (`C-; e m') opens a `*mu4e attachments*' selection buffer showing mark state, label, MIME type, and size per row, where `RET' toggles a row, `a' / `u' mark / unmark all, `s' saves the marked ones, and `q' quits. Replaced the old Embark/Vertico-workaround comment. Tests cover the attachment filtering, the duplicate-filename disambiguation, save-path construction, the no-handle error, command prompting, and the email-map bindings.
* refactor(epub): clean up calibredb-epub-config.elCraig Jennings2026-05-111-18/+37
| | | | Dropped the 1-second `:defer' from the calibredb use-package and the redundant explicit `nov-render-document' call in `cj/nov-apply-preferences'. Nov / visual-fill-column text width now recalculates on `window-configuration-change-hook'. `cj/nov--text-width-for-window' computes the (clamped, minimum-readable) width and `cj/nov-update-layout' installs it buffer-locally. Lowered `calibredb-search-page-max-rows' from 20000 to 500 (pagination was effectively disabled). Replaced the anonymous zathura keybinding with `cj/nov-open-external'. Tests cover the width computation and the external-open binding.
* fix(slack): harden and curate the C-; S ! reaction workflowCraig Jennings2026-05-111-1/+94
| | | | Two problems with `C-; S !'. First, emacs-slack's `slack-reaction-echo-description' runs in a buffer-local `post-command-hook' and can error on every keystroke when a reaction widget's text properties are malformed, which makes it hard to leave the Slack buffer or recover with C-g. It's now wrapped in `condition-case' that, on error, removes the hook from that buffer's local `post-command-hook' and messages once. Second, without emojify the upstream reaction picker is a flat completing-read over 1600+ names. `cj/slack-message-add-reaction' now offers a curated common list (`thumbsup', `pray', `eyes', `white_check_mark', `heart', `joy', `thinking_face', `rocket', `tada') with "Other..." delegating back to `slack-message-reaction-input' for the full set. Tests cover the hook hardening, the curated picker, and the `C-; S !' rebinding.
* perf(lorem-optimum): speed up the Markov generation pathCraig Jennings2026-05-111-45/+57
| | | | `cj/markov-join-tokens' collects tokens in a list and `mapconcat's once instead of repeated string concatenation. `cj/markov-generate' uses `push'/`nreverse' instead of repeated `append'. The Markov keys are cached as a vector so random key selection is O(1). Re-enabled the benchmark tests (the `:slow' tags were stale) and added a `cj/lipsum-title' test after byte-compilation flagged a malformed form there. `assets/liber-primus.txt' is left as-is (36 KB / 5,374 words, small enough not to need trimming). 100K-word learning now measures about 196 ms.
* refactor(prog-lsp): replace obsolete lsp-eldoc-hookCraig Jennings2026-05-111-1/+8
| | | | lsp-mode 9.0.0 made `lsp-eldoc-hook' an obsolete alias for Emacs's `eldoc-documentation-functions', and `lsp-managed-mode' already adds `lsp-eldoc-function' to that buffer-local hook. Dropped the obsolete `(setq lsp-eldoc-hook nil)'. `cj/lsp--disable-eldoc-hover' now removes `lsp-eldoc-function' from the buffer-local `eldoc-documentation-functions' via `lsp-managed-mode-hook', which clears the obsolete-variable byte-compile warning. Tests cover the hook removal, leaving the default `eldoc-documentation-functions' value alone, and the module no longer naming `lsp-eldoc-hook'.
* perf(org-capture): cache file+headline target markersCraig Jennings2026-05-111-0/+106
| | | | A task capture took 15-20 seconds: Org resolves a `(file+headline FILE "Headline")' target by opening the file, widening, and regex-scanning from the top for the headline, and the inbox template captures to `(file+headline inbox-file "Inbox")' over and over. An `:around' advice on `org-capture-set-target-location' caches a marker per resolved file/headline. On the next capture it validates the marker (still live, still in an Org buffer, still at a heading, headline text still matches) and jumps straight there. On any mismatch it falls back to the normal scan/create and refreshes the cache. `M-x cj/org-capture-clear-target-cache' resets it. Tests cover the cache hit, marker invalidation after the headline text changes, and creating a missing headline.
* fix(calendar-sync): give the no-init .ics worker its module load-pathCraig Jennings2026-05-111-1/+3
| | | | The async .ics-to-Org worker runs `emacs --batch --no-site-file --no-site-lisp' and loads `calendar-sync.el' by absolute path, but that doesn't make its sibling `(require 'cj-org-text-lib)' resolvable, so the conversion died with "Cannot open load file: cj-org-text-lib". `calendar-sync--worker-command' now inserts `-L <module-dir>' before `-l calendar-sync.el', which keeps the worker isolated from `init.el' while letting it load its local module deps. Updated the worker-command test and added a regression test that runs the real no-init worker shape.
* feat(window): kill the other window's buffer with C-; b KCraig Jennings2026-05-112-1/+22
| | | | `cj/kill-other-window-buffer' (in undead-buffers.el, on `C-; b K') kills or buries the buffer shown in the other window and leaves that window and the split alone. The window just shows whatever bury/kill surfaces next. It reuses `cj/kill-buffer-or-bury-alive', so buffers in `cj/undead-buffer-list' (like `*scratch*') get buried. With more than two windows it acts on `next-window'. Sibling of `cj/kill-other-window' (M-S-o), which deletes the other window. This one keeps it.
* feat(window): resize the split with C-; b <arrow>Craig Jennings2026-05-112-7/+47
| | | | | | `C-; b <left>/<right>/<up>/<down>' moves the active window's divider that way (via `windsize'), then keeps `cj/window-resize-map' active so bare arrows keep nudging until any other key (or `C-g'/`<escape>'). `C-u N C-; b <right>' resizes by N. windsize was on `C-s-<arrow>' (Ctrl+Super), which a tiling WM intercepts, so those keys were useless. I dropped that binding. The package is now `:commands'-deferred, and `windsize-cols'/`windsize-rows' drop to 2 (8/4 overshoots in a held nudge loop). `cj/window-resize-sticky' dispatches on the arrow that triggered it and arms the loop.
* feat(ai-vterm): order the project picker by most-recently-usedCraig Jennings2026-05-111-11/+57
| | | | | | The picker's active group (projects with a live tmux session) used to sort alphabetically. It now leads with projects opened this session, most-recent first, then the rest of the active group alpha, then the no-session group alpha. An in-session list (`cj/--ai-vterm-mru'), pushed to the front by `cj/--ai-vterm-show-or-create' on every open, drives the order. An empty list reproduces the old alphabetical behavior. I also pulled in a fix: `cj/--ai-vterm-tmux-session-name' now sanitizes `.' and `:' in the basename to `_'. tmux disallows those chars in session names and silently rewrites them, so `.emacs.d' really runs in session `aiv-_emacs_d', not `aiv-.emacs.d'. The computed name never matched, so `.emacs.d' was wrongly treated as having no session and landed in the no-session picker group. (A crash-recovery would also spawn a duplicate instead of reattaching.) Sanitizing the same way tmux does keeps the names in sync.
* feat(keymap): bind eval-buffer to C-; b eCraig Jennings2026-05-111-3/+5
| | | | `C-; b e' read best for `eval-buffer', but `e' was `cj/view-email-in-buffer' and the requested fallback `C-; b m' is `cj/move-buffer-and-file', so email-view moves to `C-; b E' (docstring and which-key updated).
* feat(vterm): unify the keys in vterm copy-mode and tmux historyCraig Jennings2026-05-111-26/+31
| | | | | | | | `vterm-copy-mode' and the `C-; x h' tmux-history buffer now share one key story. `M-w' copies the active region and stays put, so I can copy several things in a row. `C-g', `<escape>', or `q' leaves (resuming the live terminal, or closing the history buffer) without copying. `RET' is unbound (no special "copy and exit"). In copy-mode that meant removing vterm's default `RET' -> `vterm-copy-mode-done' binding. Before, `M-w' exited and copied as it went, which made grabbing more than one selection awkward. The history buffer's `cj/vterm-tmux-history-copy-and-quit' was the copy-and-exit one-shot. It's gone. `M-w' then `q' is the equivalent. I also moved `cj/vterm-tmux-history' from `C-; x C' to `C-; x h' (unshifted, and it frees `C') and refreshed the file's stale commentary header, which still referenced the old `C-; V' prefix and `<pause>'.
* feat(ai-vterm): keep emacsclient files out of the agent windowCraig Jennings2026-05-111-0/+45
| | | | | | `server-start' leaves `server-window' nil, so `server-switch-buffer' opens an `emacsclient -n' file in the selected window. When I'm typing in the agent vterm, the selected window is the agent window, so "tell the agent to open something" replaced the agent buffer with that file. I wired `server-window' to a function. When the selected window shows an `agent [...]' buffer, it puts the file in a non-agent window instead, splitting one off to the left of the agent when the agent is the only window. emacsclient invocations from anywhere else still go through `pop-to-buffer' unchanged. `cj/--ai-vterm-non-agent-window' picks the target window. It skips the minibuffer, dedicated windows, and any window already showing an agent buffer.
* fix(ui-config): use the writeable cursor color in a live vtermCraig Jennings2026-05-111-6/+23
| | | | | | `vterm-mode' sets `buffer-read-only', so `cj/set-cursor-color-according-to-mode' painted the cursor with the read-only color (orange) whenever point was in a vterm. That includes the live terminal, not just `vterm-copy-mode'. But a live terminal takes input: keystrokes go to the process, not the buffer. So a live vterm now reports `unmodified' instead. `vterm-copy-mode' still reports `read-only': there it really is a read-only Emacs buffer the user navigates, and the orange cursor is the right signal. I pulled the state cond out of `cj/set-cursor-color-according-to-mode' into `cj/--buffer-cursor-state' so it's unit-testable without a real frame or `set-cursor-color'.
* refactor(ai-vterm): rename Claude-specific names to a generic "agent"Craig Jennings2026-05-112-76/+79
| | | | | | | | | | | | | | | | | I may add other terminal agents to this launcher (aider, an open-source LLM TUI), so the buffer prefix, the user knob, and the internal helpers shouldn't say "Claude". The module name (ai-vterm) and the `cj/ai-vterm-*` customs were already generic. This finishes the job: - buffer prefix `claude [<basename>]` -> `agent [<basename>]` (the `defconst` and the matching display-buffer-alist regex move together) - `cj/ai-vterm-claude-command` -> `cj/ai-vterm-agent-command` (the default still runs the `claude` CLI, with a docstring note on swapping it) - `cj/--ai-vterm-claude-buffers` / `-displayed-claude-window` / `-reuse-existing-claude` -> `-agent-*`, and their test files renamed to match - prose in the module commentary and docstrings, plus the matching test docstrings and buffer-name literals `vterm-config.el` hardcodes the same buffer prefix in `cj/--vterm-toggle-buffer-p` (F12 excludes agent buffers from its candidate set), so that literal moved too. Collapsing it into the shared `cj/--ai-vterm-name-prefix` is a cleanup for another day. After a reload, a project's buffer opens as `agent [foo]` instead of `claude [foo]`. Old buffers keep their names until killed. I also corrected two stale `eshell-vterm-config.el` references in ai-vterm.el docstrings (that module was split into `vterm-config.el`). Two things keep saying "Claude": the `cj/ai-vterm-agent-command` default value (the actual CLI), and the "Claude Code" example in `vterm-config.el`'s cursor-restore docstring (a concrete TUI example, not branding). 90 tests pass. `make validate-modules` clean.
* feat(ai-vterm): name the tmux session's first window "ai"Craig Jennings2026-05-111-6/+20
| | | | I pass `tmux new-session -n` so the window running the AI tool shows up as "ai" in the window list instead of auto-naming after the running program. A shell opened by hand in a later window still auto-names (e.g. "zsh"), so the two read distinctly. The name is a new `defcustom` (`cj/ai-vterm-tmux-window-name`), symmetric with the session-prefix custom.
* feat(ai-vterm): surface surviving tmux sessions in the project pickerCraig Jennings2026-05-111-21/+119
| | | | | | Each project's tmux session is now named `<cj/ai-vterm-tmux-session-prefix><basename>` (default `aiv-`), so `tmux ls` can be filtered to AI-vterm's own sessions. After an Emacs crash the C-F9 project picker reads `tmux list-sessions`, matches surviving sessions back to their directories, and sorts those to the top: `[detached]` when only the tmux session is alive, `[running]` when a vterm buffer exists. The rest follow alphabetically. With tmux missing or no server running, it falls back to a plain alphabetical list. The picker's collection is a completion table that pins display order so Vertico doesn't re-sort and undo the active-first grouping. The prefix is a new `defcustom` rather than `claude-`, which collides with hand-rolled tmux sessions. Sessions named before this change use the bare basename and won't be matched afterward. One `tmux kill-server` clears any orphans.
* refactor(external-open): extract external-open-lib for shared helpersCraig Jennings2026-05-104-23/+45
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Same shared-helpers split-pattern that ai-vterm/vterm-config use through cj-window-toggle-lib and that calendar-sync uses through cj-org-text-lib. Pull the two pure dispatch helpers out of the external-open feature module into a sibling library so consumers that only need the dispatch don't have to require the whole feature. New `modules/external-open-lib.el' carries: - `cj/external-open-command' - `cj/external-open-launcher-p' `modules/external-open.el' stays as the feature module: the `default-open-extensions' defcustom, the `find-file' advice (`cj/find-file-auto'), and the interactive commands (`cj/xdg-open', `cj/open-this-file-with'). It now requires external-open-lib for the dispatch helpers. Migrate consumers: - system-utils.el used to require `external-open' for `cj/external-open-launcher-p' alone -- now requires `external-open-lib' directly. - dirvish-config.el calls `cj/external-open-command' from `cj/dirvish-open-file-manager-here' -- add an explicit `(require \='external-open-lib)'. Test files renamed to match the system-lib naming pattern (test-<library>-<feature>.el): - test-external-open-command.el -> test-external-open-lib-command.el - test-external-open-launcher-p.el -> test-external-open-lib-launcher-p.el No behavior change.