Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 27 additions & 14 deletions ai-code-discussion.el
Original file line number Diff line number Diff line change
Expand Up @@ -268,23 +268,36 @@ into the AI prompt file and optionally sends to AI."
(defun ai-code--explain-dired ()
"Handle explain for Dired buffer."
(let* ((file-at-point (dired-get-filename nil t))
(git-relative-path (when file-at-point
(car (ai-code--get-git-relative-paths (list file-at-point)))))
(files-context-string (when git-relative-path
(concat "\nFiles:\n@" git-relative-path)))
(all-marked (dired-get-marked-files))
(has-marked-files (> (length all-marked) 1))
(context-files (if has-marked-files
Comment on lines +271 to +273

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat a single Dired mark as selected context

ai-code--explain-dired only treats marks as active when > 1 entries are returned, so an explicit single marked file is ignored and the command falls back to file-at-point. In Dired, users commonly mark exactly one file and move point elsewhere before running commands, so this now explains the wrong file/directory in that workflow. Using the same “truly marked” logic as ai-code--ask-question-dired would avoid this regression.

Useful? React with 👍 / 👎.

all-marked
(when file-at-point
(list file-at-point))))
Comment on lines 270 to +276
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dired-get-marked-files is now called unguarded. In Dired, this can signal an error when point is not on a file line (e.g., header/subdir lines), which would regress the previous behavior that handled nil from dired-get-filename gracefully. Consider wrapping the call in ignore-errors and/or using the existing “explicit marks” detection pattern used elsewhere (e.g., treat a single entry equal to file-at-point as ‘no explicit marks’) so the command doesn’t error and also supports a single explicitly-marked file.

Copilot uses AI. Check for mistakes.
(git-relative-paths (when context-files
(ai-code--get-git-relative-paths context-files)))
(files-context-string (when git-relative-paths
(concat "\nFiles:\n"
(mapconcat (lambda (path) (concat "@" path))
git-relative-paths
"\n"))))
(file-type (if (and file-at-point (file-directory-p file-at-point))
"directory"
"file"))
(initial-prompt (if git-relative-path
(format "Please explain the %s at path @%s.\n\nProvide a clear explanation of what this %s contains, its purpose, and its role in the project structure.%s"
"directory"
"file"))
(initial-prompt (cond
(has-marked-files
(format "Please explain the selected files or directories.\n\nProvide a clear explanation of what these files or directories contain, their purpose, and their role in the project structure.%s"
(or files-context-string "")))
((car git-relative-paths)
(format "Please explain the %s at path @%s.\n\nProvide a clear explanation of what this %s contains, its purpose, and its role in the project structure.%s"
file-type
git-relative-path
(car git-relative-paths)
file-type
(or files-context-string ""))
"No file or directory found at cursor point."))
(final-prompt (if git-relative-path
(ai-code-read-string "Prompt: " initial-prompt)
initial-prompt)))
(or files-context-string "")))
(t "No file or directory found at cursor point.")))
(final-prompt (if git-relative-paths
(ai-code-read-string "Prompt: " initial-prompt)
initial-prompt)))
(when final-prompt
(ai-code--insert-prompt final-prompt))))

Expand Down
284 changes: 280 additions & 4 deletions ai-code-session-link.el
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

(declare-function ai-code-session-navigate-link-at-mouse "ai-code-input" (event))
(declare-function ai-code-session-navigate-link-at-point "ai-code-input" ())
(declare-function helm-gtags-find-tag "ext:helm-gtags" (tagname))
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The declare-function for helm-gtags-find-tag points at "ext:helm-gtags", but there is no corresponding library file in the repo and this doesn’t match typical declare-function usage (it’s usually the providing library name like "helm-gtags"). Using a non-existent file string can confuse tooling (e.g., check-declare/jump-to-definition). Consider changing it to the actual library name or removing the declaration and relying on the existing (fboundp ...) checks.

Suggested change
(declare-function helm-gtags-find-tag "ext:helm-gtags" (tagname))
(declare-function helm-gtags-find-tag "helm-gtags" (tagname))

Copilot uses AI. Check for mistakes.
(declare-function xref-find-definitions "xref" (identifier))

(defvar ai-code-backends-infra--session-directory nil
"Session working directory set by ai-code-backends-infra buffers.")
Expand All @@ -34,17 +36,72 @@ terminal output redraw."
map)
"Keymap used for clickable session links.")

(declare-function ai-code-session-link-navigate-symbol-at-mouse "ai-code-session-link" (event))
(declare-function ai-code-session-link-navigate-symbol-at-point "ai-code-session-link" ())

(defvar ai-code-session-link--symbol-keymap
(let ((map (make-sparse-keymap)))
(define-key map [mouse-1] #'ai-code-session-link-navigate-symbol-at-mouse)
(define-key map [mouse-2] #'ai-code-session-link-navigate-symbol-at-mouse)
(define-key map (kbd "RET") #'ai-code-session-link-navigate-symbol-at-point)
map)
"Keymap used for clickable session symbols.")

(defconst ai-code-session-link--linkify-min-tail-width 512
"Minimum number of tail characters to rescan for session links.")

(defconst ai-code-session-link--url-pattern-regexp
"\\(https?://[^][(){}<>\"' \t\n]+\\)"
"Regexp matching http/https URLs in session buffers.")

(defconst ai-code-session-link--symbol-neighborhood-max-width 192
"Maximum number of characters to scan for symbols near a file link.")

(defconst ai-code-session-link--blank-line-regexp
"\n[[:space:]]*\n"
"Regexp matching a blank line boundary for nearby symbol scanning.")

(defconst ai-code-session-link--path-base-regexp
"@?[[:alnum:]_./~-]*[./][[:alnum:]_./~-]+"
"Regexp matching a local file-like or directory-like path.")

(defconst ai-code-session-link--symbol-identifier-regexp
"[[:alpha:]_][[:alnum:]_*!?]*"
"Regexp matching one conservative code identifier segment.")

(defconst ai-code-session-link--camel-case-symbol-regexp
"[[:upper:]][[:alnum:]]+"
"Regexp matching a bare CamelCase-style symbol candidate.")

(defconst ai-code-session-link--snake-case-symbol-regexp
"_*[[:lower:]][[:lower:][:digit:]]*\\(?:_[[:lower:][:digit:]]+\\)+"
"Regexp matching a bare snake_case-style symbol candidate.")

(defconst ai-code-session-link--symbol-candidate-regexp
(concat
"\\("
"\\(?:"
ai-code-session-link--symbol-identifier-regexp
"\\(?:\\.\\|::\\|#\\)"
ai-code-session-link--symbol-identifier-regexp
"\\(?:\\(?:\\.\\|::\\|#\\)"
ai-code-session-link--symbol-identifier-regexp
"\\)*"
"\\(?:()\\)?"
"\\|"
ai-code-session-link--symbol-identifier-regexp
"()"
"\\|"
ai-code-session-link--symbol-identifier-regexp
"\\(?:-[[:alnum:]_*!?]*\\)+"
"\\|"
ai-code-session-link--camel-case-symbol-regexp
"\\|"
ai-code-session-link--snake-case-symbol-regexp
"\\)"
"\\)")
"Regexp matching conservative symbol candidates near a file link.")

(defun ai-code-session-link--path-pattern (suffix)
"Return a session link regexp for `ai-code-session-link--path-base-regexp' plus SUFFIX."
(concat "\\(" ai-code-session-link--path-base-regexp "\\)" suffix))
Expand Down Expand Up @@ -165,6 +222,23 @@ terminal output redraw."
(car (ai-code-session-link--matching-project-files normalized root project-files)))
(t nil)))))))

(defun ai-code-session-link--parse-file-link-text (text)
"Parse file-like session link TEXT into a plist."
(when (stringp text)
(catch 'parsed
(dolist (pattern ai-code-session-link--file-patterns)
(let ((regexp (concat "\\`" (car pattern) "\\'")))
(when (string-match regexp text)
(throw
'parsed
(list :file (match-string (nth 1 pattern) text)
:line-start (when-let ((group (nth 2 pattern))
(line (match-string group text)))
(string-to-number line))
:column-start (when-let ((group (nth 3 pattern))
(column (match-string group text)))
(string-to-number column))))))))))

(defun ai-code-session-link--apply-properties (start end &optional text help-echo)
"Apply session link properties from START to END."
(add-text-properties
Expand All @@ -178,6 +252,113 @@ terminal output redraw."
'font-lock-face 'link
'face 'link)))

(defun ai-code-session-link--apply-symbol-properties (start end symbol file-link)
"Apply clickable symbol properties from START to END."
(add-text-properties
start end
(list 'ai-code-session-link file-link
'ai-code-session-symbol-link symbol
'ai-code-session-symbol-file file-link
'mouse-face 'highlight
'help-echo "mouse-1: Jump to symbol context"
'keymap ai-code-session-link--symbol-keymap
'follow-link t
'font-lock-face 'link
'face 'link)))

(defun ai-code-session-link--elisp-symbol-candidate-p (candidate)
"Return non-nil when CANDIDATE looks like an Elisp symbol worth linking."
(or (intern-soft candidate)
(string-match-p "--" candidate)
(string-match-p
"\\(?:-p\\|-mode\\|-hook\\|-function\\|-command\\|-local\\|\\*\\|\\?\\)\\'"
candidate)))

(defun ai-code-session-link--case-sensitive-match-p (regexp candidate)
"Return non-nil when CANDIDATE fully matches REGEXP with case-sensitive search."
(let ((case-fold-search nil))
(string-match-p regexp candidate)))

(defun ai-code-session-link--java-camel-case-symbol-p (candidate)
"Return non-nil when CANDIDATE looks like a Java-style CamelCase symbol."
(and (ai-code-session-link--case-sensitive-match-p
(concat "\\`" ai-code-session-link--camel-case-symbol-regexp "\\'")
candidate)
(ai-code-session-link--case-sensitive-match-p "[[:lower:]]" candidate)
(ai-code-session-link--case-sensitive-match-p
"[[:upper:]][[:lower:][:digit:]]+[[:upper:]]"
candidate)))

(defun ai-code-session-link--snake-case-symbol-p (candidate)
"Return non-nil when CANDIDATE looks like a bare snake_case symbol."
(ai-code-session-link--case-sensitive-match-p
"\\`_*[[:lower:]][[:lower:][:digit:]]*\\(?:_[[:lower:][:digit:]]+\\)+\\'"
candidate))

(defun ai-code-session-link--bare-symbol-candidate-p (candidate)
"Return non-nil when bare CANDIDATE looks like a supported code symbol."
(or (ai-code-session-link--java-camel-case-symbol-p candidate)
(ai-code-session-link--snake-case-symbol-p candidate)
(and (string-match-p "-" candidate)
(ai-code-session-link--elisp-symbol-candidate-p candidate))))

(defun ai-code-session-link--symbol-candidate-p (candidate)
"Return non-nil when CANDIDATE is worth linkifying."
(and (stringp candidate)
(> (length candidate) 2)
(not (string-match-p "\\`https?://" candidate))
(not (string-match-p "[/\\\\]" candidate))
(not (string-match-p "\\`[0-9]" candidate))
(not (string-match-p "\\(?:[:#][Ll]?[0-9]+\\)\\'" candidate))
(or (string-match-p "\\." candidate)
(string-match-p "::" candidate)
(string-match-p "#" candidate)
(string-suffix-p "()" candidate)
(ai-code-session-link--bare-symbol-candidate-p candidate))))

(defun ai-code-session-link--next-nearby-symbol-boundary (start end)
"Return the next boundary after START for symbol scanning up to END."
(let ((boundary end)
(file-boundary nil))
(save-excursion
(goto-char start)
(when (re-search-forward ai-code-session-link--blank-line-regexp end t)
(setq boundary (min boundary (match-beginning 0))))
(goto-char start)
(when (re-search-forward ai-code-session-link--url-pattern-regexp end t)
(setq boundary (min boundary (match-beginning 1))))
(dolist (pattern ai-code-session-link--file-patterns)
(goto-char start)
(catch 'matched
(while (re-search-forward (car pattern) end t)
(let ((path (match-string-no-properties (nth 1 pattern))))
(when (ai-code-session-link--resolve-session-file path)
(setq file-boundary
(if file-boundary
(min file-boundary (match-beginning 0))
(match-beginning 0)))
(throw 'matched t)))))))
(if file-boundary
(min boundary file-boundary)
boundary)))

(defun ai-code-session-link--linkify-symbols-near-file (file-link scan-start end)
"Linkify code-like symbols near FILE-LINK from SCAN-START up to END."
(let* ((window-end (min end (+ scan-start ai-code-session-link--symbol-neighborhood-max-width)))
(scan-end (ai-code-session-link--next-nearby-symbol-boundary scan-start window-end)))
(when (< scan-start scan-end)
(save-excursion
(let ((case-fold-search nil))
(goto-char scan-start)
(while (re-search-forward ai-code-session-link--symbol-candidate-regexp scan-end t)
(let ((symbol-start (match-beginning 1))
(symbol-end (match-end 1))
(candidate (match-string-no-properties 1)))
(when (and (not (get-text-property symbol-start 'ai-code-session-link))
(ai-code-session-link--symbol-candidate-p candidate))
(ai-code-session-link--apply-symbol-properties
symbol-start symbol-end candidate file-link)))))))))

(defun ai-code-session-link--linkify-url-region (start end)
"Apply URL session links between START and END."
(save-excursion
Expand All @@ -201,10 +382,102 @@ terminal output redraw."
(match-end (match-end 0))
(path (match-string-no-properties (nth 1 pattern))))
(when (ai-code-session-link--resolve-session-file path)
(ai-code-session-link--apply-properties
match-start match-end
(buffer-substring-no-properties match-start match-end)
"mouse-1: Visit file"))))))))
(let ((link-text (buffer-substring-no-properties match-start match-end)))
(ai-code-session-link--apply-properties
match-start match-end link-text "mouse-1: Visit file")
(ai-code-session-link--linkify-symbols-near-file
link-text match-end end)))))))))

(defun ai-code-session-link--property-at-point (property)
"Return PROPERTY at point or immediately before point."
(or (get-text-property (point) property)
(when (> (point) (point-min))
(get-text-property (1- (point)) property))))

(defun ai-code-session-link--symbol-search-terms (symbol)
"Return conservative search terms for SYMBOL."
(let* ((trimmed (string-remove-suffix "()" symbol))
(parts (split-string trimmed "\\(?:\\.\\|::\\|#\\)" t))
(tail (car (last parts))))
(delete-dups (delq nil (list trimmed tail)))))

(defun ai-code-session-link--primary-symbol-search-term (symbol)
"Return the primary lookup term for SYMBOL."
(car (last (ai-code-session-link--symbol-search-terms symbol))))

(defun ai-code-session-link--open-file-link (text)
"Open the file described by file-like link TEXT."
(when-let* ((link (ai-code-session-link--parse-file-link-text text))
(file (plist-get link :file))
(abs-file (ai-code-session-link--resolve-session-file file)))
(find-file-other-window abs-file)
Comment on lines +410 to +413

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Disambiguate duplicate filenames before opening symbol context

Symbol navigation opens the associated file via ai-code-session-link--resolve-session-file, which resolves basename collisions by taking the first match, so in repos with duplicate filenames (for example multiple utils.py files) clicking a symbol can jump into the wrong file and then run xref/search there. Regular file-link navigation already handles this by prompting when multiple candidates exist, so this path should use equivalent disambiguation instead of silently picking one match.

Useful? React with 👍 / 👎.

(goto-char (point-min))
(when-let ((line-start (plist-get link :line-start)))
(forward-line (1- line-start)))
(when-let ((column-start (plist-get link :column-start)))
(when (> column-start 0)
(move-to-column (1- column-start))))
t))

(defun ai-code-session-link--try-xref-definition (symbol)
"Try xref lookup for SYMBOL in the current buffer."
(when-let ((lookup (ai-code-session-link--primary-symbol-search-term symbol)))
(when (fboundp 'xref-find-definitions)
(condition-case nil
(progn
(xref-find-definitions lookup)
t)
(error nil)))))

(defun ai-code-session-link--try-helm-gtags-definition (symbol)
"Try helm-gtags lookup for SYMBOL in the current buffer."
(when-let ((lookup (ai-code-session-link--primary-symbol-search-term symbol)))
(when (fboundp 'helm-gtags-find-tag)
(condition-case nil
(progn
(helm-gtags-find-tag lookup)
t)
(error nil)))))

(defun ai-code-session-link--search-symbol-in-current-buffer (symbol)
"Search for SYMBOL in the current buffer."
(catch 'found
(dolist (term (ai-code-session-link--symbol-search-terms symbol))
(goto-char (point-min))
(when (search-forward term nil t)
(goto-char (match-beginning 0))
(throw 'found t)))))

;;;###autoload
(defun ai-code-session-link-navigate-symbol-at-point ()
"Navigate using the clickable symbol at point."
(interactive)
(when-let* ((symbol (ai-code-session-link--property-at-point 'ai-code-session-symbol-link))
(file-link (or (ai-code-session-link--property-at-point 'ai-code-session-symbol-file)
(ai-code-session-link--property-at-point 'ai-code-session-link))))
(if (ai-code-session-link--open-file-link file-link)
(progn
(or (ai-code-session-link--try-xref-definition symbol)
(ai-code-session-link--try-helm-gtags-definition symbol)
(ai-code-session-link--search-symbol-in-current-buffer symbol))
(message "Navigated to %s via %s" symbol file-link)
t)
(progn
(message "Unable to resolve symbol context: %s" symbol)
nil))))

;;;###autoload
(defun ai-code-session-link-navigate-symbol-at-mouse (event)
"Navigate using the clickable symbol clicked by mouse EVENT."
(interactive "e")
(let* ((start (event-start event))
(window (posn-window start))
(position (posn-point start)))
(when (window-live-p window)
(select-window window)
(when (integer-or-marker-p position)
(goto-char position)
(ai-code-session-link-navigate-symbol-at-point)))))

(defun ai-code-session-link--linkify-session-region (start end)
"Make supported URLs and in-project file references clickable from START to END."
Expand All @@ -225,6 +498,8 @@ terminal output redraw."
(remove-text-properties
pos next
'(ai-code-session-link nil
ai-code-session-symbol-link nil
ai-code-session-symbol-file nil
mouse-face nil
help-echo nil
keymap nil
Expand Down Expand Up @@ -277,6 +552,7 @@ terminal output redraw."
(start (max (point-min) (- end visible-width))))
(ai-code-session-link--linkify-session-region start end))))


(provide 'ai-code-session-link)

;;; ai-code-session-link.el ends here
Loading
Loading