aboutsummaryrefslogtreecommitdiff
path: root/modules
Commit message (Collapse)AuthorAgeFilesLines
* refactor(host-env): fix env-desktop-p doc and normalize the X predicatesCraig Jennings2026-05-231-4/+7
| | | | | | env-desktop-p's docstring described a laptop, but the function returns t for the desktop case (no battery). env-x-p compared the window system against the string "x" while its sibling env-x11-p used `eq` against the symbol, so the two read differently for the same check. I corrected the docstring and switched env-x-p to the symbol comparison. I also spelled out the difference between env-x-p (any X display, including XWayland) and env-x11-p (a real X11 session, no Wayland). Behavior is unchanged, so the existing display-predicate tests stay green.
* fix(dirvish): guard nil file and reject path-traversal playlist namesCraig Jennings2026-05-231-12/+25
| | | | | | cj/set-wallpaper passed `(dired-file-name-at-point)` straight to `expand-file-name`, so running it with no file at point raised a bare `wrong-type-argument` instead of a clear error. cj/dired-create-playlist-from-marked expanded the raw playlist name under `music-dir` without checking it, so a name like "../foo" or "/etc/foo" would write outside the music directory. I added a nil-file guard to set-wallpaper and a `cj/--playlist-name-safe-p` check that rejects any name carrying a directory separator before the path is built. Both paths now fail cleanly with a user-error. Regression tests went into the existing wrapper and playlist test files.
* fix(dirvish): declare runtime constant/util deps with plain requireCraig Jennings2026-05-231-2/+2
| | | | | | dirvish-config builds `dirvish-quick-access-entries` from `code-dir`, `music-dir`, `pix-dir`, and the recording dirs at load time, and binds keys to `cj/xdg-open` and `cj/open-file-with-command`. Those come from user-constants and system-utils, but the module only required them under `eval-when-compile`, so the compiled module carries no runtime require and leans on init order having loaded them first. I switched both to plain requires, matching host-environment, system-lib, and external-open-lib right below. Added a dependency-contract smoke test that fails if the requires are dropped.
* refactor(org): give org-log-done a single homeCraig Jennings2026-05-222-2/+2
| | | | | | `org-log-done` was set in two places: `cj/org-todo-settings` in org-config.el set it nil, and org-roam-config.el's `:config` set it to 'time. Whichever module loaded last won, so the effective value was load-order-dependent and fragile. I set it once in `cj/org-todo-settings` and dropped the org-roam-config setter, leaving a comment at the old site so it doesn't creep back. The value is 'time rather than nil because the dated-completion workflow wants a CLOSED timestamp stamped on every TODO->DONE.
* fix(org-roam): always save the daily after a journal task-copyCraig Jennings2026-05-221-5/+13
| | | | | | The save lived inside the `unless` branch that only ran when the completed task needed an `org-refile` into a different file. When the task was already in today's daily, the copy left the buffer modified but unsaved. A crash before the next manual save lost it, and shutdown prompted about the unsaved journal buffer. I pulled the save out of the refile branch into a `cj/--org-roam-save-daily` helper that runs on both paths and only writes when the buffer is modified. Extracting it also makes the save logic testable without driving the org-roam capture machinery.
* refactor(auth): consolidate the auth-source secret lookup into one helperCraig Jennings2026-05-225-33/+29
| | | | | | | | The auth-source-search + funcall-the-secret block was copied four times: calendar-sync--calendar-url, cj/auth-source-secret (ai-config), cj/--auth-source-password (transcription), and cj/slack--get-credential. Each searched authinfo, pulled :secret, and called it when the netrc backend returned a function. I pulled that into cj/auth-source-secret-value in system-lib (a leaf, so calendar-sync doesn't have to depend on ai-config and drag in the gptel stack). It takes an optional user and returns the secret or nil. The four callers now delegate to it: ai-config layers its required-secret error on top, and the others keep their nil-on-miss behavior. With the direct auth-source-search calls gone, I dropped the now-unused (require 'auth-source) from transcription, slack, and calendar-sync. The helper's autoload covers it. The transcription tests that exercise the delegated path stay green, and the primitive and the error wrapper get their own tests.
* refactor(dashboard): derive the navigator and keybindings from one launcher ↵Craig Jennings2026-05-221-81/+50
| | | | | | | | table The 12 dashboard launchers were inlined twice (once as navigator icon buttons, once as dashboard-mode-map keybindings), so adding or reordering one meant editing both lists, and the icon-row order could drift from the key order. I pulled them into a single cj/dashboard--launchers table of (KEY ICON-FN ICON-NAME LABEL TOOLTIP ACTION) tuples. cj/dashboard--navigator-rows chunks it four per row into the navigator buttons, and cj/dashboard--bind-launchers binds each key to its action. The icons and the keys now come from one place, with no behavior change: same icons, labels, order, and keys, locked by tests.
* fix(dashboard): center the banner subtitle and color the navigator and itemsCraig Jennings2026-05-221-1/+1
| | | | | | The banner subtitle sat left of center because dashboard-banner-title-offset was 5, which over-shifts. I dropped it to 3, which lines the subtitle up under the banner image. The navigator and the recentf/project/bookmark list rendered in the default near-white. I set dashboard-items-face to steel+2 so they pick up a theme color, and the section headers stay blue via dashboard-heading. The navigator and the items share dashboard-items-face, because the navigator is drawn with a dashboard-items-face overlay that wins over its per-button dashboard-navigator face, so they take one color by design here.
* fix(ai-config): require gptel backend libs so the fork's constructors loadCraig Jennings2026-05-221-1/+11
| | | | | | cj/toggle-gptel and gptel chat errored with "Symbol's function definition is void: gptel-make-anthropic". The local gptel fork on :load-path with :ensure nil ships no generated autoloads, so (require 'gptel) loads gptel.el but never gptel-anthropic.el or gptel-openai.el, where the gptel-make-* constructors live. cj/ensure-gptel-backends then reached gptel-make-anthropic before it was defined. cj/ensure-gptel-backends now requires gptel-anthropic and gptel-openai first, through a small cj/--gptel-load-backend-libs helper. Verified end-to-end: with the fork on load-path, the constructors are fbound and both backends build.
* feat(org-config): add cj/org-finalize-task with testsCraig Jennings2026-05-221-0/+70
| | | | | | | | | | I added a command on C-; O d that finalizes the task at point. It prompts for a finalized keyword from org-done-keywords, so the picker tracks org-todo-keywords automatically. Marking the task done fires the org-roam journal-copy hook, so the completed task lands in today's daily. Then the heading is reshaped by depth. A sub-task (level 3 or deeper, or a VERIFY at any depth) becomes a dated log entry: the keyword and priority cookie are stripped, a sortable timestamp is prepended, and the tags are kept. A top-level task keeps its keyword and gains a date-only CLOSED line. The command binds org-inhibit-logging around the org-todo call so it owns the CLOSED line rather than depending on org-log-done, which is set inconsistently across two modules. The journal hook keys off org-state, not org-log-done, so the copy still fires. Tests run in org temp-buffers with the journal hook bound to nil, exercise the real org primitives, and inject a fixed time so the stamp shape is deterministic.
* fix(org-contacts): set org-contacts-files eagerly so launch doesn't errorCraig Jennings2026-05-211-4/+18
| | | | | | | | At startup the agenda-finalize hook ran cj/org-contacts-anniversaries-safe, which calls org-contacts-anniversaries, which calls org-contacts-files. That function messages "[org-contacts] ERROR: Your custom variable `org-contacts-files' is nil." when the variable is nil, and at that point it was nil. The value was set via the use-package :custom, which only applies when org-contacts loads, and that load is deferred behind :after (org mu4e) — later than the first agenda finalize. I set org-contacts-files eagerly at require time instead, so it's never nil by the time the hook fires. I also guarded the wrapper: org-contacts-files emits a message rather than signaling, so ignore-errors couldn't suppress it on its own. Now the call only runs when the variable is set. Three tests cover the eager set, the guard skipping when files are nil, and the wrapper running when they're set. Full suite green.
* feat(ai-vterm): add graceful agent close on M-f9 / C-S-f9Craig Jennings2026-05-211-28/+91
| | | | | | | | cj/ai-vterm-close tears an agent down cleanly: it kills the agent's tmux session (stopping the process), removes the vterm window when it isn't the only one in the frame, then kills the buffer. It targets the current agent buffer, the sole live agent, or prompts among several, and confirms before killing since that interrupts work in progress. I also folded the whole F9 family onto ai-vterm. M-f9 used to run cj/toggle-gptel, but gptel is broken right now (the local fork doesn't load, so gptel-make-anthropic is void), and grouping every ai-vterm command under F9 reads better anyway. M-f9 is the primary close binding. C-S-f9 is a second binding that the Wayland/PGTK layer may swallow on some machines. I covered it with 7 tests over the tmux-kill helper, the per-buffer teardown, and target selection, mocking process-file and the prompt at the boundary.
* feat(calendar-sync): resolve .ics feed URLs from auth-sourceCraig Jennings2026-05-211-5/+27
| | | | | | A calendar's .ics feed URL is a secret token, so I'd rather not keep it in a plaintext config file. A calendar can now name a :secret-host, and calendar-sync--calendar-url looks the URL up in auth-source (~/.authinfo.gpg) at sync time. Inline :url still works and wins when both are set, so existing configs are unaffected. I added 7 tests covering the explicit-url, string-secret, function-secret, precedence, and no-match paths, and switched the .example template to the :secret-host shape.
* fix(dashboard): trim padding newlines and reset window-start on openCraig Jennings2026-05-201-6/+7
| | | | | | The dashboard often opened already scrolled: content sat partly above the visible window with empty lines stranded at the bottom. There were two causes. The startupify list inserted five padding newlines that pushed the content past one screenful, and cj/dashboard-only moved point to point-min without resetting window-start, so a previously-scrolled view leaked into the next display. I trimmed the padding to one newline after the banner title and one before the items, and added a set-window-start to point-min in cj/dashboard-only so the view always starts at the top. A characterization test locks the window-start reset.
* feat(ai-vterm): default to bottom-75% on laptop, right-50% on desktopCraig Jennings2026-05-201-18/+52
| | | | | | | | The agent window's default placement was hardcoded to a right-side split at 50% width. That's wrong on a laptop, where the screen is shorter and a bottom split with more height fits better than a narrow side panel. Pick the default from the host: bottom at 75% height on a laptop, right at 50% width on a desktop, branching on env-laptop-p in cj/--ai-vterm-default-direction and cj/--ai-vterm-default-size. The defaults still feed the existing toggle-capture mechanism, so re-orienting the window mid-session sticks the same way it did before. Renamed cj/ai-vterm-window-width to cj/ai-vterm-desktop-width and added cj/ai-vterm-laptop-height so each axis has its own knob.
* feat(calendar-sync): dispatch Google calendars through API helperCraig Jennings2026-05-201-6/+110
| | | | | | | | | | The Python helper from d6a995b could fetch and render on its own, but nothing in Emacs called it. This wires it in. Each entry in calendar-sync-calendars now takes a :fetcher key. 'api routes through the helper, and the default 'ics keeps the existing curl + Elisp parser path. Proton and any plain .ics feed work unchanged because the key defaults to 'ics. The 'api path reads :account and :calendar-id off the calendar plist, builds the helper command (honoring the past/future window and the calendar-sync-skip-declined toggle), and runs it through make-process. The script writes the org file directly, so the sentinel only handles state bookkeeping and failure reporting, the same as the .ics worker. I split the old --sync-calendar body into --sync-calendar-ics and turned --sync-calendar into a dispatcher. The command builder and script-path resolution are pure functions, tested directly. The dispatch routing is tested with both leaf syncers stubbed, so no process runs. I added 14 tests across the two new files, and the full suite is green. Running the 'api path still needs the one-time OAuth bootstrap from docs/calendar-sync-api-setup.org.
* fix(calendar-sync): drop declined events from synced outputCraig Jennings2026-05-191-0/+25
| | | | | | | | | | | | | | | | The sync parsed PARTSTAT into a :STATUS: declined property but kept the event. Meetings I'd declined still landed in dcal.org / gcal.org and showed on the agenda. I added a pure --filter-declined helper called inside --parse-ics after event collection, plus the calendar-sync-skip-declined defvar (default t) so it can be flipped off without code changes. The .ics feed and the Calendar API can disagree on PARTSTAT. OOO auto-declines sometimes only write API-side, so a few declined events may still slip through. I'm calling this out because the filter looks absolute from the agenda but isn't. Tests cover Normal/Boundary/Error (11 cases). Full suite is green.
* fix(vterm): stop wheel/escape forwarders from blocking EmacsCraig Jennings2026-05-181-4/+23
| | | | | | | | | | | | | | | | vterm-send-string ends with (accept-process-output ... vterm-timer-delay ...). The global vterm-timer-delay is nil in this config, so the call blocks forever when the pty's program consumes the event without producing output -- a common pattern for TUIs like Claude Code reacting to mouse wheel or Escape. The symptom is a spinning cursor until C-g. cj/vterm--send-mouse-wheel and cj/vterm-send-escape now wrap the send in a let-binding that pins vterm-timer-delay to 0, so accept-process-output returns immediately. A top-level (defvar vterm-timer-delay) declaration goes alongside so the let is dynamic. Without it, lexical-binding-t in this file makes the binding lexical, invisible to vterm-send-string across files. The backtrace from the failing case confirmed the lookup was still receiving nil before the declaration.
* feat(vterm): forward <escape> to the pty in vterm-modeCraig Jennings2026-05-181-1/+13
| | | | | | | | `<escape>' is bound globally to `keyboard-escape-quit' in modules/keybindings.el, so Emacs swallows the key before it can reach the pty. Bind it in vterm-mode-map to cj/vterm-send-escape, which writes a literal ESC byte via vterm-send-string. tmux's copy-mode `cancel' binding then fires; vi-mode exits, fzf cancel, etc., also work as expected.
* feat(vterm): forward wheel events and route C-; x c into tmux copy-modeCraig Jennings2026-05-181-9/+75
| | | | | | | | | | | | | | | vterm-mode-map binds only mouse-1 and mouse-yank-primary, so wheel events fall through to Emacs scrolling and never reach the pty. tmux's `set -g mouse on' never sees them. Bind wheel-up / wheel-down (and X11 mouse-4 / mouse-5) to send SGR mouse-wheel escapes via vterm-send-string. tmux's existing WheelUpPane / WheelDownPane bindings route into copy-mode from there. For keyboard parity, route C-; x c through cj/vterm-copy-mode-dwim, which sends C-b [ when a tmux client is attached and falls back to vterm-copy-mode otherwise. tmux's history-limit is now reachable from either entry point. The matching copy-mode keys (M-w stays, C-g / q / Escape exit, Enter unbound) land in the dotfiles repo alongside.
* refactor(ai-config): switch gptel to local fork, drop tab-width adviceCraig Jennings2026-05-181-22/+2
| | | | | | | | | | | | | | I switched the gptel use-package form to `:load-path "~/code/gptel"` with `:ensure nil` so Emacs loads from the fork instead of the MELPA release. The fork now carries the narrow `tab-width' copy in `gptel-org--create-prompt' that karthink redirected the upstream PR to, which replaces the local `:around' advice on `gptel--with-buffer-copy-internal' I'd been carrying. I also dropped the stale test file `tests/test-ai-config-gptel-prompt-tab-width.el' and the matching stub in `tests/testutil-ai-config.el'. Both existed only to test the advice I removed.
* feat(ai-mcp): add pure-helper foundation with testsCraig Jennings2026-05-171-0/+416
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | First of nine phases for wiring mcp.el into GPTel. I scoped this phase to sections 1 (constants and defcustoms) and 3 (pure helpers) of the seven-section outline in the design doc. The other five sections ship in later phases. The module loads only under the test harness for now. init.el wiring waits for Phase 4. What I added: - cj/mcp-server-specs defconst: secret-free description of the 9 servers (linear, notion, figma, slack-deepsat, drawio, google-calendar, google-docs-personal, google-docs-work, google-keep). - Seven defcustoms: claude-config path, enabled-servers list, start-on-entry-points scope, two timeouts, per-tool confirm overrides, audit-log toggle. - cj/mcp--read-claude-config with an mtime cache and structured (:ok t/nil :reason ...) returns. - cj/mcp--get-server-entry, get-env, and get-secret-arg for pulling server data from the parsed config (figma's API key lives in args, not env). - cj/mcp--build-server-alist: pure transformer from specs plus config to the alist mcp-hub-servers expects. - cj/mcp--confirm-p classifier with write-pattern, read-pattern, and unknown-fails-closed branches, plus a cj/mcp-tool-confirm-overrides alist override. - cj/mcp--normalize-description prefixing tool descriptions with [SERVER], [SERVER WRITE], or [SERVER ?]. - cj/mcp--redact masking --token, --secret, --password, and --figma-api-key flags, Authorization headers, and ?token= URL params. Tests in tests/test-ai-mcp-helpers.el (41 ERT tests, all green): fixtures via make-temp-file, no real ~/.claude.json reads, no subprocesses, no network. Sentinel REDACTED_TEST_SECRET never appears in any redactor output. Design doc: docs/design/mcp-el-gptel-integration.org
* feat(ai-conversations): autosave on a periodic timerCraig Jennings2026-05-161-4/+56
| | | | | | | | | | | | The existing autosave only fired after gptel-send returned, so a conversation paused mid-thought wasn't on disk if Emacs crashed. I added a buffer-local repeating timer that calls cj/gptel--save-buffer-to-file every cj/gptel-autosave-interval seconds (default 60) for as long as cj/gptel--autosave-active-p holds. Toggle-off and kill-buffer-hook cancel it cleanly. Tests cover start/stop idempotency, the active-p predicate, the kill-buffer cleanup hook, and the toggle integration.
* refactor: consolidate runtime state into persist/Craig Jennings2026-05-166-3/+10
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Six previously-scattered runtime state files now live under persist/ in user-emacs-directory: - theme-file (was .emacs-theme) - pdf-view-restore-filename (was .pdf-view-restore) - time-zones--city-list-file (was .time-zones.el) - calendar-sync--state-file (was data/calendar-sync-state.el) - prescient-save-file (was var/prescient-save.el) - org-id-locations-file (was .org-id-locations) The defaults in each module now expand to persist/<name> instead of the user-emacs-directory root or ad-hoc subdirs. Existing files moved into persist/ alongside this change so the next launch picks up the state without regenerating. test-ui-theme-default-theme-file-is-emacs-dotfile renamed to test-ui-theme-default-theme-file-is-under-persist and updated to assert the new default path. lsp-session-file is left at the root for now -- prog-lsp.el has no (require) reference anywhere, so the use-package block that would carry the redirect never runs. Tier 3 follow-up: confirm the module is dead, then delete it or wire it into the load chain. The var/ directory is now empty and removed. data/ retains the calendar agenda content (dcal/gcal/pcal.org) and the .rest API examples -- content, not state, stays where it is.
* chore: drop stale custom pdf-continuous-scroll files + dead use-packageCraig Jennings2026-05-161-10/+0
| | | | | | | | | Removes custom/pdf-continuous-scroll-mode.el and the -latest.el variant along with the commented-out use-package block that referenced them. Two stale copies sat in custom/ unused. pdf-continuous-scroll-mode is intentionally not enabled because of a known bad interaction with org-noter. If that decision changes, the package can be added back through normal use-package + ELPA channels.
* fix(config-utilities): guard emacsql-close against nil sqlite handleCraig Jennings2026-05-161-0/+20
| | | | | | | | | | | | | EmacSQL 4.3.1 registers a finalizer per connection that calls emacsql-close after GC. The sqlite-builtin and sqlite-module backends clear their handle slot during an explicit close, so the finalizer later runs emacsql-close on a closed connection and sqlite-close fires: finalizer failed: (wrong-type-argument sqlitep nil) Adds an :around method on emacsql-close for both backends that short-circuits when the handle is already nil. Requires cl-generic and eieio at the top of the file so the cl-defmethod forms expand.
* feat(org-config): hide :PROPERTIES: drawers via org-tidyCraig Jennings2026-05-161-0/+11
| | | | | | | | | | | Adds an org-tidy use-package block hooked into org-mode and sets org-tidy-properties-style to 'inline so each :PROPERTIES: drawer collapses to a small marker in the heading line. The drawer stays editable through TAB cycling or via M-x org-tidy-mode toggle. Also sets org-cycle-hide-drawers to 'all in cj/org-general-settings so drawers fold whenever their parent heading folds -- the native companion to org-tidy's overlay-based hiding.
* fix(flycheck): wrap languagetool checker definition in eval+backquoteCraig Jennings2026-05-161-10/+15
| | | | | | | | | | | | | | | | | | | | | | | flycheck's `flycheck-define-checker' macro requires the `:command' executable to be a string literal at macro-expansion time -- it does `(stringp (car command))' and errors otherwise. The previous `(eval (expand-file-name ...))' form (commit d84aa437, the externalization fix) put a `(eval FORM)' wrapper in the executable position, which flycheck rejected at load: Error (use-package): recentf/:config: Command executable for syntax checker languagetool must be a string: (eval (expand-file-name "scripts/languagetool-flycheck" user-emacs-directory)) `(eval FORM)' is only valid for SUBSEQUENT command-list elements (arguments), not the executable. Wrap the entire `flycheck-define-checker' invocation in `eval' + backquote so the expanded path is spliced in as a string literal before the macro sees it. The hardcoded `~/.emacs.d/...' path is gone for the same reason the original externalization wanted it gone: survives a non-standard `user-emacs-directory'.
* fix(ai-config): gptel-model must be a symbol, not a stringCraig Jennings2026-05-161-3/+5
| | | | | | | | | | | | | | The default-backend swap to gpt-5.5 (commit 0f029ab5) set `gptel-model' as the string "gpt-5.5". gptel's modeline-display code calls `symbolp' on the model value and signals `wrong-type-argument symbolp "gpt-5.5"' on every render, which manifested as Emacs freezing in the AI-Assistant buffer ("Querying ChatGPT..." → error in process sentinel → repeated redisplay errors). Both default-setting sites now use `'gpt-5.5' (interned symbol). The Anthropic backend tolerated string model names so the original "claude-opus-4-7" string worked, which is why this hadn't surfaced before.
* chore(ai-config): switch default gptel backend to ChatGPT / gpt-5.5Craig Jennings2026-05-161-5/+5
| | | | | | | | Two places set the default backend + model on gptel initialization -- `cj/ensure-gptel-backends' (the lazy-init fallback) and the `use-package gptel :config' block (the eager-set after initialization). Both now pick the ChatGPT backend with `gpt-5.5' instead of Claude with `claude-opus-4-7'.
* feat(gptel-tools): wire web_fetch as a local toolCraig Jennings2026-05-161-1/+2
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Fourth ADOPT entry from `docs/design/gptel-tools-shortlist.org'. Lets gptel pull a URL into the conversation so the model can read docs / current API shapes / etc. without me copy-pasting. Shape: - URL must be `http://' or `https://' (file://, ftp://, javascript:, scheme-less, etc. are rejected at the validator). - HTML responses go through `pandoc -f html -t plain' so the model gets a reading shape that isn't full of markup; falls back to `w3m -dump -T text/html' if pandoc isn't on PATH; signals `user-error' if neither is. Pass `raw=t' to skip stripping. - Output capped at 200KB by default, hard cap 1MB; `max_bytes' argument lets the caller pick a lower cap. Truncation reported inline. - 4xx / 5xx response codes signal `error' with the code -- the alternative is returning an error page body, which the model would treat as content. `:confirm t' on the tool because every call is a real outbound network request. The tool's description warns that URLs go wherever the user-agent points, including internal networks if that's what the URL names. `tests/test-gptel-tools-web-fetch.el' -- 20 tests across Normal / Boundary / Error. URL validator covers http / https / non-string / empty / non-http schemes. `--effective-max-bytes' covers default / low-clamp / hard-cap / passthrough. Truncate helper covers under-cap / at-cap / over-cap with the marker. HTML stripper runs against real pandoc / w3m (both installed in dev env, neither should mangle simple markup). Orchestrator stubs `cj/gptel-web-fetch--retrieve' via `cl-letf' to cover normal / raw / 4xx / 5xx / oversize / bad-scheme paths. Wired into `cj/gptel-local-tool-features' so gptel exposes the tool on next restart. Note: `call-process-region' invocation flattened to a single `with-temp-buffer' with DELETE=t -- the initial draft nested a second temp buffer and routed output to the inner one, which got killed before `buffer-string' on the outer ran. Test caught it.
* feat(gptel-tools): wire git_status / git_log / git_diff as local toolsCraig Jennings2026-05-161-1/+4
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Three read-only git context tools so gptel can see what's changed without me pasting `git status` / `git log` / `git diff` output into every chat turn. Builds the first batch from the ADOPT bucket in `docs/design/gptel-tools-shortlist.org`. Shape per tool: - `gptel-tools/git_status.el` — `git status --short --branch` for a directory inside a git working tree under HOME. Returns the porcelain output, or a "Clean working tree" marker when only the branch line is present. - `gptel-tools/git_log.el` — `git log --oneline -nN` with an optional `--since` filter. N defaults to 20, capped at 100; nil / non- integer / out-of-range N falls back to the default. - `gptel-tools/git_diff.el` — `git diff [REF1 [REF2]] [-- FILE]`. Output capped at ~500KB so a runaway diff can't blow up context; truncation is reported inline. Validation is uniform: path must resolve under HOME, must be a directory, must be inside a git working tree (verified via `git rev-parse --is-inside-work-tree`). Color is disabled via `-c color.ui=false` at the git level (`git status` doesn't accept `--no-color` directly). Tests run against real temp git repos created via `process-file`, not mocked — there's nothing in gptel-tools/git_*.el that's process-mockable in a meaningful way, and a real `git init` + a couple of commits is cheaper than building a fake. 31 tests total: 7 for git_status, 11 for git_log, 13 for git_diff. Wired into `cj/gptel-local-tool-features` so gptel exposes the three tools on next restart.
* fix(nerd-icons): restore `:demand t' so dashboard-config can loadCraig Jennings2026-05-161-5/+10
| | | | | | | | | | | | | | | | | Commit d618bb46's defer change broke startup with a `void-function nerd-icons-faicon' error. `dashboard-config.el' calls `nerd-icons-faicon' / `nerd-icons-mdicon' / `nerd-icons-devicon' at load time to build `dashboard-navigator-buttons', so nerd-icons must be loaded eagerly before dashboard-config requires. The "defer for batch and headless" intent doesn't hold here -- dashboard loads unconditionally at startup, so nerd-icons does too either way. Kept the `with-eval-after-load 'nerd-icons' safety net for the re-evaluation case (advice still attaches if this module is re-required after nerd-icons already loaded). Comment in the file records why deferral isn't workable here so a future cleanup pass doesn't try the same change again.
* refactor(integrations): five hygiene fixes from the module-by-module re-reviewCraig Jennings2026-05-164-22/+61
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | - markdown-config.el: two related fixes on `markdown-preview'. First, the URL was `https://localhost:8080/imp' but simple-httpd serves plaintext on port 8080 -- the browser hit a TLS handshake against a non-TLS listener and the preview never rendered. Changed to `http://' and switched from `browse-url-generic' to plain `browse-url' so the user's default protocol handler picks the browser. Second, the function used to start the network listener as a side effect of opening a preview; that's split into a separate `cj/markdown-preview-server-start' command and `markdown-preview' now signals a `user-error' (with the recovery command in the message) when the server isn't running. - slack-config.el: wrap the `which-key-add-keymap-based-replacements' call in `with-eval-after-load 'which-key'. Matches the pattern other config modules use and means a slow / missing which-key load won't block requiring slack-config. - ai-vterm.el: pass the inner shell-command-string through `shell-quote-argument' before wrapping in the tmux invocation. The default value with embedded double quotes was safe under the prior literal-single-quote wrap, but a user-customized `cj/ai-vterm-agent-command' containing a single quote silently broke the shell parse. Two existing tests updated to tolerate the post-quote escape shape; new regression test asserts a single-quote-bearing custom command survives. - eshell-config.el: scope the `TERM=xterm-256color' override to eshell-spawned processes only via an `eshell-mode' hook that prepends to a buffer-local `process-environment'. The previous global `setenv' at config-time changed `TERM' for every subsequent `start-process' across the Emacs session, so any subprocess (not just eshell pipelines) inherited `xterm-256color' regardless of whether the receiver could interpret the escapes.
* refactor(prog): six programming-track hygiene fixes from re-reviewCraig Jennings2026-05-167-7/+74
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | - prog-lsp.el: rename `cj/lsp--remove-eldoc-provider' → `cj/lsp--remove-eldoc-provider-global' and call it once from the lsp-mode `:config' block instead of attaching it per-buffer via `lsp-managed-mode-hook'. The previous per-buffer remove with the buffer-local flag raced lsp-mode's own population of the local hook; removing the provider from the global default before any LSP buffer attaches makes the absence stick. Two existing tests updated to the new contract (remove-from-default + idempotent re-run). - prog-webdev.el / prog-python.el: warn at load time when `prettier' or `pyright' is missing on PATH via `cj/executable-find-or-warn'. Both modules now `(require 'system-lib)' to expose the helper. Missing dependencies surface up front instead of mid-edit at first format/LSP attach. - keyboard-compat.el: document existing idempotence. The hook install uses a named function so `add-hook' deduplicates, and the hook body only calls `define-key' (latest binding wins, same value) -- adding a comment so future readers don't re-question. - dev-fkeys.el: add a `typescript' clause to `cj/--f6-test-runner-cmd-for'. F6 now runs `npx --no-install vitest <path>' when vitest is on PATH, otherwise `npx --no-install jest <path>'. Updates the matching test from "returns nil" to cover both code paths; the impl-level test now asserts the routed command instead of expecting a user-error. - flycheck-config.el: build the LanguageTool wrapper path with `(expand-file-name "scripts/languagetool-flycheck" user-emacs-directory)' instead of a hardcoded `~/.emacs.d/...'. Survives a non-standard `user-emacs-directory'. - latex-config.el: replace the hardcoded Zathura viewer with `cj/--latex-select-pdf-viewer', which walks `cj/--latex-pdf-viewer-candidates' (zathura → evince → okular → SumatraPDF → xdg-open) and falls back to "PDF Tools" when nothing is on PATH. Each entry maps an executable to the matching TeX-view-program-list name so AUCTeX's defaults handle the actual viewer invocation.
* refactor(org-workflow): four hygiene fixes from the module-by-module re-reviewCraig Jennings2026-05-163-16/+44
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | - org-roam-config.el: extract `cj/--org-roam-should-copy-completed-task-p' and gate the `org-after-todo-state-change-hook' on it. Skips fileless buffers (org-capture, indirect, temp Org) where `buffer-file-name' is nil and the downstream copy used to crash. Same gcal.org skip preserved. Five existing tests updated to bind `buffer-file-name' inside `run-hooks' so the positive-case hook still fires. - org-webclipper.el: drop the redundant `org-protocol-protocol-alist' registration inside `cj/webclipper-ensure-initialized'. The `with-eval-after-load 'org-protocol' block at the bottom of the module is the single registration site now; comment in the initializer explains why. Split the matching test into two: one for template registration (the initializer's actual job) and one for protocol registration (which now fires from the after-load block when `org-protocol' provides). - org-webclipper.el: validate `:url' and `:title' in `cj/org-protocol-webclip'. `:url' must be a non-empty string; `:title' must be a string when provided. Signals `user-error' with the unexpected value instead of silently setting the globals to nil and failing downstream in the capture handler. - mu4e-org-contacts-integration.el: declare `contacts-file' (via `eval-when-compile (defvar ...)') and `cj/get-all-contact-emails' (via `declare-function') near the top of the file. Byte-compile in isolation no longer warns about free variables / unknown functions; the cross-module dependency is explicit at the top.
* refactor(ui): four UI/navigation hygiene fixes from module-by-module re-reviewCraig Jennings2026-05-164-18/+52
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | - popper-config.el: move `(popper-mode +1)` and `(popper-echo-mode +1)` from the use-package `:init` block into `:config`. `:disabled t' on use-package skips `:config' but still runs `:init', so the previous shape enabled popper-mode on every load, including batch / test runs, despite the disabled marker. - modeline-config.el: make `cj/modeline-vc-fetch' fall back when the internal `vc-git--symbolic-ref' is missing. `require' uses `nil 'noerror', the call sits inside an `fboundp' guard, and `ignore-errors' wraps the call itself so an Emacs version that renames or removes the accessor leaves `branch' at `vc-working-revision''s output instead of crashing the modeline. - ui-config.el: guard the cursor-color `post-command-hook' behind `(display-graphic-p)' both at install time and inside the function body. Batch / TTY runs short-circuit cleanly with no per-command overhead. A `server-after-make-frame-hook' catches the daemon case where the first GUI frame is created after ui-config loads and installs the hook lazily. Updates test-ui-config--buffer-cursor-state and test-ui-cursor-color-integration to stub `display-graphic-p' so the work body still runs under batch. - nerd-icons-config.el: drop `:demand t' (`:defer t' now), keeping the `:config' advice install as the natural lazy-on-load path. Add a `with-eval-after-load 'nerd-icons' block as a safety net for the already-loaded case on re-eval; the block uses `advice-member-p' so the advice never stacks.
* refactor(custom-editing): five hygiene fixes from the module-by-module re-reviewCraig Jennings2026-05-165-23/+69
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | - Guard `cj/duplicate-line-or-region' when COMMENT is non-nil but the current mode has no `comment-start' (e.g. fundamental-mode). Previously the function silently produced malformed output via `comment-region'; now it signals a clear `user-error'. - Factor the `find-file' advice install in external-open.el into `cj/external-open-install-advice'. Same idempotent shape (remove-then-add) but the intent is named. - Add `cj/--validate-decoration-char' in custom-comments.el and wire it into all six divider / border / box helpers. Rejects multi-char strings, empty strings, and control characters like newline/tab that would corrupt subsequent `M-q' flows. Updated the five nil-decoration ERT tests from `:type 'wrong-type-argument' (the old crash signal from `string-to-char' on nil) to `:type 'user-error', since the validator produces a clear message instead of a deep crash. - Extract `cj/--require-spell-checker' in flyspell-and-abbrev.el. Both `cj/flyspell-toggle' and `cj/flyspell-then-abbrev' now call the shared helper; the checker list lives in `cj/--spell-checker-executables', so adding nuspell or any other checker is a one-line edit. - Preserve trailing newlines in custom-ordering output. Both `cj/--arrayify' and `cj/--unarrayify' now detect a trailing newline on the input region and re-append it to the result, matching the pattern custom-text-enclose.el already uses.
* refactor(foundation): hygiene pass across early-init, user-constants, ↵Craig Jennings2026-05-163-73/+69
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | system-defaults, chrono-tools Six small fixes the 2026-05-15 module-by-module re-review surfaced: - Consolidate `user-home-dir` -- canonical defconst stays in early-init.el (package-archive bootstrap needs it before normal modules load); user-constants.el switches to a `defvar` with the identical `(getenv "HOME")` expression so the module still loads / byte-compiles standalone, but at runtime early-init's defconst wins. - Drop the redundant `(autoload 'env-bsd-p ...)` line in system-defaults.el. The `(eval-when-compile (require 'host-environment))` already exposes the symbol to the byte compiler, and at runtime host-environment is loaded earlier in init.el. Added a comment documenting the boundary. - Convert `cj/debug-modules` and `cj/use-online-repos` from `defvar` to `defcustom`, with `:type`, `:group 'cj`, and a top-level `(defgroup cj ...)` so both show up in M-x customize. - Name the package-archive priorities in early-init.el. Nine new defconsts replace the magic numbers (200 / 125 / 120 / 115 / 100 / 25 / 20 / 15 / 5) with one constant each, plus a header comment explaining the local-first ordering and the gnu > nongnu > melpa > melpa-stable trust ranking within each tier. - Delete the 19-line commented-out `use-package time` world-clock block in chrono-tools.el. `time-zones` immediately above is the active replacement; git history preserves the old config if anyone needs it. - Add coverage for `cj/tmr-select-sound-file`. Collapsed the prefix-arg branch into a delegation to `cj/tmr-reset-sound-to-default` (single reset source) and extracted `cj/tmr--available-sound-files` as a pure helper that tests directly. 9 ERT tests across Normal / Boundary / Error cover the available-sounds helper, the reset path, the prefix-arg delegation (no prompt), the normal selection path, and the empty-dir / missing-dir / cancel boundaries.
* feat(modeline): surface flycheck status in the custom modelineCraig Jennings2026-05-162-0/+12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The custom modeline builds `mode-line-format` from explicit segments and skips `minor-mode-alist`, so flycheck's lighter never appears. That hid error and warning counts even in buffers where flycheck was auto-enabling (every emacs-lisp and sh buffer). The fix is Option 4 from the design doc: customize the flycheck modeline variables, then add a single guarded `(:eval ...)` form to `mode-line-format`. Five new lines total, two-file change. `modules/flycheck-config.el` :custom block gets: (flycheck-mode-line-prefix "🐛") (flycheck-mode-success-indicator " ✓") `flycheck-mode-line-color` stays default-t so error / warning counts pick up their faces automatically. `modules/modeline-config.el` `mode-line-format` gets an `(:eval ...)` between the recording indicator and `cj/modeline-vc-branch`: (:eval (when (and (mode-line-window-selected-p) (bound-and-true-p flycheck-mode)) (flycheck-mode-line-status-text))) The `mode-line-window-selected-p` guard mirrors `cj/modeline-vc-branch` and `cj/modeline-misc-info` -- segments hide in inactive windows. The `bound-and-true-p flycheck-mode` guard keeps the form silent in buffers where flycheck hasn't loaded or isn't enabled, which is safer than referencing `flycheck-mode` directly. The `(:eval ...)` is inline rather than a named `defvar-local`, so no addition to the risky-local-variable list is needed. `tests/test-modeline-config-flycheck-segment.el` -- 3 smoke tests asserting the segment is present and both guards are in place. All existing tests stay green. Manual verification (per the design doc) is the user's call -- the emoji prefix and the colored count behavior need a running GUI Emacs to observe.
* feat(ai-conversations-browser): dired-style browser for saved GPTel ↵Craig Jennings2026-05-162-0/+244
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | conversations `cj/gptel-load-conversation` prompts via `completing-read`. A dedicated browser shows what each conversation is about at a glance and supports single-key load / delete / rename without having to scroll a minibuffer list. New module `modules/ai-conversations-browser.el` + `cj/gptel-browse-conversations` entry point bound to `C-; a b` ("browse conversations"). Opens `*GPTel-Conversations*` in `cj/gptel-browser-mode` (a `special-mode` derivative). Each row shows date, time, topic slug, and a preview of the most recent message (length configurable via `cj/gptel-browser-preview-length`, default 60 chars). Rows sort newest first. In the browser: - `RET` / `l`: load the conversation (delegates to `cj/gptel-load-conversation` with the file pre-selected via a `cl-letf` stub on `completing-read` so the user isn't prompted twice), then bury the window. - `d`: delete the file under point after `y-or-n-p` confirmation, re-render. - `r`: rename the file under point. Preserves the timestamp, slugifies the new topic, refuses unchanged input and existing targets. - `g`: refresh. - `n` / `p`: next / previous row. - `q`: quit-window. 21 tests cover the helpers (topic parsing, header stripping, preview shaping for truncate / short / empty cases, row-for-file with conversation + non-conversation filenames, rows enumeration, render output for empty + populated cases, newest-first sort, rename-target preservation of timestamp + slug, rename-target error on missing timestamp) and the file-touching actions (delete with y, cancel with n, rename, rename-on-empty-line error).
* feat(ai-rewrite): add directive-picker wrappers around gptel-rewriteCraig Jennings2026-05-162-2/+114
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `gptel-rewrite` is the killer feature for the keep-gptel decision, and it now lives behind two commands instead of the bare call: - `cj/gptel-rewrite-with-directive` (`C-; a r`, replacing the former bare `gptel-rewrite` binding): completing-read on a directive name from `cj/gptel-rewrite-directives`, then rewrite the active region. - `cj/gptel-rewrite-redo-with-different-directive` (`C-; a R`): replay the prior region with a different directive. The region is preserved via markers stored buffer-local on the first call so it survives accept/reject of the prior rewrite. I picked the hook injection approach over an `:after`-advice + state-capture pattern. `gptel-rewrite-directives-hook` is an abnormal hook gptel-rewrite already consults for a per-call system message. Wrapping the call in a one-shot `let`-binding on that hook gives the directive exactly the lifetime of the rewrite and leaves nothing to clean up. Mutating `gptel-directives` globally would mean either restoring it afterward or living with the change -- both worse than the hook. Directives ship inline as a `defcustom` alist with the six names called out in the task -- `terse`, `fix-grammar`, `refactor-readability`, `add-docstring`, `explain-as-comment`, `shorten`. Customization is a `customize-variable` or `setq` away. 9 tests cover the defcustom shape (default names present, bodies non-empty strings), the wrapper (normal path, no-region error, unknown-directive error, last-state recording), and the redo (replays the prior region, errors when no previous, excludes the current directive from the re-pick prompt). `gptel-rewrite` stubbed in tests so no rewrite UI fires.
* feat(ai-quick-ask): add cj/gptel-quick-ask one-shot commandCraig Jennings2026-05-162-0/+136
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | New module `modules/ai-quick-ask.el`. Bound to `C-; a q` via `cj/ai-keymap` ("quick ask"). `cj/gptel-quick-ask` reads a prompt in the minibuffer, creates a transient `*GPTel-Quick*` buffer in `cj/gptel-quick-mode` (a special-mode derivative with `q` / `escape` / `c` bindings), inserts "Q: <prompt>" plus a response marker, then calls `gptel-request` with `:stream t` so the answer streams into the buffer. Doesn't touch `*AI-Assistant*`, doesn't autosave. Two follow-up commands work in the buffer: - `cj/gptel-quick-dismiss` (`q` / `escape`): delete the window and kill the buffer. Idempotent when the buffer is absent. - `cj/gptel-quick-continue` (`c`): extract the prompt + response, seed them into `*AI-Assistant*` under proper org headings (matching the `cj/gptel--fresh-org-prefix` shape), display the side window, then dismiss the quick buffer. 13 tests cover the pure helpers (initial-text shape, response extraction across normal / multi-line / no-marker / empty inputs, seed-text shape), the ask path (buffer created in right mode, prompt recorded, gptel-request called, empty-prompt error), the dismiss path (kills buffer / no-op when absent), and the continue path (seeds `*AI-Assistant*`, dismisses quick buffer, errors outside a quick buffer). `gptel-request` is stubbed in tests so nothing hits the network.
* feat(ai-conversations): add cj/gptel-autosave-toggle with [AS] mode-line ↵Craig Jennings2026-05-162-0/+45
| | | | | | | | | | | | | | | | | | | | | | | | | indicator `cj/gptel-autosave-enabled` flipped to t inside the save/load entry points with no way back off short of editing the variable or clearing the buffer, and no visible indicator that it was on. Two pieces: - `cj/gptel-autosave-toggle` flips the buffer-local state in the current GPTel buffer. Bound to `C-; a A` via `cj/ai-keymap` (which-key: "toggle autosave"). When autosave is OFF and no filepath is configured yet, the command prompts to save the conversation first so a save target exists; otherwise it just flips the bit. - `cj/gptel-autosave-mode-line-format` surfaces " [AS]" in the mode-line when autosave is on, blank when off. Installed via a `gptel-mode-hook` so every GPTel buffer picks it up. The install helper is idempotent. 6 new tests cover enable/disable paths, the no-filepath prompt path, the not-a-gptel-buffer error path, the mode-line format evaluation, and the install idempotence.
* fix(ai-config): hook gptel-magit wiring per-feature, not on magitCraig Jennings2026-05-161-6/+13
| | | | | | | | | | | | | | | | | | | | | | | | | | | The wiring keyed on `with-eval-after-load 'magit` fires while two of its three references are still undefined. `magit.el` calls `(provide 'magit)` BEFORE its `cl-eval-when (load eval)` block requires `magit-commit` and `magit-stash`. At that moment the `magit-commit` transient prefix doesn't exist, and `transient-append-suffix` silently no-ops on missing prefixes (default `transient-error-on-insert-failure` is nil). The "g Generate commit" and "x Explain" suffixes never landed. Only the M-g binding worked, because `git-commit` IS required before provide. Three per-feature hooks replace the single `'magit` hook: one each on `git-commit`, `magit-commit`, and `magit-diff`. Each hooks the exact dependency the wiring needs, side-stepping the load-order race entirely. The companion test was rewritten to check `after-load-alist` registration rather than drive the hooks through `provide`. Emacs 30 batch mode doesn't fire registered `eval-after-load` callbacks on `provide` alone -- only an actual `load` does. Inspecting the registration is the stronger guard anyway: the regression is "a single `'magit` hook," and the right shape of that check is "no entry under `magit`, entries under `git-commit`, `magit-commit`, `magit-diff`."
* feat(gptel-tools): wire update_text_file as a local tool with testsCraig Jennings2026-05-161-0/+1
| | | | | | | | | | | | | | | | | | | | | I rewrote `update_text_file.el` in pure Elisp. The previous version shelled out to sed for everything, had a stray quote terminator at EOF (line 149) that broke loading, produced literal backslash-n where actual newlines were expected, and prompted via `y-or-n-p` redundantly with gptel's own `:confirm t` flag. The five operations -- replace, append, prepend, insert-at-line, delete-lines -- split into pure string transforms that test without touching the disk. The file-level wrapper validates the path, enforces a 10MB size limit, takes a timestamped backup, and writes atomically. No backup is created when the operation is a no-op. Patterns are literal substrings (not regex) so the model can't trip over metacharacter quoting. `tests/test-update-text-file.el` covers Normal / Boundary / Error per operation plus the file-level wrapper. 48 tests green. Added `update_text_file` to `cj/gptel-local-tool-features` so gptel exposes the tool after restart.
* fix(ai-config): force tab-width=8 in gptel org-mode prompt buffersCraig Jennings2026-05-161-0/+22
| | | | | | | | gptel's `gptel--with-buffer-copy-internal` copies the source buffer's `major-mode` symbol but doesn't run mode hooks. An inherited-org-mode prompt buffer keeps `tab-width` at this config's global default of 4 instead of the 8 that `org-mode-hook` would set. When gptel later parses the prompt buffer with `org-element`, Org's `tab-width=8` guard raises "Tab width in Org files must be 8, not 4." I was hitting this on every second `gptel-magit-generate-message` from COMMIT_EDITMSG. `vc-config.el` sets `git-commit-major-mode 'org-mode'`, and the diffs contained list-shaped content that `org-element--list-struct` parsed. The advice forces `tab-width=8` in the prompt buffer when its inherited mode is org-mode. It's a local workaround for an upstream gap. An upstream patch to run `(delay-mode-hooks (funcall major-mode))` in the buffer-copy is the real fix. I'll send it next.
* fix(ai-config): Ensure gptel-magit is installed via use-packageCraig Jennings2026-05-151-16/+16
| | | | | | | Replace raw autoload calls with a `use-package` declaration so `use-package-always-ensure` installs gptel-magit on machines that haven't run `package-install`, fixing the "Cannot open load file" error on transient setup.
* fix(flycheck): correct abbrev-mode no-arg toggle in cj/prose-helpers-onCraig Jennings2026-05-151-6/+5
| | | | | | | | | | | | | | | | | The shape (if (not (abbrev-mode)) (abbrev-mode)) calls abbrev-mode with no argument -- that's the toggle signature, not a query. When the mode was already on the function flipped it off then on instead of being a no-op. Replaced with (unless (bound-and-true-p VAR) (MODE 1)) for both abbrev-mode and flycheck-mode. 4 ERT tests cover both-off, both-on, and the two mixed states. Also ran the module hardening pass across 24 newly-added modules, renamed the six completed Review sub-tasks to Harden, filed 11 new findings under their Harden parents, and broke three design specs (EMMS-free music, dev F-keys, dev-setup-project) into 20 dependency-ordered sub-tasks via parallel subagents. Verified the sqlite finalizer bug from 2026-04-26 is gone and closed its tracking entry.
* refactor(org-babel): drop redundant cj structure templateCraig Jennings2026-05-151-3/+3
| | | | Now that yasnippet handles `<cj` + TAB in every buffer including org-mode, the `cj` entry in `org-structure-template-alist` is redundant. I removed it and left a one-line comment pointing to where the marker now lives.