From 9ec7092d754dcf07c0805944ac05358116b70349 Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:04:35 +0000 Subject: [PATCH 01/44] Applying session list/load PR #263 by @travisjeffery Related features #284 #268 #190 #105 --- README.org | 1 + agent-shell-viewport.el | 2 + agent-shell.el | 357 ++++++++++++++++++++++++++++++++++++- tests/agent-shell-tests.el | 256 ++++++++++++++++++++++++++ 4 files changed, 614 insertions(+), 2 deletions(-) diff --git a/README.org b/README.org index ec24f05..c8243ce 100644 --- a/README.org +++ b/README.org @@ -607,6 +607,7 @@ always go to Evil modes if you need to with ~C-z~). | agent-shell-qwen-command | Command and parameters for the Qwen Code client. | | agent-shell-qwen-environment | Environment variables for the Qwen Code client. | | agent-shell-screenshot-command | The program to use for capturing screenshots. | +| agent-shell-session-load-strategy | How to choose existing sessions when session/list and session/load are available. | | agent-shell-section-functions | Abnormal hook run after overlays are applied (experimental). | | agent-shell-show-busy-indicator | Non-nil to show the busy indicator animation in the header and mode line. | | agent-shell-show-config-icons | Whether to show icons in agent config selection. | diff --git a/agent-shell-viewport.el b/agent-shell-viewport.el index 38e6494..6364210 100644 --- a/agent-shell-viewport.el +++ b/agent-shell-viewport.el @@ -770,6 +770,7 @@ For example, offer to kill associated shell session." (define-key map (kbd "C-c C-p") #'agent-shell-viewport-compose-peek-last) (define-key map (kbd "C-c C-k") #'agent-shell-viewport-compose-cancel) (define-key map (kbd "C-") #'agent-shell-viewport-cycle-session-mode) + (define-key map (kbd "C-c C-d") #'agent-shell-delete-session) (define-key map (kbd "C-c C-m") #'agent-shell-viewport-set-session-mode) (define-key map (kbd "C-c C-v") #'agent-shell-viewport-set-session-model) (define-key map (kbd "C-c C-o") #'agent-shell-other-buffer) @@ -801,6 +802,7 @@ For example, offer to kill associated shell session." (define-key map (kbd "r") #'agent-shell-viewport-reply) (define-key map (kbd "q") #'bury-buffer) (define-key map (kbd "C-") #'agent-shell-viewport-cycle-session-mode) + (define-key map (kbd "C-c C-d") #'agent-shell-delete-session) (define-key map (kbd "v") #'agent-shell-viewport-set-session-model) (define-key map (kbd "m") #'agent-shell-viewport-set-session-mode) (define-key map (kbd "o") #'agent-shell-other-buffer) diff --git a/agent-shell.el b/agent-shell.el index 3daa72c..5b48e8b 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -402,6 +402,19 @@ configuration alist for backwards compatibility." :key-type symbol :value-type sexp)) :group 'agent-shell) +(defcustom agent-shell-session-load-strategy 'latest + "How to choose an existing session when `session/list' and `session/load' are available. + +Available values: + + `latest': Load the latest session returned by `session/list'. + `prompt': Prompt to choose which session to load (or start a new one). + `new': Always start a new session and skip `session/list' and `session/load'." + :type '(choice (const :tag "Load latest session" latest) + (const :tag "Prompt for session" prompt) + (const :tag "Always start new session" new)) + :group 'agent-shell) + (defun agent-shell--resolve-preferred-config () "Resolve `agent-shell-preferred-agent-config' to a full configuration. @@ -513,6 +526,9 @@ HEARTBEAT, and AUTHENTICATE-REQUEST-MAKER." (cons :tool-calls nil) (cons :available-commands nil) (cons :available-modes nil) + (cons :supports-session-list nil) + (cons :supports-session-load nil) + (cons :supports-session-delete nil) (cons :prompt-capabilities nil) (cons :pending-requests nil) (cons :usage (list (cons :total-tokens 0) @@ -802,6 +818,7 @@ When FORCE is non-nil, skip confirmation prompt." "p" #'agent-shell-previous-item "C-" #'agent-shell-cycle-session-mode "C-c C-c" #'agent-shell-interrupt + "C-c C-d" #'agent-shell-delete-session "C-c C-m" #'agent-shell-set-session-mode "C-c C-v" #'agent-shell-set-session-model "C-c C-o" #'agent-shell-other-buffer) @@ -1856,7 +1873,7 @@ Returns propertized labels in :status and :title propertized." (agent-shell--status-label (map-elt entry 'status))) (lambda (entry) (map-elt entry 'content))) - :separator " " + :separator " " :joiner "\n")) (cl-defun agent-shell--make-button (&key text help kind action keymap) @@ -2741,6 +2758,16 @@ Must provide ON-INITIATED (lambda ())." :write-text-file-capability agent-shell-text-file-capabilities) :on-success (lambda (response) (with-current-buffer shell-buffer + (let ((session-capabilities (or (map-elt response 'sessionCapabilities) + (map-nested-elt response '(agentCapabilities sessionCapabilities))))) + (map-put! agent-shell--state :supports-session-list + (and (listp session-capabilities) + (assq 'list session-capabilities) + t)) + (map-put! agent-shell--state :supports-session-delete + (and (listp session-capabilities) + (assq 'delete session-capabilities) + t))) ;; Save prompt capabilities from agent, converting to internal symbols (when-let ((prompt-capabilities (map-nested-elt response '(agentCapabilities promptCapabilities)))) @@ -2757,6 +2784,8 @@ Must provide ON-INITIATED (lambda ())." (:description . ,(map-elt mode 'description)))) (map-elt modes 'availableModes)))))) (when-let ((agent-capabilities (map-elt response 'agentCapabilities))) + (map-put! agent-shell--state :supports-session-load + (eq (map-elt agent-capabilities 'loadSession) t)) (agent-shell--update-fragment :state agent-shell--state :namespace-id "bootstrapping" @@ -2865,6 +2894,270 @@ Must provide ON-SESSION-INIT (lambda ())." :block-id "starting" :body "\n\nCreating session..." :append t)) + (if (and (map-elt (agent-shell--state) :supports-session-list) + (map-elt (agent-shell--state) :supports-session-load) + (not (eq agent-shell-session-load-strategy 'new))) + (agent-shell--initiate-session-list-and-load + :shell shell + :on-session-init on-session-init) + (agent-shell--initiate-new-session + :shell shell + :on-session-init on-session-init))) + +(defun agent-shell--session-choice-label (session) + "Return completion label for SESSION." + (let* ((session-id (or (map-elt session 'sessionId) + "unknown-session")) + (title (or (map-elt session 'title) + "Untitled")) + (updated-at (or (map-elt session 'updatedAt) + (map-elt session 'createdAt) + "unknown-time"))) + (format "%s | %s | %s" title updated-at session-id))) + +(defconst agent-shell--start-new-session-choice "Start a new session" + "Label for creating a new session from the session picker.") + +(defun agent-shell--session-picker-sort (candidates) + "Return CANDIDATES with `agent-shell--start-new-session-choice' first." + (if (member agent-shell--start-new-session-choice candidates) + (cons agent-shell--start-new-session-choice + (delete agent-shell--start-new-session-choice + (copy-sequence candidates))) + candidates)) + +(defun agent-shell--prompt-select-session-to-load (sessions) + "Prompt to choose one from SESSIONS. + +Return selected session alist, or nil to start a new session." + (when sessions + (let* ((session-choices (mapcar (lambda (session) + (cons (agent-shell--session-choice-label session) + session)) + sessions)) + (choices (cons (cons agent-shell--start-new-session-choice nil) + session-choices)) + (completion-extra-properties + '(:display-sort-function agent-shell--session-picker-sort + :cycle-sort-function agent-shell--session-picker-sort)) + (selection (completing-read "Load session: " + (mapcar #'car choices) + nil t nil nil + agent-shell--start-new-session-choice))) + (cdr (assoc selection choices))))) + +(defun agent-shell--select-session-to-load (sessions) + "Select a session from SESSIONS based on `agent-shell-session-load-strategy'." + (pcase agent-shell-session-load-strategy + ('new nil) + ('latest (car sessions)) + ('prompt (if noninteractive + (car sessions) + (agent-shell--prompt-select-session-to-load sessions))) + (_ (car sessions)))) + +(defun agent-shell--prompt-select-session-to-delete (sessions) + "Prompt to choose one from SESSIONS for deletion. + +Return selected session alist, or nil if user quit." + (when sessions + (let* ((choices (mapcar (lambda (session) + (cons (agent-shell--session-choice-label session) + session)) + sessions)) + (selection (completing-read "Delete session: " + (mapcar #'car choices) + nil t))) + (cdr (assoc selection choices))))) + +(defun agent-shell--select-session-to-delete (sessions) + "Select a session from SESSIONS for deletion." + (if noninteractive + (car sessions) + (agent-shell--prompt-select-session-to-delete sessions))) + +(defun agent-shell--clear-session-state () + "Reset current session-scoped state for the active shell." + (let* ((state (agent-shell--state)) + (session (or (map-elt state :session) + (list (cons :id nil) + (cons :mode-id nil) + (cons :modes nil))))) + (map-put! session :id nil) + (map-put! session :mode-id nil) + (map-put! session :modes nil) + ;; Clear optional fields if they were previously populated. + (map-put! session :model-id nil) + (map-put! session :models nil) + (map-put! state :session session) + (map-put! state :set-session-mode nil) + (map-put! state :set-model nil) + (map-put! state :tool-calls nil) + (map-put! state :available-commands nil) + (agent-shell--update-header-and-mode-line))) + +(cl-defun agent-shell--delete-session-by-id (&key shell session-id on-success) + "Delete SESSION-ID via ACP using SHELL. + +ON-SUCCESS is called with no args after successful delete." + (unless session-id + (error "Missing required argument: :session-id")) + (with-current-buffer (map-elt (agent-shell--state) :buffer) + (agent-shell--update-fragment + :state (agent-shell--state) + :block-id "session_delete" + :label-left (propertize "Deleting session" 'font-lock-face 'font-lock-doc-markup-face) + :body (format "Requesting deletion for %s..." (substring-no-properties session-id)) + :append t)) + (acp-send-request + :client (map-elt (agent-shell--state) :client) + :request `((:method . "session/delete") + (:params . ((sessionId . ,session-id)))) + :buffer (current-buffer) + :on-success (lambda (_response) + (with-current-buffer (map-elt (agent-shell--state) :buffer) + (agent-shell--update-fragment + :state (agent-shell--state) + :block-id "session_delete" + :body "\n\nDone" + :append t)) + (when on-success + (funcall on-success))) + :on-failure (agent-shell--make-error-handler + :state (agent-shell--state) :shell shell))) + +(defun agent-shell-delete-session (&optional force-current) + "Delete an existing agent session from the agent's session history. + +This requires the agent to support the experimental ACP method +\"session/delete\". + +With prefix argument FORCE-CURRENT, delete the current session without +prompting for a session to pick (still asks for confirmation)." + (interactive "P") + (unless (or (derived-mode-p 'agent-shell-mode) + (derived-mode-p 'agent-shell-viewport-view-mode) + (derived-mode-p 'agent-shell-viewport-edit-mode)) + (user-error "Not in an agent-shell buffer")) + (let* ((shell-buffer (if (derived-mode-p 'agent-shell-mode) + (current-buffer) + (or (agent-shell-viewport--shell-buffer) + (user-error "No shell buffer available"))))) + (with-current-buffer shell-buffer + (unless (map-elt (agent-shell--state) :client) + (user-error "Agent not initialized")) + (unless (map-elt (agent-shell--state) :supports-session-delete) + (user-error "Agent does not support session/delete")) + (let* ((shell `((:buffer . ,(current-buffer)))) + (current-session-id (map-nested-elt (agent-shell--state) '(:session :id)))) + (cond + ((and force-current current-session-id) + (when (y-or-n-p (format "Delete current session %s? " + (substring-no-properties current-session-id))) + (agent-shell--delete-session-by-id + :shell shell + :session-id current-session-id + :on-success (lambda () + (agent-shell--clear-session-state) + (message "Deleted session %s" + (substring-no-properties current-session-id)))))) + ((map-elt (agent-shell--state) :supports-session-list) + (with-current-buffer (map-elt (agent-shell--state) :buffer) + (agent-shell--update-fragment + :state (agent-shell--state) + :block-id "session_delete" + :label-left (propertize "Deleting session" 'font-lock-face 'font-lock-doc-markup-face) + :body "\n\nLooking for existing sessions..." + :append t)) + (acp-send-request + :client (map-elt (agent-shell--state) :client) + :request `((:method . "session/list") + (:params . ((cwd . ,(agent-shell--resolve-path (agent-shell-cwd))))))) + :buffer (current-buffer) + :on-success (lambda (response) + (let* ((sessions (append (or (map-elt response 'sessions) '()) nil)) + (selected-session (agent-shell--select-session-to-delete sessions)) + (session-id (and selected-session + (map-elt selected-session 'sessionId)))) + (cond + ((not session-id) + (message "No session selected")) + ((not (y-or-n-p (format "Delete session %s? " + (substring-no-properties session-id)))) + (message "Cancelled")) + (t + (agent-shell--delete-session-by-id + :shell shell + :session-id session-id + :on-success (lambda () + (when (and current-session-id + (equal (substring-no-properties session-id) + (substring-no-properties current-session-id))) + (agent-shell--clear-session-state)) + (message "Deleted session %s" + (substring-no-properties session-id)))))))) + :on-failure (agent-shell--make-error-handler + :state (agent-shell--state) :shell shell))) + (current-session-id + (when (y-or-n-p (format "Delete current session %s? " + (substring-no-properties current-session-id))) + (agent-shell--delete-session-by-id + :shell shell + :session-id current-session-id + :on-success (lambda () + (agent-shell--clear-session-state) + (message "Deleted session %s" + (substring-no-properties current-session-id)))))) + (t + (user-error "No session to delete")))))) + +(cl-defun agent-shell--set-session-from-response (&key response session-id) + "Set active session state from RESPONSE and SESSION-ID." + (map-put! agent-shell--state + :session (list (cons :id session-id) + (cons :mode-id (map-nested-elt response '(modes currentModeId))) + (cons :modes (mapcar (lambda (mode) + `((:id . ,(map-elt mode 'id)) + (:name . ,(map-elt mode 'name)) + (:description . ,(map-elt mode 'description)))) + (map-nested-elt response '(modes availableModes)))) + (cons :model-id (map-nested-elt response '(models currentModelId))) + (cons :models (mapcar (lambda (model) + `((:model-id . ,(map-elt model 'modelId)) + (:name . ,(map-elt model 'name)) + (:description . ,(map-elt model 'description)))) + (map-nested-elt response '(models availableModels))))))) + +(cl-defun agent-shell--finalize-session-init (&key on-session-init) + "Finalize session initialization and invoke ON-SESSION-INIT." + (agent-shell--update-fragment + :state agent-shell--state + :block-id "starting" + :label-left (format "%s %s" + (agent-shell--status-label "completed") + (propertize "Starting agent" 'font-lock-face 'font-lock-doc-markup-face)) + :body "\n\nReady" + :append t) + (agent-shell--update-header-and-mode-line) + (when (map-nested-elt agent-shell--state '(:session :models)) + (agent-shell--update-fragment + :state agent-shell--state + :block-id "available_models" + :label-left (propertize "Available models" 'font-lock-face 'font-lock-doc-markup-face) + :body (agent-shell--format-available-models + (map-nested-elt agent-shell--state '(:session :models))))) + (when (agent-shell--get-available-modes agent-shell--state) + (agent-shell--update-fragment + :state agent-shell--state + :block-id "available_modes" + :label-left (propertize "Available modes" 'font-lock-face 'font-lock-doc-markup-face) + :body (agent-shell--format-available-modes + (agent-shell--get-available-modes agent-shell--state)))) + (agent-shell--update-header-and-mode-line) + (funcall on-session-init)) + +(cl-defun agent-shell--initiate-new-session (&key shell on-session-init) + "Initiate ACP session/new with SHELL and ON-SESSION-INIT." (acp-send-request :client (map-elt (agent-shell--state) :client) :request (acp-make-session-new-request @@ -2917,6 +3210,64 @@ Must provide ON-SESSION-INIT (lambda ())." :on-failure (agent-shell--make-error-handler :state agent-shell--state :shell-buffer shell-buffer))) +(cl-defun agent-shell--initiate-session-list-and-load (&key shell on-session-init) + "Try loading latest existing session with SHELL and ON-SESSION-INIT." + (with-current-buffer (map-elt (agent-shell--state) :buffer) + (agent-shell--update-fragment + :state (agent-shell--state) + :block-id "starting" + :body "\n\nLooking for existing sessions..." + :append t)) + (acp-send-request + :client (map-elt (agent-shell--state) :client) + :request `((:method . "session/list") + (:params . ((cwd . ,(agent-shell--resolve-path (agent-shell-cwd)))))) + :buffer (current-buffer) + :on-success (lambda (response) + (let* ((sessions (append (or (map-elt response 'sessions) '()) nil)) + (selected-session + (condition-case nil + (agent-shell--select-session-to-load sessions) + (quit nil))) + (session-id (and selected-session + (map-elt selected-session 'sessionId)))) + (if session-id + (progn + (agent-shell--update-fragment + :state (agent-shell--state) + :block-id "starting" + :body (format "\n\nLoading session %s..." + (substring-no-properties session-id)) + :append t) + (acp-send-request + :client (map-elt (agent-shell--state) :client) + :request `((:method . "session/load") + (:params . ((sessionId . ,session-id) + (cwd . ,(agent-shell--resolve-path (agent-shell-cwd))) + (mcpServers . ,(or (agent-shell--mcp-servers) []))))) + :buffer (current-buffer) + :on-success (lambda (load-response) + (agent-shell--set-session-from-response + :response load-response + :session-id session-id) + (agent-shell--finalize-session-init :on-session-init on-session-init)) + :on-failure (lambda (_error _raw-message) + (agent-shell--update-fragment + :state (agent-shell--state) + :block-id "starting" + :body "\n\nCould not load existing session. Creating a new one..." + :append t) + (agent-shell--initiate-new-session + :shell shell + :on-session-init on-session-init)))) + (agent-shell--initiate-new-session + :shell shell + :on-session-init on-session-init)))) + :on-failure (lambda (_error _raw-message) + (agent-shell--initiate-new-session + :shell shell + :on-session-init on-session-init)))) + (defun agent-shell--eval-dynamic-values (obj) "Recursively evaluate any lambda values in OBJ. Named functions (symbols) are not evaluated to avoid accidentally @@ -4009,7 +4360,9 @@ Returns an alist with insertion details or nil otherwise: ((:buffer . BUFFER) (:start . START) - (:end . END))" + (:end . END)) + +Uses optional SHELL-BUFFER to make paths relative to shell project." (if agent-shell-prefer-viewport-interaction (agent-shell-viewport--show-buffer :append text :submit submit :no-focus no-focus :shell-buffer shell-buffer) diff --git a/tests/agent-shell-tests.el b/tests/agent-shell-tests.el index 246a02e..b531f75 100644 --- a/tests/agent-shell-tests.el +++ b/tests/agent-shell-tests.el @@ -859,5 +859,261 @@ code block content with spaces (should (equal (buffer-string) "test ")) (should (equal (point) 6)))) +(ert-deftest agent-shell--initiate-session-prefers-list-and-load-when-supported () + "Test `agent-shell--initiate-session' prefers session/list + session/load." + (with-temp-buffer + (let* ((agent-shell-session-load-strategy 'latest) + (requests '()) + (session-init-called nil) + (state `((:buffer . ,(current-buffer)) + (:client . test-client) + (:session . ((:id . nil) + (:mode-id . nil) + (:modes . nil))) + (:supports-session-list . t) + (:supports-session-load . t)))) + (setq-local agent-shell--state state) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state)) + ((symbol-function 'agent-shell--update-fragment) + (lambda (&rest _args) nil)) + ((symbol-function 'agent-shell--update-header-and-mode-line) + (lambda () nil)) + ((symbol-function 'agent-shell-cwd) + (lambda () "/tmp")) + ((symbol-function 'agent-shell--resolve-path) + (lambda (path) path)) + ((symbol-function 'agent-shell--mcp-servers) + (lambda () [])) + ((symbol-function 'acp-send-request) + (lambda (&rest args) + (push args requests) + (let* ((request (plist-get args :request)) + (method (map-elt request :method))) + (pcase method + ("session/list" + (funcall (plist-get args :on-success) + '((sessions . [((sessionId . "session-123") + (cwd . "/tmp") + (title . "Recent session"))])))) + ("session/load" + (funcall (plist-get args :on-success) + '((modes (currentModeId . "default") + (availableModes . [((id . "default") + (name . "Default") + (description . "Default mode"))])) + (models (currentModelId . "gpt-5") + (availableModels . [((modelId . "gpt-5") + (name . "GPT-5") + (description . "Test model"))]))))) + (_ (error "Unexpected method: %s" method))))))) + (agent-shell--initiate-session + :shell `((:buffer . ,(current-buffer))) + :on-session-init (lambda () + (setq session-init-called t))) + (let ((ordered-requests (nreverse requests))) + (should (equal (mapcar (lambda (req) + (map-elt (plist-get req :request) :method)) + ordered-requests) + '("session/list" "session/load"))) + (let* ((load-request (plist-get (nth 1 ordered-requests) :request)) + (load-params (map-elt load-request :params))) + (should (equal (map-elt load-params 'sessionId) "session-123")) + (should (equal (map-elt load-params 'cwd) "/tmp")))) + (should session-init-called) + (should (equal (map-nested-elt agent-shell--state '(:session :id)) "session-123")))))) + +(ert-deftest agent-shell--initiate-session-falls-back-to-new-on-list-failure () + "Test `agent-shell--initiate-session' falls back to session/new on list failure." + (with-temp-buffer + (let* ((agent-shell-session-load-strategy 'latest) + (requests '()) + (session-init-called nil) + (state `((:buffer . ,(current-buffer)) + (:client . test-client) + (:session . ((:id . nil) + (:mode-id . nil) + (:modes . nil))) + (:supports-session-list . t) + (:supports-session-load . t)))) + (setq-local agent-shell--state state) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state)) + ((symbol-function 'agent-shell--update-fragment) + (lambda (&rest _args) nil)) + ((symbol-function 'agent-shell--update-header-and-mode-line) + (lambda () nil)) + ((symbol-function 'agent-shell-cwd) + (lambda () "/tmp")) + ((symbol-function 'agent-shell--resolve-path) + (lambda (path) path)) + ((symbol-function 'agent-shell--mcp-servers) + (lambda () [])) + ((symbol-function 'acp-send-request) + (lambda (&rest args) + (push args requests) + (let* ((request (plist-get args :request)) + (method (map-elt request :method))) + (pcase method + ("session/list" + (funcall (plist-get args :on-failure) + '((code . -32601) + (message . "Method not found")) + nil)) + ("session/new" + (funcall (plist-get args :on-success) + '((sessionId . "new-session-456")))) + (_ (error "Unexpected method: %s" method))))))) + (agent-shell--initiate-session + :shell `((:buffer . ,(current-buffer))) + :on-session-init (lambda () + (setq session-init-called t))) + (let ((ordered-requests (nreverse requests))) + (should (equal (mapcar (lambda (req) + (map-elt (plist-get req :request) :method)) + ordered-requests) + '("session/list" "session/new")))) + (should session-init-called) + (should (equal (map-nested-elt agent-shell--state '(:session :id)) "new-session-456")))))) + +(ert-deftest agent-shell--prompt-select-session-to-load-test () + "Test `agent-shell--prompt-select-session-to-load' choices." + (let* ((session-a '((sessionId . "session-1") + (title . "First") + (updatedAt . "2026-01-19T14:00:00Z"))) + (session-b '((sessionId . "session-2") + (title . "Second") + (updatedAt . "2026-01-20T16:00:00Z"))) + (sessions (list session-a session-b))) + ;; Select existing session + (cl-letf (((symbol-function 'completing-read) + (lambda (&rest _args) + (agent-shell--session-choice-label session-b)))) + (should (equal (agent-shell--prompt-select-session-to-load sessions) + session-b))) + ;; Select "new session" option + (cl-letf (((symbol-function 'completing-read) + (lambda (&rest _args) + agent-shell--start-new-session-choice))) + (should-not (agent-shell--prompt-select-session-to-load sessions))))) + +(ert-deftest agent-shell--session-picker-sort-test () + "Test `agent-shell--session-picker-sort' keeps the new-session option first." + (let* ((session-a-label "First | 2026-01-19T14:00:00Z | session-1") + (session-b-label "Second | 2026-01-20T16:00:00Z | session-2") + (candidates (list session-a-label + agent-shell--start-new-session-choice + session-b-label))) + (should (equal (agent-shell--session-picker-sort candidates) + (list agent-shell--start-new-session-choice + session-a-label + session-b-label))))) + +(ert-deftest agent-shell--prompt-select-session-to-load-defaults-to-new-session-test () + "Test prompt defaults to `agent-shell--start-new-session-choice'." + (let* ((session-a '((sessionId . "session-1") + (title . "First") + (updatedAt . "2026-01-19T14:00:00Z"))) + (session-b '((sessionId . "session-2") + (title . "Second") + (updatedAt . "2026-01-20T16:00:00Z"))) + (sessions (list session-a session-b)) + (captured-default nil)) + (cl-letf (((symbol-function 'completing-read) + (lambda (&rest args) + (setq captured-default (nth 6 args)) + agent-shell--start-new-session-choice))) + (agent-shell--prompt-select-session-to-load sessions) + (should (equal captured-default agent-shell--start-new-session-choice))))) + +(ert-deftest agent-shell--initiate-session-strategy-new-skips-list-load () + "Test `agent-shell--initiate-session' skips list/load when strategy is `new'." + (with-temp-buffer + (let* ((agent-shell-session-load-strategy 'new) + (requests '()) + (session-init-called nil) + (state `((:buffer . ,(current-buffer)) + (:client . test-client) + (:session . ((:id . nil) + (:mode-id . nil) + (:modes . nil))) + (:supports-session-list . t) + (:supports-session-load . t)))) + (setq-local agent-shell--state state) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state)) + ((symbol-function 'agent-shell--update-fragment) + (lambda (&rest _args) nil)) + ((symbol-function 'agent-shell--update-header-and-mode-line) + (lambda () nil)) + ((symbol-function 'agent-shell-cwd) + (lambda () "/tmp")) + ((symbol-function 'agent-shell--resolve-path) + (lambda (path) path)) + ((symbol-function 'agent-shell--mcp-servers) + (lambda () [])) + ((symbol-function 'acp-send-request) + (lambda (&rest args) + (push args requests) + (let* ((request (plist-get args :request)) + (method (map-elt request :method))) + (pcase method + ("session/new" + (funcall (plist-get args :on-success) + '((sessionId . "new-session-789")))) + (_ (error "Unexpected method: %s" method))))))) + (agent-shell--initiate-session + :shell `((:buffer . ,(current-buffer))) + :on-session-init (lambda () + (setq session-init-called t))) + (let ((ordered-requests (nreverse requests))) + (should (equal (mapcar (lambda (req) + (map-elt (plist-get req :request) :method)) + ordered-requests) + '("session/new")))) + (should session-init-called) + (should (equal (map-nested-elt agent-shell--state '(:session :id)) "new-session-789")))))) + +(ert-deftest agent-shell--delete-session-by-id-sends-session-delete () + "Test `agent-shell--delete-session-by-id' sends session/delete request." + (with-temp-buffer + (let* ((requests '()) + (success-called nil) + (state `((:buffer . ,(current-buffer)) + (:client . test-client) + (:session . ((:id . "session-2") + (:mode-id . nil) + (:modes . nil))) + (:supports-session-list . t) + (:supports-session-delete . t)))) + (setq-local agent-shell--state state) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state)) + ((symbol-function 'agent-shell--update-fragment) + (lambda (&rest _args) nil)) + ((symbol-function 'agent-shell--update-header-and-mode-line) + (lambda () nil)) + ((symbol-function 'acp-send-request) + (lambda (&rest args) + (push args requests) + (let* ((request (plist-get args :request)) + (method (map-elt request :method))) + (pcase method + ("session/delete" + (let ((params (map-elt request :params))) + (should (equal (map-elt params 'sessionId) "session-2"))) + (funcall (plist-get args :on-success) '((ok . t)))) + (_ (error "Unexpected method: %s" method))))))) + (agent-shell--delete-session-by-id + :shell `((:buffer . ,(current-buffer))) + :session-id "session-2" + :on-success (lambda () (setq success-called t))) + (should success-called) + (let ((ordered-requests (nreverse requests))) + (should (equal (mapcar (lambda (req) + (map-elt (plist-get req :request) :method)) + ordered-requests) + '("session/delete")))))))) + (provide 'agent-shell-tests) ;;; agent-shell-tests.el ends here From aa946f3d3fac982e6f3c65e8bfb7991964aaa80d Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:59:06 +0000 Subject: [PATCH 02/44] Rebase on to main to enable bootstrapping and session resume support Related features #284 #268 #190 #105 --- agent-shell.el | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index 5b48e8b..9a55dda 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -528,6 +528,7 @@ HEARTBEAT, and AUTHENTICATE-REQUEST-MAKER." (cons :available-modes nil) (cons :supports-session-list nil) (cons :supports-session-load nil) + (cons :supports-session-resume nil) (cons :supports-session-delete nil) (cons :prompt-capabilities nil) (cons :pending-requests nil) @@ -2767,6 +2768,10 @@ Must provide ON-INITIATED (lambda ())." (map-put! agent-shell--state :supports-session-delete (and (listp session-capabilities) (assq 'delete session-capabilities) + t)) + (map-put! agent-shell--state :supports-session-resume + (and (listp session-capabilities) + (assq 'resume session-capabilities) t))) ;; Save prompt capabilities from agent, converting to internal symbols (when-let ((prompt-capabilities @@ -2895,13 +2900,14 @@ Must provide ON-SESSION-INIT (lambda ())." :body "\n\nCreating session..." :append t)) (if (and (map-elt (agent-shell--state) :supports-session-list) - (map-elt (agent-shell--state) :supports-session-load) + (or (map-elt (agent-shell--state) :supports-session-load) + (map-elt (agent-shell--state) :supports-session-resume)) (not (eq agent-shell-session-load-strategy 'new))) (agent-shell--initiate-session-list-and-load - :shell shell + :shell-buffer shell-buffer :on-session-init on-session-init) (agent-shell--initiate-new-session - :shell shell + :shell-buffer shell-buffer :on-session-init on-session-init))) (defun agent-shell--session-choice-label (session) @@ -3137,11 +3143,13 @@ prompting for a session to pick (still asks for confirmation)." (agent-shell--status-label "completed") (propertize "Starting agent" 'font-lock-face 'font-lock-doc-markup-face)) :body "\n\nReady" + :namespace-id "bootstrapping" :append t) (agent-shell--update-header-and-mode-line) (when (map-nested-elt agent-shell--state '(:session :models)) (agent-shell--update-fragment :state agent-shell--state + :namespace-id "bootstrapping" :block-id "available_models" :label-left (propertize "Available models" 'font-lock-face 'font-lock-doc-markup-face) :body (agent-shell--format-available-models @@ -3149,6 +3157,7 @@ prompting for a session to pick (still asks for confirmation)." (when (agent-shell--get-available-modes agent-shell--state) (agent-shell--update-fragment :state agent-shell--state + :namespace-id "bootstrapping" :block-id "available_modes" :label-left (propertize "Available modes" 'font-lock-face 'font-lock-doc-markup-face) :body (agent-shell--format-available-modes @@ -3156,8 +3165,8 @@ prompting for a session to pick (still asks for confirmation)." (agent-shell--update-header-and-mode-line) (funcall on-session-init)) -(cl-defun agent-shell--initiate-new-session (&key shell on-session-init) - "Initiate ACP session/new with SHELL and ON-SESSION-INIT." +(cl-defun agent-shell--initiate-new-session (&key shell-buffer on-session-init) + "Initiate ACP session/new with SHELL-BUFFER and ON-SESSION-INIT." (acp-send-request :client (map-elt (agent-shell--state) :client) :request (acp-make-session-new-request @@ -3210,8 +3219,8 @@ prompting for a session to pick (still asks for confirmation)." :on-failure (agent-shell--make-error-handler :state agent-shell--state :shell-buffer shell-buffer))) -(cl-defun agent-shell--initiate-session-list-and-load (&key shell on-session-init) - "Try loading latest existing session with SHELL and ON-SESSION-INIT." +(cl-defun agent-shell--initiate-session-list-and-load (&key shell-buffer on-session-init) + "Try loading latest existing session with SHELL-BUFFER and ON-SESSION-INIT." (with-current-buffer (map-elt (agent-shell--state) :buffer) (agent-shell--update-fragment :state (agent-shell--state) @@ -3221,7 +3230,8 @@ prompting for a session to pick (still asks for confirmation)." (acp-send-request :client (map-elt (agent-shell--state) :client) :request `((:method . "session/list") - (:params . ((cwd . ,(agent-shell--resolve-path (agent-shell-cwd)))))) + ;; Must remove trailing / to make sure Claude recognizes previous CWDs. + (:params . ((cwd . ,(agent-shell--resolve-path (string-remove-suffix "/" (agent-shell-cwd))))))) :buffer (current-buffer) :on-success (lambda (response) (let* ((sessions (append (or (map-elt response 'sessions) '()) nil)) @@ -3241,9 +3251,11 @@ prompting for a session to pick (still asks for confirmation)." :append t) (acp-send-request :client (map-elt (agent-shell--state) :client) - :request `((:method . "session/load") + :request `((:method . ,(if (map-elt (agent-shell--state) :supports-session-load) + "session/load" + "session/resume")) (:params . ((sessionId . ,session-id) - (cwd . ,(agent-shell--resolve-path (agent-shell-cwd))) + (cwd . ,(agent-shell--resolve-path (string-remove-suffix "/" (agent-shell-cwd)))) (mcpServers . ,(or (agent-shell--mcp-servers) []))))) :buffer (current-buffer) :on-success (lambda (load-response) @@ -3258,14 +3270,14 @@ prompting for a session to pick (still asks for confirmation)." :body "\n\nCould not load existing session. Creating a new one..." :append t) (agent-shell--initiate-new-session - :shell shell + :shell-buffer shell-buffer :on-session-init on-session-init)))) (agent-shell--initiate-new-session - :shell shell + :shell-buffer shell-buffer :on-session-init on-session-init)))) :on-failure (lambda (_error _raw-message) (agent-shell--initiate-new-session - :shell shell + :shell-buffer shell-buffer :on-session-init on-session-init)))) (defun agent-shell--eval-dynamic-values (obj) From f66ff7e66228661baffcab10492681feb763c33f Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:58:43 +0000 Subject: [PATCH 03/44] Align session columns in session picker (completing-read) For example: Let's build something Today, 16:25 Let's refactor my hobby project Yesterday, 20:18 Let's optimize the rocket engine Feb 12, 21:02 Related to: #190 #105 --- agent-shell.el | 41 +++++++++++++++++++++++++++++++++----- tests/agent-shell-tests.el | 26 ++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index 9a55dda..a9a9c3b 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -2910,16 +2910,47 @@ Must provide ON-SESSION-INIT (lambda ())." :shell-buffer shell-buffer :on-session-init on-session-init))) +(defun agent-shell--format-session-date (iso-timestamp) + "Format ISO-TIMESTAMP as a human-friendly date string. + +Returns \"Today, HH:MM\", \"Yesterday, HH:MM\", \"Mon DD, HH:MM\" +for the current year, or \"Mon DD, YYYY\" for other years." + (condition-case nil + (let* ((time (date-to-time iso-timestamp)) + (now (current-time)) + (decoded-now (decode-time now)) + (today-start (encode-time 0 0 0 + (decoded-time-day decoded-now) + (decoded-time-month decoded-now) + (decoded-time-year decoded-now))) + (yesterday-start (time-subtract today-start (seconds-to-time (* 24 60 60)))) + (current-year (decoded-time-year (decode-time now))) + (timestamp-year (decoded-time-year (decode-time time)))) + (cond + ((not (time-less-p time today-start)) + (format-time-string "Today, %H:%M" time)) + ((not (time-less-p time yesterday-start)) + (format-time-string "Yesterday, %H:%M" time)) + ((= timestamp-year current-year) + (format-time-string "%b %d, %H:%M" time)) + (t + (format-time-string "%b %d, %Y" time)))) + (error iso-timestamp))) + (defun agent-shell--session-choice-label (session) "Return completion label for SESSION." - (let* ((session-id (or (map-elt session 'sessionId) - "unknown-session")) - (title (or (map-elt session 'title) + (let* ((title (or (map-elt session 'title) "Untitled")) + (title (if (> (length title) 50) + (concat (substring title 0 47) "...") + title)) (updated-at (or (map-elt session 'updatedAt) (map-elt session 'createdAt) - "unknown-time"))) - (format "%s | %s | %s" title updated-at session-id))) + "unknown-time")) + (date-str (propertize (agent-shell--format-session-date updated-at) + 'face 'font-lock-comment-face)) + (padding (make-string (max 2 (- 52 (length title))) ?\s))) + (concat title padding date-str))) (defconst agent-shell--start-new-session-choice "Start a new session" "Label for creating a new session from the session picker.") diff --git a/tests/agent-shell-tests.el b/tests/agent-shell-tests.el index b531f75..7b4e489 100644 --- a/tests/agent-shell-tests.el +++ b/tests/agent-shell-tests.el @@ -976,6 +976,28 @@ code block content with spaces (should session-init-called) (should (equal (map-nested-elt agent-shell--state '(:session :id)) "new-session-456")))))) +(ert-deftest agent-shell--format-session-date-test () + "Test `agent-shell--format-session-date' humanizes timestamps." + ;; Today + (let* ((now (current-time)) + (today-iso (format-time-string "%Y-%m-%dT10:30:00Z" now))) + (should (equal (agent-shell--format-session-date today-iso) + "Today, 10:30"))) + ;; Yesterday + (let* ((yesterday (time-subtract (current-time) (* 24 60 60))) + (yesterday-iso (format-time-string "%Y-%m-%dT15:45:00Z" yesterday))) + (should (equal (agent-shell--format-session-date yesterday-iso) + "Yesterday, 15:45"))) + ;; Same year, older + (should (string-match-p "^[A-Z][a-z]+ [0-9]+, [0-9]+:[0-9]+" + (agent-shell--format-session-date "2026-01-05T09:00:00Z"))) + ;; Different year + (should (string-match-p "^[A-Z][a-z]+ [0-9]+, [0-9]\\{4\\}" + (agent-shell--format-session-date "2025-06-15T12:00:00Z"))) + ;; Invalid input falls back gracefully + (should (equal (agent-shell--format-session-date "not-a-date") + "not-a-date"))) + (ert-deftest agent-shell--prompt-select-session-to-load-test () "Test `agent-shell--prompt-select-session-to-load' choices." (let* ((session-a '((sessionId . "session-1") @@ -999,8 +1021,8 @@ code block content with spaces (ert-deftest agent-shell--session-picker-sort-test () "Test `agent-shell--session-picker-sort' keeps the new-session option first." - (let* ((session-a-label "First | 2026-01-19T14:00:00Z | session-1") - (session-b-label "Second | 2026-01-20T16:00:00Z | session-2") + (let* ((session-a-label "First Jan 19, 14:00") + (session-b-label "Second Jan 20, 16:00") (candidates (list session-a-label agent-shell--start-new-session-choice session-b-label))) From 0b7cea3b1e1b3349f929166cd262c28d84b39594 Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:22:57 +0000 Subject: [PATCH 04/44] Migrate #263 away from :shell params Related to: #190 #105 --- agent-shell.el | 17 ++++++++--------- tests/agent-shell-tests.el | 32 ++++++++++++++++---------------- 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index a9a9c3b..1194a99 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -3033,8 +3033,8 @@ Return selected session alist, or nil if user quit." (map-put! state :available-commands nil) (agent-shell--update-header-and-mode-line))) -(cl-defun agent-shell--delete-session-by-id (&key shell session-id on-success) - "Delete SESSION-ID via ACP using SHELL. +(cl-defun agent-shell--delete-session-by-id (&key shell-buffer session-id on-success) + "Delete SESSION-ID via ACP using SHELL-BUFFER. ON-SUCCESS is called with no args after successful delete." (unless session-id @@ -3061,7 +3061,7 @@ ON-SUCCESS is called with no args after successful delete." (when on-success (funcall on-success))) :on-failure (agent-shell--make-error-handler - :state (agent-shell--state) :shell shell))) + :state (agent-shell--state) :shell-buffer shell-buffer))) (defun agent-shell-delete-session (&optional force-current) "Delete an existing agent session from the agent's session history. @@ -3085,14 +3085,13 @@ prompting for a session to pick (still asks for confirmation)." (user-error "Agent not initialized")) (unless (map-elt (agent-shell--state) :supports-session-delete) (user-error "Agent does not support session/delete")) - (let* ((shell `((:buffer . ,(current-buffer)))) - (current-session-id (map-nested-elt (agent-shell--state) '(:session :id)))) + (let* ((current-session-id (map-nested-elt (agent-shell--state) '(:session :id)))) (cond ((and force-current current-session-id) (when (y-or-n-p (format "Delete current session %s? " (substring-no-properties current-session-id))) (agent-shell--delete-session-by-id - :shell shell + :shell-buffer shell-buffer :session-id current-session-id :on-success (lambda () (agent-shell--clear-session-state) @@ -3124,7 +3123,7 @@ prompting for a session to pick (still asks for confirmation)." (message "Cancelled")) (t (agent-shell--delete-session-by-id - :shell shell + :shell-buffer shell-buffer :session-id session-id :on-success (lambda () (when (and current-session-id @@ -3134,12 +3133,12 @@ prompting for a session to pick (still asks for confirmation)." (message "Deleted session %s" (substring-no-properties session-id)))))))) :on-failure (agent-shell--make-error-handler - :state (agent-shell--state) :shell shell))) + :state (agent-shell--state) :shell-buffer shell-buffer))) (current-session-id (when (y-or-n-p (format "Delete current session %s? " (substring-no-properties current-session-id))) (agent-shell--delete-session-by-id - :shell shell + :shell-buffer shell-buffer :session-id current-session-id :on-success (lambda () (agent-shell--clear-session-state) diff --git a/tests/agent-shell-tests.el b/tests/agent-shell-tests.el index 7b4e489..a15c005 100644 --- a/tests/agent-shell-tests.el +++ b/tests/agent-shell-tests.el @@ -191,21 +191,21 @@ (dolist (test-case `(;; Graphical display mode ( :graphic t :homogeneous-expected - ,(concat " pending Update state initialization\n" - " pending Update session initialization") + ,(concat " pending Update state initialization\n" + " pending Update session initialization") :mixed-expected - ,(concat " pending First task\n" - " in progress Second task\n" - " completed Third task")) + ,(concat " pending First task\n" + " in progress Second task\n" + " completed Third task")) ;; Terminal display mode ( :graphic nil :homogeneous-expected - ,(concat "[pending] Update state initialization\n" - "[pending] Update session initialization") + ,(concat "[pending] Update state initialization\n" + "[pending] Update session initialization") :mixed-expected - ,(concat "[pending] First task\n" - "[in progress] Second task\n" - "[completed] Third task")))) + ,(concat "[pending] First task\n" + "[in progress] Second task\n" + "[completed] Third task")))) (cl-letf (((symbol-function 'display-graphic-p) (lambda (&optional _display) (plist-get test-case :graphic)))) ;; Test homogeneous statuses @@ -479,7 +479,7 @@ ;; Send a simple command (agent-shell--send-command :prompt "Hello agent" - :shell nil) + :shell-buffer nil) ;; Verify request was sent (should sent-request) @@ -516,7 +516,7 @@ ;; Now verify send-command handles the error gracefully (agent-shell--send-command :prompt "Test prompt with @file.txt" - :shell nil) + :shell-buffer nil) ;; Verify request was sent (fallback succeeded) (should sent-request) @@ -908,7 +908,7 @@ code block content with spaces (description . "Test model"))]))))) (_ (error "Unexpected method: %s" method))))))) (agent-shell--initiate-session - :shell `((:buffer . ,(current-buffer))) + :shell-buffer (current-buffer) :on-session-init (lambda () (setq session-init-called t))) (let ((ordered-requests (nreverse requests))) @@ -965,7 +965,7 @@ code block content with spaces '((sessionId . "new-session-456")))) (_ (error "Unexpected method: %s" method))))))) (agent-shell--initiate-session - :shell `((:buffer . ,(current-buffer))) + :shell-buffer (current-buffer) :on-session-init (lambda () (setq session-init-called t))) (let ((ordered-requests (nreverse requests))) @@ -1085,7 +1085,7 @@ code block content with spaces '((sessionId . "new-session-789")))) (_ (error "Unexpected method: %s" method))))))) (agent-shell--initiate-session - :shell `((:buffer . ,(current-buffer))) + :shell-buffer (current-buffer) :on-session-init (lambda () (setq session-init-called t))) (let ((ordered-requests (nreverse requests))) @@ -1127,7 +1127,7 @@ code block content with spaces (funcall (plist-get args :on-success) '((ok . t)))) (_ (error "Unexpected method: %s" method))))))) (agent-shell--delete-session-by-id - :shell `((:buffer . ,(current-buffer))) + :shell-buffer (current-buffer) :session-id "session-2" :on-success (lambda () (setq success-called t))) (should success-called) From 96fa53e534fb911a2fc704f13afcd26058391fef Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:56:04 +0000 Subject: [PATCH 05/44] Use session list/delete/load ACP helpers as per @farra's PR #248 https://github.com/xenodium/acp.el/issues/11 https://github.com/xenodium/agent-shell/issues/105 https://github.com/xenodium/agent-shell/issues/190 https://github.com/xenodium/agent-shell/issues/268 --- agent-shell.el | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index 1194a99..a8d593c 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -3048,8 +3048,8 @@ ON-SUCCESS is called with no args after successful delete." :append t)) (acp-send-request :client (map-elt (agent-shell--state) :client) - :request `((:method . "session/delete") - (:params . ((sessionId . ,session-id)))) + :request (acp-make-session-delete-request + :session-id session-id) :buffer (current-buffer) :on-success (lambda (_response) (with-current-buffer (map-elt (agent-shell--state) :buffer) @@ -3107,8 +3107,8 @@ prompting for a session to pick (still asks for confirmation)." :append t)) (acp-send-request :client (map-elt (agent-shell--state) :client) - :request `((:method . "session/list") - (:params . ((cwd . ,(agent-shell--resolve-path (agent-shell-cwd))))))) + :request (acp-make-session-list-request + :cwd (agent-shell--resolve-path (agent-shell-cwd)))) :buffer (current-buffer) :on-success (lambda (response) (let* ((sessions (append (or (map-elt response 'sessions) '()) nil)) @@ -3259,9 +3259,8 @@ prompting for a session to pick (still asks for confirmation)." :append t)) (acp-send-request :client (map-elt (agent-shell--state) :client) - :request `((:method . "session/list") - ;; Must remove trailing / to make sure Claude recognizes previous CWDs. - (:params . ((cwd . ,(agent-shell--resolve-path (string-remove-suffix "/" (agent-shell-cwd))))))) + :request (acp-make-session-list-request + :cwd (agent-shell--resolve-path (agent-shell-cwd))) :buffer (current-buffer) :on-success (lambda (response) (let* ((sessions (append (or (map-elt response 'sessions) '()) nil)) @@ -3281,12 +3280,17 @@ prompting for a session to pick (still asks for confirmation)." :append t) (acp-send-request :client (map-elt (agent-shell--state) :client) - :request `((:method . ,(if (map-elt (agent-shell--state) :supports-session-load) - "session/load" - "session/resume")) - (:params . ((sessionId . ,session-id) - (cwd . ,(agent-shell--resolve-path (string-remove-suffix "/" (agent-shell-cwd)))) - (mcpServers . ,(or (agent-shell--mcp-servers) []))))) + :request (let ((cwd (agent-shell--resolve-path (agent-shell-cwd))) + (mcp-servers (agent-shell--mcp-servers))) + (if (map-elt (agent-shell--state) :supports-session-load) + (acp-make-session-load-request + :session-id session-id + :cwd cwd + :mcp-servers mcp-servers) + (acp-make-session-resume-request + :session-id session-id + :cwd cwd + :mcp-servers mcp-servers))) :buffer (current-buffer) :on-success (lambda (load-response) (agent-shell--set-session-from-response From 26eae7c746a51909cafd3640a320b091f435883e Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:50:26 +0000 Subject: [PATCH 06/44] Fixing docstring warnings Related to: #190 #105 --- agent-shell.el | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index a8d593c..0e8a935 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -403,13 +403,14 @@ configuration alist for backwards compatibility." :group 'agent-shell) (defcustom agent-shell-session-load-strategy 'latest - "How to choose an existing session when `session/list' and `session/load' are available. + "How to choose an existing session when both +`session/list' and `session/load' are available. Available values: - `latest': Load the latest session returned by `session/list'. - `prompt': Prompt to choose which session to load (or start a new one). - `new': Always start a new session and skip `session/list' and `session/load'." + `latest': Load the latest session from `session/list'. + `prompt': Prompt to choose a session (or start new). + `new': Always start a new session, skip list/load." :type '(choice (const :tag "Load latest session" latest) (const :tag "Prompt for session" prompt) (const :tag "Always start new session" new)) From 56e76ee2d5e623c61940debc967126951041a727 Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:50:50 +0000 Subject: [PATCH 07/44] Fixing misplaced paren in agent-shell-delete-session Related to: #190 --- agent-shell.el | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index 0e8a935..85b0eff 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -3109,7 +3109,7 @@ prompting for a session to pick (still asks for confirmation)." (acp-send-request :client (map-elt (agent-shell--state) :client) :request (acp-make-session-list-request - :cwd (agent-shell--resolve-path (agent-shell-cwd)))) + :cwd (agent-shell--resolve-path (agent-shell-cwd))) :buffer (current-buffer) :on-success (lambda (response) (let* ((sessions (append (or (map-elt response 'sessions) '()) nil)) @@ -3145,8 +3145,8 @@ prompting for a session to pick (still asks for confirmation)." (agent-shell--clear-session-state) (message "Deleted session %s" (substring-no-properties current-session-id)))))) - (t - (user-error "No session to delete")))))) + (t + (user-error "No session to delete"))))))) (cl-defun agent-shell--set-session-from-response (&key response session-id) "Set active session state from RESPONSE and SESSION-ID." From c6f638ebd8e5ebc7da7b618dd01504a0e5367eeb Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:16:24 +0000 Subject: [PATCH 08/44] Simplify session picker Related to: #190 #105 --- agent-shell.el | 67 +++++++++++++++++++++++--------------------------- 1 file changed, 31 insertions(+), 36 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index 85b0eff..ae258bb 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -2953,46 +2953,35 @@ for the current year, or \"Mon DD, YYYY\" for other years." (padding (make-string (max 2 (- 52 (length title))) ?\s))) (concat title padding date-str))) -(defconst agent-shell--start-new-session-choice "Start a new session" - "Label for creating a new session from the session picker.") - -(defun agent-shell--session-picker-sort (candidates) - "Return CANDIDATES with `agent-shell--start-new-session-choice' first." - (if (member agent-shell--start-new-session-choice candidates) - (cons agent-shell--start-new-session-choice - (delete agent-shell--start-new-session-choice - (copy-sequence candidates))) - candidates)) - -(defun agent-shell--prompt-select-session-to-load (sessions) +(defun agent-shell--prompt-select-session (sessions) "Prompt to choose one from SESSIONS. -Return selected session alist, or nil to start a new session." +Return selected session alist, or nil to start a new session. +Falls back to latest session in batch mode (e.g. tests)." (when sessions - (let* ((session-choices (mapcar (lambda (session) - (cons (agent-shell--session-choice-label session) - session)) - sessions)) - (choices (cons (cons agent-shell--start-new-session-choice nil) - session-choices)) - (completion-extra-properties - '(:display-sort-function agent-shell--session-picker-sort - :cycle-sort-function agent-shell--session-picker-sort)) - (selection (completing-read "Load session: " - (mapcar #'car choices) + (if noninteractive + (car sessions) + (let* ((new-session-choice "Start a new session") + (choices (cons (cons new-session-choice nil) + (mapcar (lambda (session) + (cons (agent-shell--session-choice-label session) + session)) + sessions))) + (candidates (mapcar #'car choices)) + ;; Some completion frameworks yielded appended (nil) to each line + ;; unless this-command was bound. + ;; + ;; For example: + ;; + ;; Let's build something Today, 16:25 (nil) + ;; Let's optimize the rocket engine Feb 12, 21:02 (nil) + (this-command 'agent-shell) + (selection (completing-read "Resume session: " + candidates nil t nil nil - agent-shell--start-new-session-choice))) - (cdr (assoc selection choices))))) + new-session-choice))) + (map-elt choices selection))))) -(defun agent-shell--select-session-to-load (sessions) - "Select a session from SESSIONS based on `agent-shell-session-load-strategy'." - (pcase agent-shell-session-load-strategy - ('new nil) - ('latest (car sessions)) - ('prompt (if noninteractive - (car sessions) - (agent-shell--prompt-select-session-to-load sessions))) - (_ (car sessions)))) (defun agent-shell--prompt-select-session-to-delete (sessions) "Prompt to choose one from SESSIONS for deletion. @@ -3267,7 +3256,13 @@ prompting for a session to pick (still asks for confirmation)." (let* ((sessions (append (or (map-elt response 'sessions) '()) nil)) (selected-session (condition-case nil - (agent-shell--select-session-to-load sessions) + (pcase agent-shell-session-load-strategy + ('new nil) + ('latest (car sessions)) + ('prompt (agent-shell--prompt-select-session sessions)) + (_ (message "Unknown session load strategy '%s', starting a new session" + agent-shell-session-load-strategy) + nil)) (quit nil))) (session-id (and selected-session (map-elt selected-session 'sessionId)))) From 693040684e99edeb0683d645e0fd07ed1ffbdf49 Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:17:04 +0000 Subject: [PATCH 09/44] Fixing indent Related to: #190 #105 --- agent-shell.el | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index ae258bb..5de1816 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -3261,7 +3261,7 @@ prompting for a session to pick (still asks for confirmation)." ('latest (car sessions)) ('prompt (agent-shell--prompt-select-session sessions)) (_ (message "Unknown session load strategy '%s', starting a new session" - agent-shell-session-load-strategy) + agent-shell-session-load-strategy) nil)) (quit nil))) (session-id (and selected-session @@ -3277,16 +3277,16 @@ prompting for a session to pick (still asks for confirmation)." (acp-send-request :client (map-elt (agent-shell--state) :client) :request (let ((cwd (agent-shell--resolve-path (agent-shell-cwd))) - (mcp-servers (agent-shell--mcp-servers))) - (if (map-elt (agent-shell--state) :supports-session-load) - (acp-make-session-load-request - :session-id session-id - :cwd cwd - :mcp-servers mcp-servers) - (acp-make-session-resume-request - :session-id session-id - :cwd cwd - :mcp-servers mcp-servers))) + (mcp-servers (agent-shell--mcp-servers))) + (if (map-elt (agent-shell--state) :supports-session-load) + (acp-make-session-load-request + :session-id session-id + :cwd cwd + :mcp-servers mcp-servers) + (acp-make-session-resume-request + :session-id session-id + :cwd cwd + :mcp-servers mcp-servers))) :buffer (current-buffer) :on-success (lambda (load-response) (agent-shell--set-session-from-response From 45f2e822f5ef88871854168c8954cdf21ef157ea Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:31:55 +0000 Subject: [PATCH 10/44] Identify acp-related vars/data and name accordingly This helps identify where the data/structure originated from Related to: #190 #105 --- agent-shell.el | 142 ++++++++++++++++++++++++------------------------- 1 file changed, 71 insertions(+), 71 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index 5de1816..3b1a910 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -2760,19 +2760,19 @@ Must provide ON-INITIATED (lambda ())." :write-text-file-capability agent-shell-text-file-capabilities) :on-success (lambda (response) (with-current-buffer shell-buffer - (let ((session-capabilities (or (map-elt response 'sessionCapabilities) - (map-nested-elt response '(agentCapabilities sessionCapabilities))))) + (let ((acp-session-capabilities (or (map-elt response 'sessionCapabilities) + (map-nested-elt response '(agentCapabilities sessionCapabilities))))) (map-put! agent-shell--state :supports-session-list - (and (listp session-capabilities) - (assq 'list session-capabilities) + (and (listp acp-session-capabilities) + (assq 'list acp-session-capabilities) t)) (map-put! agent-shell--state :supports-session-delete - (and (listp session-capabilities) - (assq 'delete session-capabilities) + (and (listp acp-session-capabilities) + (assq 'delete acp-session-capabilities) t)) (map-put! agent-shell--state :supports-session-resume - (and (listp session-capabilities) - (assq 'resume session-capabilities) + (and (listp acp-session-capabilities) + (assq 'resume acp-session-capabilities) t))) ;; Save prompt capabilities from agent, converting to internal symbols (when-let ((prompt-capabilities @@ -2938,35 +2938,35 @@ for the current year, or \"Mon DD, YYYY\" for other years." (format-time-string "%b %d, %Y" time)))) (error iso-timestamp))) -(defun agent-shell--session-choice-label (session) - "Return completion label for SESSION." - (let* ((title (or (map-elt session 'title) +(defun agent-shell--session-choice-label (acp-session) + "Return completion label for ACP-SESSION." + (let* ((title (or (map-elt acp-session 'title) "Untitled")) (title (if (> (length title) 50) (concat (substring title 0 47) "...") title)) - (updated-at (or (map-elt session 'updatedAt) - (map-elt session 'createdAt) + (updated-at (or (map-elt acp-session 'updatedAt) + (map-elt acp-session 'createdAt) "unknown-time")) (date-str (propertize (agent-shell--format-session-date updated-at) 'face 'font-lock-comment-face)) (padding (make-string (max 2 (- 52 (length title))) ?\s))) (concat title padding date-str))) -(defun agent-shell--prompt-select-session (sessions) - "Prompt to choose one from SESSIONS. +(defun agent-shell--prompt-select-session (acp-sessions) + "Prompt to choose one from ACP-SESSIONS. Return selected session alist, or nil to start a new session. Falls back to latest session in batch mode (e.g. tests)." - (when sessions + (when acp-sessions (if noninteractive - (car sessions) + (car acp-sessions) (let* ((new-session-choice "Start a new session") (choices (cons (cons new-session-choice nil) - (mapcar (lambda (session) - (cons (agent-shell--session-choice-label session) - session)) - sessions))) + (mapcar (lambda (acp-session) + (cons (agent-shell--session-choice-label acp-session) + acp-session)) + acp-sessions))) (candidates (mapcar #'car choices)) ;; Some completion frameworks yielded appended (nil) to each line ;; unless this-command was bound. @@ -2983,25 +2983,25 @@ Falls back to latest session in batch mode (e.g. tests)." (map-elt choices selection))))) -(defun agent-shell--prompt-select-session-to-delete (sessions) - "Prompt to choose one from SESSIONS for deletion. +(defun agent-shell--prompt-select-session-to-delete (acp-sessions) + "Prompt to choose one from ACP-SESSIONS for deletion. Return selected session alist, or nil if user quit." - (when sessions - (let* ((choices (mapcar (lambda (session) - (cons (agent-shell--session-choice-label session) - session)) - sessions)) + (when acp-sessions + (let* ((choices (mapcar (lambda (acp-session) + (cons (agent-shell--session-choice-label acp-session) + acp-session)) + acp-sessions)) (selection (completing-read "Delete session: " (mapcar #'car choices) nil t))) (cdr (assoc selection choices))))) -(defun agent-shell--select-session-to-delete (sessions) - "Select a session from SESSIONS for deletion." +(defun agent-shell--select-session-to-delete (acp-sessions) + "Select a session from ACP-SESSIONS for deletion." (if noninteractive - (car sessions) - (agent-shell--prompt-select-session-to-delete sessions))) + (car acp-sessions) + (agent-shell--prompt-select-session-to-delete acp-sessions))) (defun agent-shell--clear-session-state () "Reset current session-scoped state for the active shell." @@ -3023,23 +3023,23 @@ Return selected session alist, or nil if user quit." (map-put! state :available-commands nil) (agent-shell--update-header-and-mode-line))) -(cl-defun agent-shell--delete-session-by-id (&key shell-buffer session-id on-success) - "Delete SESSION-ID via ACP using SHELL-BUFFER. +(cl-defun agent-shell--delete-session-by-id (&key shell-buffer acp-session-id on-success) + "Delete ACP-SESSION-ID via ACP using SHELL-BUFFER. ON-SUCCESS is called with no args after successful delete." - (unless session-id - (error "Missing required argument: :session-id")) + (unless acp-session-id + (error "Missing required argument: :acp-session-id")) (with-current-buffer (map-elt (agent-shell--state) :buffer) (agent-shell--update-fragment :state (agent-shell--state) :block-id "session_delete" :label-left (propertize "Deleting session" 'font-lock-face 'font-lock-doc-markup-face) - :body (format "Requesting deletion for %s..." (substring-no-properties session-id)) + :body (format "Requesting deletion for %s..." (substring-no-properties acp-session-id)) :append t)) (acp-send-request :client (map-elt (agent-shell--state) :client) :request (acp-make-session-delete-request - :session-id session-id) + :session-id acp-session-id) :buffer (current-buffer) :on-success (lambda (_response) (with-current-buffer (map-elt (agent-shell--state) :buffer) @@ -3082,7 +3082,7 @@ prompting for a session to pick (still asks for confirmation)." (substring-no-properties current-session-id))) (agent-shell--delete-session-by-id :shell-buffer shell-buffer - :session-id current-session-id + :acp-session-id current-session-id :on-success (lambda () (agent-shell--clear-session-state) (message "Deleted session %s" @@ -3100,28 +3100,28 @@ prompting for a session to pick (still asks for confirmation)." :request (acp-make-session-list-request :cwd (agent-shell--resolve-path (agent-shell-cwd))) :buffer (current-buffer) - :on-success (lambda (response) - (let* ((sessions (append (or (map-elt response 'sessions) '()) nil)) - (selected-session (agent-shell--select-session-to-delete sessions)) - (session-id (and selected-session - (map-elt selected-session 'sessionId)))) + :on-success (lambda (acp-response) + (let* ((acp-sessions (append (or (map-elt acp-response 'sessions) '()) nil)) + (acp-session (agent-shell--select-session-to-delete acp-sessions)) + (acp-session-id (and acp-session + (map-elt acp-session 'sessionId)))) (cond - ((not session-id) + ((not acp-session-id) (message "No session selected")) ((not (y-or-n-p (format "Delete session %s? " - (substring-no-properties session-id)))) + (substring-no-properties acp-session-id)))) (message "Cancelled")) (t (agent-shell--delete-session-by-id :shell-buffer shell-buffer - :session-id session-id + :acp-session-id acp-session-id :on-success (lambda () (when (and current-session-id - (equal (substring-no-properties session-id) + (equal (substring-no-properties acp-session-id) (substring-no-properties current-session-id))) (agent-shell--clear-session-state)) (message "Deleted session %s" - (substring-no-properties session-id)))))))) + (substring-no-properties acp-session-id)))))))) :on-failure (agent-shell--make-error-handler :state (agent-shell--state) :shell-buffer shell-buffer))) (current-session-id @@ -3129,7 +3129,7 @@ prompting for a session to pick (still asks for confirmation)." (substring-no-properties current-session-id))) (agent-shell--delete-session-by-id :shell-buffer shell-buffer - :session-id current-session-id + :acp-session-id current-session-id :on-success (lambda () (agent-shell--clear-session-state) (message "Deleted session %s" @@ -3137,22 +3137,22 @@ prompting for a session to pick (still asks for confirmation)." (t (user-error "No session to delete"))))))) -(cl-defun agent-shell--set-session-from-response (&key response session-id) - "Set active session state from RESPONSE and SESSION-ID." +(cl-defun agent-shell--set-session-from-response (&key acp-response acp-session-id) + "Set active session state from ACP-RESPONSE and ACP-SESSION-ID." (map-put! agent-shell--state - :session (list (cons :id session-id) - (cons :mode-id (map-nested-elt response '(modes currentModeId))) + :session (list (cons :id acp-session-id) + (cons :mode-id (map-nested-elt acp-response '(modes currentModeId))) (cons :modes (mapcar (lambda (mode) `((:id . ,(map-elt mode 'id)) (:name . ,(map-elt mode 'name)) (:description . ,(map-elt mode 'description)))) - (map-nested-elt response '(modes availableModes)))) - (cons :model-id (map-nested-elt response '(models currentModelId))) + (map-nested-elt acp-response '(modes availableModes)))) + (cons :model-id (map-nested-elt acp-response '(models currentModelId))) (cons :models (mapcar (lambda (model) `((:model-id . ,(map-elt model 'modelId)) (:name . ,(map-elt model 'name)) (:description . ,(map-elt model 'description)))) - (map-nested-elt response '(models availableModels))))))) + (map-nested-elt acp-response '(models availableModels))))))) (cl-defun agent-shell--finalize-session-init (&key on-session-init) "Finalize session initialization and invoke ON-SESSION-INIT." @@ -3252,27 +3252,27 @@ prompting for a session to pick (still asks for confirmation)." :request (acp-make-session-list-request :cwd (agent-shell--resolve-path (agent-shell-cwd))) :buffer (current-buffer) - :on-success (lambda (response) - (let* ((sessions (append (or (map-elt response 'sessions) '()) nil)) - (selected-session + :on-success (lambda (acp-response) + (let* ((acp-sessions (append (or (map-elt acp-response 'sessions) '()) nil)) + (acp-session (condition-case nil (pcase agent-shell-session-load-strategy ('new nil) - ('latest (car sessions)) - ('prompt (agent-shell--prompt-select-session sessions)) + ('latest (car acp-sessions)) + ('prompt (agent-shell--prompt-select-session acp-sessions)) (_ (message "Unknown session load strategy '%s', starting a new session" agent-shell-session-load-strategy) nil)) (quit nil))) - (session-id (and selected-session - (map-elt selected-session 'sessionId)))) - (if session-id + (acp-session-id (and acp-session + (map-elt acp-session 'sessionId)))) + (if acp-session-id (progn (agent-shell--update-fragment :state (agent-shell--state) :block-id "starting" :body (format "\n\nLoading session %s..." - (substring-no-properties session-id)) + (substring-no-properties acp-session-id)) :append t) (acp-send-request :client (map-elt (agent-shell--state) :client) @@ -3280,18 +3280,18 @@ prompting for a session to pick (still asks for confirmation)." (mcp-servers (agent-shell--mcp-servers))) (if (map-elt (agent-shell--state) :supports-session-load) (acp-make-session-load-request - :session-id session-id + :session-id acp-session-id :cwd cwd :mcp-servers mcp-servers) (acp-make-session-resume-request - :session-id session-id + :session-id acp-session-id :cwd cwd :mcp-servers mcp-servers))) :buffer (current-buffer) - :on-success (lambda (load-response) + :on-success (lambda (acp-load-response) (agent-shell--set-session-from-response - :response load-response - :session-id session-id) + :acp-response acp-load-response + :acp-session-id acp-session-id) (agent-shell--finalize-session-init :on-session-init on-session-init)) :on-failure (lambda (_error _raw-message) (agent-shell--update-fragment From f6f3b749d52ad3d7e7bd19dcbd2b83e88a2dbe41 Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:35:34 +0000 Subject: [PATCH 11/44] Removing unnecessarily substring-no-properties Related to: #190 --- agent-shell.el | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index 3b1a910..a5c3e0d 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -3034,7 +3034,7 @@ ON-SUCCESS is called with no args after successful delete." :state (agent-shell--state) :block-id "session_delete" :label-left (propertize "Deleting session" 'font-lock-face 'font-lock-doc-markup-face) - :body (format "Requesting deletion for %s..." (substring-no-properties acp-session-id)) + :body (format "Requesting deletion for %s..." acp-session-id) :append t)) (acp-send-request :client (map-elt (agent-shell--state) :client) @@ -3078,15 +3078,13 @@ prompting for a session to pick (still asks for confirmation)." (let* ((current-session-id (map-nested-elt (agent-shell--state) '(:session :id)))) (cond ((and force-current current-session-id) - (when (y-or-n-p (format "Delete current session %s? " - (substring-no-properties current-session-id))) + (when (y-or-n-p (format "Delete current session %s? " current-session-id)) (agent-shell--delete-session-by-id :shell-buffer shell-buffer :acp-session-id current-session-id :on-success (lambda () (agent-shell--clear-session-state) - (message "Deleted session %s" - (substring-no-properties current-session-id)))))) + (message "Deleted session %s" current-session-id))))) ((map-elt (agent-shell--state) :supports-session-list) (with-current-buffer (map-elt (agent-shell--state) :buffer) (agent-shell--update-fragment @@ -3108,8 +3106,7 @@ prompting for a session to pick (still asks for confirmation)." (cond ((not acp-session-id) (message "No session selected")) - ((not (y-or-n-p (format "Delete session %s? " - (substring-no-properties acp-session-id)))) + ((not (y-or-n-p (format "Delete session %s? " acp-session-id))) (message "Cancelled")) (t (agent-shell--delete-session-by-id @@ -3117,23 +3114,19 @@ prompting for a session to pick (still asks for confirmation)." :acp-session-id acp-session-id :on-success (lambda () (when (and current-session-id - (equal (substring-no-properties acp-session-id) - (substring-no-properties current-session-id))) + (equal acp-session-id current-session-id)) (agent-shell--clear-session-state)) - (message "Deleted session %s" - (substring-no-properties acp-session-id)))))))) + (message "Deleted session %s" acp-session-id))))))) :on-failure (agent-shell--make-error-handler :state (agent-shell--state) :shell-buffer shell-buffer))) (current-session-id - (when (y-or-n-p (format "Delete current session %s? " - (substring-no-properties current-session-id))) + (when (y-or-n-p (format "Delete current session %s? " current-session-id)) (agent-shell--delete-session-by-id :shell-buffer shell-buffer :acp-session-id current-session-id :on-success (lambda () (agent-shell--clear-session-state) - (message "Deleted session %s" - (substring-no-properties current-session-id)))))) + (message "Deleted session %s" current-session-id))))) (t (user-error "No session to delete"))))))) @@ -3271,8 +3264,7 @@ prompting for a session to pick (still asks for confirmation)." (agent-shell--update-fragment :state (agent-shell--state) :block-id "starting" - :body (format "\n\nLoading session %s..." - (substring-no-properties acp-session-id)) + :body (format "\n\nLoading session %s..." acp-session-id) :append t) (acp-send-request :client (map-elt (agent-shell--state) :client) From 7b43f9c4c070ab18f21a336ed42dbf2e1b6d3b52 Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:39:56 +0000 Subject: [PATCH 12/44] Removing session-deletion code as not yet validated Details at https://github.com/xenodium/agent-shell/pull/263#issuecomment-3899448962 --- agent-shell.el | 153 ------------------------------------- tests/agent-shell-tests.el | 41 ---------- 2 files changed, 194 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index a5c3e0d..3502a53 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -530,7 +530,6 @@ HEARTBEAT, and AUTHENTICATE-REQUEST-MAKER." (cons :supports-session-list nil) (cons :supports-session-load nil) (cons :supports-session-resume nil) - (cons :supports-session-delete nil) (cons :prompt-capabilities nil) (cons :pending-requests nil) (cons :usage (list (cons :total-tokens 0) @@ -820,7 +819,6 @@ When FORCE is non-nil, skip confirmation prompt." "p" #'agent-shell-previous-item "C-" #'agent-shell-cycle-session-mode "C-c C-c" #'agent-shell-interrupt - "C-c C-d" #'agent-shell-delete-session "C-c C-m" #'agent-shell-set-session-mode "C-c C-v" #'agent-shell-set-session-model "C-c C-o" #'agent-shell-other-buffer) @@ -2766,10 +2764,6 @@ Must provide ON-INITIATED (lambda ())." (and (listp acp-session-capabilities) (assq 'list acp-session-capabilities) t)) - (map-put! agent-shell--state :supports-session-delete - (and (listp acp-session-capabilities) - (assq 'delete acp-session-capabilities) - t)) (map-put! agent-shell--state :supports-session-resume (and (listp acp-session-capabilities) (assq 'resume acp-session-capabilities) @@ -2983,153 +2977,6 @@ Falls back to latest session in batch mode (e.g. tests)." (map-elt choices selection))))) -(defun agent-shell--prompt-select-session-to-delete (acp-sessions) - "Prompt to choose one from ACP-SESSIONS for deletion. - -Return selected session alist, or nil if user quit." - (when acp-sessions - (let* ((choices (mapcar (lambda (acp-session) - (cons (agent-shell--session-choice-label acp-session) - acp-session)) - acp-sessions)) - (selection (completing-read "Delete session: " - (mapcar #'car choices) - nil t))) - (cdr (assoc selection choices))))) - -(defun agent-shell--select-session-to-delete (acp-sessions) - "Select a session from ACP-SESSIONS for deletion." - (if noninteractive - (car acp-sessions) - (agent-shell--prompt-select-session-to-delete acp-sessions))) - -(defun agent-shell--clear-session-state () - "Reset current session-scoped state for the active shell." - (let* ((state (agent-shell--state)) - (session (or (map-elt state :session) - (list (cons :id nil) - (cons :mode-id nil) - (cons :modes nil))))) - (map-put! session :id nil) - (map-put! session :mode-id nil) - (map-put! session :modes nil) - ;; Clear optional fields if they were previously populated. - (map-put! session :model-id nil) - (map-put! session :models nil) - (map-put! state :session session) - (map-put! state :set-session-mode nil) - (map-put! state :set-model nil) - (map-put! state :tool-calls nil) - (map-put! state :available-commands nil) - (agent-shell--update-header-and-mode-line))) - -(cl-defun agent-shell--delete-session-by-id (&key shell-buffer acp-session-id on-success) - "Delete ACP-SESSION-ID via ACP using SHELL-BUFFER. - -ON-SUCCESS is called with no args after successful delete." - (unless acp-session-id - (error "Missing required argument: :acp-session-id")) - (with-current-buffer (map-elt (agent-shell--state) :buffer) - (agent-shell--update-fragment - :state (agent-shell--state) - :block-id "session_delete" - :label-left (propertize "Deleting session" 'font-lock-face 'font-lock-doc-markup-face) - :body (format "Requesting deletion for %s..." acp-session-id) - :append t)) - (acp-send-request - :client (map-elt (agent-shell--state) :client) - :request (acp-make-session-delete-request - :session-id acp-session-id) - :buffer (current-buffer) - :on-success (lambda (_response) - (with-current-buffer (map-elt (agent-shell--state) :buffer) - (agent-shell--update-fragment - :state (agent-shell--state) - :block-id "session_delete" - :body "\n\nDone" - :append t)) - (when on-success - (funcall on-success))) - :on-failure (agent-shell--make-error-handler - :state (agent-shell--state) :shell-buffer shell-buffer))) - -(defun agent-shell-delete-session (&optional force-current) - "Delete an existing agent session from the agent's session history. - -This requires the agent to support the experimental ACP method -\"session/delete\". - -With prefix argument FORCE-CURRENT, delete the current session without -prompting for a session to pick (still asks for confirmation)." - (interactive "P") - (unless (or (derived-mode-p 'agent-shell-mode) - (derived-mode-p 'agent-shell-viewport-view-mode) - (derived-mode-p 'agent-shell-viewport-edit-mode)) - (user-error "Not in an agent-shell buffer")) - (let* ((shell-buffer (if (derived-mode-p 'agent-shell-mode) - (current-buffer) - (or (agent-shell-viewport--shell-buffer) - (user-error "No shell buffer available"))))) - (with-current-buffer shell-buffer - (unless (map-elt (agent-shell--state) :client) - (user-error "Agent not initialized")) - (unless (map-elt (agent-shell--state) :supports-session-delete) - (user-error "Agent does not support session/delete")) - (let* ((current-session-id (map-nested-elt (agent-shell--state) '(:session :id)))) - (cond - ((and force-current current-session-id) - (when (y-or-n-p (format "Delete current session %s? " current-session-id)) - (agent-shell--delete-session-by-id - :shell-buffer shell-buffer - :acp-session-id current-session-id - :on-success (lambda () - (agent-shell--clear-session-state) - (message "Deleted session %s" current-session-id))))) - ((map-elt (agent-shell--state) :supports-session-list) - (with-current-buffer (map-elt (agent-shell--state) :buffer) - (agent-shell--update-fragment - :state (agent-shell--state) - :block-id "session_delete" - :label-left (propertize "Deleting session" 'font-lock-face 'font-lock-doc-markup-face) - :body "\n\nLooking for existing sessions..." - :append t)) - (acp-send-request - :client (map-elt (agent-shell--state) :client) - :request (acp-make-session-list-request - :cwd (agent-shell--resolve-path (agent-shell-cwd))) - :buffer (current-buffer) - :on-success (lambda (acp-response) - (let* ((acp-sessions (append (or (map-elt acp-response 'sessions) '()) nil)) - (acp-session (agent-shell--select-session-to-delete acp-sessions)) - (acp-session-id (and acp-session - (map-elt acp-session 'sessionId)))) - (cond - ((not acp-session-id) - (message "No session selected")) - ((not (y-or-n-p (format "Delete session %s? " acp-session-id))) - (message "Cancelled")) - (t - (agent-shell--delete-session-by-id - :shell-buffer shell-buffer - :acp-session-id acp-session-id - :on-success (lambda () - (when (and current-session-id - (equal acp-session-id current-session-id)) - (agent-shell--clear-session-state)) - (message "Deleted session %s" acp-session-id))))))) - :on-failure (agent-shell--make-error-handler - :state (agent-shell--state) :shell-buffer shell-buffer))) - (current-session-id - (when (y-or-n-p (format "Delete current session %s? " current-session-id)) - (agent-shell--delete-session-by-id - :shell-buffer shell-buffer - :acp-session-id current-session-id - :on-success (lambda () - (agent-shell--clear-session-state) - (message "Deleted session %s" current-session-id))))) - (t - (user-error "No session to delete"))))))) - (cl-defun agent-shell--set-session-from-response (&key acp-response acp-session-id) "Set active session state from ACP-RESPONSE and ACP-SESSION-ID." (map-put! agent-shell--state diff --git a/tests/agent-shell-tests.el b/tests/agent-shell-tests.el index a15c005..1170dec 100644 --- a/tests/agent-shell-tests.el +++ b/tests/agent-shell-tests.el @@ -1096,46 +1096,5 @@ code block content with spaces (should session-init-called) (should (equal (map-nested-elt agent-shell--state '(:session :id)) "new-session-789")))))) -(ert-deftest agent-shell--delete-session-by-id-sends-session-delete () - "Test `agent-shell--delete-session-by-id' sends session/delete request." - (with-temp-buffer - (let* ((requests '()) - (success-called nil) - (state `((:buffer . ,(current-buffer)) - (:client . test-client) - (:session . ((:id . "session-2") - (:mode-id . nil) - (:modes . nil))) - (:supports-session-list . t) - (:supports-session-delete . t)))) - (setq-local agent-shell--state state) - (cl-letf (((symbol-function 'agent-shell--state) - (lambda () agent-shell--state)) - ((symbol-function 'agent-shell--update-fragment) - (lambda (&rest _args) nil)) - ((symbol-function 'agent-shell--update-header-and-mode-line) - (lambda () nil)) - ((symbol-function 'acp-send-request) - (lambda (&rest args) - (push args requests) - (let* ((request (plist-get args :request)) - (method (map-elt request :method))) - (pcase method - ("session/delete" - (let ((params (map-elt request :params))) - (should (equal (map-elt params 'sessionId) "session-2"))) - (funcall (plist-get args :on-success) '((ok . t)))) - (_ (error "Unexpected method: %s" method))))))) - (agent-shell--delete-session-by-id - :shell-buffer (current-buffer) - :session-id "session-2" - :on-success (lambda () (setq success-called t))) - (should success-called) - (let ((ordered-requests (nreverse requests))) - (should (equal (mapcar (lambda (req) - (map-elt (plist-get req :request) :method)) - ordered-requests) - '("session/delete")))))))) - (provide 'agent-shell-tests) ;;; agent-shell-tests.el ends here From 6e4c4d2ae89528cd677591236521c554117c0d76 Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:47:12 +0000 Subject: [PATCH 13/44] Removing session-deletion code from agent-shell-delete-session too Details at https://github.com/xenodium/agent-shell/pull/263#issuecomment-3899448962 --- agent-shell-viewport.el | 2 -- 1 file changed, 2 deletions(-) diff --git a/agent-shell-viewport.el b/agent-shell-viewport.el index 6364210..38e6494 100644 --- a/agent-shell-viewport.el +++ b/agent-shell-viewport.el @@ -770,7 +770,6 @@ For example, offer to kill associated shell session." (define-key map (kbd "C-c C-p") #'agent-shell-viewport-compose-peek-last) (define-key map (kbd "C-c C-k") #'agent-shell-viewport-compose-cancel) (define-key map (kbd "C-") #'agent-shell-viewport-cycle-session-mode) - (define-key map (kbd "C-c C-d") #'agent-shell-delete-session) (define-key map (kbd "C-c C-m") #'agent-shell-viewport-set-session-mode) (define-key map (kbd "C-c C-v") #'agent-shell-viewport-set-session-model) (define-key map (kbd "C-c C-o") #'agent-shell-other-buffer) @@ -802,7 +801,6 @@ For example, offer to kill associated shell session." (define-key map (kbd "r") #'agent-shell-viewport-reply) (define-key map (kbd "q") #'bury-buffer) (define-key map (kbd "C-") #'agent-shell-viewport-cycle-session-mode) - (define-key map (kbd "C-c C-d") #'agent-shell-delete-session) (define-key map (kbd "v") #'agent-shell-viewport-set-session-model) (define-key map (kbd "m") #'agent-shell-viewport-set-session-mode) (define-key map (kbd "o") #'agent-shell-other-buffer) From cd7400cdaa3e9759ee6eed1d4946e519fa0f25da Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:39:33 +0000 Subject: [PATCH 14/44] Adds event subscriptions #62 Example usage: ;; Subscribe to all events (agent-shell-subscribe-to :shell-buffer shell-buffer :on-event (lambda (event) (message \"event: %s\" (map-elt event :event)))) ;; Subscribe to file writes (agent-shell-subscribe-to :shell-buffer shell-buffer :event \\='file-write :on-event (lambda (event) (let ((data (map-elt event :data))) (message \"wrote: %s\" (map-elt data :path))))) ;; Unsubscribe (let ((token (agent-shell-subscribe-to :shell-buffer shell-buffer :on-event #\\='my-handler))) (agent-shell-unsubscribe :subscription token))" (unless on-event (error "Missing required argument: :on-event")) (unless shell-buffer (error "Missing required argument: :shell-buffer")) (let ((token (cl-incf agent-shell--subscription-counter))) (with-current-buffer shell-buffer (let ((subscriptions (map-elt (agent-shell--state) :event-subscriptions))) (map-put! (agent-shell--state) :event-subscriptions (cons (list (cons :token token) (cons :event event) (cons :on-event on-event)) subscriptions)))) token)) --- agent-shell.el | 135 +++++++++++++++++++++++++++++++++++-- tests/agent-shell-tests.el | 122 ++++++++++++++++++++++++++++++++- 2 files changed, 251 insertions(+), 6 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index badbce2..b8fff6d 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -526,6 +526,7 @@ HEARTBEAT, and AUTHENTICATE-REQUEST-MAKER." (cons :available-commands nil) (cons :available-modes nil) (cons :prompt-capabilities nil) + (cons :event-subscriptions nil) (cons :pending-requests nil) (cons :usage (list (cons :total-tokens 0) (cons :input-tokens 0) @@ -846,6 +847,7 @@ Flow: (shell-maker--current-request-id)) (cond ((not (map-elt (agent-shell--state) :client)) ;; Needs a client + (agent-shell--emit-event :event 'init-started) (when (and agent-shell-show-busy-indicator (not command)) (agent-shell-heartbeat-start @@ -959,9 +961,12 @@ Flow: :on-mode-changed (lambda () (map-put! (agent-shell--state) :set-session-mode t) (agent-shell--handle :command command :shell-buffer shell-buffer)))) - ;; Send ACP prompt request - ((and command (not (string-empty-p (string-trim command)))) - (agent-shell--send-command :prompt command :shell-buffer shell-buffer))))) + ;; Initialization complete + (t + (agent-shell--emit-event :event 'init-finished) + ;; Send ACP prompt request + (when (and command (not (string-empty-p (string-trim command)))) + (agent-shell--send-command :prompt command :shell-buffer shell-buffer)))))) (cl-defun agent-shell--on-error (&key state error) "Handle ERROR with SHELL an STATE." @@ -1021,6 +1026,10 @@ otherwise returns COMMAND unchanged." (cons :content (map-elt update 'content))) (when-let ((diff (agent-shell--make-diff-info :tool-call update))) (list (cons :diff diff))))) + (agent-shell--emit-event + :event 'tool-call-update + :data (list (cons :tool-call-id (map-elt update 'toolCallId)) + (cons :tool-call (map-nested-elt state (list :tool-calls (map-elt update 'toolCallId)))))) (let ((tool-call-labels (agent-shell-make-tool-call-label state (map-elt update 'toolCallId)))) (agent-shell--update-fragment @@ -1130,6 +1139,10 @@ otherwise returns COMMAND unchanged." (list (cons :title command))) (when-let ((diff (agent-shell--make-diff-info :tool-call update))) (list (cons :diff diff))))) + (agent-shell--emit-event + :event 'tool-call-update + :data (list (cons :tool-call-id .toolCallId) + (cons :tool-call (map-nested-elt state (list :tool-calls .toolCallId))))) (let* ((diff (map-nested-elt state `(:tool-calls ,.toolCallId :diff))) (output (concat "\n\n" @@ -1419,6 +1432,10 @@ function before returning." (lambda () (replace-buffer-contents content-buffer 1.0))) (basic-save-buffer))))) + (agent-shell--emit-event + :event 'file-write + :data (list (cons :path path) + (cons :content content))) (acp-send-response :client (map-elt state :client) :response (acp-make-fs-write-text-file-response @@ -2725,6 +2742,102 @@ INSTALL-INSTRUCTIONS is optional installation guidance." (error "No shell state available")) agent-shell--state) +;;; Events + +(defvar agent-shell--subscription-counter 0 + "Counter for generating unique subscription tokens.") + +(cl-defun agent-shell-subscribe-to (&key shell-buffer event on-event) + "Subscribe to events in SHELL-BUFFER. + +ON-EVENT is a function called with an event alist containing: + :event - A symbol identifying the event + +When EVENT is non-nil, only events matching that symbol are dispatched. +When EVENT is nil, all events are dispatched. + +Initialization events (emitted in order): + `init-started' - Initialization pipeline started + `init-client' - ACP client created + `init-subscriptions' - ACP event subscriptions registered + `init-handshake' - ACP initialize/handshake RPC completed + `init-authenticate' - ACP authentication completed (optional) + `init-session' - ACP session created + `init-model' - Default model set (optional) + `init-session-mode' - Default session mode set (optional) + `init-finished' - Initialization pipeline completed + +Session events: + `tool-call-update' - Tool call started or updated + :data contains :tool-call-id and :tool-call + `file-write' - File written via fs/write_text_file + :data contains :path and :content + `permission-response' - Permission response sent + :data contains :request-id, :tool-call-id, :option-id, :cancelled + +Returns a subscription token for use with `agent-shell-unsubscribe'. + +Example usage: + + ;; Subscribe to all events + (agent-shell-subscribe-to + :shell-buffer shell-buffer + :on-event (lambda (event) + (message \"event: %s\" (map-elt event :event)))) + + ;; Subscribe to file writes + (agent-shell-subscribe-to + :shell-buffer shell-buffer + :event \\='file-write + :on-event (lambda (event) + (let ((data (map-elt event :data))) + (message \"wrote: %s\" (map-elt data :path))))) + + ;; Unsubscribe + (let ((token (agent-shell-subscribe-to + :shell-buffer shell-buffer + :on-event #\\='my-handler))) + (agent-shell-unsubscribe :subscription token))" + (unless on-event + (error "Missing required argument: :on-event")) + (unless shell-buffer + (error "Missing required argument: :shell-buffer")) + (let ((token (cl-incf agent-shell--subscription-counter))) + (with-current-buffer shell-buffer + (let ((subscriptions (map-elt (agent-shell--state) :event-subscriptions))) + (map-put! (agent-shell--state) + :event-subscriptions + (cons (list (cons :token token) + (cons :event event) + (cons :on-event on-event)) + subscriptions)))) + token)) + +(cl-defun agent-shell-unsubscribe (&key subscription) + "Remove event SUBSCRIPTION by token. + +SUBSCRIPTION is a token returned by `agent-shell-subscribe-to'." + (unless subscription + (error "Missing required argument: :subscription")) + (let ((subscriptions (map-elt (agent-shell--state) :event-subscriptions))) + (map-put! (agent-shell--state) + :event-subscriptions + (seq-remove (lambda (sub) + (equal (map-elt sub :token) subscription)) + subscriptions)))) + +(cl-defun agent-shell--emit-event (&key event data) + "Emit an EVENT to matching subscribers. +EVENT is a symbol identifying the event. +DATA is an optional alist of event-specific data." + (let ((event-alist (list (cons :event event)))) + (when data + (push (cons :data data) event-alist)) + (dolist (sub (map-elt (agent-shell--state) :event-subscriptions)) + (when (or (not (map-elt sub :event)) + (eq (map-elt sub :event) event)) + (funcall (map-elt sub :on-event) event-alist))))) + ;;; Initialization (cl-defun agent-shell--initialize-client () @@ -2742,6 +2855,7 @@ INSTALL-INSTRUCTIONS is optional installation guidance." (map-put! (agent-shell--state) :client (funcall (map-elt agent-shell--state :client-maker) (map-elt agent-shell--state :buffer))) + (agent-shell--emit-event :event 'init-client) t) (shell-maker-write-output :config shell-maker--config :output "No :client-maker found") @@ -2762,6 +2876,7 @@ INSTALL-INSTRUCTIONS is optional installation guidance." (if (map-elt agent-shell--state :client) (progn (agent-shell--subscribe-to-client-events :state agent-shell--state) + (agent-shell--emit-event :event 'init-subscriptions) t) (shell-maker-write-output :config shell-maker--config :output "No :client found") @@ -2813,7 +2928,8 @@ Must provide ON-INITIATED (lambda ())." :namespace-id "bootstrapping" :block-id "agent_capabilities" :label-left (propertize "Agent capabilities" 'font-lock-face 'font-lock-doc-markup-face) - :body (agent-shell--format-agent-capabilities agent-capabilities)))) + :body (agent-shell--format-agent-capabilities agent-capabilities))) + (agent-shell--emit-event :event 'init-handshake)) (funcall on-initiated)) :on-failure (agent-shell--make-error-handler :state agent-shell--state :shell-buffer shell-buffer))) @@ -2834,6 +2950,8 @@ Must provide ON-AUTHENTICATED (lambda ())." :request (funcall (map-elt agent-shell--state :authenticate-request-maker)) :on-success (lambda (_response) ;; TODO: More to be handled? + (with-current-buffer shell-buffer + (agent-shell--emit-event :event 'init-authenticate)) (funcall on-authenticated)) :on-failure (agent-shell--make-error-handler :state (agent-shell--state) :shell-buffer shell-buffer)) @@ -2869,6 +2987,7 @@ Call ON-MODEL-CHANGED on success." (map-put! updated-session :model-id model-id) (map-put! (agent-shell--state) :session updated-session)) (agent-shell--update-header-and-mode-line) + (agent-shell--emit-event :event 'init-model) (when on-model-changed (funcall on-model-changed))) :on-failure (agent-shell--make-error-handler @@ -2901,6 +3020,7 @@ Call ON-MODE-CHANGED on success." (map-put! updated-session :mode-id mode-id) (map-put! (agent-shell--state) :session updated-session)) (agent-shell--update-header-and-mode-line) + (agent-shell--emit-event :event 'init-session-mode) (when on-mode-changed (funcall on-mode-changed))) :on-failure (agent-shell--make-error-handler @@ -2966,6 +3086,7 @@ Must provide ON-SESSION-INIT (lambda ())." :body (agent-shell--format-available-modes (agent-shell--get-available-modes agent-shell--state)))) (agent-shell--update-header-and-mode-line) + (agent-shell--emit-event :event 'init-session) (funcall on-session-init)) :on-failure (agent-shell--make-error-handler :state agent-shell--state :shell-buffer shell-buffer))) @@ -3744,6 +3865,12 @@ MESSAGE-TEXT: Optional message to display after sending the response." (agent-shell--delete-fragment :state state :block-id (format "permission-%s" tool-call-id)) (map-put! state :tool-calls (map-delete (map-elt state :tool-calls) tool-call-id)) + (agent-shell--emit-event + :event 'permission-response + :data (list (cons :request-id request-id) + (cons :tool-call-id tool-call-id) + (cons :option-id option-id) + (cons :cancelled cancelled))) (when message-text (message "%s" message-text)) ;; Jump to any remaining permission buttons, or go to end of buffer. diff --git a/tests/agent-shell-tests.el b/tests/agent-shell-tests.el index 246a02e..ec22a3a 100644 --- a/tests/agent-shell-tests.el +++ b/tests/agent-shell-tests.el @@ -479,7 +479,7 @@ ;; Send a simple command (agent-shell--send-command :prompt "Hello agent" - :shell nil) + :shell-buffer nil) ;; Verify request was sent (should sent-request) @@ -516,7 +516,7 @@ ;; Now verify send-command handles the error gracefully (agent-shell--send-command :prompt "Test prompt with @file.txt" - :shell nil) + :shell-buffer nil) ;; Verify request was sent (fallback succeeded) (should sent-request) @@ -859,5 +859,123 @@ code block content with spaces (should (equal (buffer-string) "test ")) (should (equal (point) 6)))) +(ert-deftest agent-shell-subscribe-to-test () + "Test `agent-shell-subscribe-to' and event dispatching." + (let* ((received-events nil) + (agent-shell--state (list (cons :buffer (current-buffer)) + (cons :event-subscriptions nil)))) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state))) + (agent-shell-subscribe-to + :shell-buffer (current-buffer) + :on-event (lambda (event) + (push event received-events))) + + (agent-shell--emit-event :event 'init-client) + (agent-shell--emit-event :event 'init-session) + (agent-shell--emit-event :event 'init-model) + + (should (= (length received-events) 3)) + + ;; Events are pushed, so most recent is first + (should (equal (map-elt (nth 2 received-events) :event) 'init-client)) + (should (equal (map-elt (nth 1 received-events) :event) 'init-session)) + (should (equal (map-elt (nth 0 received-events) :event) 'init-model))))) + +(ert-deftest agent-shell-subscribe-to-filtered-test () + "Test `agent-shell-subscribe-to' with :event filter." + (let* ((received-events nil) + (agent-shell--state (list (cons :buffer (current-buffer)) + (cons :event-subscriptions nil)))) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state))) + (agent-shell-subscribe-to + :shell-buffer (current-buffer) + :event 'init-session + :on-event (lambda (event) + (push event received-events))) + + (agent-shell--emit-event :event 'init-client) + (agent-shell--emit-event :event 'init-session) + (agent-shell--emit-event :event 'init-client) + (agent-shell--emit-event :event 'init-session) + + ;; Only init-session events should be received + (should (= (length received-events) 2)) + (should (equal (map-elt (nth 0 received-events) :event) 'init-session)) + (should (equal (map-elt (nth 1 received-events) :event) 'init-session))))) + +(ert-deftest agent-shell-unsubscribe-test () + "Test `agent-shell-unsubscribe' removes subscription." + (let* ((received-events nil) + (agent-shell--state (list (cons :buffer (current-buffer)) + (cons :event-subscriptions nil)))) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state))) + (let ((token (agent-shell-subscribe-to + :shell-buffer (current-buffer) + :on-event (lambda (event) + (push event received-events))))) + + (agent-shell--emit-event :event 'init-client) + (should (= (length received-events) 1)) + + (agent-shell-unsubscribe :subscription token) + + (agent-shell--emit-event :event 'init-session) + ;; Should still be 1 — no new events after unsubscribe + (should (= (length received-events) 1)))))) + +(ert-deftest agent-shell--emit-event-with-data-test () + "Test `agent-shell--emit-event' passes :data to subscribers." + (let* ((received-events nil) + (agent-shell--state (list (cons :buffer (current-buffer)) + (cons :event-subscriptions nil)))) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state))) + (agent-shell-subscribe-to + :shell-buffer (current-buffer) + :on-event (lambda (event) + (push event received-events))) + + (agent-shell--emit-event + :event 'file-write + :data (list (cons :path "/tmp/test.txt") + (cons :content "hello"))) + + (should (= (length received-events) 1)) + (let ((event (car received-events))) + (should (equal (map-elt event :event) 'file-write)) + (should (equal (map-elt (map-elt event :data) :path) "/tmp/test.txt")) + (should (equal (map-elt (map-elt event :data) :content) "hello")))))) + +(ert-deftest agent-shell--emit-event-data-omitted-when-nil-test () + "Test `agent-shell--emit-event' omits :data when nil." + (let* ((received-events nil) + (agent-shell--state (list (cons :buffer (current-buffer)) + (cons :event-subscriptions nil)))) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state))) + (agent-shell-subscribe-to + :shell-buffer (current-buffer) + :on-event (lambda (event) + (push event received-events))) + + (agent-shell--emit-event :event 'init-client) + + (should (= (length received-events) 1)) + (let ((event (car received-events))) + (should (equal (map-elt event :event) 'init-client)) + (should-not (assoc :data event)))))) + +(ert-deftest agent-shell--emit-event-no-subscribers-test () + "Test `agent-shell--emit-event' works with no subscribers." + (let ((agent-shell--state (list (cons :buffer (current-buffer)) + (cons :event-subscriptions nil)))) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state))) + ;; Should not error when no subscriptions exist + (agent-shell--emit-event :event 'init-client)))) + (provide 'agent-shell-tests) ;;; agent-shell-tests.el ends here From af68b7067bcd2281f8fda7760e07b3a500b1fad0 Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:04:35 +0000 Subject: [PATCH 15/44] Applying session list/load PR #263 by @travisjeffery Related features #284 #268 #190 #105 --- README.org | 1 + agent-shell-viewport.el | 2 + agent-shell.el | 357 ++++++++++++++++++++++++++++++++++++- tests/agent-shell-tests.el | 256 ++++++++++++++++++++++++++ 4 files changed, 614 insertions(+), 2 deletions(-) diff --git a/README.org b/README.org index ec24f05..c8243ce 100644 --- a/README.org +++ b/README.org @@ -607,6 +607,7 @@ always go to Evil modes if you need to with ~C-z~). | agent-shell-qwen-command | Command and parameters for the Qwen Code client. | | agent-shell-qwen-environment | Environment variables for the Qwen Code client. | | agent-shell-screenshot-command | The program to use for capturing screenshots. | +| agent-shell-session-load-strategy | How to choose existing sessions when session/list and session/load are available. | | agent-shell-section-functions | Abnormal hook run after overlays are applied (experimental). | | agent-shell-show-busy-indicator | Non-nil to show the busy indicator animation in the header and mode line. | | agent-shell-show-config-icons | Whether to show icons in agent config selection. | diff --git a/agent-shell-viewport.el b/agent-shell-viewport.el index 38e6494..6364210 100644 --- a/agent-shell-viewport.el +++ b/agent-shell-viewport.el @@ -770,6 +770,7 @@ For example, offer to kill associated shell session." (define-key map (kbd "C-c C-p") #'agent-shell-viewport-compose-peek-last) (define-key map (kbd "C-c C-k") #'agent-shell-viewport-compose-cancel) (define-key map (kbd "C-") #'agent-shell-viewport-cycle-session-mode) + (define-key map (kbd "C-c C-d") #'agent-shell-delete-session) (define-key map (kbd "C-c C-m") #'agent-shell-viewport-set-session-mode) (define-key map (kbd "C-c C-v") #'agent-shell-viewport-set-session-model) (define-key map (kbd "C-c C-o") #'agent-shell-other-buffer) @@ -801,6 +802,7 @@ For example, offer to kill associated shell session." (define-key map (kbd "r") #'agent-shell-viewport-reply) (define-key map (kbd "q") #'bury-buffer) (define-key map (kbd "C-") #'agent-shell-viewport-cycle-session-mode) + (define-key map (kbd "C-c C-d") #'agent-shell-delete-session) (define-key map (kbd "v") #'agent-shell-viewport-set-session-model) (define-key map (kbd "m") #'agent-shell-viewport-set-session-mode) (define-key map (kbd "o") #'agent-shell-other-buffer) diff --git a/agent-shell.el b/agent-shell.el index 2ad92c7..f881a26 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -444,6 +444,19 @@ configuration alist for backwards compatibility." :key-type symbol :value-type sexp)) :group 'agent-shell) +(defcustom agent-shell-session-load-strategy 'latest + "How to choose an existing session when `session/list' and `session/load' are available. + +Available values: + + `latest': Load the latest session returned by `session/list'. + `prompt': Prompt to choose which session to load (or start a new one). + `new': Always start a new session and skip `session/list' and `session/load'." + :type '(choice (const :tag "Load latest session" latest) + (const :tag "Prompt for session" prompt) + (const :tag "Always start new session" new)) + :group 'agent-shell) + (defun agent-shell--resolve-preferred-config () "Resolve `agent-shell-preferred-agent-config' to a full configuration. @@ -555,6 +568,9 @@ HEARTBEAT, and AUTHENTICATE-REQUEST-MAKER." (cons :tool-calls nil) (cons :available-commands nil) (cons :available-modes nil) + (cons :supports-session-list nil) + (cons :supports-session-load nil) + (cons :supports-session-delete nil) (cons :prompt-capabilities nil) (cons :event-subscriptions nil) (cons :pending-requests nil) @@ -845,6 +861,7 @@ When FORCE is non-nil, skip confirmation prompt." "p" #'agent-shell-previous-item "C-" #'agent-shell-cycle-session-mode "C-c C-c" #'agent-shell-interrupt + "C-c C-d" #'agent-shell-delete-session "C-c C-m" #'agent-shell-set-session-mode "C-c C-v" #'agent-shell-set-session-model "C-c C-o" #'agent-shell-other-buffer) @@ -1982,7 +1999,7 @@ Returns propertized labels in :status and :title propertized." (agent-shell--status-label (map-elt entry 'status))) (lambda (entry) (map-elt entry 'content))) - :separator " " + :separator " " :joiner "\n")) (cl-defun agent-shell--make-button (&key text help kind action keymap) @@ -2966,6 +2983,16 @@ Must provide ON-INITIATED (lambda ())." :write-text-file-capability agent-shell-text-file-capabilities) :on-success (lambda (response) (with-current-buffer shell-buffer + (let ((session-capabilities (or (map-elt response 'sessionCapabilities) + (map-nested-elt response '(agentCapabilities sessionCapabilities))))) + (map-put! agent-shell--state :supports-session-list + (and (listp session-capabilities) + (assq 'list session-capabilities) + t)) + (map-put! agent-shell--state :supports-session-delete + (and (listp session-capabilities) + (assq 'delete session-capabilities) + t))) ;; Save prompt capabilities from agent, converting to internal symbols (when-let ((prompt-capabilities (map-nested-elt response '(agentCapabilities promptCapabilities)))) @@ -2982,6 +3009,8 @@ Must provide ON-INITIATED (lambda ())." (:description . ,(map-elt mode 'description)))) (map-elt modes 'availableModes)))))) (when-let ((agent-capabilities (map-elt response 'agentCapabilities))) + (map-put! agent-shell--state :supports-session-load + (eq (map-elt agent-capabilities 'loadSession) t)) (agent-shell--update-fragment :state agent-shell--state :namespace-id "bootstrapping" @@ -3097,6 +3126,270 @@ Must provide ON-SESSION-INIT (lambda ())." :block-id "starting" :body "\n\nCreating session..." :append t)) + (if (and (map-elt (agent-shell--state) :supports-session-list) + (map-elt (agent-shell--state) :supports-session-load) + (not (eq agent-shell-session-load-strategy 'new))) + (agent-shell--initiate-session-list-and-load + :shell shell + :on-session-init on-session-init) + (agent-shell--initiate-new-session + :shell shell + :on-session-init on-session-init))) + +(defun agent-shell--session-choice-label (session) + "Return completion label for SESSION." + (let* ((session-id (or (map-elt session 'sessionId) + "unknown-session")) + (title (or (map-elt session 'title) + "Untitled")) + (updated-at (or (map-elt session 'updatedAt) + (map-elt session 'createdAt) + "unknown-time"))) + (format "%s | %s | %s" title updated-at session-id))) + +(defconst agent-shell--start-new-session-choice "Start a new session" + "Label for creating a new session from the session picker.") + +(defun agent-shell--session-picker-sort (candidates) + "Return CANDIDATES with `agent-shell--start-new-session-choice' first." + (if (member agent-shell--start-new-session-choice candidates) + (cons agent-shell--start-new-session-choice + (delete agent-shell--start-new-session-choice + (copy-sequence candidates))) + candidates)) + +(defun agent-shell--prompt-select-session-to-load (sessions) + "Prompt to choose one from SESSIONS. + +Return selected session alist, or nil to start a new session." + (when sessions + (let* ((session-choices (mapcar (lambda (session) + (cons (agent-shell--session-choice-label session) + session)) + sessions)) + (choices (cons (cons agent-shell--start-new-session-choice nil) + session-choices)) + (completion-extra-properties + '(:display-sort-function agent-shell--session-picker-sort + :cycle-sort-function agent-shell--session-picker-sort)) + (selection (completing-read "Load session: " + (mapcar #'car choices) + nil t nil nil + agent-shell--start-new-session-choice))) + (cdr (assoc selection choices))))) + +(defun agent-shell--select-session-to-load (sessions) + "Select a session from SESSIONS based on `agent-shell-session-load-strategy'." + (pcase agent-shell-session-load-strategy + ('new nil) + ('latest (car sessions)) + ('prompt (if noninteractive + (car sessions) + (agent-shell--prompt-select-session-to-load sessions))) + (_ (car sessions)))) + +(defun agent-shell--prompt-select-session-to-delete (sessions) + "Prompt to choose one from SESSIONS for deletion. + +Return selected session alist, or nil if user quit." + (when sessions + (let* ((choices (mapcar (lambda (session) + (cons (agent-shell--session-choice-label session) + session)) + sessions)) + (selection (completing-read "Delete session: " + (mapcar #'car choices) + nil t))) + (cdr (assoc selection choices))))) + +(defun agent-shell--select-session-to-delete (sessions) + "Select a session from SESSIONS for deletion." + (if noninteractive + (car sessions) + (agent-shell--prompt-select-session-to-delete sessions))) + +(defun agent-shell--clear-session-state () + "Reset current session-scoped state for the active shell." + (let* ((state (agent-shell--state)) + (session (or (map-elt state :session) + (list (cons :id nil) + (cons :mode-id nil) + (cons :modes nil))))) + (map-put! session :id nil) + (map-put! session :mode-id nil) + (map-put! session :modes nil) + ;; Clear optional fields if they were previously populated. + (map-put! session :model-id nil) + (map-put! session :models nil) + (map-put! state :session session) + (map-put! state :set-session-mode nil) + (map-put! state :set-model nil) + (map-put! state :tool-calls nil) + (map-put! state :available-commands nil) + (agent-shell--update-header-and-mode-line))) + +(cl-defun agent-shell--delete-session-by-id (&key shell session-id on-success) + "Delete SESSION-ID via ACP using SHELL. + +ON-SUCCESS is called with no args after successful delete." + (unless session-id + (error "Missing required argument: :session-id")) + (with-current-buffer (map-elt (agent-shell--state) :buffer) + (agent-shell--update-fragment + :state (agent-shell--state) + :block-id "session_delete" + :label-left (propertize "Deleting session" 'font-lock-face 'font-lock-doc-markup-face) + :body (format "Requesting deletion for %s..." (substring-no-properties session-id)) + :append t)) + (acp-send-request + :client (map-elt (agent-shell--state) :client) + :request `((:method . "session/delete") + (:params . ((sessionId . ,session-id)))) + :buffer (current-buffer) + :on-success (lambda (_response) + (with-current-buffer (map-elt (agent-shell--state) :buffer) + (agent-shell--update-fragment + :state (agent-shell--state) + :block-id "session_delete" + :body "\n\nDone" + :append t)) + (when on-success + (funcall on-success))) + :on-failure (agent-shell--make-error-handler + :state (agent-shell--state) :shell shell))) + +(defun agent-shell-delete-session (&optional force-current) + "Delete an existing agent session from the agent's session history. + +This requires the agent to support the experimental ACP method +\"session/delete\". + +With prefix argument FORCE-CURRENT, delete the current session without +prompting for a session to pick (still asks for confirmation)." + (interactive "P") + (unless (or (derived-mode-p 'agent-shell-mode) + (derived-mode-p 'agent-shell-viewport-view-mode) + (derived-mode-p 'agent-shell-viewport-edit-mode)) + (user-error "Not in an agent-shell buffer")) + (let* ((shell-buffer (if (derived-mode-p 'agent-shell-mode) + (current-buffer) + (or (agent-shell-viewport--shell-buffer) + (user-error "No shell buffer available"))))) + (with-current-buffer shell-buffer + (unless (map-elt (agent-shell--state) :client) + (user-error "Agent not initialized")) + (unless (map-elt (agent-shell--state) :supports-session-delete) + (user-error "Agent does not support session/delete")) + (let* ((shell `((:buffer . ,(current-buffer)))) + (current-session-id (map-nested-elt (agent-shell--state) '(:session :id)))) + (cond + ((and force-current current-session-id) + (when (y-or-n-p (format "Delete current session %s? " + (substring-no-properties current-session-id))) + (agent-shell--delete-session-by-id + :shell shell + :session-id current-session-id + :on-success (lambda () + (agent-shell--clear-session-state) + (message "Deleted session %s" + (substring-no-properties current-session-id)))))) + ((map-elt (agent-shell--state) :supports-session-list) + (with-current-buffer (map-elt (agent-shell--state) :buffer) + (agent-shell--update-fragment + :state (agent-shell--state) + :block-id "session_delete" + :label-left (propertize "Deleting session" 'font-lock-face 'font-lock-doc-markup-face) + :body "\n\nLooking for existing sessions..." + :append t)) + (acp-send-request + :client (map-elt (agent-shell--state) :client) + :request `((:method . "session/list") + (:params . ((cwd . ,(agent-shell--resolve-path (agent-shell-cwd))))))) + :buffer (current-buffer) + :on-success (lambda (response) + (let* ((sessions (append (or (map-elt response 'sessions) '()) nil)) + (selected-session (agent-shell--select-session-to-delete sessions)) + (session-id (and selected-session + (map-elt selected-session 'sessionId)))) + (cond + ((not session-id) + (message "No session selected")) + ((not (y-or-n-p (format "Delete session %s? " + (substring-no-properties session-id)))) + (message "Cancelled")) + (t + (agent-shell--delete-session-by-id + :shell shell + :session-id session-id + :on-success (lambda () + (when (and current-session-id + (equal (substring-no-properties session-id) + (substring-no-properties current-session-id))) + (agent-shell--clear-session-state)) + (message "Deleted session %s" + (substring-no-properties session-id)))))))) + :on-failure (agent-shell--make-error-handler + :state (agent-shell--state) :shell shell))) + (current-session-id + (when (y-or-n-p (format "Delete current session %s? " + (substring-no-properties current-session-id))) + (agent-shell--delete-session-by-id + :shell shell + :session-id current-session-id + :on-success (lambda () + (agent-shell--clear-session-state) + (message "Deleted session %s" + (substring-no-properties current-session-id)))))) + (t + (user-error "No session to delete")))))) + +(cl-defun agent-shell--set-session-from-response (&key response session-id) + "Set active session state from RESPONSE and SESSION-ID." + (map-put! agent-shell--state + :session (list (cons :id session-id) + (cons :mode-id (map-nested-elt response '(modes currentModeId))) + (cons :modes (mapcar (lambda (mode) + `((:id . ,(map-elt mode 'id)) + (:name . ,(map-elt mode 'name)) + (:description . ,(map-elt mode 'description)))) + (map-nested-elt response '(modes availableModes)))) + (cons :model-id (map-nested-elt response '(models currentModelId))) + (cons :models (mapcar (lambda (model) + `((:model-id . ,(map-elt model 'modelId)) + (:name . ,(map-elt model 'name)) + (:description . ,(map-elt model 'description)))) + (map-nested-elt response '(models availableModels))))))) + +(cl-defun agent-shell--finalize-session-init (&key on-session-init) + "Finalize session initialization and invoke ON-SESSION-INIT." + (agent-shell--update-fragment + :state agent-shell--state + :block-id "starting" + :label-left (format "%s %s" + (agent-shell--status-label "completed") + (propertize "Starting agent" 'font-lock-face 'font-lock-doc-markup-face)) + :body "\n\nReady" + :append t) + (agent-shell--update-header-and-mode-line) + (when (map-nested-elt agent-shell--state '(:session :models)) + (agent-shell--update-fragment + :state agent-shell--state + :block-id "available_models" + :label-left (propertize "Available models" 'font-lock-face 'font-lock-doc-markup-face) + :body (agent-shell--format-available-models + (map-nested-elt agent-shell--state '(:session :models))))) + (when (agent-shell--get-available-modes agent-shell--state) + (agent-shell--update-fragment + :state agent-shell--state + :block-id "available_modes" + :label-left (propertize "Available modes" 'font-lock-face 'font-lock-doc-markup-face) + :body (agent-shell--format-available-modes + (agent-shell--get-available-modes agent-shell--state)))) + (agent-shell--update-header-and-mode-line) + (funcall on-session-init)) + +(cl-defun agent-shell--initiate-new-session (&key shell on-session-init) + "Initiate ACP session/new with SHELL and ON-SESSION-INIT." (acp-send-request :client (map-elt (agent-shell--state) :client) :request (acp-make-session-new-request @@ -3150,6 +3443,64 @@ Must provide ON-SESSION-INIT (lambda ())." :on-failure (agent-shell--make-error-handler :state agent-shell--state :shell-buffer shell-buffer))) +(cl-defun agent-shell--initiate-session-list-and-load (&key shell on-session-init) + "Try loading latest existing session with SHELL and ON-SESSION-INIT." + (with-current-buffer (map-elt (agent-shell--state) :buffer) + (agent-shell--update-fragment + :state (agent-shell--state) + :block-id "starting" + :body "\n\nLooking for existing sessions..." + :append t)) + (acp-send-request + :client (map-elt (agent-shell--state) :client) + :request `((:method . "session/list") + (:params . ((cwd . ,(agent-shell--resolve-path (agent-shell-cwd)))))) + :buffer (current-buffer) + :on-success (lambda (response) + (let* ((sessions (append (or (map-elt response 'sessions) '()) nil)) + (selected-session + (condition-case nil + (agent-shell--select-session-to-load sessions) + (quit nil))) + (session-id (and selected-session + (map-elt selected-session 'sessionId)))) + (if session-id + (progn + (agent-shell--update-fragment + :state (agent-shell--state) + :block-id "starting" + :body (format "\n\nLoading session %s..." + (substring-no-properties session-id)) + :append t) + (acp-send-request + :client (map-elt (agent-shell--state) :client) + :request `((:method . "session/load") + (:params . ((sessionId . ,session-id) + (cwd . ,(agent-shell--resolve-path (agent-shell-cwd))) + (mcpServers . ,(or (agent-shell--mcp-servers) []))))) + :buffer (current-buffer) + :on-success (lambda (load-response) + (agent-shell--set-session-from-response + :response load-response + :session-id session-id) + (agent-shell--finalize-session-init :on-session-init on-session-init)) + :on-failure (lambda (_error _raw-message) + (agent-shell--update-fragment + :state (agent-shell--state) + :block-id "starting" + :body "\n\nCould not load existing session. Creating a new one..." + :append t) + (agent-shell--initiate-new-session + :shell shell + :on-session-init on-session-init)))) + (agent-shell--initiate-new-session + :shell shell + :on-session-init on-session-init)))) + :on-failure (lambda (_error _raw-message) + (agent-shell--initiate-new-session + :shell shell + :on-session-init on-session-init)))) + (defun agent-shell--eval-dynamic-values (obj) "Recursively evaluate any lambda values in OBJ. Named functions (symbols) are not evaluated to avoid accidentally @@ -4273,7 +4624,9 @@ Returns an alist with insertion details or nil otherwise: ((:buffer . BUFFER) (:start . START) - (:end . END))" + (:end . END)) + +Uses optional SHELL-BUFFER to make paths relative to shell project." (if agent-shell-prefer-viewport-interaction (agent-shell-viewport--show-buffer :append text :submit submit :no-focus no-focus :shell-buffer shell-buffer) diff --git a/tests/agent-shell-tests.el b/tests/agent-shell-tests.el index ec22a3a..b630efa 100644 --- a/tests/agent-shell-tests.el +++ b/tests/agent-shell-tests.el @@ -977,5 +977,261 @@ code block content with spaces ;; Should not error when no subscriptions exist (agent-shell--emit-event :event 'init-client)))) +(ert-deftest agent-shell--initiate-session-prefers-list-and-load-when-supported () + "Test `agent-shell--initiate-session' prefers session/list + session/load." + (with-temp-buffer + (let* ((agent-shell-session-load-strategy 'latest) + (requests '()) + (session-init-called nil) + (state `((:buffer . ,(current-buffer)) + (:client . test-client) + (:session . ((:id . nil) + (:mode-id . nil) + (:modes . nil))) + (:supports-session-list . t) + (:supports-session-load . t)))) + (setq-local agent-shell--state state) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state)) + ((symbol-function 'agent-shell--update-fragment) + (lambda (&rest _args) nil)) + ((symbol-function 'agent-shell--update-header-and-mode-line) + (lambda () nil)) + ((symbol-function 'agent-shell-cwd) + (lambda () "/tmp")) + ((symbol-function 'agent-shell--resolve-path) + (lambda (path) path)) + ((symbol-function 'agent-shell--mcp-servers) + (lambda () [])) + ((symbol-function 'acp-send-request) + (lambda (&rest args) + (push args requests) + (let* ((request (plist-get args :request)) + (method (map-elt request :method))) + (pcase method + ("session/list" + (funcall (plist-get args :on-success) + '((sessions . [((sessionId . "session-123") + (cwd . "/tmp") + (title . "Recent session"))])))) + ("session/load" + (funcall (plist-get args :on-success) + '((modes (currentModeId . "default") + (availableModes . [((id . "default") + (name . "Default") + (description . "Default mode"))])) + (models (currentModelId . "gpt-5") + (availableModels . [((modelId . "gpt-5") + (name . "GPT-5") + (description . "Test model"))]))))) + (_ (error "Unexpected method: %s" method))))))) + (agent-shell--initiate-session + :shell `((:buffer . ,(current-buffer))) + :on-session-init (lambda () + (setq session-init-called t))) + (let ((ordered-requests (nreverse requests))) + (should (equal (mapcar (lambda (req) + (map-elt (plist-get req :request) :method)) + ordered-requests) + '("session/list" "session/load"))) + (let* ((load-request (plist-get (nth 1 ordered-requests) :request)) + (load-params (map-elt load-request :params))) + (should (equal (map-elt load-params 'sessionId) "session-123")) + (should (equal (map-elt load-params 'cwd) "/tmp")))) + (should session-init-called) + (should (equal (map-nested-elt agent-shell--state '(:session :id)) "session-123")))))) + +(ert-deftest agent-shell--initiate-session-falls-back-to-new-on-list-failure () + "Test `agent-shell--initiate-session' falls back to session/new on list failure." + (with-temp-buffer + (let* ((agent-shell-session-load-strategy 'latest) + (requests '()) + (session-init-called nil) + (state `((:buffer . ,(current-buffer)) + (:client . test-client) + (:session . ((:id . nil) + (:mode-id . nil) + (:modes . nil))) + (:supports-session-list . t) + (:supports-session-load . t)))) + (setq-local agent-shell--state state) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state)) + ((symbol-function 'agent-shell--update-fragment) + (lambda (&rest _args) nil)) + ((symbol-function 'agent-shell--update-header-and-mode-line) + (lambda () nil)) + ((symbol-function 'agent-shell-cwd) + (lambda () "/tmp")) + ((symbol-function 'agent-shell--resolve-path) + (lambda (path) path)) + ((symbol-function 'agent-shell--mcp-servers) + (lambda () [])) + ((symbol-function 'acp-send-request) + (lambda (&rest args) + (push args requests) + (let* ((request (plist-get args :request)) + (method (map-elt request :method))) + (pcase method + ("session/list" + (funcall (plist-get args :on-failure) + '((code . -32601) + (message . "Method not found")) + nil)) + ("session/new" + (funcall (plist-get args :on-success) + '((sessionId . "new-session-456")))) + (_ (error "Unexpected method: %s" method))))))) + (agent-shell--initiate-session + :shell `((:buffer . ,(current-buffer))) + :on-session-init (lambda () + (setq session-init-called t))) + (let ((ordered-requests (nreverse requests))) + (should (equal (mapcar (lambda (req) + (map-elt (plist-get req :request) :method)) + ordered-requests) + '("session/list" "session/new")))) + (should session-init-called) + (should (equal (map-nested-elt agent-shell--state '(:session :id)) "new-session-456")))))) + +(ert-deftest agent-shell--prompt-select-session-to-load-test () + "Test `agent-shell--prompt-select-session-to-load' choices." + (let* ((session-a '((sessionId . "session-1") + (title . "First") + (updatedAt . "2026-01-19T14:00:00Z"))) + (session-b '((sessionId . "session-2") + (title . "Second") + (updatedAt . "2026-01-20T16:00:00Z"))) + (sessions (list session-a session-b))) + ;; Select existing session + (cl-letf (((symbol-function 'completing-read) + (lambda (&rest _args) + (agent-shell--session-choice-label session-b)))) + (should (equal (agent-shell--prompt-select-session-to-load sessions) + session-b))) + ;; Select "new session" option + (cl-letf (((symbol-function 'completing-read) + (lambda (&rest _args) + agent-shell--start-new-session-choice))) + (should-not (agent-shell--prompt-select-session-to-load sessions))))) + +(ert-deftest agent-shell--session-picker-sort-test () + "Test `agent-shell--session-picker-sort' keeps the new-session option first." + (let* ((session-a-label "First | 2026-01-19T14:00:00Z | session-1") + (session-b-label "Second | 2026-01-20T16:00:00Z | session-2") + (candidates (list session-a-label + agent-shell--start-new-session-choice + session-b-label))) + (should (equal (agent-shell--session-picker-sort candidates) + (list agent-shell--start-new-session-choice + session-a-label + session-b-label))))) + +(ert-deftest agent-shell--prompt-select-session-to-load-defaults-to-new-session-test () + "Test prompt defaults to `agent-shell--start-new-session-choice'." + (let* ((session-a '((sessionId . "session-1") + (title . "First") + (updatedAt . "2026-01-19T14:00:00Z"))) + (session-b '((sessionId . "session-2") + (title . "Second") + (updatedAt . "2026-01-20T16:00:00Z"))) + (sessions (list session-a session-b)) + (captured-default nil)) + (cl-letf (((symbol-function 'completing-read) + (lambda (&rest args) + (setq captured-default (nth 6 args)) + agent-shell--start-new-session-choice))) + (agent-shell--prompt-select-session-to-load sessions) + (should (equal captured-default agent-shell--start-new-session-choice))))) + +(ert-deftest agent-shell--initiate-session-strategy-new-skips-list-load () + "Test `agent-shell--initiate-session' skips list/load when strategy is `new'." + (with-temp-buffer + (let* ((agent-shell-session-load-strategy 'new) + (requests '()) + (session-init-called nil) + (state `((:buffer . ,(current-buffer)) + (:client . test-client) + (:session . ((:id . nil) + (:mode-id . nil) + (:modes . nil))) + (:supports-session-list . t) + (:supports-session-load . t)))) + (setq-local agent-shell--state state) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state)) + ((symbol-function 'agent-shell--update-fragment) + (lambda (&rest _args) nil)) + ((symbol-function 'agent-shell--update-header-and-mode-line) + (lambda () nil)) + ((symbol-function 'agent-shell-cwd) + (lambda () "/tmp")) + ((symbol-function 'agent-shell--resolve-path) + (lambda (path) path)) + ((symbol-function 'agent-shell--mcp-servers) + (lambda () [])) + ((symbol-function 'acp-send-request) + (lambda (&rest args) + (push args requests) + (let* ((request (plist-get args :request)) + (method (map-elt request :method))) + (pcase method + ("session/new" + (funcall (plist-get args :on-success) + '((sessionId . "new-session-789")))) + (_ (error "Unexpected method: %s" method))))))) + (agent-shell--initiate-session + :shell `((:buffer . ,(current-buffer))) + :on-session-init (lambda () + (setq session-init-called t))) + (let ((ordered-requests (nreverse requests))) + (should (equal (mapcar (lambda (req) + (map-elt (plist-get req :request) :method)) + ordered-requests) + '("session/new")))) + (should session-init-called) + (should (equal (map-nested-elt agent-shell--state '(:session :id)) "new-session-789")))))) + +(ert-deftest agent-shell--delete-session-by-id-sends-session-delete () + "Test `agent-shell--delete-session-by-id' sends session/delete request." + (with-temp-buffer + (let* ((requests '()) + (success-called nil) + (state `((:buffer . ,(current-buffer)) + (:client . test-client) + (:session . ((:id . "session-2") + (:mode-id . nil) + (:modes . nil))) + (:supports-session-list . t) + (:supports-session-delete . t)))) + (setq-local agent-shell--state state) + (cl-letf (((symbol-function 'agent-shell--state) + (lambda () agent-shell--state)) + ((symbol-function 'agent-shell--update-fragment) + (lambda (&rest _args) nil)) + ((symbol-function 'agent-shell--update-header-and-mode-line) + (lambda () nil)) + ((symbol-function 'acp-send-request) + (lambda (&rest args) + (push args requests) + (let* ((request (plist-get args :request)) + (method (map-elt request :method))) + (pcase method + ("session/delete" + (let ((params (map-elt request :params))) + (should (equal (map-elt params 'sessionId) "session-2"))) + (funcall (plist-get args :on-success) '((ok . t)))) + (_ (error "Unexpected method: %s" method))))))) + (agent-shell--delete-session-by-id + :shell `((:buffer . ,(current-buffer))) + :session-id "session-2" + :on-success (lambda () (setq success-called t))) + (should success-called) + (let ((ordered-requests (nreverse requests))) + (should (equal (mapcar (lambda (req) + (map-elt (plist-get req :request) :method)) + ordered-requests) + '("session/delete")))))))) + (provide 'agent-shell-tests) ;;; agent-shell-tests.el ends here From 3dec6410829066f250f4e87cc00952c5cbea88b3 Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:59:06 +0000 Subject: [PATCH 16/44] Rebase on to main to enable bootstrapping and session resume support Related features #284 #268 #190 #105 --- agent-shell.el | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index f881a26..57871aa 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -570,6 +570,7 @@ HEARTBEAT, and AUTHENTICATE-REQUEST-MAKER." (cons :available-modes nil) (cons :supports-session-list nil) (cons :supports-session-load nil) + (cons :supports-session-resume nil) (cons :supports-session-delete nil) (cons :prompt-capabilities nil) (cons :event-subscriptions nil) @@ -2992,6 +2993,10 @@ Must provide ON-INITIATED (lambda ())." (map-put! agent-shell--state :supports-session-delete (and (listp session-capabilities) (assq 'delete session-capabilities) + t)) + (map-put! agent-shell--state :supports-session-resume + (and (listp session-capabilities) + (assq 'resume session-capabilities) t))) ;; Save prompt capabilities from agent, converting to internal symbols (when-let ((prompt-capabilities @@ -3127,13 +3132,14 @@ Must provide ON-SESSION-INIT (lambda ())." :body "\n\nCreating session..." :append t)) (if (and (map-elt (agent-shell--state) :supports-session-list) - (map-elt (agent-shell--state) :supports-session-load) + (or (map-elt (agent-shell--state) :supports-session-load) + (map-elt (agent-shell--state) :supports-session-resume)) (not (eq agent-shell-session-load-strategy 'new))) (agent-shell--initiate-session-list-and-load - :shell shell + :shell-buffer shell-buffer :on-session-init on-session-init) (agent-shell--initiate-new-session - :shell shell + :shell-buffer shell-buffer :on-session-init on-session-init))) (defun agent-shell--session-choice-label (session) @@ -3369,11 +3375,13 @@ prompting for a session to pick (still asks for confirmation)." (agent-shell--status-label "completed") (propertize "Starting agent" 'font-lock-face 'font-lock-doc-markup-face)) :body "\n\nReady" + :namespace-id "bootstrapping" :append t) (agent-shell--update-header-and-mode-line) (when (map-nested-elt agent-shell--state '(:session :models)) (agent-shell--update-fragment :state agent-shell--state + :namespace-id "bootstrapping" :block-id "available_models" :label-left (propertize "Available models" 'font-lock-face 'font-lock-doc-markup-face) :body (agent-shell--format-available-models @@ -3381,6 +3389,7 @@ prompting for a session to pick (still asks for confirmation)." (when (agent-shell--get-available-modes agent-shell--state) (agent-shell--update-fragment :state agent-shell--state + :namespace-id "bootstrapping" :block-id "available_modes" :label-left (propertize "Available modes" 'font-lock-face 'font-lock-doc-markup-face) :body (agent-shell--format-available-modes @@ -3388,8 +3397,8 @@ prompting for a session to pick (still asks for confirmation)." (agent-shell--update-header-and-mode-line) (funcall on-session-init)) -(cl-defun agent-shell--initiate-new-session (&key shell on-session-init) - "Initiate ACP session/new with SHELL and ON-SESSION-INIT." +(cl-defun agent-shell--initiate-new-session (&key shell-buffer on-session-init) + "Initiate ACP session/new with SHELL-BUFFER and ON-SESSION-INIT." (acp-send-request :client (map-elt (agent-shell--state) :client) :request (acp-make-session-new-request @@ -3443,8 +3452,8 @@ prompting for a session to pick (still asks for confirmation)." :on-failure (agent-shell--make-error-handler :state agent-shell--state :shell-buffer shell-buffer))) -(cl-defun agent-shell--initiate-session-list-and-load (&key shell on-session-init) - "Try loading latest existing session with SHELL and ON-SESSION-INIT." +(cl-defun agent-shell--initiate-session-list-and-load (&key shell-buffer on-session-init) + "Try loading latest existing session with SHELL-BUFFER and ON-SESSION-INIT." (with-current-buffer (map-elt (agent-shell--state) :buffer) (agent-shell--update-fragment :state (agent-shell--state) @@ -3454,7 +3463,8 @@ prompting for a session to pick (still asks for confirmation)." (acp-send-request :client (map-elt (agent-shell--state) :client) :request `((:method . "session/list") - (:params . ((cwd . ,(agent-shell--resolve-path (agent-shell-cwd)))))) + ;; Must remove trailing / to make sure Claude recognizes previous CWDs. + (:params . ((cwd . ,(agent-shell--resolve-path (string-remove-suffix "/" (agent-shell-cwd))))))) :buffer (current-buffer) :on-success (lambda (response) (let* ((sessions (append (or (map-elt response 'sessions) '()) nil)) @@ -3474,9 +3484,11 @@ prompting for a session to pick (still asks for confirmation)." :append t) (acp-send-request :client (map-elt (agent-shell--state) :client) - :request `((:method . "session/load") + :request `((:method . ,(if (map-elt (agent-shell--state) :supports-session-load) + "session/load" + "session/resume")) (:params . ((sessionId . ,session-id) - (cwd . ,(agent-shell--resolve-path (agent-shell-cwd))) + (cwd . ,(agent-shell--resolve-path (string-remove-suffix "/" (agent-shell-cwd)))) (mcpServers . ,(or (agent-shell--mcp-servers) []))))) :buffer (current-buffer) :on-success (lambda (load-response) @@ -3491,14 +3503,14 @@ prompting for a session to pick (still asks for confirmation)." :body "\n\nCould not load existing session. Creating a new one..." :append t) (agent-shell--initiate-new-session - :shell shell + :shell-buffer shell-buffer :on-session-init on-session-init)))) (agent-shell--initiate-new-session - :shell shell + :shell-buffer shell-buffer :on-session-init on-session-init)))) :on-failure (lambda (_error _raw-message) (agent-shell--initiate-new-session - :shell shell + :shell-buffer shell-buffer :on-session-init on-session-init)))) (defun agent-shell--eval-dynamic-values (obj) From beea09c499beaaa176eb1e5ba83ff7973fc05afc Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:58:43 +0000 Subject: [PATCH 17/44] Align session columns in session picker (completing-read) For example: Let's build something Today, 16:25 Let's refactor my hobby project Yesterday, 20:18 Let's optimize the rocket engine Feb 12, 21:02 Related to: #190 #105 --- agent-shell.el | 41 +++++++++++++++++++++++++++++++++----- tests/agent-shell-tests.el | 26 ++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index 57871aa..40e9a0f 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -3142,16 +3142,47 @@ Must provide ON-SESSION-INIT (lambda ())." :shell-buffer shell-buffer :on-session-init on-session-init))) +(defun agent-shell--format-session-date (iso-timestamp) + "Format ISO-TIMESTAMP as a human-friendly date string. + +Returns \"Today, HH:MM\", \"Yesterday, HH:MM\", \"Mon DD, HH:MM\" +for the current year, or \"Mon DD, YYYY\" for other years." + (condition-case nil + (let* ((time (date-to-time iso-timestamp)) + (now (current-time)) + (decoded-now (decode-time now)) + (today-start (encode-time 0 0 0 + (decoded-time-day decoded-now) + (decoded-time-month decoded-now) + (decoded-time-year decoded-now))) + (yesterday-start (time-subtract today-start (seconds-to-time (* 24 60 60)))) + (current-year (decoded-time-year (decode-time now))) + (timestamp-year (decoded-time-year (decode-time time)))) + (cond + ((not (time-less-p time today-start)) + (format-time-string "Today, %H:%M" time)) + ((not (time-less-p time yesterday-start)) + (format-time-string "Yesterday, %H:%M" time)) + ((= timestamp-year current-year) + (format-time-string "%b %d, %H:%M" time)) + (t + (format-time-string "%b %d, %Y" time)))) + (error iso-timestamp))) + (defun agent-shell--session-choice-label (session) "Return completion label for SESSION." - (let* ((session-id (or (map-elt session 'sessionId) - "unknown-session")) - (title (or (map-elt session 'title) + (let* ((title (or (map-elt session 'title) "Untitled")) + (title (if (> (length title) 50) + (concat (substring title 0 47) "...") + title)) (updated-at (or (map-elt session 'updatedAt) (map-elt session 'createdAt) - "unknown-time"))) - (format "%s | %s | %s" title updated-at session-id))) + "unknown-time")) + (date-str (propertize (agent-shell--format-session-date updated-at) + 'face 'font-lock-comment-face)) + (padding (make-string (max 2 (- 52 (length title))) ?\s))) + (concat title padding date-str))) (defconst agent-shell--start-new-session-choice "Start a new session" "Label for creating a new session from the session picker.") diff --git a/tests/agent-shell-tests.el b/tests/agent-shell-tests.el index b630efa..e7e211e 100644 --- a/tests/agent-shell-tests.el +++ b/tests/agent-shell-tests.el @@ -1094,6 +1094,28 @@ code block content with spaces (should session-init-called) (should (equal (map-nested-elt agent-shell--state '(:session :id)) "new-session-456")))))) +(ert-deftest agent-shell--format-session-date-test () + "Test `agent-shell--format-session-date' humanizes timestamps." + ;; Today + (let* ((now (current-time)) + (today-iso (format-time-string "%Y-%m-%dT10:30:00Z" now))) + (should (equal (agent-shell--format-session-date today-iso) + "Today, 10:30"))) + ;; Yesterday + (let* ((yesterday (time-subtract (current-time) (* 24 60 60))) + (yesterday-iso (format-time-string "%Y-%m-%dT15:45:00Z" yesterday))) + (should (equal (agent-shell--format-session-date yesterday-iso) + "Yesterday, 15:45"))) + ;; Same year, older + (should (string-match-p "^[A-Z][a-z]+ [0-9]+, [0-9]+:[0-9]+" + (agent-shell--format-session-date "2026-01-05T09:00:00Z"))) + ;; Different year + (should (string-match-p "^[A-Z][a-z]+ [0-9]+, [0-9]\\{4\\}" + (agent-shell--format-session-date "2025-06-15T12:00:00Z"))) + ;; Invalid input falls back gracefully + (should (equal (agent-shell--format-session-date "not-a-date") + "not-a-date"))) + (ert-deftest agent-shell--prompt-select-session-to-load-test () "Test `agent-shell--prompt-select-session-to-load' choices." (let* ((session-a '((sessionId . "session-1") @@ -1117,8 +1139,8 @@ code block content with spaces (ert-deftest agent-shell--session-picker-sort-test () "Test `agent-shell--session-picker-sort' keeps the new-session option first." - (let* ((session-a-label "First | 2026-01-19T14:00:00Z | session-1") - (session-b-label "Second | 2026-01-20T16:00:00Z | session-2") + (let* ((session-a-label "First Jan 19, 14:00") + (session-b-label "Second Jan 20, 16:00") (candidates (list session-a-label agent-shell--start-new-session-choice session-b-label))) From 6dd6e2c0f70cb1e87714f876363aa4b6cd444735 Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:22:57 +0000 Subject: [PATCH 18/44] Migrate #263 away from :shell params Related to: #190 #105 --- agent-shell.el | 17 ++++++++--------- tests/agent-shell-tests.el | 28 ++++++++++++++-------------- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index 40e9a0f..7706c15 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -3265,8 +3265,8 @@ Return selected session alist, or nil if user quit." (map-put! state :available-commands nil) (agent-shell--update-header-and-mode-line))) -(cl-defun agent-shell--delete-session-by-id (&key shell session-id on-success) - "Delete SESSION-ID via ACP using SHELL. +(cl-defun agent-shell--delete-session-by-id (&key shell-buffer session-id on-success) + "Delete SESSION-ID via ACP using SHELL-BUFFER. ON-SUCCESS is called with no args after successful delete." (unless session-id @@ -3293,7 +3293,7 @@ ON-SUCCESS is called with no args after successful delete." (when on-success (funcall on-success))) :on-failure (agent-shell--make-error-handler - :state (agent-shell--state) :shell shell))) + :state (agent-shell--state) :shell-buffer shell-buffer))) (defun agent-shell-delete-session (&optional force-current) "Delete an existing agent session from the agent's session history. @@ -3317,14 +3317,13 @@ prompting for a session to pick (still asks for confirmation)." (user-error "Agent not initialized")) (unless (map-elt (agent-shell--state) :supports-session-delete) (user-error "Agent does not support session/delete")) - (let* ((shell `((:buffer . ,(current-buffer)))) - (current-session-id (map-nested-elt (agent-shell--state) '(:session :id)))) + (let* ((current-session-id (map-nested-elt (agent-shell--state) '(:session :id)))) (cond ((and force-current current-session-id) (when (y-or-n-p (format "Delete current session %s? " (substring-no-properties current-session-id))) (agent-shell--delete-session-by-id - :shell shell + :shell-buffer shell-buffer :session-id current-session-id :on-success (lambda () (agent-shell--clear-session-state) @@ -3356,7 +3355,7 @@ prompting for a session to pick (still asks for confirmation)." (message "Cancelled")) (t (agent-shell--delete-session-by-id - :shell shell + :shell-buffer shell-buffer :session-id session-id :on-success (lambda () (when (and current-session-id @@ -3366,12 +3365,12 @@ prompting for a session to pick (still asks for confirmation)." (message "Deleted session %s" (substring-no-properties session-id)))))))) :on-failure (agent-shell--make-error-handler - :state (agent-shell--state) :shell shell))) + :state (agent-shell--state) :shell-buffer shell-buffer))) (current-session-id (when (y-or-n-p (format "Delete current session %s? " (substring-no-properties current-session-id))) (agent-shell--delete-session-by-id - :shell shell + :shell-buffer shell-buffer :session-id current-session-id :on-success (lambda () (agent-shell--clear-session-state) diff --git a/tests/agent-shell-tests.el b/tests/agent-shell-tests.el index e7e211e..7929852 100644 --- a/tests/agent-shell-tests.el +++ b/tests/agent-shell-tests.el @@ -191,21 +191,21 @@ (dolist (test-case `(;; Graphical display mode ( :graphic t :homogeneous-expected - ,(concat " pending Update state initialization\n" - " pending Update session initialization") + ,(concat " pending Update state initialization\n" + " pending Update session initialization") :mixed-expected - ,(concat " pending First task\n" - " in progress Second task\n" - " completed Third task")) + ,(concat " pending First task\n" + " in progress Second task\n" + " completed Third task")) ;; Terminal display mode ( :graphic nil :homogeneous-expected - ,(concat "[pending] Update state initialization\n" - "[pending] Update session initialization") + ,(concat "[pending] Update state initialization\n" + "[pending] Update session initialization") :mixed-expected - ,(concat "[pending] First task\n" - "[in progress] Second task\n" - "[completed] Third task")))) + ,(concat "[pending] First task\n" + "[in progress] Second task\n" + "[completed] Third task")))) (cl-letf (((symbol-function 'display-graphic-p) (lambda (&optional _display) (plist-get test-case :graphic)))) ;; Test homogeneous statuses @@ -1026,7 +1026,7 @@ code block content with spaces (description . "Test model"))]))))) (_ (error "Unexpected method: %s" method))))))) (agent-shell--initiate-session - :shell `((:buffer . ,(current-buffer))) + :shell-buffer (current-buffer) :on-session-init (lambda () (setq session-init-called t))) (let ((ordered-requests (nreverse requests))) @@ -1083,7 +1083,7 @@ code block content with spaces '((sessionId . "new-session-456")))) (_ (error "Unexpected method: %s" method))))))) (agent-shell--initiate-session - :shell `((:buffer . ,(current-buffer))) + :shell-buffer (current-buffer) :on-session-init (lambda () (setq session-init-called t))) (let ((ordered-requests (nreverse requests))) @@ -1203,7 +1203,7 @@ code block content with spaces '((sessionId . "new-session-789")))) (_ (error "Unexpected method: %s" method))))))) (agent-shell--initiate-session - :shell `((:buffer . ,(current-buffer))) + :shell-buffer (current-buffer) :on-session-init (lambda () (setq session-init-called t))) (let ((ordered-requests (nreverse requests))) @@ -1245,7 +1245,7 @@ code block content with spaces (funcall (plist-get args :on-success) '((ok . t)))) (_ (error "Unexpected method: %s" method))))))) (agent-shell--delete-session-by-id - :shell `((:buffer . ,(current-buffer))) + :shell-buffer (current-buffer) :session-id "session-2" :on-success (lambda () (setq success-called t))) (should success-called) From c3b6ff4a5650d7b2bcd0341c5197018305a5a994 Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:56:04 +0000 Subject: [PATCH 19/44] Use session list/delete/load ACP helpers as per @farra's PR #248 https://github.com/xenodium/acp.el/issues/11 https://github.com/xenodium/agent-shell/issues/105 https://github.com/xenodium/agent-shell/issues/190 https://github.com/xenodium/agent-shell/issues/268 --- agent-shell.el | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index 7706c15..94256fa 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -3280,8 +3280,8 @@ ON-SUCCESS is called with no args after successful delete." :append t)) (acp-send-request :client (map-elt (agent-shell--state) :client) - :request `((:method . "session/delete") - (:params . ((sessionId . ,session-id)))) + :request (acp-make-session-delete-request + :session-id session-id) :buffer (current-buffer) :on-success (lambda (_response) (with-current-buffer (map-elt (agent-shell--state) :buffer) @@ -3339,8 +3339,8 @@ prompting for a session to pick (still asks for confirmation)." :append t)) (acp-send-request :client (map-elt (agent-shell--state) :client) - :request `((:method . "session/list") - (:params . ((cwd . ,(agent-shell--resolve-path (agent-shell-cwd))))))) + :request (acp-make-session-list-request + :cwd (agent-shell--resolve-path (agent-shell-cwd)))) :buffer (current-buffer) :on-success (lambda (response) (let* ((sessions (append (or (map-elt response 'sessions) '()) nil)) @@ -3492,9 +3492,8 @@ prompting for a session to pick (still asks for confirmation)." :append t)) (acp-send-request :client (map-elt (agent-shell--state) :client) - :request `((:method . "session/list") - ;; Must remove trailing / to make sure Claude recognizes previous CWDs. - (:params . ((cwd . ,(agent-shell--resolve-path (string-remove-suffix "/" (agent-shell-cwd))))))) + :request (acp-make-session-list-request + :cwd (agent-shell--resolve-path (agent-shell-cwd))) :buffer (current-buffer) :on-success (lambda (response) (let* ((sessions (append (or (map-elt response 'sessions) '()) nil)) @@ -3514,12 +3513,17 @@ prompting for a session to pick (still asks for confirmation)." :append t) (acp-send-request :client (map-elt (agent-shell--state) :client) - :request `((:method . ,(if (map-elt (agent-shell--state) :supports-session-load) - "session/load" - "session/resume")) - (:params . ((sessionId . ,session-id) - (cwd . ,(agent-shell--resolve-path (string-remove-suffix "/" (agent-shell-cwd)))) - (mcpServers . ,(or (agent-shell--mcp-servers) []))))) + :request (let ((cwd (agent-shell--resolve-path (agent-shell-cwd))) + (mcp-servers (agent-shell--mcp-servers))) + (if (map-elt (agent-shell--state) :supports-session-load) + (acp-make-session-load-request + :session-id session-id + :cwd cwd + :mcp-servers mcp-servers) + (acp-make-session-resume-request + :session-id session-id + :cwd cwd + :mcp-servers mcp-servers))) :buffer (current-buffer) :on-success (lambda (load-response) (agent-shell--set-session-from-response From 397981d87f94ffd110158d9d5f5f3754a6ce96a3 Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:50:26 +0000 Subject: [PATCH 20/44] Fixing docstring warnings Related to: #190 #105 --- agent-shell.el | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index 94256fa..117fae9 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -445,13 +445,14 @@ configuration alist for backwards compatibility." :group 'agent-shell) (defcustom agent-shell-session-load-strategy 'latest - "How to choose an existing session when `session/list' and `session/load' are available. + "How to choose an existing session when both +`session/list' and `session/load' are available. Available values: - `latest': Load the latest session returned by `session/list'. - `prompt': Prompt to choose which session to load (or start a new one). - `new': Always start a new session and skip `session/list' and `session/load'." + `latest': Load the latest session from `session/list'. + `prompt': Prompt to choose a session (or start new). + `new': Always start a new session, skip list/load." :type '(choice (const :tag "Load latest session" latest) (const :tag "Prompt for session" prompt) (const :tag "Always start new session" new)) From b93126ae1d2eaea0bb74ba25ba4ab0b099b93b89 Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:50:50 +0000 Subject: [PATCH 21/44] Fixing misplaced paren in agent-shell-delete-session Related to: #190 --- agent-shell.el | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index 117fae9..64286b6 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -3341,7 +3341,7 @@ prompting for a session to pick (still asks for confirmation)." (acp-send-request :client (map-elt (agent-shell--state) :client) :request (acp-make-session-list-request - :cwd (agent-shell--resolve-path (agent-shell-cwd)))) + :cwd (agent-shell--resolve-path (agent-shell-cwd))) :buffer (current-buffer) :on-success (lambda (response) (let* ((sessions (append (or (map-elt response 'sessions) '()) nil)) @@ -3377,8 +3377,8 @@ prompting for a session to pick (still asks for confirmation)." (agent-shell--clear-session-state) (message "Deleted session %s" (substring-no-properties current-session-id)))))) - (t - (user-error "No session to delete")))))) + (t + (user-error "No session to delete"))))))) (cl-defun agent-shell--set-session-from-response (&key response session-id) "Set active session state from RESPONSE and SESSION-ID." From de3dc4b4111f782ae0cd633463179c4063c7b5a9 Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:16:24 +0000 Subject: [PATCH 22/44] Simplify session picker Related to: #190 #105 --- agent-shell.el | 67 +++++++++++++++++++++++--------------------------- 1 file changed, 31 insertions(+), 36 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index 64286b6..8e51be1 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -3185,46 +3185,35 @@ for the current year, or \"Mon DD, YYYY\" for other years." (padding (make-string (max 2 (- 52 (length title))) ?\s))) (concat title padding date-str))) -(defconst agent-shell--start-new-session-choice "Start a new session" - "Label for creating a new session from the session picker.") - -(defun agent-shell--session-picker-sort (candidates) - "Return CANDIDATES with `agent-shell--start-new-session-choice' first." - (if (member agent-shell--start-new-session-choice candidates) - (cons agent-shell--start-new-session-choice - (delete agent-shell--start-new-session-choice - (copy-sequence candidates))) - candidates)) - -(defun agent-shell--prompt-select-session-to-load (sessions) +(defun agent-shell--prompt-select-session (sessions) "Prompt to choose one from SESSIONS. -Return selected session alist, or nil to start a new session." +Return selected session alist, or nil to start a new session. +Falls back to latest session in batch mode (e.g. tests)." (when sessions - (let* ((session-choices (mapcar (lambda (session) - (cons (agent-shell--session-choice-label session) - session)) - sessions)) - (choices (cons (cons agent-shell--start-new-session-choice nil) - session-choices)) - (completion-extra-properties - '(:display-sort-function agent-shell--session-picker-sort - :cycle-sort-function agent-shell--session-picker-sort)) - (selection (completing-read "Load session: " - (mapcar #'car choices) + (if noninteractive + (car sessions) + (let* ((new-session-choice "Start a new session") + (choices (cons (cons new-session-choice nil) + (mapcar (lambda (session) + (cons (agent-shell--session-choice-label session) + session)) + sessions))) + (candidates (mapcar #'car choices)) + ;; Some completion frameworks yielded appended (nil) to each line + ;; unless this-command was bound. + ;; + ;; For example: + ;; + ;; Let's build something Today, 16:25 (nil) + ;; Let's optimize the rocket engine Feb 12, 21:02 (nil) + (this-command 'agent-shell) + (selection (completing-read "Resume session: " + candidates nil t nil nil - agent-shell--start-new-session-choice))) - (cdr (assoc selection choices))))) + new-session-choice))) + (map-elt choices selection))))) -(defun agent-shell--select-session-to-load (sessions) - "Select a session from SESSIONS based on `agent-shell-session-load-strategy'." - (pcase agent-shell-session-load-strategy - ('new nil) - ('latest (car sessions)) - ('prompt (if noninteractive - (car sessions) - (agent-shell--prompt-select-session-to-load sessions))) - (_ (car sessions)))) (defun agent-shell--prompt-select-session-to-delete (sessions) "Prompt to choose one from SESSIONS for deletion. @@ -3500,7 +3489,13 @@ prompting for a session to pick (still asks for confirmation)." (let* ((sessions (append (or (map-elt response 'sessions) '()) nil)) (selected-session (condition-case nil - (agent-shell--select-session-to-load sessions) + (pcase agent-shell-session-load-strategy + ('new nil) + ('latest (car sessions)) + ('prompt (agent-shell--prompt-select-session sessions)) + (_ (message "Unknown session load strategy '%s', starting a new session" + agent-shell-session-load-strategy) + nil)) (quit nil))) (session-id (and selected-session (map-elt selected-session 'sessionId)))) From 0c631410b578e42e525ca8c0324418bfa218b26b Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:17:04 +0000 Subject: [PATCH 23/44] Fixing indent Related to: #190 #105 --- agent-shell.el | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index 8e51be1..5703cf9 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -3494,7 +3494,7 @@ prompting for a session to pick (still asks for confirmation)." ('latest (car sessions)) ('prompt (agent-shell--prompt-select-session sessions)) (_ (message "Unknown session load strategy '%s', starting a new session" - agent-shell-session-load-strategy) + agent-shell-session-load-strategy) nil)) (quit nil))) (session-id (and selected-session @@ -3510,16 +3510,16 @@ prompting for a session to pick (still asks for confirmation)." (acp-send-request :client (map-elt (agent-shell--state) :client) :request (let ((cwd (agent-shell--resolve-path (agent-shell-cwd))) - (mcp-servers (agent-shell--mcp-servers))) - (if (map-elt (agent-shell--state) :supports-session-load) - (acp-make-session-load-request - :session-id session-id - :cwd cwd - :mcp-servers mcp-servers) - (acp-make-session-resume-request - :session-id session-id - :cwd cwd - :mcp-servers mcp-servers))) + (mcp-servers (agent-shell--mcp-servers))) + (if (map-elt (agent-shell--state) :supports-session-load) + (acp-make-session-load-request + :session-id session-id + :cwd cwd + :mcp-servers mcp-servers) + (acp-make-session-resume-request + :session-id session-id + :cwd cwd + :mcp-servers mcp-servers))) :buffer (current-buffer) :on-success (lambda (load-response) (agent-shell--set-session-from-response From cc5c1bc5c97a0a9212702fde797659f57561504f Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:31:55 +0000 Subject: [PATCH 24/44] Identify acp-related vars/data and name accordingly This helps identify where the data/structure originated from Related to: #190 #105 --- agent-shell.el | 142 ++++++++++++++++++++++++------------------------- 1 file changed, 71 insertions(+), 71 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index 5703cf9..3a6ffaf 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -2985,19 +2985,19 @@ Must provide ON-INITIATED (lambda ())." :write-text-file-capability agent-shell-text-file-capabilities) :on-success (lambda (response) (with-current-buffer shell-buffer - (let ((session-capabilities (or (map-elt response 'sessionCapabilities) - (map-nested-elt response '(agentCapabilities sessionCapabilities))))) + (let ((acp-session-capabilities (or (map-elt response 'sessionCapabilities) + (map-nested-elt response '(agentCapabilities sessionCapabilities))))) (map-put! agent-shell--state :supports-session-list - (and (listp session-capabilities) - (assq 'list session-capabilities) + (and (listp acp-session-capabilities) + (assq 'list acp-session-capabilities) t)) (map-put! agent-shell--state :supports-session-delete - (and (listp session-capabilities) - (assq 'delete session-capabilities) + (and (listp acp-session-capabilities) + (assq 'delete acp-session-capabilities) t)) (map-put! agent-shell--state :supports-session-resume - (and (listp session-capabilities) - (assq 'resume session-capabilities) + (and (listp acp-session-capabilities) + (assq 'resume acp-session-capabilities) t))) ;; Save prompt capabilities from agent, converting to internal symbols (when-let ((prompt-capabilities @@ -3170,35 +3170,35 @@ for the current year, or \"Mon DD, YYYY\" for other years." (format-time-string "%b %d, %Y" time)))) (error iso-timestamp))) -(defun agent-shell--session-choice-label (session) - "Return completion label for SESSION." - (let* ((title (or (map-elt session 'title) +(defun agent-shell--session-choice-label (acp-session) + "Return completion label for ACP-SESSION." + (let* ((title (or (map-elt acp-session 'title) "Untitled")) (title (if (> (length title) 50) (concat (substring title 0 47) "...") title)) - (updated-at (or (map-elt session 'updatedAt) - (map-elt session 'createdAt) + (updated-at (or (map-elt acp-session 'updatedAt) + (map-elt acp-session 'createdAt) "unknown-time")) (date-str (propertize (agent-shell--format-session-date updated-at) 'face 'font-lock-comment-face)) (padding (make-string (max 2 (- 52 (length title))) ?\s))) (concat title padding date-str))) -(defun agent-shell--prompt-select-session (sessions) - "Prompt to choose one from SESSIONS. +(defun agent-shell--prompt-select-session (acp-sessions) + "Prompt to choose one from ACP-SESSIONS. Return selected session alist, or nil to start a new session. Falls back to latest session in batch mode (e.g. tests)." - (when sessions + (when acp-sessions (if noninteractive - (car sessions) + (car acp-sessions) (let* ((new-session-choice "Start a new session") (choices (cons (cons new-session-choice nil) - (mapcar (lambda (session) - (cons (agent-shell--session-choice-label session) - session)) - sessions))) + (mapcar (lambda (acp-session) + (cons (agent-shell--session-choice-label acp-session) + acp-session)) + acp-sessions))) (candidates (mapcar #'car choices)) ;; Some completion frameworks yielded appended (nil) to each line ;; unless this-command was bound. @@ -3215,25 +3215,25 @@ Falls back to latest session in batch mode (e.g. tests)." (map-elt choices selection))))) -(defun agent-shell--prompt-select-session-to-delete (sessions) - "Prompt to choose one from SESSIONS for deletion. +(defun agent-shell--prompt-select-session-to-delete (acp-sessions) + "Prompt to choose one from ACP-SESSIONS for deletion. Return selected session alist, or nil if user quit." - (when sessions - (let* ((choices (mapcar (lambda (session) - (cons (agent-shell--session-choice-label session) - session)) - sessions)) + (when acp-sessions + (let* ((choices (mapcar (lambda (acp-session) + (cons (agent-shell--session-choice-label acp-session) + acp-session)) + acp-sessions)) (selection (completing-read "Delete session: " (mapcar #'car choices) nil t))) (cdr (assoc selection choices))))) -(defun agent-shell--select-session-to-delete (sessions) - "Select a session from SESSIONS for deletion." +(defun agent-shell--select-session-to-delete (acp-sessions) + "Select a session from ACP-SESSIONS for deletion." (if noninteractive - (car sessions) - (agent-shell--prompt-select-session-to-delete sessions))) + (car acp-sessions) + (agent-shell--prompt-select-session-to-delete acp-sessions))) (defun agent-shell--clear-session-state () "Reset current session-scoped state for the active shell." @@ -3255,23 +3255,23 @@ Return selected session alist, or nil if user quit." (map-put! state :available-commands nil) (agent-shell--update-header-and-mode-line))) -(cl-defun agent-shell--delete-session-by-id (&key shell-buffer session-id on-success) - "Delete SESSION-ID via ACP using SHELL-BUFFER. +(cl-defun agent-shell--delete-session-by-id (&key shell-buffer acp-session-id on-success) + "Delete ACP-SESSION-ID via ACP using SHELL-BUFFER. ON-SUCCESS is called with no args after successful delete." - (unless session-id - (error "Missing required argument: :session-id")) + (unless acp-session-id + (error "Missing required argument: :acp-session-id")) (with-current-buffer (map-elt (agent-shell--state) :buffer) (agent-shell--update-fragment :state (agent-shell--state) :block-id "session_delete" :label-left (propertize "Deleting session" 'font-lock-face 'font-lock-doc-markup-face) - :body (format "Requesting deletion for %s..." (substring-no-properties session-id)) + :body (format "Requesting deletion for %s..." (substring-no-properties acp-session-id)) :append t)) (acp-send-request :client (map-elt (agent-shell--state) :client) :request (acp-make-session-delete-request - :session-id session-id) + :session-id acp-session-id) :buffer (current-buffer) :on-success (lambda (_response) (with-current-buffer (map-elt (agent-shell--state) :buffer) @@ -3314,7 +3314,7 @@ prompting for a session to pick (still asks for confirmation)." (substring-no-properties current-session-id))) (agent-shell--delete-session-by-id :shell-buffer shell-buffer - :session-id current-session-id + :acp-session-id current-session-id :on-success (lambda () (agent-shell--clear-session-state) (message "Deleted session %s" @@ -3332,28 +3332,28 @@ prompting for a session to pick (still asks for confirmation)." :request (acp-make-session-list-request :cwd (agent-shell--resolve-path (agent-shell-cwd))) :buffer (current-buffer) - :on-success (lambda (response) - (let* ((sessions (append (or (map-elt response 'sessions) '()) nil)) - (selected-session (agent-shell--select-session-to-delete sessions)) - (session-id (and selected-session - (map-elt selected-session 'sessionId)))) + :on-success (lambda (acp-response) + (let* ((acp-sessions (append (or (map-elt acp-response 'sessions) '()) nil)) + (acp-session (agent-shell--select-session-to-delete acp-sessions)) + (acp-session-id (and acp-session + (map-elt acp-session 'sessionId)))) (cond - ((not session-id) + ((not acp-session-id) (message "No session selected")) ((not (y-or-n-p (format "Delete session %s? " - (substring-no-properties session-id)))) + (substring-no-properties acp-session-id)))) (message "Cancelled")) (t (agent-shell--delete-session-by-id :shell-buffer shell-buffer - :session-id session-id + :acp-session-id acp-session-id :on-success (lambda () (when (and current-session-id - (equal (substring-no-properties session-id) + (equal (substring-no-properties acp-session-id) (substring-no-properties current-session-id))) (agent-shell--clear-session-state)) (message "Deleted session %s" - (substring-no-properties session-id)))))))) + (substring-no-properties acp-session-id)))))))) :on-failure (agent-shell--make-error-handler :state (agent-shell--state) :shell-buffer shell-buffer))) (current-session-id @@ -3361,7 +3361,7 @@ prompting for a session to pick (still asks for confirmation)." (substring-no-properties current-session-id))) (agent-shell--delete-session-by-id :shell-buffer shell-buffer - :session-id current-session-id + :acp-session-id current-session-id :on-success (lambda () (agent-shell--clear-session-state) (message "Deleted session %s" @@ -3369,22 +3369,22 @@ prompting for a session to pick (still asks for confirmation)." (t (user-error "No session to delete"))))))) -(cl-defun agent-shell--set-session-from-response (&key response session-id) - "Set active session state from RESPONSE and SESSION-ID." +(cl-defun agent-shell--set-session-from-response (&key acp-response acp-session-id) + "Set active session state from ACP-RESPONSE and ACP-SESSION-ID." (map-put! agent-shell--state - :session (list (cons :id session-id) - (cons :mode-id (map-nested-elt response '(modes currentModeId))) + :session (list (cons :id acp-session-id) + (cons :mode-id (map-nested-elt acp-response '(modes currentModeId))) (cons :modes (mapcar (lambda (mode) `((:id . ,(map-elt mode 'id)) (:name . ,(map-elt mode 'name)) (:description . ,(map-elt mode 'description)))) - (map-nested-elt response '(modes availableModes)))) - (cons :model-id (map-nested-elt response '(models currentModelId))) + (map-nested-elt acp-response '(modes availableModes)))) + (cons :model-id (map-nested-elt acp-response '(models currentModelId))) (cons :models (mapcar (lambda (model) `((:model-id . ,(map-elt model 'modelId)) (:name . ,(map-elt model 'name)) (:description . ,(map-elt model 'description)))) - (map-nested-elt response '(models availableModels))))))) + (map-nested-elt acp-response '(models availableModels))))))) (cl-defun agent-shell--finalize-session-init (&key on-session-init) "Finalize session initialization and invoke ON-SESSION-INIT." @@ -3485,27 +3485,27 @@ prompting for a session to pick (still asks for confirmation)." :request (acp-make-session-list-request :cwd (agent-shell--resolve-path (agent-shell-cwd))) :buffer (current-buffer) - :on-success (lambda (response) - (let* ((sessions (append (or (map-elt response 'sessions) '()) nil)) - (selected-session + :on-success (lambda (acp-response) + (let* ((acp-sessions (append (or (map-elt acp-response 'sessions) '()) nil)) + (acp-session (condition-case nil (pcase agent-shell-session-load-strategy ('new nil) - ('latest (car sessions)) - ('prompt (agent-shell--prompt-select-session sessions)) + ('latest (car acp-sessions)) + ('prompt (agent-shell--prompt-select-session acp-sessions)) (_ (message "Unknown session load strategy '%s', starting a new session" agent-shell-session-load-strategy) nil)) (quit nil))) - (session-id (and selected-session - (map-elt selected-session 'sessionId)))) - (if session-id + (acp-session-id (and acp-session + (map-elt acp-session 'sessionId)))) + (if acp-session-id (progn (agent-shell--update-fragment :state (agent-shell--state) :block-id "starting" :body (format "\n\nLoading session %s..." - (substring-no-properties session-id)) + (substring-no-properties acp-session-id)) :append t) (acp-send-request :client (map-elt (agent-shell--state) :client) @@ -3513,18 +3513,18 @@ prompting for a session to pick (still asks for confirmation)." (mcp-servers (agent-shell--mcp-servers))) (if (map-elt (agent-shell--state) :supports-session-load) (acp-make-session-load-request - :session-id session-id + :session-id acp-session-id :cwd cwd :mcp-servers mcp-servers) (acp-make-session-resume-request - :session-id session-id + :session-id acp-session-id :cwd cwd :mcp-servers mcp-servers))) :buffer (current-buffer) - :on-success (lambda (load-response) + :on-success (lambda (acp-load-response) (agent-shell--set-session-from-response - :response load-response - :session-id session-id) + :acp-response acp-load-response + :acp-session-id acp-session-id) (agent-shell--finalize-session-init :on-session-init on-session-init)) :on-failure (lambda (_error _raw-message) (agent-shell--update-fragment From 7b0ed832625352ce6b97255156d56af9639c2302 Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:35:34 +0000 Subject: [PATCH 25/44] Removing unnecessarily substring-no-properties Related to: #190 --- agent-shell.el | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index 3a6ffaf..2f714e4 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -3266,7 +3266,7 @@ ON-SUCCESS is called with no args after successful delete." :state (agent-shell--state) :block-id "session_delete" :label-left (propertize "Deleting session" 'font-lock-face 'font-lock-doc-markup-face) - :body (format "Requesting deletion for %s..." (substring-no-properties acp-session-id)) + :body (format "Requesting deletion for %s..." acp-session-id) :append t)) (acp-send-request :client (map-elt (agent-shell--state) :client) @@ -3310,15 +3310,13 @@ prompting for a session to pick (still asks for confirmation)." (let* ((current-session-id (map-nested-elt (agent-shell--state) '(:session :id)))) (cond ((and force-current current-session-id) - (when (y-or-n-p (format "Delete current session %s? " - (substring-no-properties current-session-id))) + (when (y-or-n-p (format "Delete current session %s? " current-session-id)) (agent-shell--delete-session-by-id :shell-buffer shell-buffer :acp-session-id current-session-id :on-success (lambda () (agent-shell--clear-session-state) - (message "Deleted session %s" - (substring-no-properties current-session-id)))))) + (message "Deleted session %s" current-session-id))))) ((map-elt (agent-shell--state) :supports-session-list) (with-current-buffer (map-elt (agent-shell--state) :buffer) (agent-shell--update-fragment @@ -3340,8 +3338,7 @@ prompting for a session to pick (still asks for confirmation)." (cond ((not acp-session-id) (message "No session selected")) - ((not (y-or-n-p (format "Delete session %s? " - (substring-no-properties acp-session-id)))) + ((not (y-or-n-p (format "Delete session %s? " acp-session-id))) (message "Cancelled")) (t (agent-shell--delete-session-by-id @@ -3349,23 +3346,19 @@ prompting for a session to pick (still asks for confirmation)." :acp-session-id acp-session-id :on-success (lambda () (when (and current-session-id - (equal (substring-no-properties acp-session-id) - (substring-no-properties current-session-id))) + (equal acp-session-id current-session-id)) (agent-shell--clear-session-state)) - (message "Deleted session %s" - (substring-no-properties acp-session-id)))))))) + (message "Deleted session %s" acp-session-id))))))) :on-failure (agent-shell--make-error-handler :state (agent-shell--state) :shell-buffer shell-buffer))) (current-session-id - (when (y-or-n-p (format "Delete current session %s? " - (substring-no-properties current-session-id))) + (when (y-or-n-p (format "Delete current session %s? " current-session-id)) (agent-shell--delete-session-by-id :shell-buffer shell-buffer :acp-session-id current-session-id :on-success (lambda () (agent-shell--clear-session-state) - (message "Deleted session %s" - (substring-no-properties current-session-id)))))) + (message "Deleted session %s" current-session-id))))) (t (user-error "No session to delete"))))))) @@ -3504,8 +3497,7 @@ prompting for a session to pick (still asks for confirmation)." (agent-shell--update-fragment :state (agent-shell--state) :block-id "starting" - :body (format "\n\nLoading session %s..." - (substring-no-properties acp-session-id)) + :body (format "\n\nLoading session %s..." acp-session-id) :append t) (acp-send-request :client (map-elt (agent-shell--state) :client) From 6ad5a4c669376337c96ceaa89599f60532b17fef Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:39:56 +0000 Subject: [PATCH 26/44] Removing session-deletion code as not yet validated Details at https://github.com/xenodium/agent-shell/pull/263#issuecomment-3899448962 --- agent-shell.el | 153 ------------------------------------- tests/agent-shell-tests.el | 41 ---------- 2 files changed, 194 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index 2f714e4..869a774 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -572,7 +572,6 @@ HEARTBEAT, and AUTHENTICATE-REQUEST-MAKER." (cons :supports-session-list nil) (cons :supports-session-load nil) (cons :supports-session-resume nil) - (cons :supports-session-delete nil) (cons :prompt-capabilities nil) (cons :event-subscriptions nil) (cons :pending-requests nil) @@ -863,7 +862,6 @@ When FORCE is non-nil, skip confirmation prompt." "p" #'agent-shell-previous-item "C-" #'agent-shell-cycle-session-mode "C-c C-c" #'agent-shell-interrupt - "C-c C-d" #'agent-shell-delete-session "C-c C-m" #'agent-shell-set-session-mode "C-c C-v" #'agent-shell-set-session-model "C-c C-o" #'agent-shell-other-buffer) @@ -2991,10 +2989,6 @@ Must provide ON-INITIATED (lambda ())." (and (listp acp-session-capabilities) (assq 'list acp-session-capabilities) t)) - (map-put! agent-shell--state :supports-session-delete - (and (listp acp-session-capabilities) - (assq 'delete acp-session-capabilities) - t)) (map-put! agent-shell--state :supports-session-resume (and (listp acp-session-capabilities) (assq 'resume acp-session-capabilities) @@ -3215,153 +3209,6 @@ Falls back to latest session in batch mode (e.g. tests)." (map-elt choices selection))))) -(defun agent-shell--prompt-select-session-to-delete (acp-sessions) - "Prompt to choose one from ACP-SESSIONS for deletion. - -Return selected session alist, or nil if user quit." - (when acp-sessions - (let* ((choices (mapcar (lambda (acp-session) - (cons (agent-shell--session-choice-label acp-session) - acp-session)) - acp-sessions)) - (selection (completing-read "Delete session: " - (mapcar #'car choices) - nil t))) - (cdr (assoc selection choices))))) - -(defun agent-shell--select-session-to-delete (acp-sessions) - "Select a session from ACP-SESSIONS for deletion." - (if noninteractive - (car acp-sessions) - (agent-shell--prompt-select-session-to-delete acp-sessions))) - -(defun agent-shell--clear-session-state () - "Reset current session-scoped state for the active shell." - (let* ((state (agent-shell--state)) - (session (or (map-elt state :session) - (list (cons :id nil) - (cons :mode-id nil) - (cons :modes nil))))) - (map-put! session :id nil) - (map-put! session :mode-id nil) - (map-put! session :modes nil) - ;; Clear optional fields if they were previously populated. - (map-put! session :model-id nil) - (map-put! session :models nil) - (map-put! state :session session) - (map-put! state :set-session-mode nil) - (map-put! state :set-model nil) - (map-put! state :tool-calls nil) - (map-put! state :available-commands nil) - (agent-shell--update-header-and-mode-line))) - -(cl-defun agent-shell--delete-session-by-id (&key shell-buffer acp-session-id on-success) - "Delete ACP-SESSION-ID via ACP using SHELL-BUFFER. - -ON-SUCCESS is called with no args after successful delete." - (unless acp-session-id - (error "Missing required argument: :acp-session-id")) - (with-current-buffer (map-elt (agent-shell--state) :buffer) - (agent-shell--update-fragment - :state (agent-shell--state) - :block-id "session_delete" - :label-left (propertize "Deleting session" 'font-lock-face 'font-lock-doc-markup-face) - :body (format "Requesting deletion for %s..." acp-session-id) - :append t)) - (acp-send-request - :client (map-elt (agent-shell--state) :client) - :request (acp-make-session-delete-request - :session-id acp-session-id) - :buffer (current-buffer) - :on-success (lambda (_response) - (with-current-buffer (map-elt (agent-shell--state) :buffer) - (agent-shell--update-fragment - :state (agent-shell--state) - :block-id "session_delete" - :body "\n\nDone" - :append t)) - (when on-success - (funcall on-success))) - :on-failure (agent-shell--make-error-handler - :state (agent-shell--state) :shell-buffer shell-buffer))) - -(defun agent-shell-delete-session (&optional force-current) - "Delete an existing agent session from the agent's session history. - -This requires the agent to support the experimental ACP method -\"session/delete\". - -With prefix argument FORCE-CURRENT, delete the current session without -prompting for a session to pick (still asks for confirmation)." - (interactive "P") - (unless (or (derived-mode-p 'agent-shell-mode) - (derived-mode-p 'agent-shell-viewport-view-mode) - (derived-mode-p 'agent-shell-viewport-edit-mode)) - (user-error "Not in an agent-shell buffer")) - (let* ((shell-buffer (if (derived-mode-p 'agent-shell-mode) - (current-buffer) - (or (agent-shell-viewport--shell-buffer) - (user-error "No shell buffer available"))))) - (with-current-buffer shell-buffer - (unless (map-elt (agent-shell--state) :client) - (user-error "Agent not initialized")) - (unless (map-elt (agent-shell--state) :supports-session-delete) - (user-error "Agent does not support session/delete")) - (let* ((current-session-id (map-nested-elt (agent-shell--state) '(:session :id)))) - (cond - ((and force-current current-session-id) - (when (y-or-n-p (format "Delete current session %s? " current-session-id)) - (agent-shell--delete-session-by-id - :shell-buffer shell-buffer - :acp-session-id current-session-id - :on-success (lambda () - (agent-shell--clear-session-state) - (message "Deleted session %s" current-session-id))))) - ((map-elt (agent-shell--state) :supports-session-list) - (with-current-buffer (map-elt (agent-shell--state) :buffer) - (agent-shell--update-fragment - :state (agent-shell--state) - :block-id "session_delete" - :label-left (propertize "Deleting session" 'font-lock-face 'font-lock-doc-markup-face) - :body "\n\nLooking for existing sessions..." - :append t)) - (acp-send-request - :client (map-elt (agent-shell--state) :client) - :request (acp-make-session-list-request - :cwd (agent-shell--resolve-path (agent-shell-cwd))) - :buffer (current-buffer) - :on-success (lambda (acp-response) - (let* ((acp-sessions (append (or (map-elt acp-response 'sessions) '()) nil)) - (acp-session (agent-shell--select-session-to-delete acp-sessions)) - (acp-session-id (and acp-session - (map-elt acp-session 'sessionId)))) - (cond - ((not acp-session-id) - (message "No session selected")) - ((not (y-or-n-p (format "Delete session %s? " acp-session-id))) - (message "Cancelled")) - (t - (agent-shell--delete-session-by-id - :shell-buffer shell-buffer - :acp-session-id acp-session-id - :on-success (lambda () - (when (and current-session-id - (equal acp-session-id current-session-id)) - (agent-shell--clear-session-state)) - (message "Deleted session %s" acp-session-id))))))) - :on-failure (agent-shell--make-error-handler - :state (agent-shell--state) :shell-buffer shell-buffer))) - (current-session-id - (when (y-or-n-p (format "Delete current session %s? " current-session-id)) - (agent-shell--delete-session-by-id - :shell-buffer shell-buffer - :acp-session-id current-session-id - :on-success (lambda () - (agent-shell--clear-session-state) - (message "Deleted session %s" current-session-id))))) - (t - (user-error "No session to delete"))))))) - (cl-defun agent-shell--set-session-from-response (&key acp-response acp-session-id) "Set active session state from ACP-RESPONSE and ACP-SESSION-ID." (map-put! agent-shell--state diff --git a/tests/agent-shell-tests.el b/tests/agent-shell-tests.el index 7929852..b4b7cbe 100644 --- a/tests/agent-shell-tests.el +++ b/tests/agent-shell-tests.el @@ -1214,46 +1214,5 @@ code block content with spaces (should session-init-called) (should (equal (map-nested-elt agent-shell--state '(:session :id)) "new-session-789")))))) -(ert-deftest agent-shell--delete-session-by-id-sends-session-delete () - "Test `agent-shell--delete-session-by-id' sends session/delete request." - (with-temp-buffer - (let* ((requests '()) - (success-called nil) - (state `((:buffer . ,(current-buffer)) - (:client . test-client) - (:session . ((:id . "session-2") - (:mode-id . nil) - (:modes . nil))) - (:supports-session-list . t) - (:supports-session-delete . t)))) - (setq-local agent-shell--state state) - (cl-letf (((symbol-function 'agent-shell--state) - (lambda () agent-shell--state)) - ((symbol-function 'agent-shell--update-fragment) - (lambda (&rest _args) nil)) - ((symbol-function 'agent-shell--update-header-and-mode-line) - (lambda () nil)) - ((symbol-function 'acp-send-request) - (lambda (&rest args) - (push args requests) - (let* ((request (plist-get args :request)) - (method (map-elt request :method))) - (pcase method - ("session/delete" - (let ((params (map-elt request :params))) - (should (equal (map-elt params 'sessionId) "session-2"))) - (funcall (plist-get args :on-success) '((ok . t)))) - (_ (error "Unexpected method: %s" method))))))) - (agent-shell--delete-session-by-id - :shell-buffer (current-buffer) - :session-id "session-2" - :on-success (lambda () (setq success-called t))) - (should success-called) - (let ((ordered-requests (nreverse requests))) - (should (equal (mapcar (lambda (req) - (map-elt (plist-get req :request) :method)) - ordered-requests) - '("session/delete")))))))) - (provide 'agent-shell-tests) ;;; agent-shell-tests.el ends here From 9d5e3d0eef825433172b7d62f1ed93a33eba0d03 Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:47:12 +0000 Subject: [PATCH 27/44] Removing session-deletion code from agent-shell-delete-session too Details at https://github.com/xenodium/agent-shell/pull/263#issuecomment-3899448962 --- agent-shell-viewport.el | 2 -- 1 file changed, 2 deletions(-) diff --git a/agent-shell-viewport.el b/agent-shell-viewport.el index 6364210..38e6494 100644 --- a/agent-shell-viewport.el +++ b/agent-shell-viewport.el @@ -770,7 +770,6 @@ For example, offer to kill associated shell session." (define-key map (kbd "C-c C-p") #'agent-shell-viewport-compose-peek-last) (define-key map (kbd "C-c C-k") #'agent-shell-viewport-compose-cancel) (define-key map (kbd "C-") #'agent-shell-viewport-cycle-session-mode) - (define-key map (kbd "C-c C-d") #'agent-shell-delete-session) (define-key map (kbd "C-c C-m") #'agent-shell-viewport-set-session-mode) (define-key map (kbd "C-c C-v") #'agent-shell-viewport-set-session-model) (define-key map (kbd "C-c C-o") #'agent-shell-other-buffer) @@ -802,7 +801,6 @@ For example, offer to kill associated shell session." (define-key map (kbd "r") #'agent-shell-viewport-reply) (define-key map (kbd "q") #'bury-buffer) (define-key map (kbd "C-") #'agent-shell-viewport-cycle-session-mode) - (define-key map (kbd "C-c C-d") #'agent-shell-delete-session) (define-key map (kbd "v") #'agent-shell-viewport-set-session-model) (define-key map (kbd "m") #'agent-shell-viewport-set-session-mode) (define-key map (kbd "o") #'agent-shell-other-buffer) From 47975049d58989a1ed93c3ac5b14ec82f93c9033 Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Sun, 15 Feb 2026 00:07:01 +0000 Subject: [PATCH 28/44] Defer displaying new shell buffers until user picks a session Related to: #190 #105 #268 --- agent-shell-active-message.el | 54 ++++++++++++ agent-shell.el | 160 +++++++++++++++++++++------------- 2 files changed, 153 insertions(+), 61 deletions(-) create mode 100644 agent-shell-active-message.el diff --git a/agent-shell-active-message.el b/agent-shell-active-message.el new file mode 100644 index 0000000..df15dcc --- /dev/null +++ b/agent-shell-active-message.el @@ -0,0 +1,54 @@ +;;; agent-shell-active-message.el --- Active message utilities -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 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: +;; +;; Provides a minibuffer progress message for agent-shell. + +;;; Code: + +(eval-when-compile + (require 'cl-lib)) + +(cl-defun agent-shell-active-message-show (&key text) + "Show a minibuffer active message displaying TEXT. + +Returns an active message alist for use with +`agent-shell-active-message-hide'." + (let* ((reporter (make-progress-reporter (or text "Loading..."))) + (timer (run-at-time 0 0.1 + (lambda () + (progress-reporter-update reporter))))) + (list (cons :reporter reporter) + (cons :timer timer)))) + +(cl-defun agent-shell-active-message-hide (&key active-message) + "Hide ACTIVE-MESSAGE previously shown with +`agent-shell-active-message-show'." + (when active-message + (when-let ((timer (map-elt active-message :timer))) + (when (timerp timer) + (cancel-timer timer))) + (when-let ((reporter (map-elt active-message :reporter))) + (progress-reporter-done reporter)))) + +(provide 'agent-shell-active-message) + +;;; agent-shell-active-message.el ends here diff --git a/agent-shell.el b/agent-shell.el index 869a774..a8b1e09 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -57,6 +57,7 @@ (require 'agent-shell-google) (require 'agent-shell-goose) (require 'agent-shell-heartbeat) +(require 'agent-shell-active-message) (require 'agent-shell-mistral) (require 'agent-shell-openai) (require 'agent-shell-opencode) @@ -2175,7 +2176,30 @@ variable (see makunbound)")) (agent-shell--handle :shell-buffer shell-buffer))) ;; Display buffer if no-focus was nil, respecting agent-shell-display-action (unless no-focus - (agent-shell--display-buffer shell-buffer)) + (if (and (not agent-shell-deferred-initialization) + (eq agent-shell-session-load-strategy 'prompt)) + ;; Defer display until user selects a session. + ;; Why? The experience is janky to display a buffer + ;; and soon after that prompt the user for input. + ;; Better to prompt the user for input and then + ;; display the buffer. + (let ((active-message (agent-shell-active-message-show :text "Loading..."))) + (agent-shell-subscribe-to + :shell-buffer shell-buffer + :event 'session-prompt + :on-event (lambda (_event) + (agent-shell-active-message-hide :active-message active-message))) + (agent-shell-subscribe-to + :shell-buffer shell-buffer + :event 'session-selected + :on-event (lambda (_event) + (agent-shell--display-buffer shell-buffer))) + (agent-shell-subscribe-to + :shell-buffer shell-buffer + :event 'session-selection-cancelled + :on-event (lambda (_event) + (kill-buffer shell-buffer)))) + (agent-shell--display-buffer shell-buffer))) shell-buffer)) (cl-defun agent-shell--delete-fragment (&key state block-id) @@ -2841,6 +2865,11 @@ Initialization events (emitted in order): `init-session' - ACP session created `init-model' - Default model set (optional) `init-session-mode' - Default session mode set (optional) + `session-list' - Session list fetch initiated + `session-prompt' - About to prompt user for session selection + `session-selected' - Session chosen (new or existing) + :data contains :session-id (nil when starting new) + `session-selection-cancelled' - User cancelled session selection (C-g) `init-finished' - Initialization pipeline completed Session events: @@ -3133,9 +3162,11 @@ Must provide ON-SESSION-INIT (lambda ())." (agent-shell--initiate-session-list-and-load :shell-buffer shell-buffer :on-session-init on-session-init) - (agent-shell--initiate-new-session - :shell-buffer shell-buffer - :on-session-init on-session-init))) + (progn + (agent-shell--emit-event :event 'session-selected) + (agent-shell--initiate-new-session + :shell-buffer shell-buffer + :on-session-init on-session-init)))) (defun agent-shell--format-session-date (iso-timestamp) "Format ISO-TIMESTAMP as a human-friendly date string. @@ -3201,12 +3232,13 @@ Falls back to latest session in batch mode (e.g. tests)." ;; ;; Let's build something Today, 16:25 (nil) ;; Let's optimize the rocket engine Feb 12, 21:02 (nil) - (this-command 'agent-shell) - (selection (completing-read "Resume session: " - candidates - nil t nil nil - new-session-choice))) - (map-elt choices selection))))) + (this-command 'agent-shell)) + (agent-shell--emit-event :event 'session-prompt) + (let ((selection (completing-read "Resume session: " + candidates + nil t nil nil + new-session-choice))) + (map-elt choices selection)))))) (cl-defun agent-shell--set-session-from-response (&key acp-response acp-session-id) @@ -3255,6 +3287,7 @@ Falls back to latest session in batch mode (e.g. tests)." :body (agent-shell--format-available-modes (agent-shell--get-available-modes agent-shell--state)))) (agent-shell--update-header-and-mode-line) + (agent-shell--emit-event :event 'init-session) (funcall on-session-init)) (cl-defun agent-shell--initiate-new-session (&key shell-buffer on-session-init) @@ -3320,63 +3353,68 @@ Falls back to latest session in batch mode (e.g. tests)." :block-id "starting" :body "\n\nLooking for existing sessions..." :append t)) + (agent-shell--emit-event :event 'session-list) (acp-send-request :client (map-elt (agent-shell--state) :client) :request (acp-make-session-list-request :cwd (agent-shell--resolve-path (agent-shell-cwd))) :buffer (current-buffer) :on-success (lambda (acp-response) - (let* ((acp-sessions (append (or (map-elt acp-response 'sessions) '()) nil)) - (acp-session - (condition-case nil - (pcase agent-shell-session-load-strategy - ('new nil) - ('latest (car acp-sessions)) - ('prompt (agent-shell--prompt-select-session acp-sessions)) - (_ (message "Unknown session load strategy '%s', starting a new session" - agent-shell-session-load-strategy) - nil)) - (quit nil))) - (acp-session-id (and acp-session - (map-elt acp-session 'sessionId)))) - (if acp-session-id - (progn - (agent-shell--update-fragment - :state (agent-shell--state) - :block-id "starting" - :body (format "\n\nLoading session %s..." acp-session-id) - :append t) - (acp-send-request - :client (map-elt (agent-shell--state) :client) - :request (let ((cwd (agent-shell--resolve-path (agent-shell-cwd))) - (mcp-servers (agent-shell--mcp-servers))) - (if (map-elt (agent-shell--state) :supports-session-load) - (acp-make-session-load-request - :session-id acp-session-id - :cwd cwd - :mcp-servers mcp-servers) - (acp-make-session-resume-request - :session-id acp-session-id - :cwd cwd - :mcp-servers mcp-servers))) - :buffer (current-buffer) - :on-success (lambda (acp-load-response) - (agent-shell--set-session-from-response - :acp-response acp-load-response - :acp-session-id acp-session-id) - (agent-shell--finalize-session-init :on-session-init on-session-init)) - :on-failure (lambda (_error _raw-message) - (agent-shell--update-fragment - :state (agent-shell--state) - :block-id "starting" - :body "\n\nCould not load existing session. Creating a new one..." - :append t) - (agent-shell--initiate-new-session - :shell-buffer shell-buffer - :on-session-init on-session-init)))) - (agent-shell--initiate-new-session - :shell-buffer shell-buffer - :on-session-init on-session-init)))) + (let ((acp-sessions (append (or (map-elt acp-response 'sessions) '()) nil))) + (condition-case nil + (let* ((acp-session + (pcase agent-shell-session-load-strategy + ('new nil) + ('latest (car acp-sessions)) + ('prompt (agent-shell--prompt-select-session acp-sessions)) + (_ (message "Unknown session load strategy '%s', starting a new session" + agent-shell-session-load-strategy) + nil))) + (acp-session-id (and acp-session + (map-elt acp-session 'sessionId)))) + (agent-shell--emit-event + :event 'session-selected + :data (list (cons :session-id acp-session-id))) + (if acp-session-id + (progn + (agent-shell--update-fragment + :state (agent-shell--state) + :block-id "starting" + :body (format "\n\nLoading session %s..." acp-session-id) + :append t) + (acp-send-request + :client (map-elt (agent-shell--state) :client) + :request (let ((cwd (agent-shell--resolve-path (agent-shell-cwd))) + (mcp-servers (agent-shell--mcp-servers))) + (if (map-elt (agent-shell--state) :supports-session-load) + (acp-make-session-load-request + :session-id acp-session-id + :cwd cwd + :mcp-servers mcp-servers) + (acp-make-session-resume-request + :session-id acp-session-id + :cwd cwd + :mcp-servers mcp-servers))) + :buffer (current-buffer) + :on-success (lambda (acp-load-response) + (agent-shell--set-session-from-response + :acp-response acp-load-response + :acp-session-id acp-session-id) + (agent-shell--finalize-session-init :on-session-init on-session-init)) + :on-failure (lambda (_error _raw-message) + (agent-shell--update-fragment + :state (agent-shell--state) + :block-id "starting" + :body "\n\nCould not load existing session. Creating a new one..." + :append t) + (agent-shell--initiate-new-session + :shell-buffer shell-buffer + :on-session-init on-session-init)))) + (agent-shell--initiate-new-session + :shell-buffer shell-buffer + :on-session-init on-session-init))) + (quit + (agent-shell--emit-event :event 'session-selection-cancelled))))) :on-failure (lambda (_error _raw-message) (agent-shell--initiate-new-session :shell-buffer shell-buffer From a0ec81ce390ca3dc5ea8078716c69ece981a315f Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Sun, 15 Feb 2026 00:55:04 +0000 Subject: [PATCH 29/44] Ensure Loading... message is handled in other cases Related to: #190 #105 #268 --- agent-shell-active-message.el | 3 ++- agent-shell.el | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/agent-shell-active-message.el b/agent-shell-active-message.el index df15dcc..24dad05 100644 --- a/agent-shell-active-message.el +++ b/agent-shell-active-message.el @@ -47,7 +47,8 @@ Returns an active message alist for use with (when (timerp timer) (cancel-timer timer))) (when-let ((reporter (map-elt active-message :reporter))) - (progress-reporter-done reporter)))) + (progress-reporter-done reporter) + (message nil)))) (provide 'agent-shell-active-message) diff --git a/agent-shell.el b/agent-shell.el index a8b1e09..1c8812a 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -2193,11 +2193,13 @@ variable (see makunbound)")) :shell-buffer shell-buffer :event 'session-selected :on-event (lambda (_event) + (agent-shell-active-message-hide :active-message active-message) (agent-shell--display-buffer shell-buffer))) (agent-shell-subscribe-to :shell-buffer shell-buffer :event 'session-selection-cancelled :on-event (lambda (_event) + (agent-shell-active-message-hide :active-message active-message) (kill-buffer shell-buffer)))) (agent-shell--display-buffer shell-buffer))) shell-buffer)) From 51f0ba26ab04aa5c3d52dc399104d394e0519a1b Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Sun, 15 Feb 2026 00:55:34 +0000 Subject: [PATCH 30/44] Show project name in session picker Related to: #190 #105 --- agent-shell.el | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index 1c8812a..3dd4897 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -3199,7 +3199,11 @@ for the current year, or \"Mon DD, YYYY\" for other years." (defun agent-shell--session-choice-label (acp-session) "Return completion label for ACP-SESSION." - (let* ((title (or (map-elt acp-session 'title) + (let* ((cwd (or (map-elt acp-session 'cwd) "")) + (dir (propertize (file-name-nondirectory (directory-file-name cwd)) + 'face 'font-lock-keyword-face)) + (dir-padding (make-string (max 2 (- 22 (length (file-name-nondirectory (directory-file-name cwd))))) ?\s)) + (title (or (map-elt acp-session 'title) "Untitled")) (title (if (> (length title) 50) (concat (substring title 0 47) "...") @@ -3210,7 +3214,7 @@ for the current year, or \"Mon DD, YYYY\" for other years." (date-str (propertize (agent-shell--format-session-date updated-at) 'face 'font-lock-comment-face)) (padding (make-string (max 2 (- 52 (length title))) ?\s))) - (concat title padding date-str))) + (concat dir dir-padding title padding date-str))) (defun agent-shell--prompt-select-session (acp-sessions) "Prompt to choose one from ACP-SESSIONS. From 3b856f7dca5f56f6d84b912f4badd7bbc67b5df4 Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Sun, 15 Feb 2026 01:05:49 +0000 Subject: [PATCH 31/44] Improve session picker column alignment Related to: #190 #105 --- agent-shell.el | 43 +++++++++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index 3dd4897..f0935ef 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -3197,23 +3197,32 @@ for the current year, or \"Mon DD, YYYY\" for other years." (format-time-string "%b %d, %Y" time)))) (error iso-timestamp))) -(defun agent-shell--session-choice-label (acp-session) - "Return completion label for ACP-SESSION." - (let* ((cwd (or (map-elt acp-session 'cwd) "")) - (dir (propertize (file-name-nondirectory (directory-file-name cwd)) - 'face 'font-lock-keyword-face)) - (dir-padding (make-string (max 2 (- 22 (length (file-name-nondirectory (directory-file-name cwd))))) ?\s)) - (title (or (map-elt acp-session 'title) - "Untitled")) - (title (if (> (length title) 50) - (concat (substring title 0 47) "...") - title)) +(defun agent-shell--session-dir-name (acp-session) + "Return directory name for ACP-SESSION." + (file-name-nondirectory + (directory-file-name (or (map-elt acp-session 'cwd) "")))) + +(defun agent-shell--session-title (acp-session) + "Return display title for ACP-SESSION, truncated to 50 chars." + (let ((title (or (map-elt acp-session 'title) "Untitled"))) + (if (> (length title) 50) + (concat (substring title 0 47) "...") + title))) + +(defun agent-shell--session-choice-label (acp-session max-dir-width max-title-width) + "Return completion label for ACP-SESSION. +MAX-DIR-WIDTH is the column width for the directory name. +MAX-TITLE-WIDTH is the column width for the title." + (let* ((dir-name (agent-shell--session-dir-name acp-session)) + (dir (propertize dir-name 'face 'font-lock-keyword-face)) + (dir-padding (make-string (max 2 (- max-dir-width (length dir-name))) ?\s)) + (title (agent-shell--session-title acp-session)) (updated-at (or (map-elt acp-session 'updatedAt) (map-elt acp-session 'createdAt) "unknown-time")) (date-str (propertize (agent-shell--format-session-date updated-at) 'face 'font-lock-comment-face)) - (padding (make-string (max 2 (- 52 (length title))) ?\s))) + (padding (make-string (max 2 (- max-title-width (length title))) ?\s))) (concat dir dir-padding title padding date-str))) (defun agent-shell--prompt-select-session (acp-sessions) @@ -3224,10 +3233,16 @@ Falls back to latest session in batch mode (e.g. tests)." (when acp-sessions (if noninteractive (car acp-sessions) - (let* ((new-session-choice "Start a new session") + (let* ((max-dir-width (apply #'max (mapcar (lambda (s) + (length (agent-shell--session-dir-name s))) + acp-sessions))) + (max-title-width (apply #'max (mapcar (lambda (s) + (length (agent-shell--session-title s))) + acp-sessions))) + (new-session-choice "Start a new session") (choices (cons (cons new-session-choice nil) (mapcar (lambda (acp-session) - (cons (agent-shell--session-choice-label acp-session) + (cons (agent-shell--session-choice-label acp-session max-dir-width max-title-width) acp-session)) acp-sessions))) (candidates (mapcar #'car choices)) From 55fd96765413f0bebcd36a1de5933521122404e6 Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Sun, 15 Feb 2026 01:06:13 +0000 Subject: [PATCH 32/44] Fixing docstring warnings Related to: #190 #105 --- agent-shell.el | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index f0935ef..f10c49c 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -314,7 +314,7 @@ Assume screenshot file path will be appended to this list." (cons :save (lambda (file-path) (let ((exit-code (call-process "pngpaste" nil nil nil file-path))) (unless (zerop exit-code) - (error "pngpaste failed with exit code %d" exit-code)))))) + (error "Command pngpaste failed with exit code %d" exit-code)))))) (list (cons :command "xclip") (cons :save (lambda (file-path) (with-temp-buffer @@ -323,7 +323,7 @@ Assume screenshot file path will be appended to this list." "-selection" "clipboard" "-t" "image/png" "-o"))) (unless (zerop exit-code) - (error "xclip failed with exit code %d" exit-code)) + (error "Command xclip failed with exit code %d" exit-code)) (write-region (point-min) (point-max) file-path nil 'silent))))))) "Handlers for saving clipboard images to a file. @@ -446,8 +446,9 @@ configuration alist for backwards compatibility." :group 'agent-shell) (defcustom agent-shell-session-load-strategy 'latest - "How to choose an existing session when both -`session/list' and `session/load' are available. + "How to choose an existing session. + +Only possible if either `session/list' or `session/load' are available. Available values: @@ -2871,7 +2872,7 @@ Initialization events (emitted in order): `session-prompt' - About to prompt user for session selection `session-selected' - Session chosen (new or existing) :data contains :session-id (nil when starting new) - `session-selection-cancelled' - User cancelled session selection (C-g) + `session-selection-cancelled' - User cancelled session selection `init-finished' - Initialization pipeline completed Session events: From 1b1d82ddae45d391d0612eb8eab56831c4093a4a Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Sun, 15 Feb 2026 10:54:41 +0000 Subject: [PATCH 33/44] Make agent-shell-session-load-strategy default to new sessions --- agent-shell.el | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent-shell.el b/agent-shell.el index f10c49c..1c9829e 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -445,7 +445,7 @@ configuration alist for backwards compatibility." :key-type symbol :value-type sexp)) :group 'agent-shell) -(defcustom agent-shell-session-load-strategy 'latest +(defcustom agent-shell-session-load-strategy 'new "How to choose an existing session. Only possible if either `session/list' or `session/load' are available. From 836635adbb0aa62f19f84f35ef9ff56bb0526dac Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Sun, 15 Feb 2026 11:14:47 +0000 Subject: [PATCH 34/44] Fixing tests --- tests/agent-shell-tests.el | 56 +++++++++----------------------------- 1 file changed, 13 insertions(+), 43 deletions(-) diff --git a/tests/agent-shell-tests.el b/tests/agent-shell-tests.el index b4b7cbe..0821652 100644 --- a/tests/agent-shell-tests.el +++ b/tests/agent-shell-tests.el @@ -1116,55 +1116,25 @@ code block content with spaces (should (equal (agent-shell--format-session-date "not-a-date") "not-a-date"))) -(ert-deftest agent-shell--prompt-select-session-to-load-test () - "Test `agent-shell--prompt-select-session-to-load' choices." - (let* ((session-a '((sessionId . "session-1") +(ert-deftest agent-shell--prompt-select-session-test () + "Test `agent-shell--prompt-select-session' choices." + (let* ((noninteractive t) + (session-a '((sessionId . "session-1") (title . "First") + (cwd . "/home/user/project-a") (updatedAt . "2026-01-19T14:00:00Z"))) (session-b '((sessionId . "session-2") (title . "Second") + (cwd . "/home/user/project-b") (updatedAt . "2026-01-20T16:00:00Z"))) (sessions (list session-a session-b))) - ;; Select existing session - (cl-letf (((symbol-function 'completing-read) - (lambda (&rest _args) - (agent-shell--session-choice-label session-b)))) - (should (equal (agent-shell--prompt-select-session-to-load sessions) - session-b))) - ;; Select "new session" option - (cl-letf (((symbol-function 'completing-read) - (lambda (&rest _args) - agent-shell--start-new-session-choice))) - (should-not (agent-shell--prompt-select-session-to-load sessions))))) - -(ert-deftest agent-shell--session-picker-sort-test () - "Test `agent-shell--session-picker-sort' keeps the new-session option first." - (let* ((session-a-label "First Jan 19, 14:00") - (session-b-label "Second Jan 20, 16:00") - (candidates (list session-a-label - agent-shell--start-new-session-choice - session-b-label))) - (should (equal (agent-shell--session-picker-sort candidates) - (list agent-shell--start-new-session-choice - session-a-label - session-b-label))))) - -(ert-deftest agent-shell--prompt-select-session-to-load-defaults-to-new-session-test () - "Test prompt defaults to `agent-shell--start-new-session-choice'." - (let* ((session-a '((sessionId . "session-1") - (title . "First") - (updatedAt . "2026-01-19T14:00:00Z"))) - (session-b '((sessionId . "session-2") - (title . "Second") - (updatedAt . "2026-01-20T16:00:00Z"))) - (sessions (list session-a session-b)) - (captured-default nil)) - (cl-letf (((symbol-function 'completing-read) - (lambda (&rest args) - (setq captured-default (nth 6 args)) - agent-shell--start-new-session-choice))) - (agent-shell--prompt-select-session-to-load sessions) - (should (equal captured-default agent-shell--start-new-session-choice))))) + ;; noninteractive falls back to (car acp-sessions) + (should (equal (agent-shell--prompt-select-session sessions) + session-a)))) + +(ert-deftest agent-shell--prompt-select-session-nil-sessions-test () + "Test `agent-shell--prompt-select-session' returns nil for empty sessions." + (should-not (agent-shell--prompt-select-session nil))) (ert-deftest agent-shell--initiate-session-strategy-new-skips-list-load () "Test `agent-shell--initiate-session' skips list/load when strategy is `new'." From 00ed2b436d5ae012a53e8cea9a0cbc9a6d667da4 Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Sun, 15 Feb 2026 11:33:29 +0000 Subject: [PATCH 35/44] Improve session listing column alignment --- agent-shell.el | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index 1c9829e..3954ff9 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -3215,16 +3215,16 @@ for the current year, or \"Mon DD, YYYY\" for other years." MAX-DIR-WIDTH is the column width for the directory name. MAX-TITLE-WIDTH is the column width for the title." (let* ((dir-name (agent-shell--session-dir-name acp-session)) - (dir (propertize dir-name 'face 'font-lock-keyword-face)) - (dir-padding (make-string (max 2 (- max-dir-width (length dir-name))) ?\s)) + (dir-padding (make-string (- (+ max-dir-width 1) (length dir-name)) ?\s)) + (dir-col (propertize (concat dir-name dir-padding) 'face 'font-lock-keyword-face)) (title (agent-shell--session-title acp-session)) + (title-padding (make-string (- (+ max-title-width 1) (length title)) ?\s)) (updated-at (or (map-elt acp-session 'updatedAt) (map-elt acp-session 'createdAt) "unknown-time")) (date-str (propertize (agent-shell--format-session-date updated-at) - 'face 'font-lock-comment-face)) - (padding (make-string (max 2 (- max-title-width (length title))) ?\s))) - (concat dir dir-padding title padding date-str))) + 'face 'font-lock-comment-face))) + (concat dir-col title title-padding date-str))) (defun agent-shell--prompt-select-session (acp-sessions) "Prompt to choose one from ACP-SESSIONS. From d71d3096f28fb83551cf28e7d4f7f8cff71cf4fc Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:58:41 +0000 Subject: [PATCH 36/44] Render resumed user inputs as shell prompts Related to: #190 #105 #268 --- agent-shell-ui.el | 56 ++++++++++++++++++++++++++++++++ agent-shell.el | 81 +++++++++++++++++++++++++++++++++++------------ 2 files changed, 117 insertions(+), 20 deletions(-) diff --git a/agent-shell-ui.el b/agent-shell-ui.el index 62e30e1..fc74d4b 100644 --- a/agent-shell-ui.el +++ b/agent-shell-ui.el @@ -396,6 +396,62 @@ NAVIGATION controls navigability: (put-text-property block-start (or body-end label-right-end label-left-end) 'read-only t) (put-text-property block-start (or body-end label-right-end label-left-end) 'front-sticky '(read-only)))) +(cl-defun agent-shell-ui-update-text (&key namespace-id block-id text append create-new no-undo) + "Update or insert a plain text entry identified by NAMESPACE-ID and BLOCK-ID. + +TEXT is the string to insert or append. +When APPEND is non-nil, append TEXT to existing entry. +When CREATE-NEW is non-nil, always create a new entry. +When NO-UNDO is non-nil, disable undo recording." + (save-mark-and-excursion + (let* ((inhibit-read-only t) + (buffer-undo-list (if no-undo t buffer-undo-list)) + (qualified-id (format "%s-%s" namespace-id block-id)) + (props `(agent-shell-ui-state ((:qualified-id . ,qualified-id)) + read-only t + front-sticky (read-only))) + (match (save-mark-and-excursion + (goto-char (point-max)) + (text-property-search-backward + 'agent-shell-ui-state nil + (lambda (_ state) + (equal (map-elt state :qualified-id) qualified-id)) + t)))) + (when text + (cond + ;; Append to existing entry. + ((and match (not create-new) append) + (goto-char (prop-match-end match)) + (insert (apply #'propertize text props)) + (list (cons :block (list (cons :start (prop-match-beginning match)) + (cons :end (point)))) + (cons :padding (list (cons :start (prop-match-beginning match)) + (cons :end (point)))))) + ;; Replace existing entry. + ((and match (not create-new)) + (let ((padding-start (save-excursion + (goto-char (prop-match-beginning match)) + (skip-chars-backward "\n") + (point)))) + (delete-region (prop-match-beginning match) (prop-match-end match)) + (goto-char (prop-match-beginning match)) + (insert (apply #'propertize text props)) + (list (cons :block (list (cons :start (prop-match-beginning match)) + (cons :end (point)))) + (cons :padding (list (cons :start padding-start) + (cons :end (point))))))) + ;; New entry. + (t + (goto-char (point-max)) + (let ((padding-start (point))) + (insert (agent-shell-ui--required-newlines 2)) + (let ((block-start (point))) + (insert (apply #'propertize text props)) + (list (cons :block (list (cons :start block-start) + (cons :end (point)))) + (cons :padding (list (cons :start padding-start) + (cons :end (point))))))))))))) + (defun agent-shell-ui--required-newlines (desired) "Return string of newlines needed to reach DESIRED before POSITION." (let ((context (save-mark-and-excursion diff --git a/agent-shell.el b/agent-shell.el index 3954ff9..5e85f4f 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -1138,26 +1138,32 @@ otherwise returns COMMAND unchanged." :navigation 'never)) (map-put! state :last-entry-type "agent_message_chunk")) ((equal (map-elt update 'sessionUpdate) "user_message_chunk") - (unless (equal (map-elt state :last-entry-type) "user_message_chunk") - (map-put! state :chunked-group-count (1+ (map-elt state :chunked-group-count))) - (agent-shell--append-transcript - :text (format "## User (%s)\n\n" (format-time-string "%F %T")) - :file-path agent-shell--transcript-file)) - (let-alist update - (agent-shell--append-transcript - :text (format "> %s\n" .content.text) - :file-path agent-shell--transcript-file) - (agent-shell--update-fragment - :state state - :block-id (format "%s-user_message_chunk" - (map-elt state :chunked-group-count)) - :label-left (propertize "User" 'font-lock-face 'font-lock-doc-markup-face) - :body .content.text - :create-new (not (equal (map-elt state :last-entry-type) - "user_message_chunk")) - :append t - :expanded agent-shell-user-message-expand-by-default - :navigation 'never)) + (let ((new-prompt-p (not (equal (map-elt state :last-entry-type) + "user_message_chunk")))) + (when new-prompt-p + (map-put! state :chunked-group-count (1+ (map-elt state :chunked-group-count))) + (agent-shell--append-transcript + :text (format "## User (%s)\n\n" (format-time-string "%F %T")) + :file-path agent-shell--transcript-file)) + (let-alist update + (agent-shell--append-transcript + :text (format "> %s\n" .content.text) + :file-path agent-shell--transcript-file) + (agent-shell--update-text + :state state + :block-id (format "%s-user_message_chunk" + (map-elt state :chunked-group-count)) + :text (if new-prompt-p + (concat (propertize + (map-nested-elt + state '(:agent-config :shell-prompt)) + 'font-lock-face 'comint-highlight-prompt) + (propertize .content.text + 'font-lock-face 'comint-highlight-input)) + (propertize .content.text + 'font-lock-face 'comint-highlight-input)) + :create-new new-prompt-p + :append t))) (map-put! state :last-entry-type "user_message_chunk")) ((equal (map-elt update 'sessionUpdate) "plan") (let-alist update @@ -2332,6 +2338,41 @@ by default." (widen))) (run-hook-with-args 'agent-shell-section-functions range))))) +(cl-defun agent-shell--update-text (&key state namespace-id block-id text append create-new) + "Update plain text entry in the shell buffer. + +Uses STATE's request count as namespace unless NAMESPACE-ID is given. +BLOCK-ID uniquely identifies the entry. +TEXT is the string to insert or append. +APPEND and CREATE-NEW control update behavior." + (let ((ns (or namespace-id (map-elt state :request-count)))) + (when-let (((map-elt state :buffer)) + (viewport-buffer (agent-shell-viewport--buffer + :shell-buffer (map-elt state :buffer) + :existing-only t))) + (with-current-buffer viewport-buffer + (let ((inhibit-read-only t)) + (agent-shell-ui-update-text + :namespace-id ns + :block-id block-id + :text text + :append append + :create-new create-new + :no-undo t)))) + (with-current-buffer (map-elt state :buffer) + (shell-maker-with-auto-scroll-edit + (when-let* ((range (agent-shell-ui-update-text + :namespace-id ns + :block-id block-id + :text text + :append append + :create-new create-new + :no-undo t)) + (block-start (map-nested-elt range '(:block :start))) + (block-end (map-nested-elt range '(:block :end)))) + (let ((inhibit-read-only t)) + (add-text-properties block-start block-end '(field output)))))))) + (defun agent-shell-toggle-logging () "Toggle logging." (interactive) From 11176f9f77d0fc68c17e13d50257fbaa2a45d501 Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Sun, 15 Feb 2026 15:45:39 +0000 Subject: [PATCH 37/44] Adds agent-shell-prefer-session-resume --- agent-shell.el | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index 5e85f4f..3e208a5 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -445,6 +445,13 @@ configuration alist for backwards compatibility." :key-type symbol :value-type sexp)) :group 'agent-shell) +(defcustom agent-shell-prefer-session-resume t + "Prefer ACP session resume over session load when both are available. + +When non-nil (and supported by agent), prefer ACP session resumes over loading." + :type 'boolean + :group 'agent-shell) + (defcustom agent-shell-session-load-strategy 'new "How to choose an existing session. @@ -3449,15 +3456,18 @@ Falls back to latest session in batch mode (e.g. tests)." :client (map-elt (agent-shell--state) :client) :request (let ((cwd (agent-shell--resolve-path (agent-shell-cwd))) (mcp-servers (agent-shell--mcp-servers))) - (if (map-elt (agent-shell--state) :supports-session-load) + (let ((use-resume (if agent-shell-prefer-session-resume + (map-elt (agent-shell--state) :supports-session-resume) + (not (map-elt (agent-shell--state) :supports-session-load))))) + (if use-resume + (acp-make-session-resume-request + :session-id acp-session-id + :cwd cwd + :mcp-servers mcp-servers) (acp-make-session-load-request :session-id acp-session-id :cwd cwd - :mcp-servers mcp-servers) - (acp-make-session-resume-request - :session-id acp-session-id - :cwd cwd - :mcp-servers mcp-servers))) + :mcp-servers mcp-servers)))) :buffer (current-buffer) :on-success (lambda (acp-load-response) (agent-shell--set-session-from-response From 41c45fa9e476be01e6bee2add4112fdf3a2d5446 Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Sun, 15 Feb 2026 15:46:24 +0000 Subject: [PATCH 38/44] Fixes duplicate sessions during bootstrapping --- agent-shell.el | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/agent-shell.el b/agent-shell.el index 3e208a5..18ed540 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -3000,6 +3000,7 @@ DATA is an optional alist of event-specific data." "Initialize ACP client." (agent-shell--update-fragment :state (agent-shell--state) + :namespace-id "bootstrapping" :block-id "starting" :label-left (format "%s %s" (agent-shell--status-label "in_progress") @@ -3023,6 +3024,7 @@ DATA is an optional alist of event-specific data." "Initialize ACP client subscriptions." (agent-shell--update-fragment :state agent-shell--state + :namespace-id "bootstrapping" :block-id "starting" :label-left (format "%s %s" (agent-shell--status-label "in_progress") @@ -3049,6 +3051,7 @@ Must provide ON-INITIATED (lambda ())." (with-current-buffer (map-elt agent-shell--state :buffer) (agent-shell--update-fragment :state agent-shell--state + :namespace-id "bootstrapping" :block-id "starting" :body "\n\nInitializing..." :append t)) @@ -3109,6 +3112,7 @@ Must provide ON-AUTHENTICATED (lambda ())." (with-current-buffer (map-elt agent-shell--state :buffer) (agent-shell--update-fragment :state (agent-shell--state) + :namespace-id "bootstrapping" :block-id "starting" :body "\n\nAuthenticating..." :append t)) @@ -3203,6 +3207,7 @@ Must provide ON-SESSION-INIT (lambda ())." (with-current-buffer (map-elt (agent-shell--state) :buffer) (agent-shell--update-fragment :state (agent-shell--state) + :namespace-id "bootstrapping" :block-id "starting" :body "\n\nCreating session..." :append t)) @@ -3420,6 +3425,7 @@ Falls back to latest session in batch mode (e.g. tests)." (with-current-buffer (map-elt (agent-shell--state) :buffer) (agent-shell--update-fragment :state (agent-shell--state) + :namespace-id "bootstrapping" :block-id "starting" :body "\n\nLooking for existing sessions..." :append t)) @@ -3449,6 +3455,7 @@ Falls back to latest session in batch mode (e.g. tests)." (progn (agent-shell--update-fragment :state (agent-shell--state) + :namespace-id "bootstrapping" :block-id "starting" :body (format "\n\nLoading session %s..." acp-session-id) :append t) @@ -3477,6 +3484,7 @@ Falls back to latest session in batch mode (e.g. tests)." :on-failure (lambda (_error _raw-message) (agent-shell--update-fragment :state (agent-shell--state) + :namespace-id "bootstrapping" :block-id "starting" :body "\n\nCould not load existing session. Creating a new one..." :append t) From 9e783f24987949a86c37ac256cc39aaecd077a09 Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Sun, 15 Feb 2026 15:56:29 +0000 Subject: [PATCH 39/44] Display a "Resuming ression" fragment with status changes --- agent-shell.el | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/agent-shell.el b/agent-shell.el index 18ed540..940aba5 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -3480,6 +3480,14 @@ Falls back to latest session in batch mode (e.g. tests)." (agent-shell--set-session-from-response :acp-response acp-load-response :acp-session-id acp-session-id) + (agent-shell--update-fragment + :state (agent-shell--state) + :namespace-id "bootstrapping" + :block-id "resumed_session" + :label-left (format "%s %s" + (agent-shell--status-label "completed") + (propertize "Resuming session" 'font-lock-face 'font-lock-doc-markup-face)) + :body (or (map-elt acp-session 'title) "")) (agent-shell--finalize-session-init :on-session-init on-session-init)) :on-failure (lambda (_error _raw-message) (agent-shell--update-fragment From ae7d8c2de900aab46a2326b67b567c577a17877b Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Sun, 15 Feb 2026 21:14:14 +0000 Subject: [PATCH 40/44] Prevent shells from ever modifying an editable viewport --- agent-shell.el | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index 940aba5..5cebebd 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -2250,7 +2250,9 @@ by default." (when-let (((map-elt state :buffer)) (viewport-buffer (agent-shell-viewport--buffer :shell-buffer (map-elt state :buffer) - :existing-only t))) + :existing-only t)) + ((with-current-buffer viewport-buffer + (derived-mode-p 'agent-shell-viewport-view-mode)))) (with-current-buffer viewport-buffer (let ((inhibit-read-only t)) ;; TODO: Investigate why save-restriction isn't enough @@ -2356,7 +2358,9 @@ APPEND and CREATE-NEW control update behavior." (when-let (((map-elt state :buffer)) (viewport-buffer (agent-shell-viewport--buffer :shell-buffer (map-elt state :buffer) - :existing-only t))) + :existing-only t)) + ((with-current-buffer viewport-buffer + (derived-mode-p 'agent-shell-viewport-view-mode)))) (with-current-buffer viewport-buffer (let ((inhibit-read-only t)) (agent-shell-ui-update-text From 212529d5eb53963915e4dbfab0b84643dd53bd5a Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Sun, 15 Feb 2026 21:14:42 +0000 Subject: [PATCH 41/44] Ensure events are always emitted on the shell buffer --- agent-shell.el | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/agent-shell.el b/agent-shell.el index 5cebebd..319a703 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -2996,7 +2996,8 @@ DATA is an optional alist of event-specific data." (dolist (sub (map-elt (agent-shell--state) :event-subscriptions)) (when (or (not (map-elt sub :event)) (eq (map-elt sub :event) event)) - (funcall (map-elt sub :on-event) event-alist))))) + (with-current-buffer (map-elt (agent-shell--state) :buffer) + (funcall (map-elt sub :on-event) event-alist)))))) ;;; Initialization From d57d2746d398f7d10a2de25e494e9afdc460ec1b Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Sun, 15 Feb 2026 21:15:39 +0000 Subject: [PATCH 42/44] Make sure deferred display also works viewport buffers --- agent-shell.el | 91 +++++++++++++++++++++++++++++++------------------- 1 file changed, 57 insertions(+), 34 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index 319a703..ce7f7a4 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -650,22 +650,34 @@ handles viewport mode detection, existing shell reuse, and project context." (or (derived-mode-p 'agent-shell-viewport-view-mode) (derived-mode-p 'agent-shell-viewport-edit-mode))) (agent-shell-toggle) - (agent-shell-viewport--show-buffer - :shell-buffer (cond (switch-to-shell - (completing-read "Switch to shell: " - (mapcar #'buffer-name (or (agent-shell-buffers) - (user-error "No shells available"))) - nil t)) - (new-shell - (agent-shell--start :config (or config - (agent-shell--resolve-preferred-config) - (agent-shell-select-config - :prompt "Start new agent: ") - (error "No agent config found")) - :no-focus t - :new-session t)) - (t - (agent-shell--shell-buffer))))) + (let ((shell-buffer + (cond (switch-to-shell + (completing-read "Switch to shell: " + (mapcar #'buffer-name (or (agent-shell-buffers) + (user-error "No shells available"))) + nil t)) + (new-shell + (agent-shell--start :config (or config + (agent-shell--resolve-preferred-config) + (agent-shell-select-config + :prompt "Start new agent: ") + (error "No agent config found")) + :no-focus t + :new-session t)) + (t + (agent-shell--shell-buffer))))) + (if (and new-shell + (not agent-shell-deferred-initialization) + (eq agent-shell-session-load-strategy 'prompt)) + ;; Defer viewport display until session is selected. + (agent-shell-subscribe-to + :shell-buffer shell-buffer + :event 'session-selected + :on-event (lambda (_event) + (agent-shell-viewport--show-buffer + :shell-buffer shell-buffer))) + (agent-shell-viewport--show-buffer + :shell-buffer shell-buffer)))) (cond (switch-to-shell (let* ((shell-buffer (completing-read "Switch to shell: " @@ -2188,6 +2200,30 @@ variable (see makunbound)")) :success nil) ;; Kick off ACP session bootstrapping. (agent-shell--handle :shell-buffer shell-buffer))) + ;; Subscribe to session selection events (needed regardless of focus). + (when (and (not agent-shell-deferred-initialization) + (eq agent-shell-session-load-strategy 'prompt)) + (agent-shell-subscribe-to + :shell-buffer shell-buffer + :event 'session-selection-cancelled + :on-event (lambda (_event) + (kill-buffer shell-buffer))) + (let ((active-message (agent-shell-active-message-show :text "Loading..."))) + (agent-shell-subscribe-to + :shell-buffer shell-buffer + :event 'session-prompt + :on-event (lambda (_event) + (agent-shell-active-message-hide :active-message active-message))) + (agent-shell-subscribe-to + :shell-buffer shell-buffer + :event 'session-selected + :on-event (lambda (_event) + (agent-shell-active-message-hide :active-message active-message))) + (agent-shell-subscribe-to + :shell-buffer shell-buffer + :event 'session-selection-cancelled + :on-event (lambda (_event) + (agent-shell-active-message-hide :active-message active-message))))) ;; Display buffer if no-focus was nil, respecting agent-shell-display-action (unless no-focus (if (and (not agent-shell-deferred-initialization) @@ -2197,24 +2233,11 @@ variable (see makunbound)")) ;; and soon after that prompt the user for input. ;; Better to prompt the user for input and then ;; display the buffer. - (let ((active-message (agent-shell-active-message-show :text "Loading..."))) - (agent-shell-subscribe-to - :shell-buffer shell-buffer - :event 'session-prompt - :on-event (lambda (_event) - (agent-shell-active-message-hide :active-message active-message))) - (agent-shell-subscribe-to - :shell-buffer shell-buffer - :event 'session-selected - :on-event (lambda (_event) - (agent-shell-active-message-hide :active-message active-message) - (agent-shell--display-buffer shell-buffer))) - (agent-shell-subscribe-to - :shell-buffer shell-buffer - :event 'session-selection-cancelled - :on-event (lambda (_event) - (agent-shell-active-message-hide :active-message active-message) - (kill-buffer shell-buffer)))) + (agent-shell-subscribe-to + :shell-buffer shell-buffer + :event 'session-selected + :on-event (lambda (_event) + (agent-shell--display-buffer shell-buffer))) (agent-shell--display-buffer shell-buffer))) shell-buffer)) From 30a04ef364368478dd52c6dce244c6ac7a535b3d Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Sun, 15 Feb 2026 21:16:31 +0000 Subject: [PATCH 43/44] Don't allow submissions until session is available --- agent-shell-viewport.el | 4 ++++ agent-shell.el | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/agent-shell-viewport.el b/agent-shell-viewport.el index 38e6494..eb09430 100644 --- a/agent-shell-viewport.el +++ b/agent-shell-viewport.el @@ -162,6 +162,10 @@ Returns an alist with insertion details or nil otherwise: (interactive) (unless (derived-mode-p 'agent-shell-viewport-edit-mode) (user-error "Not in a shell viewport buffer")) + (when (and (not agent-shell-deferred-initialization) + (not (with-current-buffer (agent-shell-viewport--shell-buffer) + (map-nested-elt agent-shell--state '(:session :id))))) + (user-error "Session not ready... please wait")) (setq agent-shell-viewport--compose-snapshot nil) (if agent-shell-prefer-viewport-interaction (agent-shell-viewport-compose-send-and-wait-for-response) diff --git a/agent-shell.el b/agent-shell.el index ce7f7a4..68b561b 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -910,6 +910,10 @@ Flow: (with-current-buffer shell-buffer (unless (derived-mode-p 'agent-shell-mode) (error "Not in a shell")) + (when (and command + (not agent-shell-deferred-initialization) + (not (map-nested-elt (agent-shell--state) '(:session :id)))) + (user-error "Session not ready... please wait")) (map-put! (agent-shell--state) :request-count ;; TODO: Make public in shell-maker. (shell-maker--current-request-id)) From 1649ca24d1bcd26ccd159ae73e3e9d43d09a3129 Mon Sep 17 00:00:00 2001 From: xenodium <8107219+xenodium@users.noreply.github.com> Date: Sun, 15 Feb 2026 21:22:08 +0000 Subject: [PATCH 44/44] Don't ask user to kill viewport if already cleaning up --- agent-shell-viewport.el | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/agent-shell-viewport.el b/agent-shell-viewport.el index eb09430..20d5e03 100644 --- a/agent-shell-viewport.el +++ b/agent-shell-viewport.el @@ -758,10 +758,14 @@ For example, offer to kill associated shell session." ;; triggered by shell buffers attempting to kill viewport buffer. (let ((agent-shell-viewport--clean-up nil)) (when-let ((shell-buffers (seq-filter (lambda (shell-buffer) - (equal (agent-shell-viewport--buffer - :shell-buffer shell-buffer - :existing-only t) - (current-buffer))) + (and (equal (agent-shell-viewport--buffer + :shell-buffer shell-buffer + :existing-only t) + (current-buffer)) + ;; Skip shells already shutting down (client + ;; is nil after agent-shell--shutdown). + (buffer-local-value 'agent-shell--state shell-buffer) + (map-elt (buffer-local-value 'agent-shell--state shell-buffer) :client))) (agent-shell-buffers))) ((y-or-n-p "Kill shell session too?"))) (mapc (lambda (shell-buffer)