diff --git a/agent-shell-markdown.el b/agent-shell-markdown.el new file mode 100644 index 00000000..94cb2908 --- /dev/null +++ b/agent-shell-markdown.el @@ -0,0 +1,2288 @@ +;;; agent-shell-markdown.el --- Replace Markdown markup with propertized text -*- lexical-binding: t -*- + +;; Copyright (C) 2026 Alvaro Ramirez + +;; Author: Alvaro Ramirez https://xenodium.com +;; URL: https://github.com/xenodium/agent-shell + +;; This package is free software; you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation; either version 3, or (at your option) +;; any later version. + +;; This package is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with GNU Emacs. If not, see . + +;;; Commentary: +;; +;; Convert a Markdown string into propertized text: +;; +;; (agent-shell-markdown-convert "hello **world**") +;; +;; Or rewrite the current buffer in place: +;; +;; (agent-shell-markdown-replace-markup) +;; +;; Both remove the markup characters and leave behind face text +;; properties. Supported markup: +;; +;; bold `**X**' / `__X__' face `agent-shell-markdown-bold' +;; italic `*X*' / `_X_' face `agent-shell-markdown-italic' +;; strike `~~X~~' face `agent-shell-markdown-strikethrough' +;; header `# X' .. `###### X' face `agent-shell-markdown-header-1' .. `-6' +;; inline code `` `X` `` face `agent-shell-markdown-inline-code' +;; link `[title](url)' face `agent-shell-markdown-link', keymap opens URL +;; image `![alt](url)' `display' property carries image +;; image path bare image path on a line same as `![alt](url)' (no markup) +;; divider `---' / `***' / `___' rendered as an underlined rule line +;; fenced code ```LANG\nX\n``` body syntax-highlighted via LANG mode +;; tables `| A | B |' grid rows rendered with aligned columns, +;; unicode borders, header/zebra rows +;; and wrap-to-window-width support +;; +;; All agent-shell-markdown-* faces inherit from the conventional faces +;; (`bold', `italic', `org-level-N', etc.) so default rendering is +;; unchanged, while still letting users customize markdown output +;; without disturbing the source faces elsewhere. +;; +;; Open / streaming fenced blocks (no closing fence yet) are +;; left alone so their contents stay protected as the buffer +;; grows. + +;;; Code: + +(eval-when-compile + (require 'cl-lib)) +(require 'map) +(require 'seq) +(require 'org-faces) +(require 'url-parse) +(require 'url-util) + +(defgroup agent-shell-markdown nil + "Render Markdown text into propertized form." + :group 'text) + +(defface agent-shell-markdown-bold + '((t :inherit bold)) + "Face for bold text rendered by `agent-shell-markdown-convert'." + :group 'agent-shell-markdown) + +(defface agent-shell-markdown-italic + '((t :inherit italic)) + "Face for italic text rendered by `agent-shell-markdown-convert'." + :group 'agent-shell-markdown) + +(defface agent-shell-markdown-strikethrough + '((t :strike-through t)) + "Face for strikethrough text rendered by `agent-shell-markdown-convert'." + :group 'agent-shell-markdown) + +(defface agent-shell-markdown-inline-code + '((t :inherit font-lock-doc-markup-face)) + "Face for inline code rendered by `agent-shell-markdown-convert'." + :group 'agent-shell-markdown) + +(defface agent-shell-markdown-link + '((t :inherit link)) + "Face for link titles rendered by `agent-shell-markdown-convert'." + :group 'agent-shell-markdown) + +(defface agent-shell-markdown-blockquote + '((t :inherit font-lock-comment-face)) + "Face for blockquoted text rendered by `agent-shell-markdown-convert'." + :group 'agent-shell-markdown) + +(defface agent-shell-markdown-header-1 + '((t :inherit org-level-1)) + "Face for level-1 headers rendered by `agent-shell-markdown-convert'." + :group 'agent-shell-markdown) + +(defface agent-shell-markdown-header-2 + '((t :inherit org-level-2)) + "Face for level-2 headers rendered by `agent-shell-markdown-convert'." + :group 'agent-shell-markdown) + +(defface agent-shell-markdown-header-3 + '((t :inherit org-level-3)) + "Face for level-3 headers rendered by `agent-shell-markdown-convert'." + :group 'agent-shell-markdown) + +(defface agent-shell-markdown-header-4 + '((t :inherit org-level-4)) + "Face for level-4 headers rendered by `agent-shell-markdown-convert'." + :group 'agent-shell-markdown) + +(defface agent-shell-markdown-header-5 + '((t :inherit org-level-5)) + "Face for level-5 headers rendered by `agent-shell-markdown-convert'." + :group 'agent-shell-markdown) + +(defface agent-shell-markdown-header-6 + '((t :inherit org-level-6)) + "Face for level-6 headers rendered by `agent-shell-markdown-convert'." + :group 'agent-shell-markdown) + +(defface agent-shell-markdown-table-header + '((t :inherit bold)) + "Face for table header row content." + :group 'agent-shell-markdown) + +(defface agent-shell-markdown-table-border + '((t :inherit font-lock-comment-face)) + "Face for table borders (pipes and dashes)." + :group 'agent-shell-markdown) + +(defface agent-shell-markdown-table-zebra + '((t :inherit lazy-highlight)) + "Face for alternating (zebra) data rows in tables." + :group 'agent-shell-markdown) + +(defface agent-shell-markdown-source-block + '((t :inherit lazy-highlight :extend t)) + "Background face applied to rendered fenced source-block bodies. +`:extend t' makes the background color reach the right edge of +the window, so the block reads as a contiguous panel rather than +a per-char highlight." + :group 'agent-shell-markdown) + +(defface agent-shell-markdown-source-block-language + '((t :inherit (italic font-lock-type-face agent-shell-markdown-source-block))) + "Face for the language label shown above a fenced source block." + :group 'agent-shell-markdown) + +(defvar agent-shell-markdown-image-max-width 0.4 + "Maximum width for inline images rendered from `![alt](url)'. +An integer is taken as pixels. A float between 0 and 1 is a +ratio of the window body width.") + +(defvar agent-shell-markdown-prettify-tables t + "When non-nil, render markdown tables with aligned columns.") + +(defvar agent-shell-markdown-table-use-unicode-borders t + "When non-nil, use Unicode box-drawing chars (│ ─ ┼ ├ ┤) for borders. +When nil, fall back to ASCII pipes and dashes.") + +(defvar agent-shell-markdown-table-wrap-columns t + "When non-nil, wrap table columns to fit within window width.") + +(defvar agent-shell-markdown-table-max-width-fraction 0.9 + "Fraction of window width to use as max table width when wrapping.") + +(defvar agent-shell-markdown-table-zebra-stripe t + "When non-nil, alternate row backgrounds in tables for readability.") + +(defvar agent-shell-markdown-language-mapping + '(("elisp" . "emacs-lisp") + ("objective-c" . "objc") + ("objectivec" . "objc") + ("cpp" . "c++")) + "Map of fenced-block language aliases to Emacs major mode prefixes. +Keys are lower-case language names as written after the opening +backticks; values are the corresponding Emacs mode prefix (the +`-mode' suffix is appended internally). Example: + + (\"elisp\" . \"emacs-lisp\") ; ```elisp -> emacs-lisp-mode") + +(cl-defun agent-shell-markdown-convert (markdown) + "Convert MARKDOWN string into propertized text. + +Bold, italic, strikethrough, headers, and inline code are +rendered as text properties on the inner text; the markup +characters are removed. See `agent-shell-markdown-replace-markup' for +the in-buffer equivalent. + +For example: + + (agent-shell-markdown-convert \"_my_ **text**\") + => #(\"my text\" 0 2 (face italic) 3 7 (face bold))" + (with-temp-buffer + (insert markdown) + (agent-shell-markdown-replace-markup) + (buffer-string))) + +(cl-defun agent-shell-markdown-replace-markup (&key force) + "Replace Markdown markup in current buffer with propertized text. + +Rewrites the buffer in place: markup characters are removed and +the remaining text carries face properties. Faces compose, so a +span nested inside another type ends up with all applicable +faces. + +Markup inside fenced code blocks and inline code spans is left +alone. Streaming-friendly: an unclosed fence protects the rest +of the buffer, an unclosed inline backtick protects the rest of +its line, and incomplete bold/italic/strike spans are skipped +until their closing delimiter arrives. + +Italic, bold, and strike passes loop until a full round makes no +changes, so adjacent delimiters peel one layer per round +(e.g. `**_X_**' resolves in two rounds). Headers, inline code, +links, images, bare image-path lines, dividers, source-block +styling, and table styling run once after the loop. + +The buffer is narrowed to the streaming watermark for the +duration of the passes — content before the watermark is already +rendered and stable, so every regex / property scan starts there +instead of `point-min'. The watermark is read off the +`agent-shell-markdown-watermark' text property on the first +character and re-stamped at the end of the call. Pass FORCE +non-nil to drop the watermark and re-render the whole buffer +(useful after mid-buffer edits, or for tests)." + (save-excursion + (when force + (with-silent-modifications + (remove-text-properties (point-min) (point-max) + '(agent-shell-markdown-watermark nil)))) + (let ((watermark (agent-shell-markdown--watermark-start))) + (save-restriction + (narrow-to-region watermark (point-max)) + (let* ((source-ranges (agent-shell-markdown--sort-ranges + (agent-shell-markdown--make-markers + (agent-shell-markdown--source-block-ranges)))) + (rendered-ranges (agent-shell-markdown--make-markers + (agent-shell-markdown--frozen-ranges))) + (inline-ranges (agent-shell-markdown--make-markers + (agent-shell-markdown--inline-code-ranges + :avoid-ranges (agent-shell-markdown--sort-ranges + source-ranges rendered-ranges)))) + (avoid-ranges (agent-shell-markdown--sort-ranges + source-ranges rendered-ranges inline-ranges))) + (while (let ((italic-changed (agent-shell-markdown--replace-italics + :avoid-ranges avoid-ranges)) + (bold-changed (agent-shell-markdown--replace-bolds + :avoid-ranges avoid-ranges)) + (strike-changed (agent-shell-markdown--replace-strikethroughs + :avoid-ranges avoid-ranges))) + (or italic-changed bold-changed strike-changed))) + (agent-shell-markdown--replace-headers :avoid-ranges avoid-ranges) + (agent-shell-markdown--style-inline-code :avoid-ranges source-ranges) + (agent-shell-markdown--replace-links :avoid-ranges avoid-ranges) + (agent-shell-markdown--replace-images :avoid-ranges avoid-ranges) + (agent-shell-markdown--replace-image-file-paths :avoid-ranges avoid-ranges) + (agent-shell-markdown--style-dividers :avoid-ranges avoid-ranges) + (agent-shell-markdown--style-blockquotes :avoid-ranges avoid-ranges) + (agent-shell-markdown--style-source-blocks) + ;; Tables run last so cell content has already been processed by + ;; every other pass (bold, italic, links, inline code, etc.). + ;; The cell parser respects face and `agent-shell-markdown-frozen' + ;; so it doesn't mis-split on pipes that got swallowed by other + ;; markup. AVOID-RANGES protects content inside still-open + ;; fenced blocks (where the closing fence hasn't streamed in + ;; yet) — without it a table inside a code block would render + ;; eagerly and the fences would then strip out, leaving a + ;; rendered table. Watermark backs off past any rendered + ;; table whose extension is still possible (see + ;; `--set-watermark'), so `--find-tables' under the narrow + ;; always sees the existing `agent-shell-markdown-table-source' + ;; needed to fold new rows in. + (agent-shell-markdown--style-tables :avoid-ranges source-ranges) + ;; Mirror every `face' we composed onto `font-lock-face' so our + ;; styling survives `font-lock-mode' re-fontification — comint + ;; / shell-maker / agent-shell buffers fontify on every output + ;; chunk and would otherwise clear our `face' properties. + (agent-shell-markdown--mirror-face-to-font-lock-face + (point-min) (point-max)) + ;; Tag rendered chars so a yank into another buffer drops the + ;; styling, display overrides, internal markers, and keymaps + ;; we layered on — paste should give plain chars, not our + ;; implementation cruft. + (put-text-property (point-min) (point-max) + 'yank-handler + (list (lambda (s) + (insert (substring-no-properties s)))))))) + (agent-shell-markdown--set-watermark))) + +(cl-defun agent-shell-markdown--replace-bolds (&key avoid-ranges) + "Replace `**X**' / `__X__' spans in current buffer with bold X. + +Markup characters are deleted; remaining inner text carries face +`agent-shell-markdown-bold' layered on top of any existing face +properties. Spans that fall inside any of AVOID-RANGES are left +untouched. Returns non-nil if at least one replacement was made. + +For example, the buffer \"hello **world**.\" becomes \"hello +world.\" with face `agent-shell-markdown-bold' on \"world\"." + (let ((case-fold-search nil) + (changed nil)) + (goto-char (point-min)) + (while (re-search-forward + (rx (or line-start (syntax whitespace)) + (group + (or (seq "**" (group (one-or-more (not (any "\n*")))) "**") + (seq "__" (group (one-or-more (not (any "\n_")))) "__"))) + (or (syntax punctuation) (syntax whitespace) line-end)) + nil t) + (let* ((markup-start (match-beginning 1)) + (markup-end (match-end 1)) + (avoid (agent-shell-markdown--in-avoid-range-p + markup-start markup-end avoid-ranges))) + (if avoid + (goto-char (cdr avoid)) + (let ((text (buffer-substring + (or (match-beginning 2) (match-beginning 3)) + (or (match-end 2) (match-end 3))))) + (delete-region markup-start markup-end) + (goto-char markup-start) + (insert text) + (add-face-text-property markup-start + (+ markup-start (length text)) + 'agent-shell-markdown-bold) + (setq changed t))))) + changed)) + +(cl-defun agent-shell-markdown--replace-italics (&key avoid-ranges) + "Replace `*X*' / `_X_' spans in current buffer with italic X. + +Markup characters are deleted; remaining inner text carries face +`agent-shell-markdown-italic' layered on top of any existing face +properties. Spans that fall inside any of AVOID-RANGES are left +untouched. Returns non-nil if at least one replacement was made. + +For example, the buffer \"hello *world*.\" becomes \"hello +world.\" with face `agent-shell-markdown-italic' on \"world\"." + (let ((case-fold-search nil) + (changed nil)) + (goto-char (point-min)) + (while (re-search-forward + (rx (or (group (or bol (one-or-more (any "\n \t"))) + (group "*") + (group (one-or-more (not (any "\n*")))) "*") + (group (or bol (one-or-more (any "\n \t"))) + (group "_") + (group (one-or-more (not (any "\n_")))) "_"))) + nil t) + (let* ((markup-start (or (match-beginning 2) (match-beginning 5))) + (markup-end (match-end 0)) + (avoid (agent-shell-markdown--in-avoid-range-p + markup-start markup-end avoid-ranges))) + (if avoid + (goto-char (cdr avoid)) + (let ((text (buffer-substring + (or (match-beginning 3) (match-beginning 6)) + (or (match-end 3) (match-end 6))))) + (delete-region markup-start markup-end) + (goto-char markup-start) + (insert text) + (add-face-text-property markup-start + (+ markup-start (length text)) + 'agent-shell-markdown-italic) + (setq changed t))))) + changed)) + +(cl-defun agent-shell-markdown--replace-strikethroughs (&key avoid-ranges) + "Replace `~~X~~' spans in current buffer with strike-through-faced X. + +Markup characters are deleted; remaining inner text carries face +`agent-shell-markdown-strikethrough' layered on top of any existing face +properties. Spans inside any of AVOID-RANGES are left untouched. +Returns non-nil if at least one replacement was made. + +For example, the buffer \"a ~~b~~ c\" becomes \"a b c\" with face +`agent-shell-markdown-strikethrough' on \"b\"." + (let ((case-fold-search nil) + (changed nil)) + (goto-char (point-min)) + (while (re-search-forward + (rx "~~" (group (one-or-more (not (any "\n~")))) "~~") + nil t) + (let* ((markup-start (match-beginning 0)) + (markup-end (match-end 0)) + (avoid (agent-shell-markdown--in-avoid-range-p + markup-start markup-end avoid-ranges))) + (if avoid + (goto-char (cdr avoid)) + (let ((text (buffer-substring (match-beginning 1) (match-end 1)))) + (delete-region markup-start markup-end) + (goto-char markup-start) + (insert text) + (add-face-text-property markup-start + (+ markup-start (length text)) + 'agent-shell-markdown-strikethrough) + (setq changed t))))) + changed)) + +(cl-defun agent-shell-markdown--replace-headers (&key avoid-ranges) + "Replace `# X' / `## X' / ... headers with X faced as `org-level-N'. + +The `#' prefix and one or more separator spaces are stripped; the +title text is left with face `agent-shell-markdown-header-N' where N is +the number of `#' characters clamped to 1..6. Headers inside any +of AVOID-RANGES are left untouched. + +Requires an explicit trailing newline — a header at end-of-buffer +without `\\n' is treated as still streaming and left raw, so a +chunk that lands `# He' followed later by `llo World\\n' renders +the full `Hello World' on the second call rather than eagerly +facing `He' and leaving `llo World' plain. + +For example, the buffer \"## My title\\n\" becomes \"My title\\n\" +with face `agent-shell-markdown-header-2' on \"My title\"." + (let ((case-fold-search nil)) + (goto-char (point-min)) + (while (re-search-forward + (rx bol (zero-or-more blank) (group (one-or-more "#")) + (one-or-more blank) + (group (one-or-more (not (any "\n")))) "\n") + nil t) + (let* ((markup-start (match-beginning 0)) + (markup-end (match-end 0)) + (avoid (agent-shell-markdown--in-avoid-range-p + markup-start markup-end avoid-ranges))) + (if avoid + (goto-char (cdr avoid)) + (let* ((level (- (match-end 1) (match-beginning 1))) + (text (buffer-substring (match-beginning 2) (match-end 2))) + ;; The trailing `\\n' we re-insert below would otherwise + ;; punch a hole in the caller's contiguous block range + ;; (eg. `invisible'/`agent-shell-ui-section') and break + ;; toggle/replace operations — same hazard called out in + ;; `--style-source-blocks'. Carry over the original + ;; newline's caller props. + (carried (agent-shell-markdown--carry-properties + (1- markup-end)))) + (delete-region markup-start markup-end) + (goto-char markup-start) + (insert text "\n") + (when carried + (add-text-properties markup-start (point) carried)) + (add-face-text-property markup-start + (+ markup-start (length text)) + (intern (format "agent-shell-markdown-header-%d" + (min (max level 1) 6)))))))))) + +(cl-defun agent-shell-markdown--style-inline-code (&key avoid-ranges) + "Strip backticks from complete inline `X` spans and face the body. + +The body of each well-formed `` `X` `` is left in place with +face `agent-shell-markdown-inline-code' and tagged with the text +property `agent-shell-markdown-frozen t' so it is never re-processed +on subsequent calls (the body can legitimately contain +markdown-looking chars like `**' once the surrounding backticks +are gone). Spans inside any of AVOID-RANGES (typically fenced +code blocks) are left untouched. + +For example, the buffer \"a `code` b\" becomes \"a code b\" with +face `agent-shell-markdown-inline-code' on \"code\"." + (let ((case-fold-search nil)) + (goto-char (point-min)) + (while (re-search-forward "`\\([^`\n]+\\)`" nil t) + (let* ((markup-start (match-beginning 0)) + (markup-end (match-end 0)) + (avoid (agent-shell-markdown--in-avoid-range-p + markup-start markup-end avoid-ranges))) + (if avoid + (goto-char (cdr avoid)) + (let ((text (buffer-substring (match-beginning 1) (match-end 1)))) + (delete-region markup-start markup-end) + (goto-char markup-start) + (insert text) + (let ((end (+ markup-start (length text)))) + (add-face-text-property markup-start end 'agent-shell-markdown-inline-code) + (add-text-properties markup-start end + '(agent-shell-markdown-frozen t + rear-nonsticky (agent-shell-markdown-frozen)))))))))) + +(cl-defun agent-shell-markdown--replace-links (&key avoid-ranges) + "Replace `[title](url)' markup with title faced as link. + +The bracket/parenthesis markup is stripped; the title is left +with face `agent-shell-markdown-link' and a keymap text property that +opens the URL on RET or mouse-1. Matches preceded by `!' (the +image syntax) are skipped, as are links inside any of +AVOID-RANGES. + +For example, the buffer \"see [docs](https://example.com)\" +becomes \"see docs\" with face `agent-shell-markdown-link' on \"docs\" +and a keymap that opens the URL." + (let ((case-fold-search nil)) + (goto-char (point-min)) + (while (re-search-forward + (rx "[" + (group (one-or-more (not (any "]")))) + "]" + "(" + (group (one-or-more (not (any ")")))) + ")") + nil t) + (let* ((markup-start (match-beginning 0)) + (markup-end (match-end 0)) + (is-image (eq (char-before markup-start) ?!)) + (avoid (unless is-image + (agent-shell-markdown--in-avoid-range-p + markup-start markup-end avoid-ranges)))) + (cond + (avoid (goto-char (cdr avoid))) + (is-image nil) + (t + (let ((title (buffer-substring (match-beginning 1) (match-end 1))) + (url (buffer-substring-no-properties + (match-beginning 2) (match-end 2)))) + (delete-region markup-start markup-end) + (goto-char markup-start) + (insert title) + (let ((end (+ markup-start (length title)))) + (add-face-text-property markup-start end 'agent-shell-markdown-link) + (put-text-property markup-start end 'keymap + (agent-shell-markdown--make-ret-binding-map + (lambda () (interactive) + (agent-shell-markdown--open-link url)))) + (put-text-property markup-start end 'mouse-face 'highlight))))))))) + +(cl-defun agent-shell-markdown--replace-images (&key avoid-ranges) + "Replace `![alt](url)' image markup with displayed images. + +If URL resolves to an existing local file that is image-supported +and a graphical display is available, the full markup is replaced +by the alt text (or a single space if alt is empty) carrying a +`display' property with the image and a keymap that opens the +file on RET or mouse-1. Otherwise the markup is left untouched. +Images inside any of AVOID-RANGES are left alone. + +For example, the buffer \"see ![logo](logo.png)\" becomes +\"see logo\" with the image shown in place of \"logo\"." + (let ((case-fold-search nil)) + (goto-char (point-min)) + (while (re-search-forward + (rx "!" + "[" + (group (zero-or-more (not (any "]")))) + "]" + "(" + (group (one-or-more (not (any ")")))) + ")") + nil t) + (let* ((markup-start (match-beginning 0)) + (markup-end (match-end 0)) + (avoid (agent-shell-markdown--in-avoid-range-p + markup-start markup-end avoid-ranges))) + (cond + (avoid (goto-char (cdr avoid))) + (t + (let* ((alt (buffer-substring-no-properties + (match-beginning 1) (match-end 1))) + (url (buffer-substring-no-properties + (match-beginning 2) (match-end 2))) + (path (agent-shell-markdown--resolve-image-url url))) + (when (and path + (image-supported-file-p path) + (display-graphic-p)) + (let ((image (create-image + path nil nil + :max-width (agent-shell-markdown--image-max-width))) + (placeholder (if (string-empty-p alt) " " alt))) + (image-flush image) + (delete-region markup-start markup-end) + (goto-char markup-start) + (insert placeholder) + (let ((end (+ markup-start (length placeholder)))) + (put-text-property markup-start end 'display image) + (put-text-property markup-start end 'keymap + (agent-shell-markdown--make-ret-binding-map + (lambda () (interactive) + (find-file path)))) + (put-text-property markup-start end 'mouse-face 'highlight))))))))))) + +(cl-defun agent-shell-markdown--replace-image-file-paths (&key avoid-ranges) + "Render bare image-path lines as displayed images. + +A line that is solely a local path or `file://' URI ending in a +supported image extension is treated like an `![alt](url)' image: +when the path resolves to an existing image-supported file and a +graphical display is available, the line text is left in place +carrying a `display' property with the image and a keymap that +opens the file. Lines inside any of AVOID-RANGES are left +untouched, as are unresolvable paths. + +For example, a buffer line containing just `/abs/path/img.png' +renders the image in place of that text." + (let* ((case-fold-search t) + (ext-re (regexp-opt image-file-name-extensions)) + (regex (concat "^[ \t]*\\(\\(?:file://\\|[/~.]\\)[^ \t\n]*\\." + ext-re + "\\)[ \t]*$"))) + (goto-char (point-min)) + (while (re-search-forward regex nil t) + (let* ((line-start (match-beginning 0)) + (line-end (match-end 0)) + (avoid (agent-shell-markdown--in-avoid-range-p + line-start line-end avoid-ranges))) + (cond + (avoid (goto-char (cdr avoid))) + (t + (let* ((path-start (match-beginning 1)) + (path-end (match-end 1)) + (raw (buffer-substring-no-properties path-start path-end)) + (resolved (agent-shell-markdown--resolve-image-url raw))) + (when (and resolved + (image-supported-file-p resolved) + (display-graphic-p)) + (let ((image (create-image + resolved nil nil + :max-width (agent-shell-markdown--image-max-width)))) + (image-flush image) + (put-text-property path-start path-end 'display image) + (put-text-property path-start path-end 'keymap + (agent-shell-markdown--make-ret-binding-map + (lambda () (interactive) + (find-file resolved)))) + (put-text-property path-start path-end 'mouse-face 'highlight) + (add-text-properties path-start path-end + '(agent-shell-markdown-frozen t + rear-nonsticky (agent-shell-markdown-frozen)))))))))))) + +(cl-defun agent-shell-markdown--style-dividers (&key avoid-ranges) + "Render `---' / `***' / `___' horizontal-rule lines as styled rules. + +Each line consisting of 3+ matching dash/star/underscore chars +(optionally surrounded by spaces or tabs) gets a `display' text +property that draws an underlined rule across the window, plus a +`agent-shell-markdown-frozen' tag so subsequent calls don't re-process +it. Dividers inside any of AVOID-RANGES are left untouched. + +The chars themselves remain in the buffer beneath the display +property, so the source markdown round-trips through copy/save." + (let ((case-fold-search nil)) + (goto-char (point-min)) + (while (re-search-forward + (rx bol (zero-or-more blank) + (or (seq "***" (zero-or-more "*")) + (seq "---" (zero-or-more "-")) + (seq "___" (zero-or-more "_"))) + (zero-or-more blank) eol) + nil t) + (let* ((rule-start (match-beginning 0)) + (rule-end (match-end 0)) + (avoid (agent-shell-markdown--in-avoid-range-p + rule-start rule-end avoid-ranges))) + (if avoid + (goto-char (cdr avoid)) + (add-text-properties + rule-start rule-end + (list 'display + (concat (propertize (make-string 12 ?\s) + 'face '(:underline t)) + "\n") + 'agent-shell-markdown-frozen t + 'rear-nonsticky '(display agent-shell-markdown-frozen)))))))) + +(cl-defun agent-shell-markdown--style-blockquotes (&key avoid-ranges) + "Render `>'-prefixed lines as blockquotes with vertical bars. + +Each leading `>' character on the line is shown as `▌' via a +`display' text property; the underlying `>' chars stay in the +buffer so the source markdown round-trips through copy/save and +re-rendering remains idempotent. Remaining content on the line +gets face `agent-shell-markdown-blockquote' (composes with any +face already applied by an earlier pass — bold/italic/inline-code +inside a blockquote still render). + +Multiple nesting levels are supported: each leading `>' renders +as its own bar, so `>> text' shows two bars and `>>> text' three. +Whitespace between `>'s is preserved literally. + +Requires an explicit trailing newline — a blockquote line at +end-of-buffer without `\\n' is treated as still streaming and +left raw, matching the header behaviour. + +Lines inside any of AVOID-RANGES (e.g. fenced code blocks) are +left untouched." + (let ((case-fold-search nil) + (bar (propertize "▌" 'face 'agent-shell-markdown-blockquote))) + (goto-char (point-min)) + (while (re-search-forward + (rx bol (zero-or-more blank) + ">" (zero-or-more (any " \t>")) + (zero-or-more (not (any "\n"))) "\n") + nil t) + (let* ((line-start (match-beginning 0)) + (line-end (match-end 0)) + (avoid (agent-shell-markdown--in-avoid-range-p + line-start line-end avoid-ranges))) + (if avoid + (goto-char (cdr avoid)) + (save-excursion + (goto-char line-start) + (skip-chars-forward " \t" line-end) + (while (eq (char-after) ?>) + (put-text-property (point) (1+ (point)) 'display bar) + (forward-char 1) + (skip-chars-forward " \t" line-end))) + (add-face-text-property line-start (1- line-end) + 'agent-shell-markdown-blockquote) + (add-text-properties line-start line-end + '(agent-shell-markdown-frozen t + rear-nonsticky (agent-shell-markdown-frozen)))))))) + +(defun agent-shell-markdown--display-width () + "Return a usable display width for divider rendering. +Tries the selected window's body width and falls back to 80 +characters when no usable window is available (e.g. batch)." + (or (ignore-errors (window-body-width)) + 80)) + +(defun agent-shell-markdown--style-source-blocks () + "Strip fenced code block markup and syntax-highlight the body. + +For each complete `\\`\\`\\`LANG' / `\\`\\`\\`' fenced block, +the opening and closing fence lines are deleted from the buffer. +The body text stays in place with face properties from LANG's +major mode (when loadable) and a `agent-shell-markdown-frozen t' text +property tagging it as rendered output. That tag is read back +as an avoid-range on subsequent calls, so the body is never +re-processed as inline markup even though its surrounding +fences are gone. + +Open / streaming fences (no closing line yet) are left alone. + +For example, the buffer: + + ```elisp + (message \"hi\") + ``` + +becomes: + + (message \"hi\") + +with `emacs-lisp-mode' face properties on the body and a +`agent-shell-markdown-frozen' tag covering those same chars." + (let ((case-fold-search nil)) + (goto-char (point-min)) + ;; Group 2 captures the opening backtick run; `backref' on the + ;; closer matches the same literal run, so a 4-backtick outer + ;; fence requires a 4-backtick close — a 3-backtick line inside + ;; is just body. Note this is slightly tighter than CommonMark + ;; (which permits close > open), but every-LLM-I've-seen emits + ;; matched counts, so the simplification is worth it. + (while (re-search-forward + (rx (group bol (zero-or-more blank) + (group (>= 3 "`")) + (zero-or-more blank) + (group (zero-or-more (or alphanumeric "-" "+" "#"))) + (zero-or-more blank) "\n") + (group (*? anychar)) + "\n" + (group bol (zero-or-more blank) + (backref 2) + (zero-or-more blank) (or "\n" eol))) + nil t) + (let* ((open-start (match-beginning 1)) + (open-end (match-end 1)) + (lang (buffer-substring-no-properties (match-beginning 3) + (match-end 3))) + (body-start (copy-marker (match-beginning 4))) + (body-end (copy-marker (match-end 4))) + (close-start (match-beginning 5)) + (close-end (match-end 5)) + (highlighted (agent-shell-markdown--highlight-code + (buffer-substring-no-properties body-start body-end) + lang))) + ;; Delete in reverse position order so earlier offsets stay + ;; valid; body markers adjust automatically. + (delete-region close-start close-end) + (delete-region open-start open-end) + ;; Seed the bg panel on body chars first, then layer language + ;; font-lock faces on top — the foreground colors take priority + ;; per glyph while the `:extend t' background fills the gaps + ;; and reaches the right edge of the window. Include the + ;; trailing `\\n' (the one that sat between body and close + ;; fence, preserved by the deletes above): `:extend t' only + ;; extends the background when the face is in effect at + ;; end-of-line, so without the `\\n' carrying the face the + ;; last body line's bg would stop at the last content char. + (let ((body-bg-end (min (1+ (marker-position body-end)) + (point-max))) + ;; `line-prefix' / `wrap-prefix' visually inset each + ;; rendered line: 2 plain cols then 2 bg-tinted cols. + ;; Copying chars out of the block yanks raw source with + ;; no leading indentation. `wrap-prefix' handles long + ;; lines that wrap. Splitting the prefix this way keeps + ;; the panel from running hard to the window's left edge + ;; while still drawing a clear tinted gutter. + (prefix (concat " " + (propertize + " " 'face + 'agent-shell-markdown-source-block)))) + (put-text-property (marker-position body-start) body-bg-end + 'face 'agent-shell-markdown-source-block) + (agent-shell-markdown--apply-faces-from highlighted + (marker-position body-start)) + (add-text-properties (marker-position body-start) body-bg-end + `(agent-shell-markdown-frozen t + agent-shell-non-trimmable t + rear-nonsticky (agent-shell-markdown-frozen + agent-shell-non-trimmable) + line-prefix ,prefix + wrap-prefix ,prefix)) + ;; Insert an actionable "LANG ⧉" / "snippet ⧉" label and the + ;; surrounding panel padding as REAL BUFFER TEXT — no + ;; `display' properties (which previously caused the body's + ;; first char to be hidden / clipped, see #597 "Make code + ;; block label actual buffer text"), no overlays. Layout + ;; relative to the original body: `\\n