From 2bc247b622acce7185dbe095ff995b05878c08b3 Mon Sep 17 00:00:00 2001 From: "hadoopchetan@gmail.com" Date: Sun, 7 Sep 2025 20:05:54 -0400 Subject: [PATCH 1/6] feat(chat): add ediff and smerge mode support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for displaying file diffs using ediff, smerge, or text. Introduce a custom variable to select the diff tool. Refactor content handling code to use the new diff functions and improve tool call and progress handling.Add diff view options to ECA chat Added new function `eca-chat-diff-tool` to select display method for file-change diffs. Implemented three different display methods: • Ediff: Interactive side-by-side comparison. • Smerge: Merge-style conflict presentation with Smerge mode. • Text: Plain text unified diff in a separate buffer. Modified `eca-chat--show-diff` function to dispatch the selected view based on `eca-chat-diff-tool`. Updated tool call content display for fileChange tools, including new diff view options. --- eca-chat.el | 485 +++++++++++++++++++++++++++++----------------------- 1 file changed, 269 insertions(+), 216 deletions(-) diff --git a/eca-chat.el b/eca-chat.el index 2ea3df4..fcc8655 100644 --- a/eca-chat.el +++ b/eca-chat.el @@ -14,6 +14,8 @@ (require 'f) (require 'markdown-mode) (require 'compat) +(require 'ediff) +(require 'smerge-mode) (require 'eca-util) (require 'eca-api) @@ -140,6 +142,21 @@ Must be a valid model supported by server, check `eca-chat-select-model`." (const :tag "Last message cost" :last-mesage-cost))) :group 'eca) +(defcustom eca-chat-diff-tool 'ediff + "Select the method for displaying file-change diffs in ECA chat. + +Possible values are: +• ediff — Open an interactive side-by-side comparison using Ediff. +• smerge — Present changes in merge-conflict format with Smerge mode. +• text — Show the raw unified diff as plain text in a separate buffer. + +Defaults to ‘ediff’ for an interactive visual diff experience." + :type '(choice (const :tag "Side-by-side Ediff" ediff) + (const :tag "Merge-style Smerge" smerge) + (const :tag "Plain text diff" text)) + :group 'eca) + + ;; Faces (defface eca-chat-prompt-prefix-face @@ -877,6 +894,72 @@ If FORCE? decide to OPEN? or not." (propertize (concat "-" (number-to-string (plist-get details :linesRemoved))) 'font-lock-face 'error) " ")) +(defun eca-chat--parse-unified-diff (diff-text) + "Parse DIFF-TEXT and return a plist with :original and :new strings." + (let ((orig '()) (new '()) in-hunk) + (dolist (l (split-string diff-text "\n")) + (cond + ((string-match "^@@.*@@$" l) (setq in-hunk t)) + ((and in-hunk (string-prefix-p " " l)) + (push (substring l 1) orig) (push (substring l 1) new)) + ((and in-hunk (string-prefix-p "-" l)) + (push (substring l 1) orig)) + ((and in-hunk (string-prefix-p "+" l)) + (push (substring l 1) new)))) + (list :original (string-join (nreverse orig) "\n") + :new (string-join (nreverse new) "\n")))) + + +(defun eca-chat--show-diff-text (path diff) + "Show DIFF for file at PATH as plain unified diff text." + (with-current-buffer (get-buffer-create (format "*eca-diff:%s*" path)) + (let ((inhibit-read-only t)) + (erase-buffer) + (insert diff) + (diff-mode) + (goto-char (point-min))) + (pop-to-buffer (current-buffer)))) + +(defun eca-chat--show-diff-ediff (path diff) + "Show DIFF for file at PATH using Ediff side-by-side." + (let* ((parsed (eca-chat--parse-unified-diff diff)) + (orig (plist-get parsed :original)) + (new (plist-get parsed :new)) + (buf-orig (generate-new-buffer (format "%s" path))) + (buf-new (generate-new-buffer (format "%s" path)))) + (with-current-buffer buf-orig + (erase-buffer) (insert orig) (set-buffer-modified-p nil)) + (with-current-buffer buf-new + (erase-buffer) (insert new) (set-buffer-modified-p nil)) + (ediff-buffers buf-orig buf-new))) + +(defun eca-chat--show-diff-smerge (path diff) + "Show DIFF for file at PATH using Smerge hunk-by-hunk review." + (let* ((parsed (eca-chat--parse-unified-diff diff)) + (orig (plist-get parsed :original)) + (new (plist-get parsed :new)) + (buf (get-buffer-create (format "*eca-smerge:%s*" path)))) + (with-current-buffer buf + (let ((inhibit-read-only t)) + (erase-buffer) + (insert (concat "<<<<<<< Original\n" + orig + "\n=======\n" + new + "\n>>>>>>> New\n")) + (smerge-mode 1) + (goto-char (point-min)) + (when (smerge-find-conflict) + (smerge-refine)))) + (pop-to-buffer buf))) + +(defun eca-chat--show-diff (path diff) + "Dispatch diff view based on `eca-chat-diff-tool`." + (pcase eca-chat-diff-tool + ('ediff (eca-chat--show-diff-ediff path diff)) + ('smerge (eca-chat--show-diff-smerge path diff)) + (_ (eca-chat--show-diff-text path diff)))) + (defun eca-chat--relativize-filename-for-workspace-root (filename roots) "Relativize the FILENAME if a workspace root is found for ROOTS." (or (-some->> (-first (lambda (root) (f-ancestor-of? root filename)) roots) @@ -1196,223 +1279,193 @@ string." (eca-chat--with-current-buffer (eca-chat--get-buffer session) (setq-local eca-chat--empty nil) (pcase (plist-get content :type) - ("text" (when-let* ((text (plist-get content :text))) - (pcase role - ("user" (progn - (eca-chat--add-text-content - (propertize text - 'font-lock-face 'eca-chat-user-messages-face - 'line-prefix (propertize eca-chat-prompt-prefix 'font-lock-face 'eca-chat-user-messages-face) - 'line-spacing 10) - 'eca-chat--user-message-id eca-chat--last-request-id) - (eca-chat--mark-header) - (font-lock-ensure))) - ("system" (progn - (eca-chat--add-text-content - (propertize text - 'line-height 20 - 'font-lock-face 'eca-chat-system-messages-face)))) - (_ (eca-chat--add-text-content text))))) - ("url" (eca-chat--add-header - (concat - "🌐 " - (eca-buttonize - (plist-get content :title) - (lambda() (browse-url (plist-get content :url)))) - "\n\n"))) - ("reasonStarted" (let ((id (plist-get content :id)) - (label (propertize "Thinking..." 'font-lock-face 'eca-chat-reason-label-face))) - (eca-chat--add-expandable-content id label ""))) - ("reasonText" (let ((text (plist-get content :text)) - (id (plist-get content :id)) - (label (propertize "Thinking..." 'font-lock-face 'eca-chat-reason-label-face))) - (eca-chat--update-expandable-content id label text t))) - ("reasonFinished" (let* ((id (plist-get content :id)) - (base-label (propertize "Thought" 'font-lock-face 'eca-chat-reason-label-face)) - (total-time-ms (-some-> (plist-get content :totalTimeMs) - (eca-chat--time->presentable-time))) - (label (if total-time-ms - (concat base-label " " total-time-ms) - base-label))) - (eca-chat--update-expandable-content id label "" t))) - ("toolCallPrepare" (let* ((name (plist-get content :name)) - (origin (plist-get content :origin)) - (argsText (plist-get content :argumentsText)) - (id (plist-get content :id)) - (summary (plist-get content :summary)) - (label (concat (propertize (or summary - (format "Preparing %s tool call: %s" - (if (string= "mcp" origin) "MCP" "ECA") - name)) - 'font-lock-face 'eca-chat-mcp-tool-call-label-face) - " " - eca-chat-mcp-tool-call-loading-symbol))) - (if (eca-chat--get-expandable-content id) - (eca-chat--update-expandable-content id label argsText t) - (eca-chat--add-expandable-content id - label - (eca-chat--content-table - `(("Tool" . ,name) - ("Arguments" . ,argsText))))))) - ("toolCallRun" (let* ((name (plist-get content :name)) - (origin (plist-get content :origin)) - (args (plist-get content :arguments)) - (id (plist-get content :id)) - (manual? (plist-get content :manualApproval)) - (details (plist-get content :details)) - (summary (plist-get content :summary)) - (approvalText (when manual? - (concat - " " - (eca-buttonize - (propertize "reject" 'font-lock-face 'eca-chat-tool-call-cancel-face) - (lambda () (eca-api-notify session - :method "chat/toolCallReject" - :params (list :chatId eca-chat--id :toolCallId id)))) - " " - (eca-buttonize - (propertize "accept" 'font-lock-face 'eca-chat-tool-call-run-face) - (lambda () (eca-api-notify session - :method "chat/toolCallApprove" - :params (list :chatId eca-chat--id :toolCallId id)))))))) - (if (string= "fileChange" (plist-get details :type)) - (eca-chat--update-expandable-content - id - (concat (propertize summary 'font-lock-face 'eca-chat-mcp-tool-call-label-face) - " " - (eca-chat--file-change-details-label details) - eca-chat-mcp-tool-call-loading-symbol - approvalText) - (concat - "Tool: `" name "`\n" - (eca-chat--file-change-diff (plist-get details :path) (plist-get details :diff) roots))) - (eca-chat--update-expandable-content - id - (concat (propertize (or summary - (format "Calling %s tool: %s" - (if (string= "mcp" origin) "MCP" "ECA") - name)) - 'font-lock-face 'eca-chat-mcp-tool-call-label-face) - " " - eca-chat-mcp-tool-call-loading-symbol - approvalText) - (eca-chat--content-table `(("Tool" . ,name) - ("Arguments" . ,args))))))) - ("toolCallRunning" (let* ((name (plist-get content :name)) - (origin (plist-get content :origin)) - (args (plist-get content :arguments)) - (id (plist-get content :id)) - (details (plist-get content :details)) - (summary (plist-get content :summary))) - (if (string= "fileChange" (plist-get details :type)) - (eca-chat--update-expandable-content - id - (concat (propertize summary 'font-lock-face 'eca-chat-mcp-tool-call-label-face) - " " - (eca-chat--file-change-details-label details) - eca-chat-mcp-tool-call-loading-symbol) - (concat - "Tool: `" name "`\n" - (eca-chat--file-change-diff (plist-get details :path) (plist-get details :diff) roots))) - (eca-chat--update-expandable-content - id - (concat (propertize (or summary - (format "Calling %s tool: %s" - (if (string= "mcp" origin) "MCP" "ECA") - name)) - 'font-lock-face 'eca-chat-mcp-tool-call-label-face) + + ("text" + (when-let* ((text (plist-get content :text))) + (pcase role + ("user" + (eca-chat--add-text-content + (propertize text + 'font-lock-face 'eca-chat-user-messages-face + 'line-prefix (propertize eca-chat-prompt-prefix + 'font-lock-face 'eca-chat-user-messages-face) + 'line-spacing 10) + 'eca-chat--user-message-id + eca-chat--last-request-id) + (eca-chat--mark-header) + (font-lock-ensure)) + ("system" + (eca-chat--add-text-content + (propertize text + 'font-lock-face 'eca-chat-system-messages-face + 'line-height 20))) + (_ + (eca-chat--add-text-content text))))) + + ("url" + (eca-chat--add-header + (concat "🌐 " + (eca-buttonize + (plist-get content :title) + (lambda () (browse-url (plist-get content :url)))) + "\n\n"))) + + ("reasonStarted" + (let ((id (plist-get content :id)) + (label (propertize "Thinking..." + 'font-lock-face 'eca-chat-reason-label-face))) + (eca-chat--add-expandable-content id label ""))) + ("reasonText" + (let ((id (plist-get content :id)) + (label (propertize "Thinking..." + 'font-lock-face 'eca-chat-reason-label-face)) + (text (plist-get content :text))) + (eca-chat--update-expandable-content id label text t))) + ("reasonFinished" + (let* ((id (plist-get content :id)) + (base (propertize "Thought" + 'font-lock-face 'eca-chat-reason-label-face)) + (time (when-let ((ms (plist-get content :totalTimeMs))) + (concat " " (eca-chat--time->presentable-time ms)))) + (label (concat base time))) + (eca-chat--update-expandable-content id label "" t))) + + ("toolCallPrepare" + (let* ((id (plist-get content :id)) + (summary (or (plist-get content :summary) + (format "Preparing tool: %s" (plist-get content :name)))) + (argsText (plist-get content :argumentsText)) + (label (concat (propertize summary + 'font-lock-face 'eca-chat-mcp-tool-call-label-face) + " " eca-chat-mcp-tool-call-loading-symbol))) + (if (eca-chat--get-expandable-content id) + (eca-chat--update-expandable-content id label argsText t) + (eca-chat--add-expandable-content id label + (eca-chat--content-table + `(("Tool" . ,(plist-get content :name)) + ("Arguments" . ,argsText))))))) + + ("toolCallRun" + (let* ((id (plist-get content :id)) + (name (plist-get content :name)) + (summary (or (plist-get content :summary) + (format "Calling tool: %s" name))) + (manual? (plist-get content :manualApproval)) + (approvalText (when manual? + (concat " " + (eca-buttonize + (propertize "reject" 'font-lock-face 'eca-chat-tool-call-cancel-face) + (lambda () + (eca-api-notify session + :method "chat/toolCallReject" + :params (list :chatId eca-chat--id + :toolCallId id)))) " " - eca-chat-mcp-tool-call-loading-symbol) - (eca-chat--content-table `(("Tool" . ,name) - ("Arguments" . ,args))))))) - ("toolCallRejected" (let* ((name (plist-get content :name)) - (origin (plist-get content :origin)) - (args (plist-get content :arguments)) - (details (plist-get content :details)) - (summary (plist-get content :summary)) - (id (plist-get content :id))) - (if (string= "fileChange" (plist-get details :type)) - (eca-chat--update-expandable-content - id - (concat (propertize summary 'font-lock-face 'eca-chat-mcp-tool-call-label-face) - " " - (eca-chat--file-change-details-label details) - eca-chat-mcp-tool-call-error-symbol) - (concat - "Tool: `" name "`\n" - (eca-chat--file-change-diff (plist-get details :path) (plist-get details :diff) roots))) - (eca-chat--update-expandable-content - id - (concat (propertize (format "Rejected %s tool: %s" - (if (string= "mcp" origin) "MCP" "ECA") - name) - 'font-lock-face 'eca-chat-mcp-tool-call-label-face) - " " - eca-chat-mcp-tool-call-error-symbol) - (eca-chat--content-table `(("Tool" . ,name) - ("Arguments" . ,args))))))) - ("toolCalled" (let* ((id (plist-get content :id)) - (name (plist-get content :name)) - (origin (plist-get content :origin)) - (args (plist-get content :arguments)) - (outputs (append (plist-get content :outputs) nil)) - (total-time-ms-str (or (-some->> (plist-get content :totalTimeMs) - (eca-chat--time->presentable-time) - (concat " ")) - "")) - (summary (or (plist-get content :summary) - (format "Called %s tool: %s" - (if (string= "mcp" origin) "MCP" "ECA") - name))) - (error? (plist-get content :error)) - (output-contents (-reduce-from (lambda (txt output) (concat txt "\n" (plist-get output :text))) - "" - outputs)) - (output-contents (if (string-blank-p output-contents) - "Empty" - output-contents)) - (details (plist-get content :details)) - (status-icon (if error? - eca-chat-mcp-tool-call-error-symbol - eca-chat-mcp-tool-call-success-symbol))) - (if (string= "fileChange" (plist-get details :type)) - (eca-chat--update-expandable-content - id - (concat (propertize summary 'font-lock-face 'eca-chat-mcp-tool-call-label-face) - " " - (eca-chat--file-change-details-label details) - status-icon - total-time-ms-str) - (concat - "Tool: `" name "`\n" - (eca-chat--file-change-diff (plist-get details :path) (plist-get details :diff) roots))) - (eca-chat--update-expandable-content - id - (concat (propertize summary 'font-lock-face 'eca-chat-mcp-tool-call-label-face) - " " - status-icon - total-time-ms-str) - (eca-chat--content-table `(("Tool" . ,name) - ("Arguments" . ,args) - ("Output" . ,output-contents))))))) - ("progress" (pcase (plist-get content :state) - ("running" (progn - (unless eca-chat--spinner-timer - (eca-chat--spinner-start session)) - (setq-local eca-chat--progress-text (propertize (plist-get content :text) 'font-lock-face 'eca-chat-system-messages-face)))) - ("finished" (progn - (eca-chat--spinner-stop) - (eca-chat--add-text-content (propertize "\n" 'line-spacing 10)) - (eca-chat--set-chat-loading session nil) - (setq-local eca-chat--progress-text ""))))) - ("usage" (progn - (setq-local eca-chat--message-input-tokens (plist-get content :messageInputTokens)) - (setq-local eca-chat--message-output-tokens (plist-get content :messageOutputTokens)) - (setq-local eca-chat--session-tokens (plist-get content :sessionTokens)) - (setq-local eca-chat--session-limit-context (plist-get (plist-get content :limit) :context)) - (setq-local eca-chat--session-limit-output (plist-get (plist-get content :limit) :output)) - (setq-local eca-chat--message-cost (plist-get content :messageCost)) - (setq-local eca-chat--session-cost (plist-get content :sessionCost)))))))) + (eca-buttonize + (propertize "accept" 'font-lock-face 'eca-chat-tool-call-run-face) + (lambda () + (eca-api-notify session + :method "chat/toolCallApprove" + :params (list :chatId eca-chat--id + :toolCallId id))))))) + (details (plist-get content :details))) + (eca-chat--update-expandable-content + id + (concat (propertize summary 'font-lock-face 'eca-chat-mcp-tool-call-label-face) + " " approvalText) + (eca-chat--content-table + `(("Tool" . ,name) + ("Arguments" . ,(plist-get content :arguments))))))) + + ("toolCallRunning" + (let* ((id (plist-get content :id)) + (name (plist-get content :name)) + (summary (or (plist-get content :summary) + (format "Running tool: %s" name))) + (details (plist-get content :details)) + (status eca-chat-mcp-tool-call-loading-symbol)) + (if (and (stringp (plist-get details :type)) + (string= "fileChange" (plist-get details :type))) + (let* ((path (plist-get details :path)) + (diff (plist-get details :diff)) + (view-btn + (concat " " + (eca-buttonize + (propertize "[View Diff]" 'font-lock-face 'link) + `(lambda () + (interactive) + (eca-chat--show-diff ,path ,diff)))))) + (eca-chat--update-expandable-content + id + (concat (propertize summary 'font-lock-face 'eca-chat-mcp-tool-call-label-face) + " " + (eca-chat--file-change-details-label details) + status + view-btn) + (concat "Tool: `" name "`\n" + (eca-chat--file-change-diff path diff roots)))) + (eca-chat--update-expandable-content + id + (concat (propertize summary 'font-lock-face 'eca-chat-mcp-tool-call-label-face) + " " status) + (eca-chat--content-table + `(("Tool" . ,name) + ("Arguments" . ,(plist-get content :arguments)))))))) + ("toolCalled" + (let* ((id (plist-get content :id)) + (name (plist-get content :name)) + (summary (or (plist-get content :summary) + (format "Called tool: %s" name))) + (outputs (plist-get content :outputs)) + (output-text (if outputs + (mapconcat (lambda (o) (or (plist-get o :text) "")) outputs "\n") + "")) + (details (plist-get content :details)) + (status (if (plist-get content :error) + eca-chat-mcp-tool-call-error-symbol + eca-chat-mcp-tool-call-success-symbol))) + (if (and (stringp (plist-get details :type)) + (string= "fileChange" (plist-get details :type))) + (let ((path (plist-get details :path)) + (diff (plist-get details :diff))) + (eca-chat--update-expandable-content + id + (concat (propertize summary 'font-lock-face 'eca-chat-mcp-tool-call-label-face) + " " status) + (concat "Tool: `" name "`\n" + (eca-chat--file-change-diff path diff roots)))) + (eca-chat--update-expandable-content + id + (concat (propertize summary 'font-lock-face 'eca-chat-mcp-tool-call-label-face) + " " status) + (eca-chat--content-table + `(("Tool" . ,name) + ("Output" . ,output-text))))))) + + ("progress" + (pcase (plist-get content :state) + ("running" + (unless eca-chat--spinner-timer + (eca-chat--spinner-start session)) + (setq-local eca-chat--progress-text + (propertize (plist-get content :text) + 'font-lock-face 'eca-chat-system-messages-face))) + ("finished" + (eca-chat--spinner-stop) + (eca-chat--add-text-content "\n") + (eca-chat--set-chat-loading session nil) + (setq-local eca-chat--progress-text "")))) + + ("usage" + (setq-local eca-chat--message-input-tokens (plist-get content :messageInputTokens)) + (setq-local eca-chat--message-output-tokens (plist-get content :messageOutputTokens)) + (setq-local eca-chat--session-tokens (plist-get content :sessionTokens)) + (setq-local eca-chat--session-limit-context (plist-get (plist-get content :limit) :context)) + (setq-local eca-chat--session-limit-output (plist-get (plist-get content :limit) :output)) + (setq-local eca-chat--message-cost (plist-get content :messageCost)) + (setq-local eca-chat--session-cost (plist-get content :sessionCost))) + + (_ nil))))) (defun eca-chat--handle-mcp-server-updated (session _server) "Handle mcp SERVER updated for SESSION." From df0c7ae0bc26e01779b3bcdfb0794c383da1c851 Mon Sep 17 00:00:00 2001 From: CsBigDataHub Date: Wed, 10 Sep 2025 14:37:54 -0400 Subject: [PATCH 2/6] Support edit and merge mode to view diffs --- eca-chat.el | 261 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 227 insertions(+), 34 deletions(-) diff --git a/eca-chat.el b/eca-chat.el index e17d3cc..38a69ac 100644 --- a/eca-chat.el +++ b/eca-chat.el @@ -35,9 +35,9 @@ Can be `'left', `'right', `'top', or `'bottom'. This setting will only be used when `eca-chat-use-side-window' is non-nil." :type '(choice (const :tag "Left" left) - (const :tag "Right" right) - (const :tag "Top" top) - (const :tag "Bottom" bottom)) + (const :tag "Right" right) + (const :tag "Top" top) + (const :tag "Bottom" bottom)) :group 'eca) (defcustom eca-chat-window-width 0.40 @@ -143,14 +143,7 @@ Must be a valid model supported by server, check `eca-chat-select-model`." :group 'eca) (defcustom eca-chat-diff-tool 'ediff - "Select the method for displaying file-change diffs in ECA chat. - -Possible values are: -• ediff — Open an interactive side-by-side comparison using Ediff. -• smerge — Present changes in merge-conflict format with Smerge mode. -• text — Show the raw unified diff as plain text in a separate buffer. - -Defaults to ‘ediff’ for an interactive visual diff experience." + "Select the method for displaying file-change diffs in ECA chat." :type '(choice (const :tag "Side-by-side Ediff" ediff) (const :tag "Merge-style Smerge" smerge) (const :tag "Plain text diff" text)) @@ -179,6 +172,11 @@ Defaults to ‘ediff’ for an interactive visual diff experience." "Face for the cancel tool call action." :group 'eca) +(defface eca-chat-diff-view-face + '((t (:foreground "dodger blue" :underline t :weight bold))) + "Face for the diff view button." + :group 'eca) + (defface eca-chat-context-unlinked-face '((t (:foreground "gold" :height 0.9))) "Face for contexts to be added." @@ -912,34 +910,154 @@ If FORCE? decide to OPEN? or not." (defun eca-chat--show-diff-text (path diff) - "Show DIFF for file at PATH as plain unified diff text." + "Show DIFF for file at PATH as plain unified diff text. +Adds \\='q\\=' to quit." (with-current-buffer (get-buffer-create (format "*eca-diff:%s*" path)) (let ((inhibit-read-only t)) (erase-buffer) (insert diff) (diff-mode) - (goto-char (point-min))) + (goto-char (point-min)) + ;; Add a local 'q' binding to kill this buffer quickly + (let ((map (make-sparse-keymap))) + (set-keymap-parent map (current-local-map)) + (define-key map (kbd "q") (lambda () (interactive) (kill-buffer (current-buffer)))) + (use-local-map map))) (pop-to-buffer (current-buffer)))) (defun eca-chat--show-diff-ediff (path diff) - "Show DIFF for file at PATH using Ediff side-by-side." + "Show DIFF for file at PATH using Ediff side-by-side. +Cleanly manages frame and buffers." (let* ((parsed (eca-chat--parse-unified-diff diff)) (orig (plist-get parsed :original)) (new (plist-get parsed :new)) (buf-orig (generate-new-buffer (format "%s" path))) - (buf-new (generate-new-buffer (format "%s" path)))) + (buf-new (generate-new-buffer (format "%s" path))) + (created-frame nil) + (cleanup-function nil) + (startup-function nil) + (frame-deleted-fn nil) + (ediff-buffers-before nil) + (session-ediff-buffers nil)) (with-current-buffer buf-orig (erase-buffer) (insert orig) (set-buffer-modified-p nil)) (with-current-buffer buf-new (erase-buffer) (insert new) (set-buffer-modified-p nil)) - (ediff-buffers buf-orig buf-new))) + + ;; Capture existing Ediff buffers before we start + (setq ediff-buffers-before + (seq-filter (lambda (buf) + (string-match-p "\\*\\(ediff-\\|Ediff Control\\)" (buffer-name buf))) + (buffer-list))) + + ;; Define cleanup function + (setq cleanup-function + (lambda () + ;; Clean up temporary buffers + (when (buffer-live-p buf-orig) + (kill-buffer buf-orig)) + (when (buffer-live-p buf-new) + (kill-buffer buf-new)) + ;; Clean up our specific Ediff session buffers + (dolist (buf session-ediff-buffers) + (when (and buf (buffer-live-p buf)) + (kill-buffer buf))) + ;; Delete frame if we created it + (when (and created-frame (frame-live-p created-frame)) + (delete-frame created-frame)) + ;; Remove this hook function after use + (remove-hook 'ediff-quit-hook cleanup-function) + (when frame-deleted-fn + (remove-hook 'delete-frame-functions frame-deleted-fn)))) + + ;; Define startup function that captures the session's internal buffers + (setq startup-function + (lambda () + ;; Capture new Ediff buffers created for this session only + (let ((ediff-buffers-after + (seq-filter (lambda (buf) + (string-match-p "\\*\\(ediff-\\|Ediff Control\\)" (buffer-name buf))) + (buffer-list)))) + ;; Only track buffers that are new AND not the persistent registry + (setq session-ediff-buffers + (seq-filter (lambda (buf) + (and (not (member buf ediff-buffers-before)) + (not (string-match-p "\\*Ediff Registry\\*" (buffer-name buf))))) + ediff-buffers-after))) + ;; Navigate to first difference if any exist + (condition-case _err + (progn + ;; Set to start from beginning + (setq ediff-current-difference -1) + ;; Move to first difference + (ediff-next-difference)) + (error nil)) ; Ignore if no differences + ;; Remove this hook function after use + (remove-hook 'ediff-after-setup-windows-hook startup-function))) + + ;; Add hooks for this ediff session + (add-hook 'ediff-quit-hook cleanup-function) + (add-hook 'ediff-after-setup-windows-hook startup-function) + + ;; Ensure ediff runs in a regular window context, not from a side window + ;; This prevents both "Cannot make side window the only window" and + ;; "Cannot split side window or parent of side window" errors + (let ((regular-window (cl-find-if-not + (lambda (w) + (or (window-parameter w 'window-side) + (window-parameter (window-parent w) 'window-side))) + (window-list)))) + (if regular-window + ;; Use existing regular window + (with-selected-window regular-window + (condition-case _err + (ediff-buffers buf-orig buf-new) + (error + ;; Fallback: create new frame if window operations fail + (setq created-frame (make-frame '((fullscreen . maximized)))) + ;; If the user manually deletes the frame, also kill the temp buffers + (setq frame-deleted-fn + (lambda (frame) + (when (eq frame created-frame) + (when (buffer-live-p buf-orig) (kill-buffer buf-orig)) + (when (buffer-live-p buf-new) (kill-buffer buf-new)) + ;; Clean up our specific Ediff session buffers + (dolist (buf session-ediff-buffers) + (when (and buf (buffer-live-p buf)) + (kill-buffer buf))) + (remove-hook 'delete-frame-functions frame-deleted-fn)))) + (add-hook 'delete-frame-functions frame-deleted-fn) + (select-frame created-frame) + (ediff-buffers buf-orig buf-new)))) + ;; No regular window exists - create a new frame for ediff + (setq created-frame (make-frame '((fullscreen . maximized)))) + (setq frame-deleted-fn + (lambda (frame) + (when (eq frame created-frame) + (when (buffer-live-p buf-orig) (kill-buffer buf-orig)) + (when (buffer-live-p buf-new) (kill-buffer buf-new)) + ;; Clean up our specific Ediff session buffers + (dolist (buf session-ediff-buffers) + (when (and buf (buffer-live-p buf)) + (kill-buffer buf))) + (remove-hook 'delete-frame-functions frame-deleted-fn)))) + (add-hook 'delete-frame-functions frame-deleted-fn) + (select-frame created-frame) + (ediff-buffers buf-orig buf-new))))) (defun eca-chat--show-diff-smerge (path diff) - "Show DIFF for file at PATH using Smerge hunk-by-hunk review." + "Show DIFF for file at PATH using Smerge in a dedicated new frame. +Also enables diff-mode for syntax highlighting and ensures both the created +frame and the temporary buffer are cleaned up. Adds a local q binding to quit." (let* ((parsed (eca-chat--parse-unified-diff diff)) (orig (plist-get parsed :original)) (new (plist-get parsed :new)) - (buf (get-buffer-create (format "*eca-smerge:%s*" path)))) + ;; Always create a fresh buffer to avoid reusing an existing display + (buf (generate-new-buffer (format "*eca-smerge:%s*" path))) + (created-frame nil) + (cleanup-fn nil) + (smerge-change-fn nil) + (frame-deleted-fn nil)) (with-current-buffer buf (let ((inhibit-read-only t)) (erase-buffer) @@ -948,11 +1066,57 @@ If FORCE? decide to OPEN? or not." "\n=======\n" new "\n>>>>>>> New\n")) + ;; Major mode: diff-mode for highlighting + (diff-mode) + ;; Minor mode: smerge for conflict navigation/resolution (smerge-mode 1) + ;; Position at first conflict and refine (goto-char (point-min)) (when (smerge-find-conflict) - (smerge-refine)))) - (pop-to-buffer buf))) + (smerge-refine)) + ;; Add a local 'q' binding to kill this buffer quickly + (let ((map (make-sparse-keymap))) + (set-keymap-parent map (current-local-map)) + (define-key map (kbd "q") (lambda () (interactive) (kill-buffer (current-buffer)))) + (use-local-map map)) + ;; Define cleanup that deletes the created frame and detaches hooks + (setq cleanup-fn + (lambda () + ;; Remove frame hook FIRST to prevent recursion when deleting frame + (when frame-deleted-fn + (remove-hook 'delete-frame-functions frame-deleted-fn) + (setq frame-deleted-fn nil)) + (when (and created-frame (frame-live-p created-frame)) + (delete-frame created-frame)) + (remove-hook 'kill-buffer-hook cleanup-fn t) + (remove-hook 'smerge-mode-hook smerge-change-fn t) + (when (buffer-live-p buf) + (kill-buffer buf)))) + ;; If user disables smerge-mode, also delete the frame + (setq smerge-change-fn + (lambda () + (unless smerge-mode + (funcall cleanup-fn)))) + (add-hook 'kill-buffer-hook cleanup-fn nil t) + (add-hook 'smerge-mode-hook smerge-change-fn nil t))) + ;; Always open in a new maximized frame and show the buffer there explicitly + (setq created-frame (make-frame '((fullscreen . maximized)))) + ;; If the user manually deletes the frame, also kill the temp buffer + (setq frame-deleted-fn + (lambda (frame) + (when (eq frame created-frame) + ;; Remove hook FIRST to prevent recursion + (remove-hook 'delete-frame-functions frame-deleted-fn) + (when (buffer-live-p buf) + (kill-buffer buf))))) + (add-hook 'delete-frame-functions frame-deleted-fn) + (with-selected-frame created-frame + (let ((win (selected-window))) + (set-window-parameter win 'no-other-window t) + (set-window-parameter win 'no-delete-other-windows t) + (set-window-buffer win buf) + (set-window-dedicated-p win t) + (select-window win))))) (defun eca-chat--show-diff (path diff) "Dispatch diff view based on `eca-chat-diff-tool`." @@ -970,7 +1134,7 @@ If FORCE? decide to OPEN? or not." (defun eca-chat--context-presentable-path (filename) "Return the presentable string for FILENAME." (or (when (-first (lambda (root) (f-ancestor-of? root filename)) - (eca--session-workspace-folders (eca-session))) + (eca--session-workspace-folders (eca-session))) (f-filename filename)) filename)) @@ -1249,8 +1413,8 @@ string." (cond ((eq action 'metadata) '(metadata (category . eca-capf) - (display-sort-function . identity) - (cycle-sort-function . identity))) + (display-sort-function . identity) + (cycle-sort-function . identity))) ((eq (car-safe action) 'boundaries) nil) (t (complete-with-action action (funcall candidates-fn) probe pred)))) @@ -1370,13 +1534,33 @@ string." :params (list :chatId eca-chat--id :toolCallId id))))))) (details (plist-get content :details))) - (eca-chat--update-expandable-content - id - (concat (propertize summary 'font-lock-face 'eca-chat-mcp-tool-call-label-face) - " " approvalText) - (eca-chat--content-table - `(("Tool" . ,name) - ("Arguments" . ,(plist-get content :arguments))))))) + (if (and (stringp (plist-get details :type)) + (string= "fileChange" (plist-get details :type))) + (let* ((path (plist-get details :path)) + (diff (plist-get details :diff)) + (view-btn + (concat " " + (eca-buttonize + (propertize "view_diff" 'font-lock-face 'eca-chat-diff-view-face) + `(lambda () + (interactive) + (eca-chat--show-diff ,path ,diff)))))) + (eca-chat--update-expandable-content + id + (concat (propertize summary 'font-lock-face 'eca-chat-mcp-tool-call-label-face) + " " + (eca-chat--file-change-details-label details) + approvalText + view-btn) + (concat "Tool: `" name "`\n" + (eca-chat--file-change-diff path diff roots)))) + (eca-chat--update-expandable-content + id + (concat (propertize summary 'font-lock-face 'eca-chat-mcp-tool-call-label-face) + " " approvalText) + (eca-chat--content-table + `(("Tool" . ,name) + ("Arguments" . ,(plist-get content :arguments)))))))) ("toolCallRunning" (let* ((id (plist-get content :id)) @@ -1392,7 +1576,7 @@ string." (view-btn (concat " " (eca-buttonize - (propertize "[View Diff]" 'font-lock-face 'link) + (propertize "view_diff" 'font-lock-face 'eca-chat-diff-view-face) `(lambda () (interactive) (eca-chat--show-diff ,path ,diff)))))) @@ -1427,12 +1611,21 @@ string." eca-chat-mcp-tool-call-success-symbol))) (if (and (stringp (plist-get details :type)) (string= "fileChange" (plist-get details :type))) - (let ((path (plist-get details :path)) - (diff (plist-get details :diff))) + (let* ((path (plist-get details :path)) + (diff (plist-get details :diff)) + (view-btn + (concat " " + (eca-buttonize + (propertize "view_diff" 'font-lock-face 'eca-chat-diff-view-face) + `(lambda () + (interactive) + (eca-chat--show-diff ,path ,diff)))))) (eca-chat--update-expandable-content id (concat (propertize summary 'font-lock-face 'eca-chat-mcp-tool-call-label-face) - " " status) + " " status + (eca-chat--file-change-details-label details) + view-btn) (concat "Tool: `" name "`\n" (eca-chat--file-change-diff path diff roots)))) (eca-chat--update-expandable-content From 15f713965c896251d4291fbfd704dcafa59996ce Mon Sep 17 00:00:00 2001 From: CsBigDataHub Date: Wed, 10 Sep 2025 14:39:38 -0400 Subject: [PATCH 3/6] Update README.md with eca-chat-diff-tool --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 89dc558..217a42e 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ M-x package-install eca - `eca-chat-window-side`: customize the chat window side. - `eca-chat-window-width`: customize the chat window width. - `eca-chat-window-height`: customize the chat window height. +- `eca-chat-diff-tool`: customize viewing diffs, defaults to ediff. ### Keybindings From 249dac55294d4a8782f4095d8cd7b8949f2a5fc2 Mon Sep 17 00:00:00 2001 From: CsBigDataHub Date: Thu, 11 Sep 2025 16:30:48 -0400 Subject: [PATCH 4/6] use existing frame and windows instead of creating new frames --- eca-chat.el | 452 ++++++++++++++++++++++++++-------------------------- 1 file changed, 229 insertions(+), 223 deletions(-) diff --git a/eca-chat.el b/eca-chat.el index cff5f06..06f376a 100644 --- a/eca-chat.el +++ b/eca-chat.el @@ -777,6 +777,23 @@ Add a overlay before with OVERLAY-KEY = OVERLAY-VALUE if passed." (-first (-lambda (ov) (string= id (overlay-get ov 'eca-chat--expandable-content-id))) (overlays-in (point-min) (point-max)))) +(defun eca-chat--propertize-only-first-word (str &rest properties) + "Return a new string propertizing PROPERTIES to the first word of STR. +If STR is empty or PROPERTIES is nil, return STR unchanged. Existing +text properties on STR are preserved; only the first word gets the +additional PROPERTIES. The first word is the substring up to the first +space, tab, or newline." + (if (or (string-empty-p str) (null properties)) + str + (let* ((split-pos (or (string-match "[ \t\n]" str) + (length str))) + (first (substring str 0 split-pos)) + (rest (substring str split-pos))) + ;; Preserve existing properties on `first` (copied by `substring`) + ;; and add/override with the provided PROPERTIES only for the first word. + (add-text-properties 0 (length first) properties first) + (concat first rest)))) + (defun eca-chat--add-expandable-content (id label content) "Add LABEL to the chat current position for ID as a interactive text. When expanded, shows CONTENT. @@ -789,13 +806,13 @@ Applies LABEL-FACE to label and CONTENT-FACE to content." (let ((ov-label (make-overlay (point) (point) (current-buffer)))) (overlay-put ov-label 'eca-chat--expandable-content-id id) (overlay-put ov-label 'eca-chat--expandable-content-toggle nil) - (insert (propertize label + (insert (propertize (eca-chat--propertize-only-first-word label + 'line-prefix (unless (string-empty-p content) + eca-chat-expandable-block-open-symbol)) 'keymap (let ((km (make-sparse-keymap))) (define-key km (kbd "") (lambda () (eca-chat--expandable-content-toggle id))) (define-key km (kbd "") (lambda () (eca-chat--expandable-content-toggle id))) km) - 'line-prefix (unless (string-empty-p content) - eca-chat-expandable-block-open-symbol) 'help-echo "mouse-1 / tab / RET: expand/collapse")) (insert "\n") (let* ((start-point (point)) @@ -820,11 +837,11 @@ Applies LABEL-FACE to label and CONTENT-FACE to content." ;; Refresh the label line (cheap even when appending) (goto-char (overlay-start ov-label)) (delete-region (point) (1- (overlay-start ov-content))) - (insert (propertize label - 'line-prefix (unless (string-empty-p new-content) - (if open? - eca-chat-expandable-block-close-symbol - eca-chat-expandable-block-open-symbol)) + (insert (propertize (eca-chat--propertize-only-first-word label + 'line-prefix (unless (string-empty-p new-content) + (if open? + eca-chat-expandable-block-close-symbol + eca-chat-expandable-block-open-symbol))) 'help-echo "mouse-1 / RET / tab: expand/collapse")) (when open? (if append-content? @@ -938,197 +955,186 @@ Adds \\='q\\=' to quit." (pop-to-buffer (current-buffer)))) (defun eca-chat--show-diff-ediff (path diff) - "Show DIFF for file at PATH using Ediff side-by-side. -Cleanly manages frame and buffers." + "Show DIFF for file at PATH using Ediff side-by-side in windows. +Uses window configuration management instead of creating frames. + +If the current window is a side window, temporarily clear side-window +protections so Ediff can split windows freely. The original window +configuration is restored when Ediff quits via the cleanup hook." (let* ((parsed (eca-chat--parse-unified-diff diff)) (orig (plist-get parsed :original)) (new (plist-get parsed :new)) - (buf-orig (generate-new-buffer (format "%s" path))) - (buf-new (generate-new-buffer (format "%s" path))) - (created-frame nil) - (cleanup-function nil) - (startup-function nil) - (frame-deleted-fn nil) - (ediff-buffers-before nil) - (session-ediff-buffers nil)) + (buf-orig (generate-new-buffer (format "*eca-diff-orig:%s*" path))) + (buf-new (generate-new-buffer (format "*eca-diff-new:%s*" path))) + (cwc (current-window-configuration)) + (orig-selected (selected-window)) + (ediff-buffers-before + (seq-filter (lambda (b) + (string-match-p "\\*\\(ediff-\\|Ediff Control\\)" (buffer-name b))) + (buffer-list))) + (session-ediff-buffers nil) + (cleanup-fn nil) + (after-setup-fn nil)) + ;; Fill temporary buffers (with-current-buffer buf-orig - (erase-buffer) (insert orig) (set-buffer-modified-p nil)) + (let ((inhibit-read-only t)) + (erase-buffer) + (insert orig) + (set-buffer-modified-p nil))) (with-current-buffer buf-new - (erase-buffer) (insert new) (set-buffer-modified-p nil)) - - ;; Capture existing Ediff buffers before we start - (setq ediff-buffers-before - (seq-filter (lambda (buf) - (string-match-p "\\*\\(ediff-\\|Ediff Control\\)" (buffer-name buf))) - (buffer-list))) - - ;; Define cleanup function - (setq cleanup-function + (let ((inhibit-read-only t)) + (erase-buffer) + (insert new) + (set-buffer-modified-p nil))) + + ;; Temporarily relax side-window protections in the current frame so + ;; Ediff can split windows. The original configuration will be + ;; restored by `cleanup-fn' (which calls `set-window-configuration'). + (let ((frame (selected-frame))) + (dolist (w (seq-filter (lambda (w) + (and (eq (window-frame w) frame) + (or (window-parameter w 'no-delete-other-windows) + (window-parameter w 'window-side)))) + (window-list))) + (when (window-live-p w) + (set-window-parameter w 'no-delete-other-windows nil) + (set-window-parameter w 'window-side nil)))) + + ;; Ensure Ediff has a single full window to manage (it will split it). + (unless (one-window-p t) + (delete-other-windows)) + + ;; Cleanup: restore windows and kill temp/session buffers + (setq cleanup-fn (lambda () - ;; Clean up temporary buffers - (when (buffer-live-p buf-orig) - (kill-buffer buf-orig)) - (when (buffer-live-p buf-new) - (kill-buffer buf-new)) - ;; Clean up our specific Ediff session buffers - (dolist (buf session-ediff-buffers) - (when (and buf (buffer-live-p buf)) - (kill-buffer buf))) - ;; Delete frame if we created it - (when (and created-frame (frame-live-p created-frame)) - (delete-frame created-frame)) - ;; Remove this hook function after use - (remove-hook 'ediff-quit-hook cleanup-function) - (when frame-deleted-fn - (remove-hook 'delete-frame-functions frame-deleted-fn)))) - - ;; Define startup function that captures the session's internal buffers - (setq startup-function + ;; Restore window configuration saved at the beginning + (when (window-configuration-p cwc) + (set-window-configuration cwc)) + ;; Ensure focus returns to the original window if still live + (when (window-live-p orig-selected) + (select-window orig-selected)) + (when (buffer-live-p buf-orig) (kill-buffer buf-orig)) + (when (buffer-live-p buf-new) (kill-buffer buf-new)) + (dolist (b session-ediff-buffers) + (when (and b (buffer-live-p b)) + (kill-buffer b))) + (remove-hook 'ediff-quit-hook cleanup-fn))) + + ;; After-setup hook: capture ediff buffers and move to first diff + (setq after-setup-fn (lambda () - ;; Capture new Ediff buffers created for this session only (let ((ediff-buffers-after - (seq-filter (lambda (buf) - (string-match-p "\\*\\(ediff-\\|Ediff Control\\)" (buffer-name buf))) + (seq-filter (lambda (b) + (string-match-p "\\*\\(ediff-\\|Ediff Control\\)" (buffer-name b))) (buffer-list)))) - ;; Only track buffers that are new AND not the persistent registry (setq session-ediff-buffers - (seq-filter (lambda (buf) - (and (not (member buf ediff-buffers-before)) - (not (string-match-p "\\*Ediff Registry\\*" (buffer-name buf))))) + (seq-filter (lambda (b) + (and (not (member b ediff-buffers-before)) + (not (string-match-p "\*Ediff Registry\*" (buffer-name b))))) ediff-buffers-after))) - ;; Navigate to first difference if any exist (condition-case _err (progn - ;; Set to start from beginning (setq ediff-current-difference -1) - ;; Move to first difference (ediff-next-difference)) - (error nil)) ; Ignore if no differences - ;; Remove this hook function after use - (remove-hook 'ediff-after-setup-windows-hook startup-function))) - - ;; Add hooks for this ediff session - (add-hook 'ediff-quit-hook cleanup-function) - (add-hook 'ediff-after-setup-windows-hook startup-function) - - ;; Ensure ediff runs in a regular window context, not from a side window - ;; This prevents both "Cannot make side window the only window" and - ;; "Cannot split side window or parent of side window" errors - (let ((regular-window (cl-find-if-not - (lambda (w) - (or (window-parameter w 'window-side) - (window-parameter (window-parent w) 'window-side))) - (window-list)))) - (if regular-window - ;; Use existing regular window - (with-selected-window regular-window - (condition-case _err - (ediff-buffers buf-orig buf-new) - (error - ;; Fallback: create new frame if window operations fail - (setq created-frame (make-frame '((fullscreen . maximized)))) - ;; If the user manually deletes the frame, also kill the temp buffers - (setq frame-deleted-fn - (lambda (frame) - (when (eq frame created-frame) - (when (buffer-live-p buf-orig) (kill-buffer buf-orig)) - (when (buffer-live-p buf-new) (kill-buffer buf-new)) - ;; Clean up our specific Ediff session buffers - (dolist (buf session-ediff-buffers) - (when (and buf (buffer-live-p buf)) - (kill-buffer buf))) - (remove-hook 'delete-frame-functions frame-deleted-fn)))) - (add-hook 'delete-frame-functions frame-deleted-fn) - (select-frame created-frame) - (ediff-buffers buf-orig buf-new)))) - ;; No regular window exists - create a new frame for ediff - (setq created-frame (make-frame '((fullscreen . maximized)))) - (setq frame-deleted-fn - (lambda (frame) - (when (eq frame created-frame) - (when (buffer-live-p buf-orig) (kill-buffer buf-orig)) - (when (buffer-live-p buf-new) (kill-buffer buf-new)) - ;; Clean up our specific Ediff session buffers - (dolist (buf session-ediff-buffers) - (when (and buf (buffer-live-p buf)) - (kill-buffer buf))) - (remove-hook 'delete-frame-functions frame-deleted-fn)))) - (add-hook 'delete-frame-functions frame-deleted-fn) - (select-frame created-frame) - (ediff-buffers buf-orig buf-new))))) + (error nil)) + (remove-hook 'ediff-after-setup-windows-hook after-setup-fn))) + + (add-hook 'ediff-quit-hook cleanup-fn) + (add-hook 'ediff-after-setup-windows-hook after-setup-fn) + + ;; Start Ediff in the prepared window environment. Ediff will manage + ;; splits from here on. On error, restore original windows/config. + (condition-case err + (ediff-buffers buf-orig buf-new) + (error + ;; On error remove hooks and kill temps, and restore windows + (remove-hook 'ediff-quit-hook cleanup-fn) + (remove-hook 'ediff-after-setup-windows-hook after-setup-fn) + (when (window-configuration-p cwc) + (set-window-configuration cwc)) + (when (buffer-live-p buf-orig) (kill-buffer buf-orig)) + (when (buffer-live-p buf-new) (kill-buffer buf-new)) + (message "eca-chat: error starting ediff: %s" err))))) (defun eca-chat--show-diff-smerge (path diff) - "Show DIFF for file at PATH using Smerge in a dedicated new frame. -Also enables diff-mode for syntax highlighting and ensures both the created -frame and the temporary buffer are cleaned up. Adds a local q binding to quit." - (let* ((parsed (eca-chat--parse-unified-diff diff)) - (orig (plist-get parsed :original)) - (new (plist-get parsed :new)) - ;; Always create a fresh buffer to avoid reusing an existing display - (buf (generate-new-buffer (format "*eca-smerge:%s*" path))) - (created-frame nil) - (cleanup-fn nil) - (smerge-change-fn nil) - (frame-deleted-fn nil)) + "Show DIFF for file at PATH using Smerge in a dedicated window. +Uses regular window management instead of creating frames. + +If the current window is a side window, temporarily clear side-window +protections so `delete-other-windows' can succeed. The original window +configuration is restored when the smerge buffer is killed." + (let* ((parsed (eca-chat--parse-unified-diff diff)) + (orig (plist-get parsed :original)) + (new (plist-get parsed :new)) + (buf (generate-new-buffer (format "*eca-smerge:%s*" path))) + (cwc (current-window-configuration)) + (orig-selected (selected-window)) + (frame (selected-frame)) + (cleanup-running nil) + cleanup-fn + window-config-hook) + + ;; Fill buffer with conflict markers (with-current-buffer buf (let ((inhibit-read-only t)) (erase-buffer) - (insert (concat "<<<<<<< Original\n" - orig - "\n=======\n" - new - "\n>>>>>>> New\n")) - ;; Major mode: diff-mode for highlighting + (insert (concat "<<<<<<< Original\n" orig "\n=======\n" new "\n>>>>>>> New\n")) (diff-mode) - ;; Minor mode: smerge for conflict navigation/resolution (smerge-mode 1) - ;; Position at first conflict and refine - (goto-char (point-min)) - (when (smerge-find-conflict) - (smerge-refine)) - ;; Add a local 'q' binding to kill this buffer quickly - (let ((map (make-sparse-keymap))) - (set-keymap-parent map (current-local-map)) - (define-key map (kbd "q") (lambda () (interactive) (kill-buffer (current-buffer)))) - (use-local-map map)) - ;; Define cleanup that deletes the created frame and detaches hooks - (setq cleanup-fn - (lambda () - ;; Remove frame hook FIRST to prevent recursion when deleting frame - (when frame-deleted-fn - (remove-hook 'delete-frame-functions frame-deleted-fn) - (setq frame-deleted-fn nil)) - (when (and created-frame (frame-live-p created-frame)) - (delete-frame created-frame)) - (remove-hook 'kill-buffer-hook cleanup-fn t) - (remove-hook 'smerge-mode-hook smerge-change-fn t) - (when (buffer-live-p buf) - (kill-buffer buf)))) - ;; If user disables smerge-mode, also delete the frame - (setq smerge-change-fn - (lambda () - (unless smerge-mode - (funcall cleanup-fn)))) - (add-hook 'kill-buffer-hook cleanup-fn nil t) - (add-hook 'smerge-mode-hook smerge-change-fn nil t))) - ;; Always open in a new maximized frame and show the buffer there explicitly - (setq created-frame (make-frame '((fullscreen . maximized)))) - ;; If the user manually deletes the frame, also kill the temp buffer - (setq frame-deleted-fn - (lambda (frame) - (when (eq frame created-frame) - ;; Remove hook FIRST to prevent recursion - (remove-hook 'delete-frame-functions frame-deleted-fn) + (goto-char (point-min)))) + + ;; Define cleanup that restores window configuration and kills the buffer + (setq cleanup-fn + (lambda () + (unless cleanup-running + (setq cleanup-running t) + (when (window-configuration-p cwc) + (ignore-errors (set-window-configuration cwc))) + (when (window-live-p orig-selected) + (select-window orig-selected)) + (when (buffer-live-p buf) + (with-current-buffer buf + (remove-hook 'kill-buffer-hook cleanup-fn t))) + (when (functionp window-config-hook) + (remove-hook 'window-configuration-change-hook window-config-hook)) (when (buffer-live-p buf) (kill-buffer buf))))) - (add-hook 'delete-frame-functions frame-deleted-fn) - (with-selected-frame created-frame - (let ((win (selected-window))) - (set-window-parameter win 'no-other-window t) - (set-window-parameter win 'no-delete-other-windows t) - (set-window-buffer win buf) - (set-window-dedicated-p win t) - (select-window win))))) + + ;; If buffer is no longer visible, run cleanup + (setq window-config-hook + (lambda () + (unless (get-buffer-window buf t) + (funcall cleanup-fn)))) + (add-hook 'window-configuration-change-hook window-config-hook) + + ;; Add local keymap and kill hook to the smerge buffer + (with-current-buffer buf + (let ((map (make-sparse-keymap))) + (set-keymap-parent map (current-local-map)) + (define-key map (kbd "q") (lambda () (interactive) (funcall cleanup-fn))) + (use-local-map map)) + (add-hook 'kill-buffer-hook cleanup-fn nil t)) + + ;; Temporarily relax side-window protections in the current frame so + ;; `delete-other-windows' can succeed. The original window + ;; configuration is restored by `cleanup-fn' (which calls `set-window-configuration'). + (dolist (w (seq-filter (lambda (w) + (and (eq (window-frame w) frame) + (or (window-parameter w 'no-delete-other-windows) + (window-parameter w 'window-side)))) + (window-list))) + (when (window-live-p w) + (set-window-parameter w 'no-delete-other-windows nil) + (set-window-parameter w 'window-side nil))) + ;; Present the buffer full-frame + (when (window-live-p (frame-root-window frame)) + (select-window (frame-root-window frame))) + (unless (one-window-p t) + (delete-other-windows)) + (switch-to-buffer buf) + + ;; Return nil explicitly + nil)) (defun eca-chat--show-diff (path diff) "Dispatch diff view based on `eca-chat-diff-tool`." @@ -1481,19 +1487,18 @@ string." ("user" (eca-chat--add-text-content (propertize text - 'font-lock-face 'eca-chat-user-messages-face - 'line-prefix (propertize eca-chat-prompt-prefix - 'font-lock-face 'eca-chat-user-messages-face) - 'line-spacing 10) - 'eca-chat--user-message-id - eca-chat--last-request-id) + 'font-lock-face 'eca-chat-user-messages-face + 'line-prefix (propertize eca-chat-prompt-prefix + 'font-lock-face 'eca-chat-user-messages-face) + 'line-spacing 10) + 'eca-chat--user-message-id eca-chat--last-request-id) (eca-chat--mark-header) (font-lock-ensure)) ("system" (eca-chat--add-text-content (propertize text 'font-lock-face 'eca-chat-system-messages-face - 'line-height 20))) + 'line-height 20))) (_ (eca-chat--add-text-content text))))) @@ -1506,46 +1511,46 @@ string." "\n\n"))) ("reasonStarted" - (let ((id (plist-get content :id)) - (label (propertize "Thinking..." - 'font-lock-face 'eca-chat-reason-label-face))) + (let ((id (plist-get content :id)) + (label (propertize "Thinking..." 'font-lock-face 'eca-chat-reason-label-face))) (eca-chat--add-expandable-content id label ""))) + ("reasonText" - (let ((id (plist-get content :id)) - (label (propertize "Thinking..." - 'font-lock-face 'eca-chat-reason-label-face)) - (text (plist-get content :text))) + (let ((id (plist-get content :id)) + (label (propertize "Thinking..." 'font-lock-face 'eca-chat-reason-label-face)) + (text (plist-get content :text))) (eca-chat--update-expandable-content id label text t))) + ("reasonFinished" - (let* ((id (plist-get content :id)) - (base (propertize "Thought" - 'font-lock-face 'eca-chat-reason-label-face)) - (time (when-let ((ms (plist-get content :totalTimeMs))) - (concat " " (eca-chat--time->presentable-time ms)))) + (let* ((id (plist-get content :id)) + (base (propertize "Thought" 'font-lock-face 'eca-chat-reason-label-face)) + (time (when-let ((ms (plist-get content :totalTimeMs))) + (concat " " (eca-chat--time->presentable-time ms)))) (label (concat base time))) (eca-chat--update-expandable-content id label "" t))) ("toolCallPrepare" - (let* ((id (plist-get content :id)) - (summary (or (plist-get content :summary) - (format "Preparing tool: %s" (plist-get content :name)))) - (argsText (plist-get content :argumentsText)) - (label (concat (propertize summary - 'font-lock-face 'eca-chat-mcp-tool-call-label-face) - " " eca-chat-mcp-tool-call-loading-symbol))) + (let* ((id (plist-get content :id)) + (name (plist-get content :name)) + (argsText (plist-get content :argumentsText)) + (summary (or (plist-get content :summary) + (format "Preparing tool: %s" name))) + (label (concat (propertize summary + 'font-lock-face 'eca-chat-mcp-tool-call-label-face) + " " eca-chat-mcp-tool-call-loading-symbol))) (if (eca-chat--get-expandable-content id) (eca-chat--update-expandable-content id label argsText t) (eca-chat--add-expandable-content id label (eca-chat--content-table - `(("Tool" . ,(plist-get content :name)) + `(("Tool" . ,name) ("Arguments" . ,argsText))))))) ("toolCallRun" - (let* ((id (plist-get content :id)) - (name (plist-get content :name)) - (summary (or (plist-get content :summary) - (format "Calling tool: %s" name))) - (manual? (plist-get content :manualApproval)) + (let* ((id (plist-get content :id)) + (name (plist-get content :name)) + (summary (or (plist-get content :summary) + (format "Calling tool: %s" name))) + (manual? (plist-get content :manualApproval)) (approvalText (when manual? (concat " " (eca-buttonize @@ -1563,7 +1568,7 @@ string." :method "chat/toolCallApprove" :params (list :chatId eca-chat--id :toolCallId id))))))) - (details (plist-get content :details))) + (details (plist-get content :details))) (if (and (stringp (plist-get details :type)) (string= "fileChange" (plist-get details :type))) (let* ((path (plist-get details :path)) @@ -1593,12 +1598,12 @@ string." ("Arguments" . ,(plist-get content :arguments)))))))) ("toolCallRunning" - (let* ((id (plist-get content :id)) - (name (plist-get content :name)) + (let* ((id (plist-get content :id)) + (name (plist-get content :name)) (summary (or (plist-get content :summary) (format "Running tool: %s" name))) (details (plist-get content :details)) - (status eca-chat-mcp-tool-call-loading-symbol)) + (status eca-chat-mcp-tool-call-loading-symbol)) (if (and (stringp (plist-get details :type)) (string= "fileChange" (plist-get details :type))) (let* ((path (plist-get details :path)) @@ -1626,19 +1631,20 @@ string." (eca-chat--content-table `(("Tool" . ,name) ("Arguments" . ,(plist-get content :arguments)))))))) + ("toolCalled" - (let* ((id (plist-get content :id)) - (name (plist-get content :name)) - (summary (or (plist-get content :summary) - (format "Called tool: %s" name))) - (outputs (plist-get content :outputs)) - (output-text (if outputs - (mapconcat (lambda (o) (or (plist-get o :text) "")) outputs "\n") - "")) - (details (plist-get content :details)) - (status (if (plist-get content :error) - eca-chat-mcp-tool-call-error-symbol - eca-chat-mcp-tool-call-success-symbol))) + (let* ((id (plist-get content :id)) + (name (plist-get content :name)) + (summary (or (plist-get content :summary) + (format "Called tool: %s" name))) + (outputs (plist-get content :outputs)) + (output-text (if outputs + (mapconcat (lambda (o) (or (plist-get o :text) "")) outputs "\n") + "")) + (details (plist-get content :details)) + (status (if (plist-get content :error) + eca-chat-mcp-tool-call-error-symbol + eca-chat-mcp-tool-call-success-symbol))) (if (and (stringp (plist-get details :type)) (string= "fileChange" (plist-get details :type))) (let* ((path (plist-get details :path)) From a0d111d2aeec71c0468178e893122f311a57b3dd Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Thu, 11 Sep 2025 22:13:38 -0300 Subject: [PATCH 5/6] Fix docstring --- eca-chat.el | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eca-chat.el b/eca-chat.el index 06f376a..c5ea045 100644 --- a/eca-chat.el +++ b/eca-chat.el @@ -1137,7 +1137,7 @@ configuration is restored when the smerge buffer is killed." nil)) (defun eca-chat--show-diff (path diff) - "Dispatch diff view based on `eca-chat-diff-tool`." + "Dispatch diff view based on `eca-chat-diff-tool` for PATH." (pcase eca-chat-diff-tool ('ediff (eca-chat--show-diff-ediff path diff)) ('smerge (eca-chat--show-diff-smerge path diff)) From af7e85fc3af43b36a070ce6ae3b72f8b1cfdae26 Mon Sep 17 00:00:00 2001 From: Eric Dallo Date: Thu, 11 Sep 2025 22:14:17 -0300 Subject: [PATCH 6/6] Fix docstring --- eca-chat.el | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eca-chat.el b/eca-chat.el index c5ea045..3b7e3f2 100644 --- a/eca-chat.el +++ b/eca-chat.el @@ -1137,7 +1137,7 @@ configuration is restored when the smerge buffer is killed." nil)) (defun eca-chat--show-diff (path diff) - "Dispatch diff view based on `eca-chat-diff-tool` for PATH." + "Dispatch DIFF view based on `eca-chat-diff-tool` for PATH." (pcase eca-chat-diff-tool ('ediff (eca-chat--show-diff-ediff path diff)) ('smerge (eca-chat--show-diff-smerge path diff))